業務 SE が踏む統計情報乖離 — 本番とステで実行計画が割れる時に最初に見る 3 箇所

ステは0.5秒で返ってきた SELECT が、本番だと30秒。

この記事に辿り着いた人、たぶんそんなトラブルで頭抱えてる頃やと思う。

業務系の現場でこれを踏むと、まず DBA に丸投げしたくなる。気持ちは分かる。ただ、丸投げの前に業務 SE 側で1分で見られる箇所が3つだけある。私自身、物流系の基幹で何度かこれで燃やしました。

今日は 本番とステで実行計画が割れる時の「最初に見る 3 箇所」 を、業務 SE 目線でそのまま書く。

目次

TL;DR

  • 本番だけ遅い時の真犯人は、8 割が 統計情報の鮮度ズレ。ステは小データでオプティマイザが正解を引いて、本番は古い統計でハズレを引く
  • 業務 SE が最初に見る 3 箇所: STATS_DATE (最終更新) / rowmodctr (前回更新からの変更行数) / 本番ステの実行計画 diff (EstimatedRows と ActualRows の差)
  • 解消は UPDATE STATISTICS [table] WITH FULLSCAN が基本。インデックス断片化も同時にあるなら REBUILD で副作用更新
  • 物流系基幹で 5万件→4秒の SELECT が、統計更新で 50ms まで詰まった実測ベース。業務側に電話で頭下げる前に、まずこの3箇所を1分で見る

動作確認メモ: ここで紹介する T-SQL は Azure SQL Edge (SQL Server 2019 互換・ARM64) で実機検証しています。sys.stats / STATS_DATE() / sys.dm_db_stats_properties の挙動は本番 SQL Server 2016 / 2019 でも同じです。ただし「自動更新閾値の正確な発火タイミング」は SQL Server バージョンと互換性レベルによって変動するので、そこは MSDN / Microsoft Docs の該当バージョンを別途確認してください。

💡 SQL Server の実行計画の読み方そのものは別記事 SQL Server 実行計画の読み方 — Estimated vs Actual で業務SE が最初に見る5箇所 でまとめてます。今回は その中で「Estimated と Actual がズレてる原因が統計情報乖離だった場合」の特定と解消 に絞った話です。

統計情報とは何か (業務 SE 向けの要点だけ)

SQL Server のオプティマイザは、SELECT を投げると テーブルの中身を予測 してプランを組み立てます。「この WHERE 条件だと 100 行絞れる」「あの JOIN は 1万行返す」みたいな見積もり。

その素材になっているのが 統計情報 です。

統計情報は ヒストグラム (値の分布表) と 密度ベクトル (重複具合) の2階建てで、インデックスを作るとセットで自動生成されます。中身はテーブルから サンプリングして作る のがミソ。全行スキャンせず、一部だけ見て分布を推測する省エネ実装になっている。

問題は、サンプリング元のデータが偏ったり古くなると、オプティマイザが見積もりをミスる こと。

ハズレのプランを選ぶと、本来 Index Seek で 0.1秒のクエリが Clustered Index Scan で 30秒になる、みたいな悲劇が起きます。

これが「統計情報乖離」のざっくり輪郭。意外とシンプルじゃないですか??

乖離が起きる 2 パターン

統計情報がズレる典型は2つ。業務系の現場で踏むのは、ほぼこのどちらかです。

パターン①: 大量バッチ後の鮮度落ち

夜間バッチで 100万件 INSERT した翌朝、業務開始したら SELECT が激重。よくあるやつ。

SQL Server には統計の 自動更新 がありますが、発火閾値が「テーブル行数の約 20% が変更されたら」のような相対値です。1000万行のテーブルなら、200万行動かないと発火しない。バッチで 100万件入っても、統計はまだ古いまま放置されます。

この間にオプティマイザは「行数 1000万のまま」のつもりで計画を組むので、本来 Index Seek が効くはずの SELECT を Clustered Index Scan で振っちゃう、みたいな事故が起きるんですよね。

「ステで動けば本番でも動く」って思い込み、地味に危ない!!

パターン②: サンプリング起源の偏り

統計の自動生成は 全行ではなくサンプリング で作ります。

デフォルト sample 率はテーブルサイズに応じた可変値で、大規模テーブルだと 1% 未満になることもある。

ここで困るのが、データに偏りがあるテーブル。たとえば Status 列が '完了' = 99% / 'エラー' = 1% の分布だと、サンプリング次第で 'エラー' の存在自体を見落とすことがある。結果、WHERE Status = 'エラー' の見積もりが「1行」になって、ループ JOIN で組まれて爆遅、ということが起きます。

この 2 パターンを切り分けるのが、業務 SE 側でやれる最初の仕事です。

業務 SE が最初に見る 3 箇所

DBA に丸投げする前に、SSMS で 1 分で見られる 3 つのクエリを覚えておくと得です。

① STATS_DATE() — 統計の最終更新日時

これが一番効きます。該当テーブルの統計が「いつ更新されたか」を直接見るやつ。

SELECT
    OBJECT_NAME(s.object_id) AS table_name,
    s.name                   AS stats_name,
    STATS_DATE(s.object_id, s.stats_id) AS last_updated,
    s.auto_created,
    s.no_recompute
FROM sys.stats AS s
WHERE OBJECT_NAME(s.object_id) = 'Orders'
ORDER BY last_updated;

last_updated1週間以上前 で、その間に大量バッチが走っていたなら、そこが真犯人候補。

ステ側は小データで頻繁に再生成されるので、本番との鮮度ズレが起きやすいです。

② rowmodctr — 前回更新からの変更行数

sys.sysindexesrowmodctr カラム (互換性のために残っている旧式メタデータ) で、前回統計更新から何行動いたか がざっくり見えます。

SELECT
    OBJECT_NAME(si.id) AS table_name,
    si.name            AS index_name,
    si.rowcnt,
    si.rowmodctr,
    CAST(si.rowmodctr AS FLOAT) / NULLIF(si.rowcnt, 0) AS modification_ratio
FROM sys.sysindexes AS si
WHERE OBJECT_NAME(si.id) = 'Orders'
  AND si.indid <= 1;

modification_ratio0.2 以上 (20% 超え) なら自動更新が走っているはずですが、そこに届いていない 0.15 とかでも、業務的に効いてくる場合がある。「自動更新の閾値ギリギリで放置されてる」のが見抜けます。

③ 本番ステの実行計画 diff (EstimatedRows と ActualRows)

3 点目はクエリ単位。本番とステで同じ SELECT を投げて、SSMS の「実行プランを含める」で取ったプランを比較します。

SET STATISTICS PROFILE ON;
SELECT * FROM Orders WHERE Status = 'エラー' AND OrderDate > '2026-05-01';
SET STATISTICS PROFILE OFF;

実行後に出る EstimateRows (オプティマイザの見積もり) と ActualRows (実際の行数) の差を見る。

これが 10倍以上ズレてる なら、統計情報がハズレを返している証拠。ステ側でこの差が小さければ、ほぼ統計情報乖離で確定です。

T-SQL で sys.stats 周辺を実際に見るとこんな感じ。

sys.stats と STATS_DATE / rowmodctr の実行結果 — 業務SEが最初に見る3箇所のクエリ出力

画像 ALT: 業務SEが最初に見る3箇所 — sys.stats と STATS_DATE() / rowmodctr の実行結果

判断フローをまとめると、こんな流れになります。

flowchart TD
    A[本番だけ SELECT が遅い] --> B{STATS_DATE は<br/>1週間以内?}
    B -- No --> C[① 統計が古い候補]
    B -- Yes --> D{rowmodctr/rowcnt<br/>>= 0.2?}
    D -- Yes --> C
    D -- No --> E{Estimated と Actual<br/>10倍以上ズレ?}
    E -- Yes --> F[② 統計サンプリング偏り候補]
    E -- No --> G[統計起因ではない<br/>別軸 候補へ]
    C --> H[UPDATE STATISTICS<br/>WITH FULLSCAN]
    F --> H
    H --> I[再度 SELECT 実行で<br/>計画と速度を確認]

解消手順

3 箇所の確認で「統計情報が真犯人」と判定できたら、次は実際に直す手順です。

基本は1コマンドで済みます。

UPDATE STATISTICS Orders WITH FULLSCAN;

WITH FULLSCAN を付けると、サンプリングではなく 全行スキャン で統計を作り直すので、偏りデータでも正確なヒストグラムが出ます。大規模テーブルで重いので、業務時間外に流すのが基本。

軽量に済ませたい場合は、サンプル率指定で。

UPDATE STATISTICS Orders WITH SAMPLE 50 PERCENT;

50% サンプリングでも、デフォルト (1% 前後) よりは精度が上がります。

インデックスの断片化もありそうなら、REBUILD で副作用的に統計も FULLSCAN 相当が走るので、両方やりたい時は REBUILD が一発。

ALTER INDEX ALL ON Orders REBUILD;

REBUILD と REORGANIZE + UPDATE STATISTICS の使い分けは、こんな感じの軸になります。

REBUILD vs REORGANIZE + UPDATE STATISTICS の役割比較 — 断片化解消・統計更新・ロック影響・運用時間帯の4軸比較表

画像 ALT: REBUILD / REORGANIZE / UPDATE STATISTICS 役割比較 — 断片化解消 + 統計更新 + ロック影響 + 運用時間帯

業務系の現場でやることが多いのは、こんな感じです。

  • インデックス断片化が 30% 超REBUILD (統計も同時更新される)
  • 断片化はそこそこ・統計だけ更新したいUPDATE STATISTICS WITH FULLSCAN
  • 大規模テーブルで時間制約ありUPDATE STATISTICS WITH SAMPLE 50 PERCENT で妥協

踏みやすい罠

統計情報まわりで、業務 SE が引っかかりやすい罠を3つだけ。

罠①: 自動更新が無効になっているテーブル

sys.stats.no_recompute = 1 のテーブルは、統計の自動更新がオフになっています。

DBA が意図的に設定する場合 (大規模 OLTP で自動更新の遅延を避けたい時など) もある。業務 SE 側で知らずに UPDATE STATISTICS してしまうと、翌週には別の統計がまたズレ始める、みたいなことが起きます。

罠②: 更新統計の固定化 (USE PLAN ヒント)

「とりあえず固定化すれば変動しないやろ」で USE PLAN ヒントを使う飛び道具がありますが、これは統計情報側を直さない迂回策です。

統計が乖離したまま固定プランで走り続けるので、データ量が変わると同じ固定プランがハズレ続けるリスクがある。統計を直してから固定する順番が大事。

罠③: スケールアップで誤魔化す

「本番が遅いのは CPU 不足では」と判定してインスタンスサイズを上げる。短期的には効くんですが、統計が古いままだと 数週間後にまた同じトラブル が出ます。

スケールアップは統計起因の問題に対しては、根治じゃなく対症療法。軸を間違えると金だけ溶かします。

ここから先 (SQL Server 固有の自動更新閾値の正確な発火条件、互換性レベルによる挙動差) は別ソース要確認です。Microsoft Docs の AUTO_UPDATE_STATISTICS 該当ページが出典として正確。

私の物流系基幹での失敗談

ここからは現場メモ。

数年前、物流系の基幹で「ピッキングリスト発行 SELECT が本番だけ遅い」障害票が朝イチで上がってきました。ステは 0.5秒で返ってくる、本番は 30秒。商品マスタが 2億レコード、在庫テーブルが日次で 500万件 INSERT / DELETE される構成です。

私の最初の判断は「インデックス足りない」。EXPLAIN 見ずに、NonClustered Index を1本追加して様子見しました。

結果、30秒→32秒

むしろ遅くなった。

血の気が引いて、そこから実行計画を見ました。Estimated 100行 / Actual 5万件。オプティマイザは「100行しか返らないはず」と思い込んでループ JOIN で組んでいる。実際は 5万件返ってきて、ループが回りまくっていた構図。

「これ統計か」と気付いて UPDATE STATISTICS [InventorySnap] WITH FULLSCAN を流したら、50ms に着地

5万件 4秒だった SELECT が、オプティマイザの見積もりが直って Hash Join に切り替わり、一気に速くなりました。マジで良かった。

ただ、ここで終わりじゃなくて。業務側に電話で「すみません、朝の障害は統計情報の更新漏れでした」と説明に行く時間が、技術復旧より重かったんですよね。「で、今後同じこと起きないんですか?」を3回聞かれた。

信頼回復にもう半日。技術的な復旧時間より、そっちのほうがしんどかったです。

ん?最初から実行計画見てればよかったやん??って今は思いますよ?

でもね。。当時の私は「とりあえずインデックス足す」という発想で止まっていたんですよね。

教訓は1個。本番だけ遅い時は、まず STATS_DATE と Estimated vs Actual を 1 分で見る。そしてインデックス追加とスケールアップは、統計起因を消してからの選択肢にする。

まとめ

本番とステで実行計画が割れる時、業務 SE が最初にやることはシンプル。

  • STATS_DATE で最終更新日時を見る (1週間以上前は黄信号)
  • rowmodctr / rowcnt で前回更新からの変更率を見る (20% 超なら赤信号)
  • 本番ステの実行計画 diff で Estimated と Actual のズレを見る (10倍以上で確定)

解消は UPDATE STATISTICS [table] WITH FULLSCAN が基本。インデックス断片化も同時にあるなら REBUILD。飛び道具 (USE PLAN 固定化・スケールアップ) は統計を直してからの後手で。

困ったら実行計画を読みに行く、が結局いちばん近道です。SQL Server 実行計画の読み方 — Estimated vs Actual で業務SE が最初に見る5箇所 で書いた「Estimated と Actual のズレ」を見る目を養うと、統計起因かどうかが 1 分で判定できるようになります。

よくある質問

Q1. ステは速いのに本番だけ遅い時、まず何を疑えばいいですか?

統計情報の鮮度を疑うのが最短です。本番だけ走っている大量バッチ後に統計が古くなり、オプティマイザが間違ったプランを選ぶケースが圧倒的に多い。まずは STATS_DATE() で最終更新日時を確認してください。

Q2. UPDATE STATISTICS と REBUILD は何が違いますか?

REBUILD はインデックスを作り直す副作用で統計も FULLSCAN 相当に更新されます。UPDATE STATISTICS は統計だけを更新する軽量手段。「インデックス断片化を直す必要がない・統計だけ更新したい」時は UPDATE STATISTICS を選びます。

Q3. rowmodctr って何ですか?

テーブルへの行変更 (INSERT / UPDATE / DELETE) の累積カウンタです。sys.sysindexes で取得できる旧式の指標ですが、「前回統計更新から何行動いたか」を直感的に把握できるので、業務 SE の現場確認に便利。新しい SQL Server では sys.dm_db_stats_properties でも同等情報が取れます。

Q4. なぜ自動更新だけに任せちゃダメなんですか?

自動更新の閾値が相対値 (テーブル行数の約 20%) で、大規模テーブルだと 実数として数百万行動いても発火しない ことがあるからです。業務系の夜間バッチで 100万件 INSERT 入れる運用なら、自動更新は当てにせず手動更新をバッチに組み込むのが安全。

Q5. スケールアップで CPU 増やせば直りませんか?

直りません。統計起因の遅さは「オプティマイザがハズレのプランを選んでいる」状態なので、CPU 増やしても ハズレを高速に実行するだけ。本来 0.05秒で済む SELECT を 4秒で完了する状態が、3秒に短縮される程度にしかなりません。統計を直すと一桁速くなるので、金額対効果が全然違います。

関連記事

以上!

同じ罠でハマってる人いたら、どんどんシェア待ってるぜ!!


この記事が気に入ったら
いいねしてね!

どんどんシェア待ってるぜ!!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次