ページングを入れたのに、一覧画面が早くならない。
最初の数ページは快適なのに、後ろのページに行くほど重くなった経験はありませんか?
MyBatisに限らず、ページングは「表示の問題」というより、取り方の設計の問題でハマりやすいです。
ページングの全体像と遅くなるポイント
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件
最小の実装例
最小の形は次のようになります。
「次ページ用のカーソル(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 DESCWHERE 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を安定させる。
そして深いページが必要なら、取り方そのものを変える。この順番で考えると、無理なく整理できます。


コメント