🐢

Db2で「統計情報は最新なのにSQLが遅い」の原因を切り分ける

に公開

「統計情報は最新なのに、なぜか遅い」——Db2のSQLチューニングで、いちばん多く聞く相談かもしれません。

RUNSTATS を流しても実行計画が改善しない。インデックスを作ったのに使われない。昨日まで一瞬だったSQLが今朝から3倍かかる。これらは「統計情報が古い」という単一の原因では説明できません。実際には複数のレイヤーが絡み合っています。

この記事では、Db2で遅いSQLに出くわしたときに、統計情報 → パッケージキャッシュ → 実行計画 の順にどこを確認していけば原因にたどり着けるかを、実機(Db2 12.1)で動かしながら整理します。

STEP1:まず統計情報の「鮮度」を疑う

チューニングの出発点は、オプティマイザーが参照している統計情報が新しいかどうかです。SYSCAT.TABLES で行数(CARD)と最終取得時刻(STATS_TIME)を確認します。

SELECT TABNAME, CARD, STATS_TIME
FROM SYSCAT.TABLES
WHERE TABSCHEMA = '<スキーマ名>'
ORDER BY STATS_TIME ASC;

実機で流すと、こういう結果が返ります。

TABNAME     CARD    STATS_TIME
----------- ------- --------------------------
CL_SCHED         -1 -
EMPLOYEE         -1 -
DEPARTMENT       -1 -

ここで注目は CARD = -1。これはそのテーブルで RUNSTATS が一度も実行されていない状態です。オプティマイザーは行数すら把握できておらず、デフォルト値で当て推量の実行計画を作ります。STATS_TIME が空、あるいは何ヶ月も前なら、まずここを疑います。

インデックスやカラムのカーディナリティも同様に確認できます。

-- インデックスの統計(COLNAMESの先頭に + / - で昇降順)
SELECT INDNAME, COLNAMES, FULLKEYCARD, STATS_TIME
FROM SYSCAT.INDEXES
WHERE TABSCHEMA = '<スキーマ名>' AND TABNAME = '<テーブル名>';

-- カラムのカーディナリティ
SELECT COLNAME, COLCARD
FROM SYSCAT.COLUMNS
WHERE TABSCHEMA = '<スキーマ名>' AND TABNAME = '<テーブル名>'
ORDER BY COLNO;

RUNSTATS をいつ流すか、自動RUNSTATSを残すかどうかは別記事にまとめています。

https://zenn.dev/firese/articles/db2-runstats-reorg-timing

STEP2:統計は最新なのに遅い——4つのパターン

RUNSTATS を流して STATS_TIME が今なのに改善しない。ここからが本題です。現場で多いのは次の4パターンです。

パターン1:データに偏りがある

カーディナリティが低いカラム(ステータス区分など)に値の偏りがあると、通常の RUNSTATS では「どの値が何件あるか」まで表現できず、オプティマイザーが件数を読み違えます。分布統計を取ると改善します。

RUNSTATS ON TABLE <スキーマ>.<テーブル>
WITH DISTRIBUTION AND DETAILED INDEXES ALL;

分布統計が取れているかは SYSCAT.COLDIST で確認できます(TYPE='F' が頻度統計、'Q' が分位統計)。

SELECT COLNAME, TYPE, SEQNO, COLVALUE, VALCOUNT
FROM SYSCAT.COLDIST
WHERE TABSCHEMA = '<スキーマ>' AND TABNAME = '<テーブル>' AND TYPE = 'F'
ORDER BY COLNAME, SEQNO;

パターン2:統計は新しいが、実行計画キャッシュが古い

見落としがちなのがこれです。RUNSTATS を流しても、パッケージキャッシュに古い実行計画が残っていると、古い計画で実行され続けます。動的SQLを再コンパイルさせるにはキャッシュのフラッシュが必要です。

FLUSH PACKAGE CACHE DYNAMIC;

パターン3:複合インデックスのカラム順序が合っていない

複合インデックスは先頭カラムから順にしか使えません。WHERE句が先頭カラムを含まないと、インデックスは無視されます。実機で見たインデックスを例にすると——

COLNAMES = '+DEPT+DIV'   (先頭が DEPT、次が DIV)

WHERE DEPT = 'A'                → 使われる(先頭カラムあり)
WHERE DIV  = 1                  → 使われない(先頭 DEPT をスキップ)
WHERE DEPT = 'A' AND DIV = 1    → 使われる

「インデックスを作ったのに使われない」の多くは、この先頭カラム問題です。

パターン4:WHERE句の関数適用でインデックスが無効化される

カラムに関数を掛けると、そのカラムのインデックスは使えなくなります。

-- NG: インデックスが効かない
WHERE YEAR(created_at) = 2026

-- OK: 範囲条件に書き換えるとインデックスが効く
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'

db2exfmt を使った実行計画の読み方は本の第9章で解説しています。

STEP3:パッケージキャッシュで「遅い犯人SQL」を名指しする

「どのSQLが遅いのか」がそもそも分からない場合、パッケージキャッシュを見ればトップ nを名指しできます。まずキャッシュのヒット率から。

SELECT
    DEC(FLOAT(PKG_CACHE_LOOKUPS - PKG_CACHE_INSERTS) /
        FLOAT(PKG_CACHE_LOOKUPS) * 100, 5, 2) AS HIT_RATIO_PCT,
    PKG_CACHE_LOOKUPS, PKG_CACHE_INSERTS
FROM TABLE(MON_GET_DATABASE(-2)) AS T
WHERE PKG_CACHE_LOOKUPS > 0;

ヒット率が90%を下回っていたら、後述のリテラル値埋め込みが疑わしいです。次に、実行時間の長いSQLトップ10を取り出します。

SELECT
    SUBSTR(STMT_TEXT, 1, 80) AS SQL_TEXT,
    NUM_EXECUTIONS,
    DEC(FLOAT(STMT_EXEC_TIME) / FLOAT(NUM_EXECUTIONS), 15, 2) AS AVG_EXEC_MS,
    ROWS_READ,
    SORT_OVERFLOWS
FROM TABLE(MON_GET_PKG_CACHE_STMT(NULL, NULL, NULL, -2)) AS T
WHERE NUM_EXECUTIONS > 0
ORDER BY AVG_EXEC_MS DESC
FETCH FIRST 10 ROWS ONLY;

ROWS_READ が異常に多いSQLはフルスキャンの疑い、SORT_OVERFLOWS が出ていればソートがソートヒープに収まらずディスクにあふれています。ORDER BYROWS_READ DESC に変えれば「読みすぎているSQL」の観点でも並べ替えられます。

ここで出る値は、その文がキャッシュに載ってからの累積です。常時監視に使うなら2時点の差分を取らないと、「今遅いSQL」ではなく「これまでに時間を使ったSQL」が並びます。

https://zenn.dev/firese/articles/db2-mon-get-monitoring

STEP4:パラメーターマーカーを使う(ヒット率激減の主犯)

-- NG: 値ごとに別SQL扱い → 毎回コンパイル
SELECT * FROM orders WHERE customer_id = 12345
SELECT * FROM orders WHERE customer_id = 67890

-- OK: 1回コンパイルしてキャッシュ再利用
SELECT * FROM orders WHERE customer_id = ?

アプリ改修がすぐに難しい場合の緩和策として KEEPDYNAMIC(切断後も計画を保持)や REOPT(実値で再最適化)がありますが、いずれも状況次第で逆効果になるため、検証環境での確認が前提です。

STEP5:実行計画を実際に読む

ここまでの当たりを裏取りするのが実行計画です。まずExplainテーブルを作り(初回のみ)、EXPLAIN して db2exfmt で整形します。

-- 初回のみ:Explainテーブル作成
CALL SYSPROC.SYSINSTALLOBJECTS('EXPLAIN', 'C',
    CAST(NULL AS VARCHAR(128)), CAST(NULL AS VARCHAR(128)));

-- 対象SQLの計画を取得
EXPLAIN PLAN FOR
SELECT * FROM <スキーマ>.<テーブル> WHERE col = 'value';
db2exfmt -d <DB> -n % -s % -g TIC -w -1 -# 0 -o /tmp/explain.txt

db2exfmt の出力はこんな形です(実機の例)。

Access Plan:
    Total Cost:     6.80856
            RETURN
            FETCH
     IXSCAN    TABLE: DB2INST1

読むべきは主に3点です。

見る場所 意味 注意点
アクセス方法 IXSCAN(索引)/ TBSCAN(表スキャン) 大きい表で TBSCAN が出たら要調査
推定行数(ROWCOUNT) オプティマイザーの見積もり件数 実件数と大きくズレていれば統計が疑わしい
ソート(SORT) ソートの有無とコスト 不要なソートが混じっていないか

小さいテーブルなら TBSCAN が最適なこともあります。問題になるのは、数百万行の表に TBSCAN が選ばれているときです。

番外:昨日まで速かったSQLが突然遅くなったら

実行計画が入れ替わった可能性が高いです。原因の多くは次の3つ。

原因1:RUNSTATS が走り、オプティマイザーの判断が変わった
原因2:データ量が増え、閾値を越えて計画が切り替わった
原因3:フィックスパック適用でオプティマイザーの挙動が変わった

まとめ

確認順 見るもの 着眼点
1 SYSCAT.TABLES CARD=-1 は統計未取得。STATS_TIME が古ければ再取得
2 分布統計 / index順序 / 関数適用 統計が新しくても遅い4パターン
3 MON_GET_PKG_CACHE_STMT 遅い犯人SQLを名指しする
4 パラメーターマーカー リテラル埋め込みはヒット率を激減させる
5 EXPLAIN + db2exfmt 大表の TBSCAN・推定行数のズレ・不要ソート

📕 もっと深く:Db2運用を12章で体系化した本を書きました

この記事で扱ったSQLチューニングは、Db2運用の一領域にすぎません。

Db2はRDBMS市場でシェア数%。情報が少なく、頼れるのは膨大な公式マニュアルだけ——そんな現状を変えたくて、「なぜこの値にするのか」「この障害のとき次に何を見るのか」という、マニュアルに載っていない現場の文脈を1冊にしました。実機で検証した内容と、Db2の設計上そうなる理由をまとめました。

アーキテクチャ/構成パラメーター選定/セキュリティ/バックアップ・リカバリ/HADR/pureScale/日常点検の自動化/メモリ・ロック競合/SQLチューニング(本記事の深掘り版)/RUNSTATS・REORG/MON_GET監視/現場の難題集まで、全12章+運用シェルスクリプト集です。

https://zenn.dev/firese/books/db2-practical-bible

「統計は最新なのに遅い」以外にも、扱ってほしいテーマがあればコメントで教えてください。

Discussion