SQL Server の実務現場で「各 M(車両ID)ごとに最新の費目名を1件だけ取りたい」という “トップ1・グループごと” の要件は頻出です。しかし OUTER APPLY + TOP(1) を安易に使うと、400行程度の出力でも数分かかる遅延に直面することがあります。この記事ではボトルネックの正体と、秒単位まで短縮するための具体的な書き換え・インデックス・ヒント活用を、検証手順とともに徹底解説します。
前提と問題設定:OUTER APPLY + TOP(1) が数分かかる
対象は tFleetCars(車両台帳)と vFleetExpenses(費用ビュー)。「各 M(車両ID)について最新年月(Yr, Mth)の Name を1件だけ取得する」という要件に対し、次のような相関サブクエリで実装されていました。
OUTER APPLY (
SELECT TOP (1) Name
FROM vFleetExpenses
WHERE M = v.M
ORDER BY Yr DESC, Mth DESC
) AS d (Name)
この形は読みやすく、正しく結果も返しますが、実運用では400行の結果でも数分かかることがある――というのが相談の出発点です。サブクエリを外して単純結合すると数秒で終わるため、ボトルネックは OUTER APPLY + TOP(1) にあると判断できます。
なぜ遅くなるのか:Row Goal(行目標)と最適化の罠
TOP (1) は「少数行だけ欲しい」という意図をオプティマイザに伝えます。これをRow Goal(行目標)と呼び、しばしば次のような副作用を引き起こします。
- 過剰なネストループ:外側テーブルの行ごとに内側を検索し、「1行見つかればすぐ返す」戦略を選びがち。
- 全体最小化より“局所的にすぐ見つかる”を優先:インデックスや統計の偏り次第で、結果的に全体では遅くなる。
- ビューの複雑化:
vFleetExpensesが複雑だと、並べ替え(ORDER BY)が最下流まで押し込まれず、重いスキャンやソートが繰り返される。
| 症状 | 想定原因 | 観測ポイント |
|---|---|---|
| ネストループで内側が高回数実行 | Row Goal による戦略選択 | 実行プラン(内側反復回数 / ロジカル操作数) |
| 巨大スキャンやソート | 索引未整備・ビュー展開コスト | STATISTICS IO / TIME、ワークテーブル使用 |
| 行見積もりの外れ | 古い統計、複合条件の相関 | 実行プランの推定行数 vs 実行行数 |
最速解:ROW_NUMBER() で「最新1行」を事前に確定し、JOIN する
もっとも効果が大きく、クエリの意図も明確になるのが「ウィンドウ関数で最新行を先に確定」→「それだけを JOIN」というパターンです。実運用の測定では数分 → 約1秒に短縮しました。
;WITH LatestExpense AS (
SELECT Name, M,
ROW_NUMBER() OVER (
PARTITION BY M
ORDER BY Yr DESC, Mth DESC
) AS rn
FROM vFleetExpenses
)
SELECT v.M, v.COmb, v.Mod, v.Loc, v.Initial, v.Final, v.Kms_, v.R, v.Franq,
v.Num_Fleet, v.Pneus, v.Cli, v.Active, v.Interv,
v.Cost1, v.Cost2, e.Name,
v.Colab_Cod, col.colName
FROM tFleetCars AS v
LEFT JOIN LatestExpense AS e
ON e.M = v.M AND e.rn = 1
LEFT JOIN DB2.dbo.tCol AS col
ON col.colID = v.Colab_Cod;
ポイントは下記の通りです。
- グループ内順位付け:
PARTITION BY M ORDER BY Yr DESC, Mth DESCで「車両ごとに最新」をrn = 1へ集約。 - 適切な指数:
vFleetExpenses側に「(M, Yr DESC, Mth DESC) INCLUDE (Name)」を用意すると、ウィンドウ並べ替えの I/O が劇的に減ります(後述)。 - OUTER APPLY を排除:相関毎に
TOP(1)を繰り返す構造から、1回のスキャン+結合へ変換。
LEFT JOIN である理由
全車両を基準に「費用がない車両も出す」なら LEFT JOIN が適切です。費用がある車両だけでよいなら INNER JOIN に替えても構いません。
(代替)集約→結合:MAX() で最新年月を求めてから Name を結び付ける
MAX() は単独では他列を取れないため挫折しがちですが、「最新年月を求めてから、該当行に結び付ける」二段構えにすれば実現できます。年月が Yr と Mth に分かれているなら、比較用に「期間キー」を作ると簡潔です。
;WITH E AS (
SELECT M, Name, Yr, Mth,
PeriodKey = Yr * 100 + Mth
FROM vFleetExpenses
),
Latest AS (
SELECT M, MAX(PeriodKey) AS LatestPeriod
FROM E
GROUP BY M
)
SELECT v.M, e.Name, v.Cost1, v.Cost2
FROM tFleetCars AS v
LEFT JOIN Latest AS l
ON l.M = v.M
LEFT JOIN E AS e
ON e.M = l.M
AND e.PeriodKey = l.LatestPeriod;
同じ最新年月の行が複数あり、1行だけに絞りたいなら、ExpenseId 等でタイブレークを追加します。
;WITH E AS (
SELECT M, Name, Yr, Mth, ExpenseId,
PeriodKey = Yr * 100 + Mth
FROM vFleetExpenses
),
Latest AS (
SELECT M, MAX(PeriodKey) AS LatestPeriod
FROM E
GROUP BY M
),
PickOne AS (
SELECT M, Name, PeriodKey,
ROW_NUMBER() OVER (
PARTITION BY M, PeriodKey
ORDER BY ExpenseId DESC
) AS rn
FROM E
)
SELECT v.M, p.Name
FROM tFleetCars AS v
LEFT JOIN Latest AS l
ON l.M = v.M
LEFT JOIN PickOne AS p
ON p.M = l.M AND p.PeriodKey = l.LatestPeriod AND p.rn = 1;
(代替)NOT EXISTS パターン:より新しい行が「存在しない」行を選ぶ
ウィンドウ関数が使えない環境やポートであれば、NOT EXISTS による「反結合」で最新行を選ぶこともできます。
;WITH E AS (
SELECT M, Name, Yr, Mth, ExpenseId
FROM vFleetExpenses
)
, LatestOnly AS (
SELECT e.*
FROM E AS e
WHERE NOT EXISTS (
SELECT 1
FROM E AS newer
WHERE newer.M = e.M
AND (newer.Yr > e.Yr OR (newer.Yr = e.Yr AND newer.Mth > e.Mth))
)
)
SELECT v.M, lo.Name
FROM tFleetCars AS v
LEFT JOIN LatestOnly AS lo
ON lo.M = v.M;
最新年月で同着が複数ある場合は、ExpenseId の最大を残すなどのタイブレークを追加してください。
ヒント:Row Goal を無効化(SQL Server 2019 以降推奨)
既存の OUTER APPLY + TOP(1) 構造を大きく変えたくない場合、Row Goal を無効化するヒントが有効な場面があります。
SELECT ...
FROM tFleetCars AS v
OUTER APPLY (
SELECT TOP (1) Name
FROM vFleetExpenses
WHERE M = v.M
ORDER BY Yr DESC, Mth DESC
) AS d (Name)
OPTION (USE HINT('DISABLE_OPTIMIZER_ROWGOAL'));
これにより、相関適用の内側で「とにかく1行見つければOK」という偏った戦略を避け、より全体コスト最小のプランが選ばれる可能性が高まります。バージョンや累積更新によって挙動が異なる場合があるため、必ず実行プランと実測でアセスメントしてください。簡易な代替として OPTION (FAST 10) が効くこともありますが、根治は「事前抽出 + JOIN」や適切な索引設計です。
インデックス設計:最新1行取得を“索引だけ”で完結させる
行並べ替えと相関探索の I/O を削るカギは、並べ替え順と一致した複合インデックスです。もっとも汎用的なのは次のとおり。
-- vFleetExpenses の基になるテーブルに作成(ビューなら基表へ)
CREATE NONCLUSTERED INDEX IX_Expenses_M_Period
ON dbo.FleetExpenses (M, Yr DESC, Mth DESC)
INCLUDE (Name, ExpenseId);
さらに比較を簡潔にするため、計算列で「期間キー」を持たせると効果的です。
ALTER TABLE dbo.FleetExpenses
ADD PeriodKey AS (Yr * 100 + Mth) PERSISTED;
CREATE NONCLUSTERED INDEX IX_Expenses_M_PeriodKey
ON dbo.FleetExpenses (M, PeriodKey DESC)
INCLUDE (Name, ExpenseId);
tFleetCars 側は、結合キー (M) に加えて、選択列を INCLUDE するとルックアップを減らせます(カバリング)。
CREATE NONCLUSTERED INDEX IX_Cars_M
ON dbo.tFleetCars (M)
INCLUDE (COmb, Mod, Loc, Initial, Final, Kms_, R, Franq,
Num_Fleet, Pneus, Cli, Active, Interv, Cost1, Cost2, Colab_Cod);
ビュー vFleetExpenses に対しては、SCHEMABINDING を満たしたうえで インデックス付きビュー を検討できます。難しい場合は基表に直接索引を追加し、ビューの論理が索引を活用できるよう書き換えます。
統計・パラメータスニッフィング・再コンパイル
- 統計更新:
UPDATE STATISTICS(もしくはsp_updatestats)で最新分布を反映。特にMやPeriodKeyの分布が偏っていると推定が外れやすくなります。 - パラメータスニッフィング:特定の M の選択性に引っ張られるとプランが不安定化。必要に応じて
OPTION (RECOMPILE)、OPTIMIZE FOR UNKNOWN、プランガイド等を検討。 - カードィナリティ推定器:バージョンや互換性レベルで推定が変わります。互換性レベルの切り替えは影響範囲が大きいため、事前評価が必須です。
検証手順:再現・測定・判定を形式化する
- 計測フラグをON:
SET STATISTICS IO, TIME ON; - 問題の現行クエリ(
OUTER APPLY + TOP(1))を実行し、I/Oと経過時間、プラン(実際の実行プラン)を保存。 - ROW_NUMBER 版と集約→結合版を実行し、同様に記録。
(同一セッションでDBCC FREEPROCCACHEやOPTION (RECOMPILE)を併用し、キャッシュ影響を最小化) - 索引の有無で比較:上記の複合索引を作成・削除して差分を測定。
- Row Goal 無効化ヒントの有無で比較。
- 結果判定:I/O、CPU、経過時間、プランの安定性の観点で最終案を採用。
ケーススタディ:採用案と効果
採用案は「ROW_NUMBER() で最新1行を事前抽出 → LEFT JOIN」。これにより、従来数分かかっていたクエリが約1秒で完了しました。効果の源泉は次の3点です。
- 相関適用の反復除去:
TOP(1)を行ごとに評価する構造が消滅。 - シーケンシャルな1回の並べ替え:
PARTITION BYの並びを索引が後押し。 - 結合順序の自由度増加:オプティマイザが全体最小化しやすくなる。
| 手法 | 実装コスト | 速度 | 可読性 | 安定性 |
|---|---|---|---|---|
| OUTER APPLY + TOP(1) | 低 | 遅いことが多い | 高 | 低(Row Goal 影響) |
| ROW_NUMBER() + JOIN | 中 | 速い(最有力) | 中~高 | 高 |
| 集約(MAX)→ 結合 | 中 | 速い | 中 | 高 |
| NOT EXISTS | 中 | 中~速 | 中 | 中 |
| Row Goal 無効化ヒント | 低 | 状況次第 | 高 | 中 |
実運用の注意点:ビュー、重複、NULL、権限
- ビューの内部結合:
vFleetExpensesが複数表の結合や集計を含む場合、基表インデックスが活きないことがあります。可能ならロジックを基表側へ移し、ウィンドウ関数をCTEで完結させます。 - 重複の扱い:同一
(M, Yr, Mth)で複数レコードがあると、ROW_NUMBER のタイブレーク順序が重要です。ExpenseId DESCなど業務上の「最新」定義を明文化しましょう。 - NULL 期間:
YrやMthに NULL があると並べ替え結果が意図とずれることがあります。事前に除外や補正を入れます。 - 権限と互換性:ヒントやインデックス付きビューは権限・互換性レベルの影響を受けます。CI/CD パイプラインで環境差を吸収してください。
最終版クエリ(推奨):シンプル & 高速 & 安定
記事全体の要点を踏まえた最終版クエリは以下の通りです。ウィンドウ関数で最新行を先に確定し、LEFT JOIN で取り込むだけ。相関サブクエリは不要です。
;WITH LatestExpense AS (
SELECT M, Name,
ROW_NUMBER() OVER (
PARTITION BY M
ORDER BY Yr DESC, Mth DESC, ExpenseId DESC
) AS rn
FROM vFleetExpenses
)
SELECT v.M, v.COmb, v.Mod, v.Loc, v.Initial, v.Final, v.Kms_, v.R, v.Franq,
v.Num_Fleet, v.Pneus, v.Cli, v.Active, v.Interv,
v.Cost1, v.Cost2, e.Name,
v.Colab_Cod, col.colName
FROM tFleetCars AS v
LEFT JOIN LatestExpense AS e
ON e.M = v.M AND e.rn = 1
LEFT JOIN DB2.dbo.tCol AS col
ON col.colID = v.Colab_Cod;
索引は次の2本がコアです(基表名は環境に合わせて置換)。
CREATE NONCLUSTERED INDEX IX_Expenses_M_PeriodKey
ON dbo.FleetExpenses (M, PeriodKey DESC)
INCLUDE (Name, ExpenseId);
CREATE NONCLUSTERED INDEX IX_Cars_M
ON dbo.tFleetCars (M)
INCLUDE (COmb, Mod, Loc, Initial, Final, Kms_, R, Franq,
Num_Fleet, Pneus, Cli, Active, Interv, Cost1, Cost2, Colab_Cod);
トラブルシューティング チェックリスト
- STATISTICS IO/TIME を見て、どのテーブル・演算が支配的か。
- 実行プランでネストループの内側反復回数(Actual Number of Rows For All Executions)を確認。
- Row Goal の有無(Top 演算子)と、内側ソート/ワークテーブル発生の有無。
- 最新年月の比較を
Yr*100+Mthの計算列に置換して簡素化できるか。 - ウィンドウの
ORDER BYと一致する複合索引があるか。INCLUDE でカバリングできているか。 - ビューが複雑すぎないか。基表へロジックを押し下げられないか。
- 最新年月で同着が起きた際の業務ルール(どれを1件とするか)が明文化されているか。
- パラメータスニッフィング対策(RECOMPILE / OPTIMIZE FOR)の適用要否。
- ヒント
USE HINT('DISABLE_OPTIMIZER_ROWGOAL')の効果測定。 - Query Store でリグレッション検知・固定(プランフォース)の利用検討。
Q&A:よくある疑問
Q. TOP (1) に WITH TIES を付ければ早くなりますか?
A. WITH TIES は並び順で同着をすべて返すため、結果件数が増えます。速度改善は期待できず、要件(各 M で1件)とも矛盾します。
Q. OUTER APPLY を残したままでもチューニングできますか?
A. ヒントで Row Goal を無効にする、あるいは内側で使うビューを単純化すれば改善する場合があります。ただし根本的な再発防止の観点では、事前抽出 + JOIN が最も堅牢です。
Q. MAX() で最新年月をとった後に Name が取れないのはなぜ?
A. 集約は粒度を粗くするため、同じ SELECT で他列(Name)は一意に決まりません。最新年月を求める集約と、詳細行に戻る結合を分けて書きます。複数該当時のルール(タイブレーク)も明示しましょう。
Q. インデックスに DESC を付ける意味は?
A. 「最新から順に」という要求と同方向の並びで保持できるため、スキャン開始位置が的確になり、余計なソートや読み取りを避けられます。
まとめ
- 最速かつ安定:
ROW_NUMBER()で M ごと「最新1行」を事前抽出し、LEFT JOIN。実測で数分 → 約1秒の短縮。 - Row Goal の罠回避:
OUTER APPLY + TOP(1)は遅くなりやすい。残すならUSE HINT('DISABLE_OPTIMIZER_ROWGOAL')を検討。 - 索引が決め手:
(M, Yr DESC, Mth DESC)または(M, PeriodKey DESC)にINCLUDE(Name,...)でカバリング。 - 業務定義の見える化:同着時の1件選定ルール(
ExpenseIdなど)をクエリに明文化。 - 検証の形式化:統計更新、実行プラン、Query Store を用いて、効果と安定性を定量確認。
付録:最小再現スクリプト(ローカル検証用)
検証環境での再現・比較に使えるサンプルを掲載します。実データ量に合わせて行数を調整してください。
-- サンプルスキーマ
CREATE TABLE dbo.tFleetCars (
M int PRIMARY KEY,
COmb nvarchar(50), Mod nvarchar(50), Loc nvarchar(50),
Initial int, Final int, Kms_ int, R int, Franq money,
Num_Fleet nvarchar(20), Pneus bit, Cli nvarchar(50),
Active bit, Interv int,
Cost1 money, Cost2 money,
Colab_Cod int
);
CREATE TABLE dbo.FleetExpenses (
ExpenseId int IDENTITY(1,1) PRIMARY KEY,
M int NOT NULL,
Yr int NOT NULL,
Mth int NOT NULL,
Name nvarchar(100) NOT NULL
);
-- ダミーデータ
WITH N AS (SELECT TOP (10000) ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS n FROM sys.objects)
INSERT dbo.tFleetCars (M, COmb, Mod, Loc, Initial, Final, Kms_, R, Franq,
Num_Fleet, Pneus, Cli, Active, Interv, Cost1, Cost2, Colab_Cod)
SELECT n, 'C'||n, 'Model'||n%10, 'LOC'||(n%5), 0, 0, 0, 0, 0, 'NF'||n, 0, 'C'||(n%3), 1, 0, 0, 0, n%100
FROM N;
INSERT dbo.FleetExpenses (M, Yr, Mth, Name)
SELECT (n%10000)+1,
2018 + (n%7), 1 + (n%12),
CONCAT('EXP-', (n%100))
FROM N;
-- インデックスと計算列
ALTER TABLE dbo.FleetExpenses
ADD PeriodKey AS (Yr*100+Mth) PERSISTED;
CREATE NONCLUSTERED INDEX IX_Expenses_M_PeriodKey
ON dbo.FleetExpenses (M, PeriodKey DESC)
INCLUDE (Name, ExpenseId);
CREATE NONCLUSTERED INDEX IX_Cars_M
ON dbo.tFleetCars (M)
INCLUDE (COmb, Mod, Loc, Initial, Final, Kms_, R, Franq,
Num_Fleet, Pneus, Cli, Active, Interv, Cost1, Cost2, Colab_Cod);
-- OUTER APPLY + TOP(1)(問題例)
SET STATISTICS IO, TIME ON;
SELECT v.M, d.Name
FROM dbo.tFleetCars AS v
OUTER APPLY (
SELECT TOP (1) Name
FROM dbo.FleetExpenses AS e
WHERE e.M = v.M
ORDER BY e.Yr DESC, e.Mth DESC
) AS d;
-- ROW_NUMBER 版(推奨)
;WITH LatestExpense AS (
SELECT M, Name,
ROW_NUMBER() OVER (PARTITION BY M ORDER BY PeriodKey DESC, ExpenseId DESC) AS rn
FROM dbo.FleetExpenses
)
SELECT v.M, e.Name
FROM dbo.tFleetCars AS v
LEFT JOIN LatestExpense AS e
ON e.M = v.M AND e.rn = 1;
-- Row Goal 無効化(参考)
SELECT v.M, d.Name
FROM dbo.tFleetCars AS v
OUTER APPLY (
SELECT TOP (1) Name
FROM dbo.FleetExpenses AS e
WHERE e.M = v.M
ORDER BY e.Yr DESC, e.Mth DESC
) AS d
OPTION (USE HINT('DISABLE_OPTIMIZER_ROWGOAL'));
この最小再現でも、データ量を増やすにつれ OUTER APPLY 版のほうが顕著に遅くなり、ROW_NUMBER 版がスケールすることを確認できます。
本記事の結論
最も速く、読みやすく、運用上も安定する解は「ROW_NUMBER() + LEFT JOIN」です。OUTER APPLY + TOP(1) は便利ですが Row Goal の影響でプラン選択が不安定になりやすく、データ量や偏り次第で極端に遅くなります。どうしても既存形を残すなら Row Goal 無効化ヒントを検討し、同時に複合インデックス((M, Yr DESC, Mth DESC) もしくは (M, PeriodKey DESC))と統計更新で基盤性能を整備してください。これらを組み合わせれば、秒単位の応答と安定運用が実現できます。

コメント