IPA|情報処理技術者試験

【SQL】OFFSET/LIMITの罠|ページングのデータ重複/欠落を防ぐ「安定ソート」の重要性をDB試験対策向けに解説






OFFSET/FETCHによるページング処理と安定ソート(仮)


Webサイトやアプリケーションで当たり前のように使われている「次のページへ」というページング機能。多くの開発現場では、SQLのOFFSET句やLIMIT句を使ってこの機能を実装します。しかし、その使い方を誤ると、「1ページ目に表示されたデータが2ページ目にも重複して現れる」「本来表示されるべきデータがどこにも表示されない」といった、データの表示欠落や重複を招く致命的なバグの原因となり得ます。

この問題の核心にあるのが、ORDER BY句によるソートが安定的(Stable)であるかどうかという、データベーススペシャリト試験や応用情報技術者試験の午後問題でも問われる重要な概念です。一見すると正しく動作しているように見えても、特定のデータが登録された途端にシステムは破綻するかもしれません。

本記事では、この「ページング処理」と「安定ソート」というテーマを深掘りします。OFFSET/FETCH/LIMIT句の基本的な役割から説き起こし、なぜソートキーの重複が問題を引き起こすのか、そのメカニズムを図解と具体的なSQL例で徹底解説。そして、いかなるデータが来ても破綻しない、堅牢なページング処理を実装するための「唯一の原則」を学びます。この記事を読み終える頃には、曖昧だった知識が整理され、自信を持ってページング機能を設計・実装できるようになるでしょう。


目次

ページング処理の基本とSQL構文│OFFSET, FETCH, LIMIT句の役割

Webアプリケーション開発において、大量のデータを一度にユーザーへ見せるのは現実的ではありません。例えば、ECサイトで10万点の商品を1ページに表示すれば、ページの読み込みは遅くなり、ユーザーは目当ての商品を探すことすら困難になります。そこで不可欠となるのが、データを適切な単位に分割して表示するページング処理です。

なぜORDER BYがページングの生命線なのか?

ページングを実装する際、多くのエンジニアがLIMIT句(取得件数の指定)とOFFSET句(開始位置の指定)を使います。しかし、ここで絶対に忘れてはならないのがORDER BY句によるソート順の指定です。

なぜなら、リレーショナルデータベース(RDB)において、ORDER BYを指定しない限り、データの取得順序は保証されないからです。これは「DBのご機嫌次第で順番が変わる」と覚えておきましょう。もし順序が保証されなければ、ユーザーが「次のページ」ボタンを押した瞬間にデータの並び順が変わり、ページングは完全に破綻します。

状況 身近な例え
ORDER BYあり 「出席番号順に並んでください」という指示。何度並び直しても順番は同じ。
ORDER BYなし 「適当に並んでください」という指示。並ぶたびに順番がバラバラになる。

この「順序の不定性」が、ページングにおいていかに致命的か、ご理解いただけたでしょうか。

ページングを実現するSQL句の役割

では、ORDER BYと組み合わせて使う代表的なSQL句の役割を整理します。これらはデータベース製品によって使える句が異なりますが、機能はほぼ同じです。

役割 対応DB(例)
LIMIT {件数} 取得する行数を制限します。「1ページに何件表示するか」を決めます。 MySQL, PostgreSQL
OFFSET {行数} 指定した行数分をスキップしてからデータ取得を開始します。「何件目から表示を開始するか」を決めます。 MySQL, PostgreSQL
FETCH FIRST {件数} ROWS ONLY LIMIT句とほぼ同じ役割を持つ標準SQLの構文です。 Oracle, DB2, PostgreSQL

【SQLコード例:3ページ目を10件表示する場合】

OFFSETを使った一般的なページング処理のSQLを見てみましょう。3ページ目ということは、(3-1)ページ × 10件 = 20件をスキップして、そこから10件取得することになります。

-- 社員テーブルから、給与(salary)が高い順に並べ替え
-- 3ページ目(21件目~30件目)の10件を取得する

SELECT
  employee_id,
  employee_name,
  salary
FROM
  employees
ORDER BY
  salary DESC -- ★ まずは必ず並び順を固定する
LIMIT
  10 -- ★ そこから10件取得する
OFFSET
  20; -- ★ 最初の20件はスキップする

このORDER BY, LIMIT, OFFSETの3点セットが、ページング処理の基本形です。しかし、実はこのSQLにもまだ重大な欠陥が潜んでいます。次の章で、このコードがなぜ危険なのか、その核心に迫っていきましょう。


なぜページングは崩れるのか?安定ソート(Stable Sort)の重要性

前の章で見たORDER BY salary DESC, LIMIT 10, OFFSET 20というSQLは、一見すると完璧なページング処理に見えます。しかし、ソートキーであるsalaryに同額のデータが存在した瞬間、このコードはデータ重複や欠落を引き起こす時限爆弾へと変わります。この現象を理解する鍵が、安定ソート(Stable Sort)です。

問題の核心:ソートキーの重複が引き起こす順序の不確定性

SQLのORDER BY句は、キーが重複するデータ間の順序を保証しません。例えば、「給与が高い順」と命令しても、給与が全く同じ30万円の社員A, B, Cがいた場合、この3名がA,B,Cの順で返ってくるか、C,A,Bの順で返ってくるかは、データベースの内部的な動作(実行計画など)に依存し、実行のたびに変動する可能性があります。

これが「ソートが安定的でない」状態です。

「安定ソート」とは何か?身近なトランプの例

安定ソートとは、「ソートキーが同じ値を持つデータ同士の、ソート前における順序が、ソート後も維持される」という性質のことです。

言葉では難しいので、トランプで考えてみましょう。手元に「♠7, ♥K, ♦7, ♣A」の4枚のカードがあるとします。これをまず「数字」でソートします。

  • 不安定なソート (Unstable Sort)
    • 結果:♦7, ♠7, ♥K, ♣A
    • ソートキーである「7」は同じですが、元の順序(♠7が先、♦7が後)が入れ替わってしまいました。
  • 安定なソート (Stable Sort)
    • 結果:♠7, ♦7, ♥K, ♣A
    • 「7」同士の元の順序(♠7が先、♦7が後)が、ソート後もきちんと維持されています。

SQLのORDER BYは、単一のキーだけを指定した場合、この安定性を保証してくれないのです。

ページングが崩壊するメカニズム【図解】

では、この不安定なソートが、どのようにページングを破壊するのかを見ていきましょう。

【シナリオ】

  • テーブル: 社員テーブル
  • 要件: 給与(salary)が高い順に、1ページ2件ずつ表示する
  • データ: 同額給与(280万円)の社員が複数存在する
employee_id employee_name salary
1 佐藤 300
2 鈴木 280
3 高橋 280
4 田中 280
5 伊藤 250

【1ページ目の表示要求】 (OFFSET 0)

ユーザーが1ページ目にアクセス。DBはORDER BY salary DESCを実行し、気まぐれに以下の順序を生成して、頭から2件を返します。
DB内部の順序: 佐藤(300)鈴木(280)高橋(280)田中(280)伊藤(250)
ユーザーへの表示: 1. 佐藤 (300), 2. 鈴木 (280)

【2ページ目の表示要求】 (OFFSET 2)

ユーザーが「次へ」ボタンをクリック。再びDBはORDER BY salary DESCを実行しますが、今度は別の順序を生成してしまいます(280万円の社員の順序が入れ替わった)。
DB内部の順序: 佐藤(300)高橋(280)鈴木(280)田中(280)伊藤(250)
ユーザーへの表示: (先頭2件をスキップして) 3. 鈴木 (280), 4. 田中 (280)

【発生した問題】

  • データ重複: 鈴木さんが1ページ目と2ページ目の両方に表示されてしまいました。
  • データ欠落: 高橋さんは、本来表示されるべきだったにも関わらず、どこにも表示されませんでした。

このように、ページをめくるという別々のSQL実行の間でORDER BYの結果順序に一貫性がないため、OFFSET(スキップする行数)の基準がずれてしまい、ページングが崩壊するのです。これはWebシステムの信頼性を揺るがす致命的な不具合と言えるでしょう。


唯一の解決策:ORDER BY句でソート順を一意に確定させる方法

ページング処理が崩壊する根本原因は、ORDER BY句によるソート結果が一意に定まらない「不安定さ」にありました。ならば、解決策はただ一つ。いかなる状況でもソート順序が一意に定まるようにORDER BY句を記述することです。

安定ソートを実現する絶対的な原則

その原則とは、「ORDER BY句の指定列に、最終的に結果セットの順序をユニーク(一意)に決定できる列を追加する」ことです。

多くの場合、これはテーブルの主キー(Primary Key)を追加することを意味します。主キーはテーブル内で必ずユニークな値を持つため、それを含めてソートすれば、たとえ他のソートキー(給与、更新日時など)が重複していても、最終的な表示順序は常に一意に確定します。

順序が常に同じであれば、何度SQLを実行してもOFFSETでスキップする行の内容は変動せず、ページングは正しく機能します。

具体的なSQL修正例:主キーを第2ソートキーに追加する

それでは、前の章で問題が発生したSQLを修正してみましょう。employeesテーブルの主キーはemployee_idであるとします。

【問題があったSQL】

-- 給与が同じだと順序が不定になる
SELECT employee_id, employee_name, salary
FROM employees
ORDER BY salary DESC
LIMIT 2 OFFSET 2;

【修正後のSQL】

-- 給与が同じ場合は、employee_idの昇順で並べる
SELECT employee_id, employee_name, salary
FROM employees
ORDER BY
  salary DESC,      -- 第1ソートキー:給与
  employee_id ASC   -- 第2ソートキー:主キー
LIMIT 2 OFFSET 2;

このemployee_id ASCという一行を追加するだけで、SQLは絶対的な安定性を手に入れます。

修正後SQLによる正しいページング動作の検証

同じデータを使って、修正後のSQLがどのように動作するかを追ってみましょう。

employee_id employee_name salary
1 佐藤 300
2 鈴木 280
3 高橋 280
4 田中 280
5 伊藤 250

【DB内部で確定する唯一の順序】

ORDER BY salary DESC, employee_id ASCにより、DB内部の順序は常に以下のように確定します。給与が280で同じ「鈴木、高橋、田中」は、employee_idの昇順(2, 3, 4)で並びます。

確定した順序:
1. 佐藤 (salary: 300, id: 1)
2. 鈴木 (salary: 280, id: 2)
3. 高橋 (salary: 280, id: 3)
4. 田中 (salary: 280, id: 4)
5. 伊藤 (salary: 250, id: 5)

【ページアクセス時の動作】

  • 1ページ目 (OFFSET 0): 上記の順序から先頭2件 [佐藤, 鈴木] を返す。
  • 2ページ目 (OFFSET 2): 上記の順序の先頭2件をスキップし、次の2件 [高橋, 田中] を返す。
  • 3ページ目 (OFFSET 4): 上記の順序の先頭4件をスキップし、次の1件 [伊藤] を返す。

結果、データの重複や欠落は一切発生しません。実務においては、「更新日時順」「登録日時順」などで並び替える要件が頻出しますが、これらのカラムも値が重複する可能性は十分にあります。どのような場合でも、ソート条件の最後に主キーを追加することを徹底すれば、堅牢なページング処理を実装できます。


発展:OFFSET句のパフォーマンス問題とカーソルページネーションという選択肢

安定ソートの問題を解決し、これでページング処理は完璧に見えます。しかし、大規模なデータを扱うシステムでは、OFFSET句そのものが**パフォーマンスのボトルネック**になるという、もう一つの大きな課題が待ち受けています。

OFFSET句が抱える「読み捨て」の非効率

OFFSET 10000というSQLは、「先頭から10001件目のデータを取得せよ」という命令ではありません。データベース内部では、「まず10000件のデータを律儀に読み込み、それをすべて捨てた後、次の行からデータを取得し始める」という非常に非効率な動作をします。

これは、本を「201ページ目から読んで」と言われたとき、実際に1ページ目から200ページ目までをパラパラと手でめくっていく作業に似ています。ページ番号が大きくなればなるほど、この「読み捨て」のコストは増大し、レスポンスタイムは著しく悪化します。

特に、SNSのタイムラインのような「無限スクロール」を実装する場合、ユーザーがスクロールし続けるとOFFSETの値は数万、数十万に達し、いずれシステムは応答不能に陥るでしょう。

代替案:カーソルページネーション(キースエットページネーション)

このOFFSET問題を解決する強力な手法が、カーソルページネーション(シーク法やキースエットページネーションとも呼ばれます)です。

これは「N件スキップする」という発想を捨て、「前回取得した最後の行の、次の行からN件取得する」というアプローチを取ります。OFFSETの代わりにWHERE句を使って取得開始位置を指定するのが特徴です。

【カーソルページネーションの動作】

  1. 1ページ目の要求: 通常通りORDER BY ... LIMIT Nでデータを取得します。
  2. サーバーの応答: 取得したデータと共に、「最後の行のソートキーの値(カーソル)」をクライアントに返します。(例:salary=280, employee_id=3
  3. 2ページ目の要求: クライアントは、受け取ったカーソルをリクエストに含めてサーバーに送ります。
  4. サーバーの処理: WHERE句を使って、「カーソル(給与280、ID 3)より後」のデータを検索し、そこからN件を取得します。

この方法なら、何ページ目にアクセスしてもDBは常に効率的なインデックススキャンを実行でき、大量のデータを「読み捨て」する必要がありません。

OFFSET方式とカーソル方式の比較

どちらの方式にもメリット・デメリットがあります。要件に応じて適切に使い分けることが重要です。

項目 OFFSETページネーション カーソルページネーション
パフォーマンス 後ろのページほど遅くなる 🐢 常に一定で高速 🚀
実装の容易さ 非常に簡単 WHERE句の条件がやや複雑になる
機能的制約 「5ページ目へ」のような直接ジャンプが可能 「次へ」「前へ」は得意だが、特定ページへの直接ジャンプは実装困難

一般的なWebサイトの管理画面や、件数がそれほど多くない掲示板などでは手軽なOFFSET方式で十分な場合も多いです。しかし、APIエンドポイントの設計や無限スクロールの実装など、パフォーマンスが重視される場面では、カーソルページネーションの採用を検討すべきでしょう。

まとめ:堅牢なページング処理のためのチェックリストと試験対策

本記事では、SQLによるページング処理の基本から、安定ソートの重要性、さらにはパフォーマンス問題に至るまでを深掘りしてきました。一見単純に見えるページング機能には、「ソートの不安定性によるデータ不整合」と「OFFSET句によるパフォーマンス劣化」という、二つの大きな落とし穴が存在することを理解いただけたと思います。

最後に、これらの知識を実務や資格試験で活かすためのチェックリストと要点をまとめます。

実装前に確認したいチェックリスト

ページング機能を実装する際は、以下の項目を常に自問自答する癖をつけましょう。

  • [ ] ORDER BY句は指定されているか?
    これがなければ、ページをめくるたびに順序が変わり、話になりません。
  • [ ] ORDER BY句のキーは重複する可能性があるか?
    created_at(作成日時)や price(価格)など、ユーザーが指定するソートキーは、ほぼ全て重複の可能性があります。
  • [ ] ソートキーが重複する場合、順序を一意に決める列(主キーなど)を末尾に追加したか?
    これが安定ソートを実現する唯一の原則です。(例: ORDER BY created_at DESC, post_id ASC
  • [ ] OFFSET句による性能劣化は許容範囲か?
    想定される最大データ件数と最大ページ数を考慮しましょう。数千件程度なら問題にならないことが多いですが、数十万件を超える場合は要注意です。
  • [ ] パフォーマンス要件が厳しい場合、カーソルページネーションを検討したか?
    無限スクロール、大規模データセットを扱うAPIなどでは、カーソル方式が標準的な解決策となります。

データベーススペシャリスト・応用情報技術者試験に向けて

これらのテーマは、IPAが実施する高度情報処理技術者試験、特にデータベーススペシャリスト試験の午後問題で頻出します。

多くの場合、「提示されたシステムの仕様とSQL文の問題点を指摘し、改善案を示せ」という形式で問われます。その際、ORDER BY句にユニークなキーが含まれていないSQLは、格好の採点ポイントとなります。「このソートキーは業務上重複する可能性があるため、主キーを追加して順序を一意に確定させるべきである」と指摘できれば、高得点が期待できるでしょう。

また、大量データを扱うシナリオでOFFSETを使った非効率なSQLが示され、その性能問題を指摘させるパターンも考えられます。本記事で学んだ知識は、試験問題に潜む「出題者の意図」を的確に見抜くための強力な武器となるはずです。

-IPA|情報処理技術者試験