SQL ServerのOUTER APPLY+TOP(1)が遅い原因と最速解:ROW_NUMBER・インデックス・ヒントで秒単位にする実践チューニング

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、プランガイド等を検討。
  • カードィナリティ推定器:バージョンや互換性レベルで推定が変わります。互換性レベルの切り替えは影響範囲が大きいため、事前評価が必須です。

検証手順:再現・測定・判定を形式化する

  1. 計測フラグをON:SET STATISTICS IO, TIME ON;
  2. 問題の現行クエリ(OUTER APPLY + TOP(1))を実行し、I/Oと経過時間、プラン(実際の実行プラン)を保存。
  3. ROW_NUMBER 版と集約→結合版を実行し、同様に記録。
    (同一セッションで DBCC FREEPROCCACHE や OPTION (RECOMPILE) を併用し、キャッシュ影響を最小化)
  4. 索引の有無で比較:上記の複合索引を作成・削除して差分を測定。
  5. Row Goal 無効化ヒントの有無で比較。
  6. 結果判定:I/O、CPU、経過時間、プランの安定性の観点で最終案を採用。

ケーススタディ:採用案と効果

採用案は「ROW_NUMBER() で最新1行を事前抽出 → LEFT JOIN」。これにより、従来数分かかっていたクエリが約1秒で完了しました。効果の源泉は次の3点です。

  1. 相関適用の反復除去:TOP(1) を行ごとに評価する構造が消滅。
  2. シーケンシャルな1回の並べ替え:PARTITION BY の並びを索引が後押し。
  3. 結合順序の自由度増加:オプティマイザが全体最小化しやすくなる。
手法実装コスト速度可読性安定性
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))と統計更新で基盤性能を整備してください。これらを組み合わせれば、秒単位の応答と安定運用が実現できます。


この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次