SQLのOFFSET FETCHが遅いときのページング改善策、原因・判断基準・代替手法を解説

SQLのOFFSET FETCHが遅いとき、まず疑うべきは「必要な行だけを読めていない」ことです。SQL Serverの OFFSET ... FETCH はページごとにクエリを独立実行し、PostgreSQLでも OFFSET で飛ばす行はサーバー内で計算されます。深いページほど前の行を多く処理してから捨てるため、ページ番号が進むほど遅くなりやすく、本命の改善策は「一意な ORDER BY と対応インデックスを整える」「次へ/前へ中心なら keyset pagination に切り替える」「JOINや幅広い行は2段階取得にする」の3つです。 (Microsoft Learn)

特定ページへ直接ジャンプしたい運用では、OFFSET FETCH を残す価値もあります。逆に、注文履歴や監査ログのように「次へ」「前へ」が中心なら、ページ番号ベースより keyset pagination の方が速く、更新にも強くなります。この記事では、OFFSET FETCH が遅くなる理由から、向いている運用、仕様差、実装時の落とし穴まで、実務で判断しやすい形で整理します。 (Microsoft Learn)

なお、文法はDB製品で少し違います。SQL Serverは OFFSET ... FETCH、MySQLは LIMIT [OFFSET] が中心で、PostgreSQLは LIMIT/OFFSET と FETCH の両方が使えます。ただし、深いページで前半行の処理コストが膨らむという問題の本質は共通です。 (Microsoft Learn)

目次

OFFSET FETCHが遅くなる理由

OFFSET FETCH が遅くなる最大の理由は、DBが「50件だけ欲しい」と思っても、その手前にある何千・何万行を処理しないと目的の位置にたどり着けないことです。SQL Serverはページごとにクエリを再実行する前提で、PostgreSQLは OFFSET で飛ばす行もサーバー内で計算すると明記しています。つまり、OFFSET 0 は速いのに OFFSET 100000 が急に重くなるのは、珍しいことではありません。 (Microsoft Learn)

さらに重くなりやすいのが、ORDER BY を支えるインデックスがないケースです。MySQLの公式ドキュメントでも、ORDER BY をインデックスで処理できれば非常に高速になり得る一方、インデックスが使えないと filesort が必要になり、多くの一致行を拾ってから並べ替えることがあります。SQL ServerやPostgreSQLでも、実行計画上は同じように「大量に読んでから並べる」方向に寄りやすくなります。 (MySQL Developer Zone)

次のような症状が出ているなら、OFFSET FETCH の使い方を見直すタイミングです。

  • 1ページ目は速いのに、後ろのページだけ極端に遅い
  • ORDER BY updated_at のように、同値が出る列だけで並べている
  • 実行計画で Sort、Scan、filesort、一時領域の使用が目立つ
  • 一覧を更新しながらページをめくると、同じ行が再表示されたり抜けたりする

先に決めるべきこと

ランダムにページへ飛ぶのか、次へ/前へで十分なのか

改善策を選ぶ前に、UIやAPIの要件をはっきりさせることが大事です。Microsoftのドキュメントでも、keyset pagination は「次へ/前へ」中心の画面に向いており、特定ページへ直接ジャンプするランダムアクセスには OFFSET ベースが必要だと整理されています。実際には、ページ番号ジャンプを用意していても、利用者はほとんど「次へ」しか押していないことも少なくありません。 (Microsoft Learn)

並び順は本当に一意か

ORDER BY created_at DESC だけでは危険です。作成日時が同じ行が複数あれば、ページ境界で並び順が揺れます。公式ドキュメントでも、安定したページングには一意な ORDER BY が必要とされており、複数列で完全に順序を固定することが推奨されています。実務では ORDER BY created_at DESC, id DESC のように、最後に主キーや一意キーを足すのが定石です。 (Microsoft Learn)

WHEREとORDER BYに合うインデックスがあるか

ページングの索引は、「何で絞るか」と「どう並べるか」に合わせて作る必要があります。複数列で並べるなら、その順序を索引にも反映させるのが基本です。特に WHERE TenantId = ? AND Status = ? ORDER BY UpdatedAt DESC, OrderId DESC のような検索では、等価条件の列を先頭、その後ろに並び替え列を置く設計を先に疑うべきです。 (Microsoft Learn)

要件第一候補補足
次へ / 前へ中心keyset pagination深いページでも性能が落ちにくい
ページ番号へ直接ジャンプ必須OFFSET FETCH一意な並び順と索引が前提
両方必要ハイブリッド通常遷移は keyset、ジャンプだけ OFFSET
JOINが重い・行が太い2段階取得先にキーだけ取得して後から JOIN
CSV出力・夜間バッチカーソル / 主キー範囲分割一覧UIと分けて設計する

改善策1: OFFSET FETCHを残すなら「一意なORDER BY + 合うインデックス」

まずやるべきは、OFFSET FETCH を「使ってよい形」に整えることです。小さな管理画面や、ページ番号ジャンプが本当に必要な一覧なら、ここまでで十分なこともあります。重要なのは、ORDER BY を一意にすることと、その順序に沿って読める索引を用意することです。 (Microsoft Learn)

-- 改善前
SELECT *
FROM Orders
WHERE TenantId = @TenantId
  AND Status = 'PAID'
ORDER BY UpdatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

-- 改善後
SELECT OrderId, UpdatedAt, CustomerId, TotalAmount
FROM Orders
WHERE TenantId = @TenantId
  AND Status = 'PAID'
ORDER BY UpdatedAt DESC, OrderId DESC
OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
CREATE INDEX IX_Orders_Tenant_Status_UpdatedAt_OrderId
ON Orders (TenantId, Status, UpdatedAt DESC, OrderId DESC);

この形にすると、少なくとも「並び順が不安定」「ソートのために余計に重い」という問題は減らせます。特に、ページサイズが小さく、利用者が深いページまでほとんど進まない画面では、これだけで実用速度になることもあります。 (Microsoft Learn)

ただし、これはあくまで延命策です。OFFSET が大きくなるほど前半行を捨てるコストは残るので、後ろのページの遅さが課題なら、次の keyset pagination に進んだ方が効果は大きくなります。 (PostgreSQL)

改善策2: 深いページはkeyset paginationに切り替える

OFFSET FETCH が遅いときの本命は、keyset pagination(シーク方式)です。ページ番号ではなく「前ページの最後の行のキー」を覚えておき、WHERE 句でその続きから読む方法です。Microsoftのドキュメントでも、offset ではなく WHERE でスキップする keyset pagination が推奨代替案として紹介されており、適切なインデックスがあれば効率的で、前方の行に対する更新の影響も受けにくいとされています。 (Microsoft Learn)

-- 1ページ目
SELECT OrderId, UpdatedAt, CustomerId, TotalAmount
FROM Orders
WHERE TenantId = @TenantId
  AND Status = 'PAID'
ORDER BY UpdatedAt DESC, OrderId DESC
OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY;

-- 2ページ目以降
SELECT OrderId, UpdatedAt, CustomerId, TotalAmount
FROM Orders
WHERE TenantId = @TenantId
  AND Status = 'PAID'
  AND (
       UpdatedAt < @LastUpdatedAt
    OR (UpdatedAt = @LastUpdatedAt AND OrderId < @LastOrderId)
  )
ORDER BY UpdatedAt DESC, OrderId DESC
OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY;

この方式で大事なのは、トークンとして保持するのが「ページ番号」ではなく「最後に見た並び順キー一式」だという点です。主ソート列が UpdatedAt で、同値の解消に OrderId を使うなら、その両方を持たないと正しく次ページへ進めません。並び替えや絞り込み条件を変えたら、古いトークンは必ず捨てるべきです。 (Microsoft Learn)

前ページへ戻る実装は少し工夫が必要です。一般的には、現在ページの先頭キーを持って逆順で取り直し、アプリ側で並びを反転します。ここで比較演算子の向きや ASC/DESC の整合が崩れるとバグになりやすいので、昇順用・降順用で条件生成を分ける設計にしておくと安全です。

改善策3: JOINが重いなら2段階取得にする

一覧が遅い原因は、OFFSET そのものだけではないことがあります。行が太い、JOINが多い、計算列が多い、といったクエリでは、「どの行が1ページ分か」を決める前に重い処理をしていることがあります。そういうときは、まず軽い索引でページ対象のキーだけ取り、その後で本体テーブルや関連テーブルに JOIN する2段階取得が効きます。

WITH PageKeys AS (
    SELECT OrderId
    FROM Orders
    WHERE TenantId = @TenantId
      AND Status = 'PAID'
    ORDER BY UpdatedAt DESC, OrderId DESC
    OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY
)
SELECT o.OrderId, o.UpdatedAt, o.CustomerId, o.TotalAmount, c.CustomerName
FROM PageKeys pk
JOIN Orders o
  ON o.OrderId = pk.OrderId
JOIN Customers c
  ON c.CustomerId = o.CustomerId
ORDER BY o.UpdatedAt DESC, o.OrderId DESC;

この形の利点は、並び替えやページ切り出しを細い索引上で済ませやすいことです。特に SELECT * や多表JOINのまま OFFSET FETCH しているクエリには効きやすいです。

ただし、2段階取得は深い OFFSET の根本解決ではありません。後半ページの遅さが問題なら、2段階取得と keyset pagination を組み合わせる方が効きます。また、内側と外側で WHERE や ORDER BY の意味がズレるとページ内容が壊れるので、条件の重複管理にも注意が必要です。

ROW_NUMBER() への書き換えは万能ではない

OFFSET FETCH が遅いとき、ROW_NUMBER() を使ったCTEへ書き換える案はよく出ます。ただ、見た目のSQLが変わっても、手前の多数行に順位を振る必要が残るなら、深いページの遅さは本質的に解決しません。ページ番号UIをどうしても維持したいなら 2段階取得、性能を優先するなら keyset pagination の方が筋が良い場面が多いです。

改善策4: ランダムアクセスが必要ならハイブリッドにする

実務では「通常は次へ/前へで十分だが、管理者だけはページ番号ジャンプも欲しい」というケースがよくあります。この場合は、通常遷移を keyset pagination、特定ページへのジャンプだけ OFFSET FETCH にするハイブリッドが現実的です。Microsoftのドキュメントでも、next/previous には keyset、任意ページジャンプには offset を組み合わせる案が示されています。 (Microsoft Learn)

たとえば、ECサイトの注文履歴APIなら通常スクロールは keyset、社内の運用画面で「ページ100へ移動」が必要なときだけ OFFSET FETCH を使う、という分け方ができます。こうすると、普段の体感速度を落とさずに、どうしても必要な機能だけ残せます。

大量エクスポートやバッチは一覧ページングと分ける

CSV出力や夜間バッチを「深い OFFSET を何度も回す」設計で済ませるのは避けた方が安全です。SQL Serverでは OFFSET/FETCH のページングは各ページごとに独立実行され、カーソルは1回のクエリ実行でサーバー側に状態を保持します。PostgreSQLでも、カーソルは大きな結果を少しずつ読む仕組みとして説明されています。大量取得は、一覧UIのページングとは切り分けて、サーバーカーソルや主キー範囲分割で処理した方が設計しやすい場面が多いです。 (Microsoft Learn)

DB製品ごとの仕様差で見落としやすい点

SQL Server

SQL Serverの OFFSET ... FETCH は、各ページごとに独立したクエリとして実行されます。安定したページング結果を得るには、基データが変わらないこと、または snapshot/serializable の単一トランザクションで全ページ要求を実行すること、さらに ORDER BY が一意であることが条件として示されています。実際のWeb一覧で全ページを1トランザクションに閉じ込めるのは扱いが重いので、通常は一意な並び順と keyset で安定化を狙う方が現実的です。なお、パラメータ化した OFFSET/FETCH で実行プランの安定性が気になる場合、ドキュメントでは OPTIMIZE FOR ヒントも候補として触れられています。 (Microsoft Learn)

PostgreSQL

PostgreSQLは、OFFSET で飛ばした行もサーバー内で計算すると明記しています。さらに FOR UPDATE と LIMIT/OFFSET を組み合わせると、返していないのに OFFSET で飛ばした行もロックされます。ジョブキューやワーカー取り合いのような処理で OFFSET ベースのページングを流用すると、性能面でもロック面でも痛い目を見やすいポイントです。 (PostgreSQL)

MySQL

MySQLは LIMIT row_count OFFSET offset 構文をサポートしており、ページングの考え方自体は近いです。ただし、ORDER BY をインデックスで満たせれば速い一方、インデックスが使えないと filesort が入り、多くの一致行を拾ってから並べることがあります。また、同順位の値があると LIMIT の有無や実行計画によって返却順が変わり得るため、ここでも一意な tie-breaker が欠かせません。加えて、総件数取得のために SQL_CALC_FOUND_ROWS に頼る設計は、MySQL 8.0.17 で非推奨になっている点も押さえておきたいところです。 (MySQL Developer Zone)

実務で失敗しやすいポイント

  • ORDER BY UpdatedAt DESC だけで終わらせる。日時だけでは順序が揺れるので、OrderId DESC のような一意キーまで含める。 (Microsoft Learn)
  • 深いページが遅いのに、ROW_NUMBER() へ書き換えただけで安心する。見た目の変更だけでは本質改善にならないことが多い。
  • keyset のトークンに主ソートキーしか入れない。複合順序なら、すべての順序キーを保持する。
  • 絞り込み条件や並び替えを変えたのに、古いトークンを使い回す。条件が変わった時点でページ位置はリセットする。
  • キュー処理を一覧ページングと同じ感覚で作る。PostgreSQLでは OFFSET で飛ばした行もロックされ得て、MySQLでも FOR UPDATE は検査された行をロックし得るため、ワーカー取得は別パターンで設計した方が安全です。 (PostgreSQL)

迷ったらこの順で進める

まずは次の順で進めると、遠回りしにくくなります。

  1. ORDER BY に一意キーを足す
  2. WHERE と ORDER BY に合う複合インデックスを作る
  3. 1ページ目だけでなく、10ページ目・100ページ目・深いページでも計測する
  4. 後ろのページだけ遅いなら keyset pagination を試作する
  5. JOINや SELECT * が重いなら、ページキー取得と本体取得を分離する
  6. OFFSET FETCH は「本当に必要なページジャンプ」にだけ残す
  7. ページサイズは必要最小限にする

SQLのOFFSET FETCHは、悪者ではありません。小さな一覧や、ページ番号ジャンプが重要な画面では今でも実用的です。ただし、深いページ、頻繁な更新、時系列データでは弱点がはっきりしており、本命は keyset pagination です。まずは ORDER BY に一意キーを足し、対応インデックスを作り、浅いページと深いページの両方を測定してください。そのうえで遅さが残るなら、keyset かハイブリッドへ切り替えるのが最短ルートです。 (Microsoft Learn)

この記事を書いた人

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

コメント

コメントする

目次