MyBatisのページング設計|LIMIT/OFFSETと改善策

ページングを入れたのに、一覧画面が早くならない。
最初の数ページは快適なのに、後ろのページに行くほど重くなった経験はありませんか?

MyBatisに限らず、ページングは「表示の問題」というより、取り方の設計の問題でハマりやすいです。

この記事では、よく使われるLIMIT/OFFSET方式の基本を押さえつつ、遅くなる理由と対策を整理します。
さらに、大量データで現実的になってくる改善策として、キーセット(Seek)方式も紹介します。

目次

ページングの全体像と遅くなるポイント

LIMIT/OFFSETは何をしているのか

LIMIT/OFFSETを使用したページングの仕組みはシンプルです。

  • LIMIT:何件取るか(ページサイズ)
  • OFFSET:先頭から何件スキップするか(ページ位置)

たとえば、1ページ20件で3ページ目なら、次のようなイメージになります。

  • OFFSET 40(最初の40件を飛ばす)
  • LIMIT 20(次の20件を取る)

SQLで書くと、こういう形です。

SELECT *
FROM orders
ORDER BY id
LIMIT 20 OFFSET 40;

OFFSETが深いほど遅くなる理由

OFFSETが大きくなるほど遅くなりやすい理由は、
後ろのページを表示するために前のデータを無視しながら読む必要があるためです。

つまり、「40件飛ばして20件取って」と言っていても、DB側は「飛ばすための40件」を読む必要があります。
ページが後ろになるほど“飛ばす件数”が増えるので、処理が重くなりやすいです。

この症状は、次のような形で表面化します。

  • 1〜3ページ目は速い
  • 50ページ目あたりから急に遅くなる
  • データ件数が増えたタイミングで一気に悪化する

COUNTが重くなるパターン

ページング実装では、よく「総件数(COUNT)」も取ります。

SELECT COUNT(*)
FROM orders
WHERE status = 'PAID';

ただし、COUNTは条件によっては重くなります。

特に「結合がある」「条件が複雑」「データが巨大」な場合、
ページ本体よりCOUNTの方がボトルネックになることがあります。

実務では、次のように割り切ることもあります。

  • 総件数が必須な画面だけCOUNTを取る
  • 「次ページがあるか」だけ分かればよいUIにする
  • ざっくり件数(概算)で良い要件に調整する

ここは技術だけで解決しきれないこともあるので、まずは「COUNTが本当に必要か」を一度見直すのが効果的です。

MyBatisでLIMIT/OFFSETを実装する基本

最小の実装例

MyBatisでLIMIT/OFFSETを扱う場合、基本はパラメータで渡します。

<select id="selectOrdersByStatus" resultType="com.example.domain.Order">
  SELECT id, status, created_at
  FROM orders
  WHERE status = #{status}
  ORDER BY id
  LIMIT #{limit} OFFSET #{offset}
</select>

呼び出し側は「ページ番号 → offset」に変換します。

  • limit:ページサイズ
  • offset:(page - 1) * limit

ここで重要なのは、「limitとoffsetが数値であること」を前提にして渡すことです。
動的SQLで文字列結合するのではなく、パラメータとして安全に渡します。

ORDER BYを固定すべき理由

ページングで一番事故りやすいのは、ORDER BYが曖昧なことです。

ORDER BYがない、または一意に決まらない並びだと、次のような問題が起きます。

  • 同じページを開くたびに順番が変わる
  • 前のページと次のページで同じ行が出る(重複)
  • 行が抜ける(見えない行が出る)

特に「created_atだけで並べる」ようなケースは注意が必要です。
created_atが同じ行が複数あると、順番が安定しません。

対策はシンプルです。

  • 必ず一意になる列を含めて並べる

こうすると、created_atが同じでもidで順序が決まるので、ページングが安定します。

ありがちな事故と対処法

LIMIT/OFFSET方式は、並びが安定していないと重複や抜けが出やすいです。

とくに次の条件が重なると起きやすいです。

  • データが追加/更新され続ける一覧
  • created_atなどの“同一値”があり得る列で並べている
  • ORDER BYが固定されていない

避け方は次の通りです。

  • ORDER BYに一意な列を入れる
  • 条件が揺れないようにする(例:検索条件を固定してページングする)
  • “最新順”のように追加が頻繁な一覧は、深いページへ飛ぶUI自体を見直す

「ページングのバグ」はSQLの誤りというより、設計の曖昧さが原因になりがちです。

キーセット(Seek)方式

「続きから取る」考え方(id/日時で次ページ)

OFFSETが深くなるほど遅い問題を避けたい場合、キーセット(Seek)方式が候補になります。

キーセット方式は、ざっくり言うとこうです。

「何件飛ばすか」ではなく、「前回の最後の行の続きから取る」

たとえば、idの降順で20件ずつ表示している場合を考えます。

  • 1ページ目:最新20件(idが大きい)
  • 2ページ目:1ページ目の最後のidより小さいものを次の20件

この方式なら、DBは「条件に合う次の20件」を取りに行くだけで済むので、
深いページほど遅くなる問題が起きにくくなります。

最小の実装例

最小の形は次のようになります。

「次ページ用のカーソル(lastId)」を受け取り、それより小さいidを取得します。

<select id="selectNextOrders" resultType="com.example.domain.Order">
  SELECT id, status, created_at
  FROM orders
  WHERE status = #{status}
    AND id < #{lastId}
  ORDER BY id DESC
  LIMIT #{limit}
</select>

最初のページは lastId を持たないので、別SQLにしても良いですし、最大値を渡す運用でも構いません。

ここでのポイントは、ORDER BYとWHEREの条件がセットになっていることです。

  • ORDER BY id DESC
  • WHERE id < lastId

この組み合わせが「続きから取る」動きになります。

弱点と割り切り

キーセット方式には弱点もあります。

  • 「100ページ目へ飛ぶ」のような任意ジャンプが難しい
  • 「前へ戻る」がやりにくい(戻り用のカーソル管理が必要)
  • UI側が「カーソル(lastIdなど)」を持つ必要がある

そのため、管理画面のように「特定ページへ飛ぶ」ことが必須なら、LIMIT/OFFSETの方が向きます。

キーセット方式は、SNSのタイムラインのように「次へ次へ」と進むUIで特に強いです。

実務での使い分け

LIMIT/OFFSETで十分なケース

LIMIT/OFFSETで問題ないケースは多いです。たとえば次のような状況です。

  • データ件数がそこまで大きくない
  • 深いページに行くことがほとんどない
  • 管理画面で「ページ番号で飛べる」ことが必要
  • 更新頻度が低く、並びが安定しやすい

この場合、まずはORDER BYを安定させるだけで十分なことが多いです。

キーセットを検討すべきケース

キーセット方式を検討すべきなのは、次のような状況です。

  • データ件数が大きく、深いページに現実にアクセスされる
  • OFFSETが増えるにつれて遅くなることが確認できている
  • 「次へ進む」UIで問題ない
  • パフォーマンスを安定させたい(負荷を読みやすくしたい)

深いページ問題が顕在化してから導入すると、後からの修正が大変なので、
「深いページがあり得るか」を設計段階で考えておくのが理想です。

迷ったときの判断軸(深いページがあるか/負荷が安定しているか)

迷ったら、判断軸は2つだけで十分です。

  • 深いページが現実に使われるか
  • 深いページで遅くなって困るか

深いページがそもそも使われないなら、OFFSET問題を気にしすぎる必要はありません。
逆に、深いページが現実に使われるなら、キーセット方式を最初から検討しておく価値があります。

まとめ

MyBatisのページング設計で、まず押さえるべきことはシンプルです。

  • LIMIT/OFFSETは実装が簡単だが、深いページほど遅くなりやすい
  • ページングの安定性のために、ORDER BYは必ず固定し、一意になる列を含める
  • 深いページが現実に必要なら、キーセット(Seek)方式が有力な改善策

ページングはUIの都合に見えますが、裏側ではDB負荷に直結します。
迷ったらまず、ORDER BYを安定させる。

そして深いページが必要なら、取り方そのものを変える。この順番で考えると、無理なく整理できます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次