SQL Server 実行計画の読み方 — Estimated vs Actual で業務SE が最初に見る5箇所

みなさんこんにちは!ヒロポンです!

「この SQL 遅いんだけど見てくれない??」

業務SE の現場で 一番投げられがちな相談 のひとつです。

で、これを毎回 DBA に丸投げしてると、 後輩から「あの人 SQL のことよく分かってないっぽいですよね」って評判が立つ。 これ、 結構しんどい。

実は 実行計画って 5箇所だけ押さえれば80% は1次切り分けできます。 DBA レベルの細かい話は除外して、 業務SE が「SELECT 文の遅い原因をある程度推測する」 ところまで行ければ OK。

今回は SQL Server の実行計画 (Execution Plan) を、 SSMS のグラフィカル無しで読む方法を、 Docker で実走したターミナル出力つきで解説していきます。

目次

TL;DR

  • 実行計画は SET SHOWPLAN_TEXT ON でテキスト形式で取れる (sqlcmd でも OK)
  • 最初に見るのは ① Index Seek/Scan の種別・② Estimated と Actual の乖離・③ コスト%・④ Sort/Hash の登場・⑤ Key Lookup の有無 の5箇所
  • 件数が少ないテーブル (1万件以下くらい) は Index を作っても Scan に倒れる。これがハマりポイント№1

実行計画の正体 — Estimated と Actual の違い

実行計画には 2種類 あります。

  • Estimated (見積): クエリを 実行する前 に、 オプティマイザが「こう動くつもり」 と立てた計画。 統計情報を元に試算
  • Actual (実測): クエリを 実行した後 に、 実際にどう動いたかを記録した計画

業務SE が日常的に見るのは Estimated。 SHOWPLAN_TEXT で出るのもこっち。 ただし、 Estimated と Actual が大きくズレている時に問題が起きる

たとえば Estimated が「100行返ってくる予定」 でプランを組んだのに、 実際は10万行返ってきた、 みたいなケース。 オプティマイザが間違った前提でプランを選んだから、 結果として遅い。

ここで「統計情報が古いんじゃないか??」 と疑える業務SE は、 もう DBA 半人前くらいの戦力です。

SET SHOWPLAN_TEXT で実行計画を取る

SSMS のグラフィカル機能無しで、 こんな感じでテキストの実行計画が取れます。

SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM dbo.tmp_orders WHERE customer_id = 1;
GO
SET SHOWPLAN_TEXT OFF;
GO

ポイントは2つ。

  • SET SHOWPLAN_TEXT ON を投げた後の SELECT は 実行されず、 計画だけ返ってくる
  • GO で batch を区切ること。 SHOWPLAN_TEXT は batch の唯一の statement である必要がある (CREATE と同 batch に置けない)

Azure Data Studio や sqlcmd でも普通に動くので、 SSMS が現場 PC に入ってない時の救世主です。

ハンズオン #1 — Index Seek が出るケース

実際にやってみます。10000件のオーダーテーブルに、 customer_id 列のインデックスを張った状態。

-- 準備: 通常テーブル + インデックス + 1万件投入 + 統計更新
IF OBJECT_ID('dbo.tmp_orders','U') IS NOT NULL DROP TABLE dbo.tmp_orders;
CREATE TABLE dbo.tmp_orders (id INT IDENTITY PRIMARY KEY, customer_id INT, amount INT);
CREATE INDEX IX_customer ON dbo.tmp_orders(customer_id);

WITH n AS (
  SELECT TOP(10000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)) AS r
  FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT INTO dbo.tmp_orders (customer_id, amount)
SELECT (r % 1000) + 1, (r * 7) % 9999 FROM n;

UPDATE STATISTICS dbo.tmp_orders;
GO

-- 実行計画を取る
SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM dbo.tmp_orders WHERE customer_id = 1;
GO
SET SHOWPLAN_TEXT OFF;
GO

Azure SQL Edge container で実走した結果がこちら。

Index Seek の SHOWPLAN_TEXT 実行結果 (Nested Loops + Key Lookup 構造)

出てる構造は3段です。

|--Nested Loops(Inner Join, OUTER REFERENCES:([id]))
     |--Index Seek(OBJECT:([IX_customer]), SEEK:([customer_id]=(1)))
     |--Clustered Index Seek(OBJECT:([PK__tmp_orders]), SEEK:([id]=[id]) LOOKUP)

これ、 業務系で 滅茶苦茶よく見る形 です。

  • 上から2行目: Index Seek(IX_customer)customer_id=1 の行が入ってるインデックスから直接 id を引っ張る
  • 上から3行目: Clustered Index Seek ... LOOKUP取ってきた id を使って、 元テーブルから残りの列 (amount) を取りに行く
  • 一番上: その2つを Nested Loops で結合

この3段目の LOOKUPKey Lookup です。 「IX_customer」 には customer_id と クラスタキー (id) しか入ってないので、 SELECT *amount を取るには元テーブルに もう1回潜る 必要がある。 これがコストになる。

Key Lookup を消すチューニング

SELECT *SELECT customer_id, id に絞ると、 IX_customer だけで完結するので Key Lookup が消えます。 もしくは CREATE INDEX IX_customer ON dbo.tmp_orders(customer_id) INCLUDE(amount); という 被覆インデックス を作って amount も含めると、 同じく消える。

これ、 知ってるだけで現場で「あ、 こいつ実行計画読めるな」 ってなるやつです!!

ハンズオン #2 — Clustered Index Scan が出るケース

逆に インデックス無し列で絞った 場合がこちら。

IF OBJECT_ID('dbo.tmp_products','U') IS NOT NULL DROP TABLE dbo.tmp_products;
CREATE TABLE dbo.tmp_products (id INT IDENTITY PRIMARY KEY, name NVARCHAR(50), price INT);
INSERT INTO dbo.tmp_products (name, price) VALUES
  (N'apple', 100),(N'banana', 200),(N'cherry', 300),(N'durian', 400);

SET SHOWPLAN_TEXT ON;
GO
SELECT * FROM dbo.tmp_products WHERE name = N'apple';
GO
SET SHOWPLAN_TEXT OFF;
GO

実行結果がこちら。

Clustered Index Scan の SHOWPLAN_TEXT 実行結果 (実質 Table Scan)

|--Clustered Index Scan(OBJECT:([PK__tmp_products]), WHERE:([name]=[@1]))

ここで 業務SE がよく詰まるポイント があります。

「Table Scan って書いてある記事多いけど、 俺の実行計画には Clustered Index Scan としか出ない…??」

これ、 ハマりポイントなんですけど、 SQL Server 2008 以降は PRIMARY KEY が自動的に Clustered Index を作る ので、 (Heap テーブル以外は) Table Scan という表記は出ません。 代わりに Clustered Index Scan と表示される。

意味的にはほぼ一緒で、 「全行スキャンしてる」 という事実は同じ。 旧版の解説記事を見て「Table Scan を探してたのに無い…」 と混乱するのは、 ここが原因です。

ちなみに WHERE:([name]=[@1])[@1]パラメータ自動置換 (auto parameterization)。 同じクエリの再実行時にプランキャッシュが効くようにしてくれてる SQL Server の親切機能です。

最初に見る5箇所

ここからが今回の本題。 業務SE が実行計画を見た時に 最初に確認する5箇所 を順番に並べます。

① Index Seek vs Clustered Index Scan vs Table Scan

これが大原則。 Seek なら速い、 Scan なら全件走査で遅い。

3者の特性をマトリクスで並べたのが下の表です。

Index Seek / Index Scan / Table Scan の特性比較表

② Estimated rows と Actual rows の乖離

SET STATISTICS PROFILE ON で実行すると、 各演算子の Estimated と Actual の行数が こんな感じで両方出ます。

SET STATISTICS PROFILE ON;
SELECT * FROM dbo.tmp_orders WHERE customer_id = 1;
SET STATISTICS PROFILE OFF;

ここで Estimated 100、 Actual 10000、 みたいに 2桁以上ズレている 演算子があれば、 統計情報が腐ってる確率が高い。

③ 演算子のコスト割合 (Cost%)

SET STATISTICS XML ON で出る XML に各演算子の Cost% が入ってます。 全体の80%以上を占める演算子 が「真犯人」。 そこを潰すのが最短ルート。

④ Sort / Hash Match の登場

SortHash Match (Aggregate) が出てきたら、 メモリで並べ替えやハッシュ集計してる証拠。 大量データだと tempdb に溢れる ので地獄を見ます。

ORDER BY を外せる場面なら外す、 もしくは ORDER BY の列にインデックスを張る、 が定石。

⑤ Key Lookup の有無

さっきハンズオン #1 で見たやつ。 Index Seek の後に LOOKUP が出てれば、 必要列を取りに2度手間してる証拠。 INCLUDE 付きインデックスSELECT 列を絞る で消えます。

ハマりポイント (オプティマイザが裏切る理由)

ここまで「Index 作れば速い」みたいな雰囲気で書いてきましたが、 そうじゃないケース が現場で結構あります。

① 件数が少なすぎると Index を使ってくれない

これ、 さっきのハンズオン #1 で実は仕込みがあって、 「5件しか入れないと Index Seek じゃなく Clustered Index Scan になる」 んです。

オプティマイザは賢くて、 「全件スキャンしても5行しか読まないなら、 Index ジャンプするコストの方が高い」 と判定する。 だから1万件投入してやっと Index Seek が出る形にしました。

開発環境で「Index 作ったのに使ってくれない…??」 となるのは、 たいがいテストデータが少なすぎるのが原因。 本番想定のデータ量で検証するのが大事。

② 統計情報が古い

UPDATE STATISTICS テーブル名; で更新できます。 自動更新もデフォルト ON ですが、 大量 INSERT/UPDATE 直後は手動で叩いた方が確実。

俺もこれで、 ある日「夜間バッチで30倍遅くなった」 と呼び出されて、 統計情報更新したら直った、 という事故があります。 朝の3時に対応した記憶、 まだ残ってます。

③ パラメータスニッフィング

ストアドプロシージャの 初回コンパイル時のパラメータ値 で組まれたプランが、 別の値で使われた時に効かないやつ。

OPTION (RECOMPILE) で都度コンパイル、 もしくは OPTION (OPTIMIZE FOR UNKNOWN) で典型値用プランに固定、 が対処の定番です。

④ WHERE 列を関数で潰してる

-- ✗ Index 効かない
WHERE CONVERT(VARCHAR, created_at, 23) = '2026-05-15'

-- ✓ Index 効く
WHERE created_at >= '2026-05-15' AND created_at < '2026-05-16'

WHERE の左辺に関数をかけると、 Index が無視されて Scan に倒れる。 これは古典的な落とし穴です。

まとめ

実行計画の入口は SHOWPLAN_TEXT で十分。 グラフィカル無しで読めます。

見るのは5箇所:

  • Seek / Scan の種別
  • Estimated と Actual の乖離
  • コスト%
  • Sort / Hash Match の登場
  • Key Lookup の有無

これで 遅い SQL の80% はいい感じに1次切り分けできる ようになります。 残りの20% (パラスニ・統計情報・複合インデックス順序) は DBA に渡す時に「ここまでは見たんですけど…」 と材料を渡せる業務SE になれば、 ぶっちゃけ現場での信用は段違いに変わります。 これ、 効きます!!

動作確認メモ: 今回のスクリプトは Azure SQL Edge (SQL Server 2019 互換) container で全部実走済。 SHOWPLAN_TEXT は batch の唯一の statement である必要があるため、 setup と GO で区切る点だけ注意。 SHOWPLAN_TEXT は読み取り権限のあるユーザーなら本番でも安全に取れます (クエリ自体は実行されない)。

よくある質問

Q1. 実行計画ってグラフィカル (SSMS) じゃないと読めないんですか?

A. SET SHOWPLAN_TEXT ON でテキスト形式が取れます。 sqlcmd / Azure Data Studio / VS Code SQL 拡張、 ぜんぶで読めます。 むしろテキスト形式の方が diff や Git で履歴管理しやすい という利点もあります。 業務系の DBA レビューでテキストの方が好まれる現場もあるくらい。

Q2. Index Seek が出てるのに遅いんですが

A. Key Lookup が走ってる可能性が高い。 SELECT * を必要列だけに絞るか、 CREATE INDEX ... INCLUDE(必要列) で被覆インデックスを作ると消えます。 ハンズオン #1 で見た LOOKUP ORDERED FORWARD のことです。

Q3. Estimated と Actual が大きく違うのは何が原因ですか?

A. 統計情報が古いのが最多。 UPDATE STATISTICS テーブル名; で更新するとほぼ揃います。 それでも乖離するなら パラメータスニッフィング を疑う流れ。 ストアドなら OPTION (RECOMPILE) を試すのが定石です。

関連記事

以上!


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

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

コメント

コメントする

CAPTCHA


目次