ステは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_updated が 1週間以上前 で、その間に大量バッチが走っていたなら、そこが真犯人候補。
ステ側は小データで頻繁に再生成されるので、本番との鮮度ズレが起きやすいです。
② rowmodctr — 前回更新からの変更行数
sys.sysindexes の rowmodctr カラム (互換性のために残っている旧式メタデータ) で、前回統計更新から何行動いたか がざっくり見えます。
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_ratio が 0.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 周辺を実際に見るとこんな感じ。

画像 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 の使い分けは、こんな感じの軸になります。

画像 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秒に短縮される程度にしかなりません。統計を直すと一桁速くなるので、金額対効果が全然違います。
関連記事
- SQL Server 実行計画の読み方 — Estimated vs Actual で業務SE が最初に見る5箇所 — 統計情報乖離の判定は、実行計画の Estimated/Actual のズレを読むところから始まる
- SQL Server で OPTION(RECOMPILE) を脳死で付けて遅くなった話 —「とりあえず RECOMPILE」で統計問題を覆い隠そうとして悪化させた現場メモ
- SQL Server のカーソルを使うべきタイミングと書き方(業務SE現場の判断軸) — DBA 視点の SQL Server 周辺記事
以上!
同じ罠でハマってる人いたら、どんどんシェア待ってるぜ!!


コメント