保留統計との闘い~統計取得遅延問題 [アーキテクチャ]

 久しぶりの投稿である。保留統計が蓄積すると、統計取得が遅延する問題について調査したことを記録しておく。手元の検証環境(19.3)で動作確認をした。

◆保留統計についてのおさらい


まず簡単に保留統計について記載しておく。Oracle12cから統計情報は取得したタイミングで即時反映するのではなく、一時的に保留状態にしておき、任意のタイミングで反映(パブリッシュ)することができる。保留統計で取得された実行計画を参照するようにセッションレベルで統計を切り替えることができるため、explain plan for等で実行計画を反映前に事前に確認することができる。これにより、統計情報取得によって突然実行計画が変わり性能劣化を招くような事態を未然に防ぐことができる。

 保留統計の設定は以下の例のように表毎にプリファレンスで行う(スキーマレベルでプリファレンスを設定することも可能ではあるが、そのときにスキーマに存在するオブジェクトにしか影響しないため、注意が必要である)。

例)テーブルの保留統計の設定方法

SQL> exec dbms_stats.set_table_prefs('SCOTT','T1','PUBLISH','FALSE');

PL/SQL procedure successfully completed.

SQL> select dbms_stats.get_prefs('PUBLISH','SCOTT','T1') from dual;

DBMS_STATS.GET_PREFS('PUBLISH','SCOTT','T1')
--------------------------------------------
FALSE


 保留統計を取得するには、通常の統計取得方法と変わらずdbms_stats.gather_table/index_statsを利用する。保留統計が取得されるとdba_tab_pending_statsやdba_ind_pending_statsで統計情報を確認することができる。この時点ではあくまで保留状態であるので、統計情報は変更されておらず、実行計画への影響はない。

例)テーブル・索引の保留統計の状態

SQL> exec dbms_stats.gather_table_stats('SCOTT','T1');

PL/SQL procedure successfully completed.

SQL> --- published statistics
SQL> select num_rows, last_analyzed from dba_tab_statistics where owner='SCOTT' and table_name='T1';

  NUM_ROWS LAST_ANALYZED
---------- -------------------
      1000 2021/06/20 10:14:50

SQL> select index_name, num_rows, last_analyzed from dba_ind_statistics where owner='SCOTT' and table_name='T1';

INDEX_NAME   NUM_ROWS LAST_ANALYZED
---------- ---------- -------------------
T1_PK            1000 2021/06/20 10:14:50
T1_IDX1          1000 2021/06/20 10:14:50
T1_IDX2          1000 2021/06/20 10:14:50

SQL> -- pending statistics
SQL> select num_rows, last_analyzed from dba_tab_pending_stats where owner='SCOTT' and table_name='T1';

  NUM_ROWS LAST_ANALYZED
---------- -------------------
     10000 2021/06/20 10:14:55

SQL> select index_name, num_rows, last_analyzed from dba_ind_pending_stats where owner='SCOTT' and table_name='T1';

INDEX_NAME   NUM_ROWS LAST_ANALYZED
---------- ---------- -------------------
T1_IDX1         10000 2021/06/20 10:14:55
T1_IDX2         10000 2021/06/20 10:14:56
T1_PK           10000 2021/06/20 10:14:55


 保留統計に取得された統計で実行計画を確認するためには、以下のようにセッションレベルでoptimizer_use_pending_statisticsをtrueに設定すれば良い。下記例は、scott.t1を10000件にした状態の保留統計ではインデックスを使う実行計画になっていることがわかる。

例)
SQL> alter session set optimizer_use_pending_statistics=true;

Session altered.

SQL> explain plan for select * from scott.t1 where id<1000;

Explained.

SQL> select * from table(dbms_xplan.display);

PLAN_TABLE_OUTPUT
---------------------------------------------------------------------------------------------
Plan hash value: 1715750954

---------------------------------------------------------------------------------------------
| Id  | Operation                           | Name  | Rows  | Bytes | Cost (%CPU)| Time     |
---------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                    |       |   999 |   303K|    48   (0)| 00:00:01 |
|   1 |  TABLE ACCESS BY INDEX ROWID BATCHED| T1    |   999 |   303K|    48   (0)| 00:00:01 |
|*  2 |   INDEX RANGE SCAN                  | T1_PK |   999 |       |     3   (0)| 00:00:01 |
---------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - access("ID"<1000)


 実行計画に問題がないことを確認したら、保留統計を以下のようにパブリッシュし、通常の統計情報へ反映する。反映されれば保留統計はなくなる。パブリッシュは表にだけ実行でき、紐づく索引は同時にパブリッシュされる。索引のみパブリッシュすることはできない。

例)
SQL> exec dbms_stats.publish_pending_stats('SCOTT','T1');

PL/SQL procedure successfully completed.

SQL> --- published statistics
SQL> select num_rows, last_analyzed from dba_tab_statistics where owner='SCOTT' and table_name='T1';

  NUM_ROWS LAST_ANALYZED
---------- -------------------
     10000 2021/06/20 10:14:55

SQL> select index_name, num_rows, last_analyzed from dba_ind_statistics where owner='SCOTT' and table_name='T1';

INDEX_NAME   NUM_ROWS LAST_ANALYZED
---------- ---------- -------------------
T1_PK           10000 2021/06/20 10:14:55
T1_IDX1         10000 2021/06/20 10:14:55
T1_IDX2         10000 2021/06/20 10:14:56

SQL> -- pending statistics
SQL> select num_rows, last_analyzed from dba_tab_pending_stats where owner='SCOTT' and table_name='T1';

no rows selected

SQL> select index_name, num_rows, last_analyzed from dba_ind_pending_stats where owner='SCOTT' and table_name='T1';

no rows selected


 保留統計の削除はdbms_stats.delete_pending_statsを使う。検証用以外で実運用上は使うことは少ないと思うが、例えば保留に古い統計情報が残ってしまったときに有用だろう。

例)
SQL> exec dbms_stats.delete_pending_stats('SCOTT','T1');

PL/SQL procedure successfully completed.

◆保留統計取得遅延問題について


 保留統計をあまりため込むと、統計取得に伴う過去保留統計(ヒストグラム)の削除処理に時間がかかり、統計取得が遅延することがある。ヒストグラムの保留統計はオブジェクト毎・カラム毎に取得されるため、テーブル数(パーティション数)xカラム数が多いと、保留統計が蓄積した際に遅延することがある。これは下記DocIDに記載されている通り仕様であり、保留統計はため込まないことが推奨されている。製品機能として運用の制約は規定していないものの、感覚としては、メンテナンスウィンドウで日次に取得された保留統計は、何カ月もため込むものではなく、取得したその日のうちにパブリッシュし、保留統計はなるべく空としておく程度が良い。

  When Number of Pending Statistics Increases, It Takes Time to Collect Statistical Information (ドキュメントID 2642768.1)

 上記の仕様は理解したとしても、実際に保留統計が蓄積してしまうケースはあるだろう。この保留統計の削除処理に伴う上記DocのDELETE文に時間がかかるのは実行計画の問題である。問題のdelete文と実行計画を見てほしい。I_WRI$_OPTSTAT_H_STを使ってWRI$_OPTSTAT_HISTGRM_HISTORYの絞り込みを行っている。

SQL> select * from table(dbms_xplan.display_cursor('d8yp8608d866z'));

PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
SQL_ID  d8yp8608d866z, child number 0
-------------------------------------
delete /* QOSH:PURGE_OLD_STS *//*+ dynamic_sampling(4) */  from
sys.wri$_optstat_histgrm_history      where :1 = savtime and obj# = :2
and intcol# = nvl(:3, intcol#)

Plan hash value: 1890550155

--------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                                   | Name                         | Rows  | Bytes | Cost (%CPU)| Time     | Pstart| Pstop |
--------------------------------------------------------------------------------------------------------------------------------------------
|   0 | DELETE STATEMENT                            |                              |       |       |     2 (100)|          |       |       |
|   1 |  DELETE                                     | WRI$_OPTSTAT_HISTGRM_HISTORY |       |       |            |          |       |       |
|*  2 |   TABLE ACCESS BY GLOBAL INDEX ROWID BATCHED| WRI$_OPTSTAT_HISTGRM_HISTORY |     1 |    56 |     2   (0)| 00:00:01 | ROWID | ROWID |
|*  3 |    INDEX RANGE SCAN                         | I_WRI$_OPTSTAT_H_ST★        |     3 |       |     1   (0)| 00:00:01 |       |       |
--------------------------------------------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   2 - filter(("OBJ#"=:2 AND "INTCOL#"=NVL(:3,"INTCOL#")))
   3 - access("WRI$_OPTSTAT_HISTGRM_HISTORY"."SYS_NC00018$"=SYS_EXTRACT_UTC(:1))


 この索引はタイムスタンプ型SAVTIMEカラムのファンクション索引(SYS_EXTRACT_UTC("SAVTIME"))である。SAVTIMEがほぼユニークに絞り込める状況では効率的であるが、逆に同じ値が大量に入っている場合は非効率である。
 
SQL> select * from dba_ind_expressions where table_name='WRI$_OPTSTAT_HISTGRM_HISTORY'

INDEX_OWNE INDEX_NAME                     TABLE_OWNE TABLE_NAME                     COLUMN_EXPRESSION           COLUMN_POSITION
---------- ------------------------------ ---------- ------------------------------ --------------------------- ---------------
SYS        I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST SYS        WRI$_OPTSTAT_HISTGRM_HISTORY   SYS_EXTRACT_UTC("SAVTIME")                3
SYS        I_WRI$_OPTSTAT_H_ST            SYS        WRI$_OPTSTAT_HISTGRM_HISTORY   SYS_EXTRACT_UTC("SAVTIME")★              1


 このテーブルの索引には、I_WRI$_OPTSTAT_H_OBJ#_ICOL#_STという別の索引があることがわかる。OBJ#で絞り込みができる場合はこちらの方が良い。

SQL> select index_name, column_name from dba_ind_columns where table_name='WRI$_OPTSTAT_HISTGRM_HISTORY' order by index_name,column_position

INDEX_NAME                          COLUMN_NAME
----------------------------------- --------------------
I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST      OBJ#★
I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST      INTCOL#
I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST      SYS_NC00018$
I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST      COLNAME
I_WRI$_OPTSTAT_H_ST                 SYS_NC00018$


 この表は、もともとヒストグラムのバックアップを保持している表で、SAVTIMEはバックアップ日時が入る。しかし、保留統計の場合は、テーブル毎、カラム毎にSAVTIME=3000年12月1日で記録される。様々な表のヒストグラムが全く同じSAVTIMEで記録されるため、ある表の保留統計のレコードを識別するためにSAVTIMEでは絞り込めない。このため、表の数(厳密にはヒストグラムが取得されているカラム数)が増えてくると、保留統計取得に伴う過去ヒストグラムの削除に時間がかかるようになるのである。
 以下の例では3つの表の保留統計を取得した例であるが、表ごとに153件のヒストグラムが取得されており、すべて同じSAVTIMEである。1回の統計取得でこのdeleteが5回発行されているのは、表のカラム数に依存しているのだろう。仮にこの表が1000個あると、統計情報収集で15万件程度の規模の表をINDEX FULL SCANで5回走査する処理になるだろう。

SQL> exec dbms_stats.gather_table_stats('SCOTT','T1',method_opt=>'for all columns');

PL/SQL procedure successfully completed.

SQL> select to_char(savtime,'yyyymmdd hh24:mi:ss'), count(*) from  WRI$_OPTSTAT_HISTGRM_HISTORY group by savtime order by 1;

TO_CHAR(SAVTIME,'   COUNT(*)
----------------- ----------
30001201 01:00:00        153

SQL> select executions from v$sqlarea where sql_id='d8yp8608d866z';

EXECUTIONS
----------
         5

SQL> exec dbms_stats.gather_table_stats('SCOTT','T2',method_opt=>'for all columns');

PL/SQL procedure successfully completed.

SQL> select to_char(savtime,'yyyymmdd hh24:mi:ss'), count(*) from  WRI$_OPTSTAT_HISTGRM_HISTORY group by savtime order by 1;

TO_CHAR(SAVTIME,'   COUNT(*)
----------------- ----------
30001201 01:00:00        306

SQL> select executions from v$sqlarea where sql_id='d8yp8608d866z';

EXECUTIONS
----------
        10

SQL> exec dbms_stats.gather_table_stats('SCOTT','T3',method_opt=>'for all columns');

PL/SQL procedure successfully completed.

SQL> select to_char(savtime,'yyyymmdd hh24:mi:ss'), count(*) from  WRI$_OPTSTAT_HISTGRM_HISTORY group by savtime order by 1;

TO_CHAR(SAVTIME,'   COUNT(*)
----------------- ----------
30001201 01:00:00        459

SQL> select executions from v$sqlarea where sql_id='d8yp8608d866z';

EXECUTIONS
----------
        15


 なお、パブリッシュでもこのdeleteは発行されるが、表毎に1回しか発行されていなかった。特にカラムを絞り込むことなく削除できるからだろう。

SQL> exec dbms_stats.publish_pending_stats('SCOTT','T1');
select executions from v$sqlarea where sql_id='d8yp8608d866z';

PL/SQL procedure successfully completed.

SQL>
EXECUTIONS
----------
        16

SQL> exec dbms_stats.publish_pending_stats('SCOTT','T2');

PL/SQL procedure successfully completed.

SQL> select executions from v$sqlarea where sql_id='d8yp8608d866z';

EXECUTIONS
----------
        17

SQL> exec dbms_stats.publish_pending_stats('SCOTT','T3');

PL/SQL procedure successfully completed.

SQL> select executions from v$sqlarea where sql_id='d8yp8608d866z';

EXECUTIONS
----------
        18

SQL> select to_char(savtime,'yyyymmdd hh24:mi:ss'), count(*) from  WRI$_OPTSTAT_HISTGRM_HISTORY group by savtime order by 1;

TO_CHAR(SAVTIME,'   COUNT(*)
----------------- ----------
20210622 06:23:55        153
20210622 06:23:55        153
20210622 06:23:57        153

SQL>


 このdelete文はdbms_statsパッケージ内から発行されているSQLなのでヒント追加などで実行計画を変更できない。基本的にはパブリッシュすれば遅延は発生しないはずである。しかし、保留統計を使えば統計が蓄積されてしまうことは運用上発生し得るし、保留統計取得に時間がかかり困ることもあるだろう。
 この問題の解決策をいろいろと考えたが、今のところ一番簡単で有効と思われるのは以下のようなSQLパッチを適用することである。SQLパッチは任意のSQLIDにヒントを入れることができる機能であり、19cではdbms_sqldiag.create_sql_patchとしてマニュアルに記載されている機能である。インスタンス再起動により揮発することはない。なお、下記SQLIDは19.3の結果であり、バージョンによりSQLIDは変わる可能性がある点はご留意頂きたい。保留統計取得が遅く、このSQLで時間がかかっている場合には試してみると良いだろう。

保留統計性能改善用SQLパッチ(19c)

declare
 patch_name varchar2(20);
begin
 patch_name := dbms_sqldiag.create_sql_patch(
  sql_id=>'d8yp8608d866z',
  hint_text=>'index(@DEL$1 WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1 I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST)',
  name=>'pend_del_patch');
end;
/


 以下は実際に手元の環境でこのSQLパッチを適用した後の実行計画である。HINTに指定した通り、I_WRI$_OPTSTAT_H_OBJ#_ICOL#_STの索引が使われており、かつNoteセクションにSQL patch "pend_del_patch" used for this statementとSQLパッチが使われていることがわかる。この実行計画ならOBJ#で絞り込めるため、いくら保留統計が蓄積したとしても遅延が発生することはないだろう。なお、このSQLパッチを削除するには、dbms_sqldiag.drop_sql_patch('pend_del_patch')を使えば良い。

SQL> declare
  2   patch_name varchar2(20);
  3  begin
  4   patch_name := dbms_sqldiag.create_sql_patch(
  5    sql_id=>'d8yp8608d866z',
  6    hint_text=>'index(@DEL$1 WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1 I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST)',
  7    name=>'pend_del_patch');
  8  end;
  9  /

PL/SQL procedure successfully completed.

SQL> select name,sql_text, created from dba_sql_patches;

NAME                 SQL_TEXT                                 CREATED
-------------------- ---------------------------------------- ---------------------------------------------------------------------------
pend_del_patch       delete /* QOSH:PURGE_OLD_STS *//*+ dynam 22-JUN-21 06.31.42.940687 AM
                     ic_sampling(4) */  from sys.wri$_optstat

SQL> exec dbms_stats.publish_pending_stats('SCOTT','T1');

PL/SQL procedure successfully completed.

SQL> select * from table(dbms_xplan.display_cursor('d8yp8608d866z', null, 'ALL ALLSTATS LAST'));

PLAN_TABLE_OUTPUT
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
SQL_ID  d8yp8608d866z, child number 0
-------------------------------------
delete /* QOSH:PURGE_OLD_STS *//*+ dynamic_sampling(4) */  from
sys.wri$_optstat_histgrm_history      where :1 = savtime and obj# = :2
and intcol# = nvl(:3, intcol#)

Plan hash value: 1545931696

-----------------------------------------------------------------------------------------------------------------------------------------------
| Id  | Operation                                   | Name                           | E-Rows |E-Bytes| Cost (%CPU)| E-Time   | Pstart| Pstop |
-----------------------------------------------------------------------------------------------------------------------------------------------
|   0 | DELETE STATEMENT                            |                                |        |       |     8 (100)|          |       |       |
|   1 |  DELETE                                     | WRI$_OPTSTAT_HISTGRM_HISTORY   |        |       |            |          |       |       |
|   2 |   TABLE ACCESS BY GLOBAL INDEX ROWID BATCHED| WRI$_OPTSTAT_HISTGRM_HISTORY   |     31 |  1767 |     8   (0)| 00:00:01 | ROWID | ROWID |
|*  3 |    INDEX RANGE SCAN                         | I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST |     31 |       |     6   (0)| 00:00:01 |       |       |
-----------------------------------------------------------------------------------------------------------------------------------------------

Query Block Name / Object Alias (identified by operation id):
-------------------------------------------------------------

   1 - DEL$1
   2 - DEL$1 / WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1
   3 - DEL$1 / WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1

Predicate Information (identified by operation id):
---------------------------------------------------

   3 - access("OBJ#"=:2 AND "WRI$_OPTSTAT_HISTGRM_HISTORY"."SYS_NC00018$"=SYS_EXTRACT_UTC(:1))
       filter(("INTCOL#"=NVL(:3,"INTCOL#") AND "WRI$_OPTSTAT_HISTGRM_HISTORY"."SYS_NC00018$"=SYS_EXTRACT_UTC(:1)))

Column Projection Information (identified by operation id):
-----------------------------------------------------------

   2 - (cmp=2,3,4; cpy=5) "WRI$_OPTSTAT_HISTGRM_HISTORY".ROWID[ROWID,10], "OBJ#"[NUMBER,22], "INTCOL#"[NUMBER,22], "SAVTIME"[TIMESTAMP
       WITH TIME ZONE,13], "WRI$_OPTSTAT_HISTGRM_HISTORY"."COLNAME"[VARCHAR2,128], "WRI$_OPTSTAT_HISTGRM_HISTORY"."SYS_NC00018$"[TIMESTAMP,11]
   3 - "WRI$_OPTSTAT_HISTGRM_HISTORY".ROWID[ROWID,10], "OBJ#"[NUMBER,22], "INTCOL#"[NUMBER,22],
       "WRI$_OPTSTAT_HISTGRM_HISTORY"."SYS_NC00018$"[TIMESTAMP,11], "WRI$_OPTSTAT_HISTGRM_HISTORY"."COLNAME"[VARCHAR2,128]

Hint Report (identified by operation id / Query Block Name / Object Alias):
Total hints for statement: 2
---------------------------------------------------------------------------

   1 -  DEL$1
           -  dynamic_sampling(4)

   2 -  DEL$1 / WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1
           -  index(@DEL$1 WRI$_OPTSTAT_HISTGRM_HISTORY@DEL$1 I_WRI$_OPTSTAT_H_OBJ#_ICOL#_ST)

Note
-----
   - SQL patch "pend_del_patch" used for this statement
   - Warning: basic plan statistics not available. These are only collected when:
       * hint 'gather_plan_statistics' is used for the statement or
       * parameter 'statistics_level' is set to 'ALL', at session or system level

◆まとめ


 本稿では保留統計の基本的操作方法のまとめと、保留統計取得遅延問題について記載した。保留統計遅延問題の本質を要約すると以下3点である。
 ・保留統計は蓄積するとヒストグラム削除で時間がかかることがある(仕様)
 ・上記遅延を解消するには適切にパブリッシュし保留統計を蓄積しない運用を心がけること
 ・どうしても遅延解消したい場合は、SQLパッチでチューニングは可能(自己責任)

以上