大量 INSERT 後に SELECT が遅延…Oracle 統計情報の陳腐化の調査と復旧手順

夜間バッチの翌朝、「受注テーブルの検索が遅い」という連絡が来た。
昨日までは1秒もかからず返ってきていた SELECT 文が、今日は1分以上かかる。
DB は正常稼働中。アラートログに ORA エラーはなく、CPU・メモリにも異常なし…。

変わったことといえば、夜間バッチで新規注文を約20万件、一括 INSERT したことだけ。

原因は統計情報の陳腐化かもしれません。
今回は大量 DML 後にクエリが遅くなる仕組みと、EXPLAIN PLAN を起点とした調査・復旧の手順を実機ログとともに解説します。


1. 症状

大量 INSERT の後から、特定の SELECT 文だけが遅くなります。

本記事では受注テーブル orders(約31万件)を例に解説します。
ship_status 列には注文の出荷状態が入っており、「未出荷」と「出荷済」の2値です。
「未出荷の注文を抽出する SELECT」が夜間バッチの後から遅くなった、という想定です。

SQL> SET TIMING ON
SQL> SELECT * FROM orders WHERE ship_status = '未出荷';

201000行が選択されました。

経過: 00:00:01.47

※検証環境では全データがバッファキャッシュに載っているため(physical reads = 0)1.5秒で済んでいますが、本番ではデータがキャッシュに収まらずディスク I/O が発生するため、数百万件規模では分単位の遅延になります。

SQL の遅延以外に、目立った異常はありません。
障害対応の定石どおり、影響範囲の広い順(DB の死活 → アラートログ → OS リソース → セッション)で確認しますが、すべて正常です。

  • DB は正常稼働中(他業務は正常)
  • アラートログに ORA エラーなし
  • CPU・メモリ・接続数に異常なし

全体は正常で「この SELECT だけが遅い」。
つまり、インフラではなく SQL 固有の問題に絞り込めます。


2. 原因の仕組み

Oracle のオプティマイザは、統計情報をもとに実行計画を選びます
統計情報とは「テーブルに何行あるか」「ある列にどんな値がどれくらいの割合で入っているか(例:ship_status 列は「未出荷」が 1%・「出荷済」が 99%)」といった、データの中身の要約です。
DBMS_STATS で収集した時点のスナップショットであり、その後にデータが増減しても自動では更新されません。

統計収集時点:  未出荷 = 1,000 行(全体の 1%)→ インデックスが有利
              │
              ▼ 夜間バッチで新規注文(未出荷)を 20万件 一括 INSERT
              │
現在の実態:    未出荷 = 201,000 行(全体の 65%)→ フルスキャンが有利
              │
              ▼ しかし統計は古いまま
              │
オプティマイザ: 「未出荷は 1,000 行」と思い込み INDEX RANGE SCAN を選択
              → 201,000 行をインデックス経由で読んで遅くなる

陳腐化(STALE)の判定基準

Oracle はテーブルごとの DML 件数を監視しており、前回の統計収集時から 10% を超える行が変更されると STALE(陳腐化)と判定します。
この状態は DBA_TAB_STATISTICSSTALE_STATS 列で確認できます。

重要: STALE になっても実行計画は即座には変わらない

STALE はあくまで「再収集が推奨される」というフラグです。
オプティマイザは STALE かどうかに関係なく、保存されている統計値をそのまま使い続けます。
性能劣化が起きるのは、古い統計と実態の乖離によって実行計画の選択を誤ったときです。


3. 調査手順

Step 1: EXPLAIN PLAN で現在の実行計画を確認する

リアルタイム対応の最初の一手は、現在の実行計画の確認です。

EXPLAIN PLAN は「この SQL を今実行したらどの実行計画が選ばれるか」を、SQL を実行せずに確認できます。
重い SQL を流す必要がないため、本番環境でも手軽に使えるのがメリットです。

ただし、EXPLAIN PLAN が見せるのはあくまで予測です。
「実際に実行された」計画や実測値を見たい場合は、別のツールを使い分けます。

ツール見えるもの保存先使いどころ
EXPLAIN PLANこれから実行した場合の計画(予測)PLAN_TABLE(セッション限りの一時表)SQL を実行せずに確認できて手軽。調査の最初の一手
SET AUTOTRACE予測の計画 + 実測のセッション統計(consistent gets 等)PLAN_TABLE + V$ セッション統計SQL を実際に流して負荷も測りたいとき。要 PLUSTRACE ロール
DBMS_XPLAN.DISPLAY_CURSOR実際に実行された計画shared pool(キャッシュにある間だけ)実行直後の正確な確認。バインド変数の影響も反映される
AWR SQL レポート過去に実行された計画の履歴SYSAUX 表領域(永続・デフォルト8日分)「いつから計画が変わったか」の事後分析

下にいくほど情報は長く残りますが、その分リアルタイム性は下がります。
まず手元で素早く確認できる EXPLAIN PLAN から始めるのが基本です。

SQL> EXPLAIN PLAN FOR
     SELECT * FROM orders WHERE ship_status = '未出荷';
SQL> SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-------------------------------------------------------------------------------------------------------
| Id  | Operation                           | Name            | Rows  | Bytes | Cost (%CPU)| Time     |
-------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT                    |                 |  1000 | 27000 |    12   (0)| 00:00:01 |
|   1 |  TABLE ACCESS BY INDEX ROWID BATCHED| ORDERS          |  1000 | 27000 |    12   (0)| 00:00:01 |
|*  2 |   INDEX RANGE SCAN                  | IDX_ORDERS_SHIP |  1000 |       |     3   (0)| 00:00:01 |
-------------------------------------------------------------------------------------------------------

実行計画は、インデントが深い行(内側)から外側に向かって読みます。
今回は次の順で処理されます。

  1. Id 2(INDEX RANGE SCAN) — インデックス IDX_ORDERS_SHIP をたどり、ship_status='未出荷' に該当する行の位置(ROWID)を拾う
  2. Id 1(TABLE ACCESS BY INDEX ROWID) — 拾った位置をもとに、ORDERS テーブル本体から行データを取得する
  3. Id 0(SELECT STATEMENT) — 取得した行を結果として返す

確認ポイントは2つです。

確認ポイント見方見る行
Rows(見積もり行数)実際の件数(COUNT で確認)と大きく乖離していないか最も内側の行(今回は Id 2)
Operationデータ量に対して不自然なアクセスパスになっていないかテーブルにアクセスする行(今回は Id 1・Id 2)

最初にデータを絞り込む「最も内側の行」の Rows がズレていると、その上の行すべての判断が狂います。
今回は Id 2 で「1,000 行ヒットする」と見積もって INDEX RANGE SCAN を選んでいます。
実際の件数を COUNT で確認すると:

SQL> SELECT COUNT(*) FROM orders WHERE ship_status = '未出荷';

  COUNT(*)
----------
    201000

実際は 201,000 行で、テーブル全体(約31万件)の 65% を占めます
見積もりと実態は約 200 倍の乖離です。
ヒットする行がテーブルの大部分を占める場合は、インデックスで1行ずつたどるよりテーブルを先頭から全件読む(フルスキャン)ほうが効率的です。
本来フルスキャンが選ばれるべき状況で、インデックス経由の大量読み込みが発生しています。
この「Rows と実態の乖離」が見つかったら、次は統計情報を疑います。

Step 2: STALE_STATS で統計の陳腐化を確認する

Rows と実態の乖離が見つかったら、統計情報の状態を DBA_TAB_STATISTICS で確認します。
見るのは次の3列です。

意味
NUM_ROWS統計上の行数(オプティマイザが参照する値)
LAST_ANALYZED最後に統計を収集した日時
STALE_STATS陳腐化フラグ(10% 超の DML で YES)
SQL> SELECT table_name, num_rows, last_analyzed, stale_stats
     FROM dba_tab_statistics
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名';

TABLE_NAME    NUM_ROWS LAST_ANALYZED       STALE_S
----------- ---------- ------------------- -------
ORDERS          100000 2026-06-10 21:15:29 YES

STALE_STATS = 'YES' なら陳腐化しています。
NUM_ROWS(統計上の行数)と実際の行数を比べると、どれだけ実態とズレているかを目で確認できます。

SQL> SELECT COUNT(*) FROM orders;

  COUNT(*)
----------
    311000

統計上は 100,000 行、実際は 311,000 行。
LAST_ANALYZED で最後に統計を取得した日時も確認できます。

インデックス統計も同様に確認します。
STALE の判定はテーブル単位の DML 監視に基づくため、テーブルが STALE になると、そのテーブルに属するインデックスの統計も同時に STALE になります

SQL> SELECT index_name, num_rows, last_analyzed, stale_stats
     FROM dba_ind_statistics
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名';

INDEX_NAME        NUM_ROWS LAST_ANALYZED       STA
--------------- ---------- ------------------- ---
SYS_C008356         100000 2026-06-10 21:15:29 YES
IDX_ORDERS_SHIP     100000 2026-06-10 21:15:29 YES

注意: 大量 DML の直後は STALE_STATS が NO のままに見えることがある

判定材料である「変更件数」が2段階で記録されているためです。

INSERT/UPDATE/DELETE を実行
        │
        ▼
変更件数が SGA(メモリ)上のカウンタに記録される
        │
        ▼ SMON が随時ディクショナリへ書き出し(即時ではない)
        │
データディクショナリに反映 ← STALE_STATS はここを見て判定

大量 INSERT の直後は、カウンタがまだメモリ上にあって判定に反映されていないことがあります。
このタイミングの STALE_STATS = NO を信じると「統計は陳腐化していない」と誤った切り分けをしてしまいます。
直後に確認する場合は、先に書き出しを強制実行してください。

SQL> EXEC DBMS_STATS.FLUSH_DATABASE_MONITORING_INFO;

書き出されるのはテーブルごとの変更件数の集計値だけなので、ごく軽量で本番環境でも安全に実行できます(DML の件数には比例しません)。

なお、書き出された変更件数は DBA_TAB_MODIFICATIONS で確認できます。
「何件変わったから STALE なのか」の裏取りに使えます。

SQL> SELECT table_name, inserts, updates, deletes
     FROM dba_tab_modifications
     WHERE table_owner = 'スキーマ名';

Step 3: 統計の履歴を確認する(参考)

Oracle は統計収集のたびに旧バージョンを自動保存しています(デフォルト31日間)。
保持期間は以下で確認できます。

SQL> SELECT DBMS_STATS.GET_STATS_HISTORY_RETENTION FROM dual;

GET_STATS_HISTORY_RETENTION
---------------------------
                         31

いつ統計が更新されたかの履歴は DBA_TAB_STATS_HISTORY で確認できます。

SQL> SELECT stats_update_time
     FROM dba_tab_stats_history
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名'
     ORDER BY stats_update_time DESC;

STATS_UPDATE_TIME
---------------------------------------------------------------------------
26-06-10 21:26:38.556311 +09:00
26-06-10 21:15:29.738894 +09:00
26-06-10 21:14:04.044420 +09:00

「夜間に自動統計収集ジョブが走って計画が変わった」パターンの切り分けにも使えます。

補足:自動統計収集ジョブとは

Oracle には、STALE になったテーブルの統計を自動で収集する「自動統計収集ジョブ」が標準で組み込まれています。
既定では夜間のメンテナンスウィンドウで動きます。

セクション2で「統計は自動更新されない」と説明しましたが、正確には DML の瞬間には更新されず、その後のジョブのタイミングで最新化される という動きです。
そのため「夜間に統計が更新され、翌朝から実行計画が変わっていた」というケースがあり、DBA_TAB_STATS_HISTORY の更新時刻でこれを切り分けられます。


4. 復旧手順

方法A: GATHER_TABLE_STATS で最新統計を取得する(正攻法)

統計が古い・未取得の場合の正攻法です。
DBMS_STATS.GATHER_TABLE_STATS で対象テーブルの統計を再収集し、実態に合った値に更新します。

SQL> EXEC DBMS_STATS.GATHER_TABLE_STATS('スキーマ名', 'テーブル名', CASCADE => TRUE);

PL/SQLプロシージャが正常に完了しました。

CASCADE => TRUE でインデックスの統計も同時に収集します。
収集後、テーブル・インデックスの両方で STALE_STATS が NO になっていることを確認します。

SQL> SELECT table_name, num_rows, last_analyzed, stale_stats
     FROM dba_tab_statistics
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名';

TABLE_NAME    NUM_ROWS LAST_ANALYZED       STALE_S
----------- ---------- ------------------- -------
ORDERS          311000 2026-06-10 21:26:38 NO

SQL> SELECT index_name, num_rows, last_analyzed, stale_stats
     FROM dba_ind_statistics
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名';

INDEX_NAME        NUM_ROWS LAST_ANALYZED       STA
--------------- ---------- ------------------- ---
SYS_C008356         311000 2026-06-10 21:26:38 NO
IDX_ORDERS_SHIP     311000 2026-06-10 21:26:38 NO

テーブルと2つのインデックスが同一時刻に更新され、num_rows も実態(311,000)に揃いました。
実行計画も実態に合ったものに切り替わります。

SQL> EXPLAIN PLAN FOR
     SELECT * FROM orders WHERE ship_status = '未出荷';
SQL> SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

----------------------------------------------------------------------------
| Id  | Operation         | Name   | Rows  | Bytes | Cost (%CPU)| Time     |
----------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |        |   200K|  5299K|   381   (1)| 00:00:01 |
|*  1 |  TABLE ACCESS FULL| ORDERS |   200K|  5299K|   381   (1)| 00:00:01 |
----------------------------------------------------------------------------

見積もりが Rows=200K(実態どおり)になり、TABLE ACCESS FULL が選択されました。

方法B: RESTORE_TABLE_STATS で過去の統計に戻す(切り戻し)

逆に「統計を収集した直後から遅くなった」場合は、過去の統計バージョンへの切り戻しが有効です。

まず、どの時点の統計に戻せるかを履歴から確認します。

SQL> SELECT stats_update_time
     FROM dba_tab_stats_history
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名'
     ORDER BY stats_update_time DESC;

STATS_UPDATE_TIME
---------------------------------------------------------------------------
26-06-10 21:26:38.556311 +09:00   ← 直近の収集(この後から遅くなった)
26-06-10 21:15:29.738894 +09:00   ← 戻したい統計(問題発生前)
26-06-10 21:14:04.044420 +09:00

as_of_timestamp には「その時点で有効だった統計」を指す時刻を指定します。
今回は 21:26:38 の収集後から遅くなった想定なので、その直前(21:26:00)を指定して、ひとつ前の統計(21:15:29 収集分)に戻します。

SQL> EXEC DBMS_STATS.RESTORE_TABLE_STATS( -
       ownname => 'スキーマ名', -
       tabname => 'テーブル名', -
       as_of_timestamp => TO_TIMESTAMP('2026-06-10 21:26:00', 'YYYY-MM-DD HH24:MI:SS'))

PL/SQLプロシージャが正常に完了しました。

実行後、統計が巻き戻ったことを確認します。

SQL> SELECT table_name, num_rows, last_analyzed, stale_stats
     FROM dba_tab_statistics
     WHERE owner = 'スキーマ名' AND table_name = 'テーブル名';

TABLE_NAME    NUM_ROWS LAST_ANALYZED       STALE_S
----------- ---------- ------------------- -------
ORDERS          100000 2026-06-10 21:15:29 NO

対処法の使い分け

方法タイミング
GATHER_TABLE_STATS統計が古い・未取得の場合の正攻法
RESTORE_TABLE_STATS統計取得後に性能劣化した場合の切り戻し
ヒント句緊急回避(アプリ変更が必要)
SPM(SQL Plan Management)恒久的な計画固定

ヒント句と SPM は本記事の手順では使いませんが、選択肢として知っておくと対処の幅が広がります。

ヒント句:SQL 文中に /*+ FULL(orders) */ のようなコメント形式の指示を埋め込み、実行計画を強制する方法です。
即効性がありますが、SQL そのものを書き換えるためアプリの修正が必要で、原因(統計)を直した後も指示が残り続けます。
統計をすぐに直せない事情があるときの緊急回避と位置づけてください。

SPM(SQL Plan Management):検証済みの実行計画を「ベースライン」として DB に登録し、オプティマイザがそれ以外の計画を勝手に選ばないようにする仕組みです。
アプリの変更は不要で、統計が変動しても実行計画が暴れなくなります。
計画変動による性能劣化を繰り返したくない重要 SQL への恒久対策です。


5. Q&A

Q: 統計が STALE になると、すぐに実行計画が変わるのですか?

A: 変わりません。
オプティマイザは STALE かどうかに関係なく、保存されている統計値をそのまま使い続けます。
STALE は「再収集が推奨される」というフラグにすぎません。
実機検証でも、10% 超の INSERT で STALE=YES になった直後の実行計画は変化しませんでした。
性能劣化が起きるのは、古い統計と実態の乖離によって実行計画の選択を誤ったときです。

Q: STALE_STATS を確認しても YES になりません。なぜですか?

A: DML 監視情報(変更件数)がまだメモリからデータディクショナリに書き出されていない可能性があります。
判定は書き出し後に反映されるため、即時確認したい場合は先に以下を実行してください。

SQL> EXEC DBMS_STATS.FLUSH_DATABASE_MONITORING_INFO;

Q: 統計を収集したのに、見積もり(Rows)が実態と合いません。

A: ヒストグラム(値の偏りを記録した統計)が作られていない可能性があります。

基本の統計でオプティマイザが知っているのは「テーブルの行数」と「列の値の種類数」までです。
偏りを知らないオプティマイザは「2種類なら半々」と均等分布を仮定します。
実機検証(10万行・未出荷1%)でも、ヒストグラムがない状態では未出荷を 50,000 行(50%)と見積もり、フルスキャンが選ばれました。

ヒストグラムは値ごとの件数を記録する統計で、作成と利用のタイミングは次のとおりです。

【統計収集時】
その列に WHERE 句で使われた実績がある + データに偏りがある
        ↓ 両方を満たす列にだけ
ヒストグラム作成(値ごとの件数を記録)

【SQL 実行時】
WHERE ship_status = '未出荷'
        ↓
ヒストグラム参照 → 「1,000 行(1%)」と正確に見積もり
        ↓
正確な見積もり → 適切なアクセスパス選択

注意が必要なのは【統計収集時】の条件です。
「テーブル作成 → データ投入 → 一度もクエリせず統計収集」という流れでは WHERE 句の使用実績がないため、ヒストグラムが作られません。
対象のクエリを一度実行してから統計を再収集すれば、ヒストグラムが作成されて見積もりが実態に合います。

なお、この挙動(デフォルト設定 METHOD_OPT => 'FOR ALL COLUMNS SIZE AUTO' の動作)は Oracle 公式ドキュメント「SQL チューニング・ガイド」のヒストグラムの章に記載されています。
列ごとの使用実績は DBMS_STATS.REPORT_COL_USAGE('スキーマ名', 'テーブル名') で確認できます。

Q: SET AUTOTRACE でも実行計画が見られますが、EXPLAIN PLAN と何が違いますか?

A: AUTOTRACE の出力は2つの合成です。
「実行計画」セクションは EXPLAIN PLAN と同じ予測(内部で PLAN_TABLE を使用)、「統計」セクション(consistent gets など)は SQL を実際に実行した実測値です。
実測の負荷を測りながら計画も確認したいときに便利ですが、計画部分は予測のため、バインド変数を使う SQL などでは実際に使われた計画と食い違うことがあります。
その場合でも統計セクションの数値は「実際に動いた計画の実測値」です。
実際に使われた計画を保証したい場合は DBMS_XPLAN.DISPLAY_CURSOR を使ってください。
なお、統計セクションの表示には PLUSTRACE ロールが必要です。

Q: RESTORE_TABLE_STATS はどこまで遡れますか?

A: デフォルトで31日分です。
Oracle は統計収集のたびに旧バージョンを自動保存しており、保存期間は DBMS_STATS.GET_STATS_HISTORY_RETENTION で確認、ALTER_STATS_HISTORY_RETENTION で変更できます。
履歴は DBA_TAB_STATS_HISTORY で確認できます。


6. まとめ

大量 DML 後にクエリが遅くなったら、まず EXPLAIN PLAN で現在の実行計画を確認し、見積もり(Rows)と実態の乖離を見る。
乖離があれば STALE_STATS で統計の陳腐化を確認し、GATHER_TABLE_STATS で再収集する。
これが基本の流れです。

対応フロー

大量 DML 後に特定の SELECT が遅い
        │
        ▼
EXPLAIN PLAN で現在の実行計画を確認
  → Rows の見積もりと実際の件数(COUNT)を比較
        │
   乖離あり?
        │
   YES  ▼
DBA_TAB_STATISTICS で STALE_STATS / LAST_ANALYZED を確認
        │
   STALE = YES?
        │
   YES  ▼
GATHER_TABLE_STATS で最新統計を取得
  (統計収集後に劣化した場合は RESTORE_TABLE_STATS で切り戻し)
        │
        ▼
EXPLAIN PLAN で実行計画が実態に合ったことを確認 → 復旧完了
        │
        ▼
再発防止: 大量バッチの直後に統計収集をジョブへ組み込む