LIMIT OFFSETページングの限界とSEEK方式の使い方

ソースコードが表示されたノートPC SQL

LIMIT OFFSET のページングが遅くなるのは、MySQL が OFFSET の分の行を毎回読んでから捨てるからです。深いページほど読む行が増えます。直すには、前のページの最後の行の値を WHERE に渡して続きから読む「SEEK 方式(キーセットページング)」に切り替えます。

100万件のテーブルで実際に測ると、OFFSET 19980 のページは 0.66 秒かかり、SEEK 方式ではどのページも 1 ミリ秒以下でした。ただし、SEEK 方式の条件を (created_at, id) < (…) と行の比較で書くと、深いページで 6 秒以上かかります。この記事では、速い書き方と遅い書き方の違いまで、実行結果つきで説明します。

以下の結果は MySQL 8.0 で実際に実行したものです。時間は実行した環境での値なので、目安として見てください。

このシナリオで考える

ブログ投稿を管理する posts テーブルに、100万件を入れて試します。

CREATE TABLE posts (
  id         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id    INT UNSIGNED NOT NULL,
  title      VARCHAR(200) NOT NULL,
  created_at DATETIME     NOT NULL,
  PRIMARY KEY (id),
  INDEX idx_created (created_at, id)
) ENGINE=InnoDB;

-- 100万件を入れる(3件ずつ同じ created_at になる)
SET SESSION cte_max_recursion_depth = 1000000;
INSERT INTO posts (user_id, title, created_at)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1000000
)
SELECT n % 1000, CONCAT('post ', n), '2024-01-01' + INTERVAL (n DIV 3) MINUTE
FROM seq;

一覧ページは1ページあたり20件、新しい順に表示します。「次へ」ボタンで順に送る画面を作る場面で考えます。

LIMIT/OFFSETページングの基本と限界

基本的なページングは次のように書きます。

-- 1ページ目(1〜20件目)
SELECT id, user_id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;

-- 1000ページ目(19981〜20000件目)
SELECT id, user_id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 19980;

1000ページ目を EXPLAIN で見ると、インデックスが使われず、全件を読んで並べかえていました。

EXPLAIN
SELECT id, user_id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 19980;
+-------+------+------+--------+----------------+
| table | type | key  | rows   | Extra          |
+-------+------+------+--------+----------------+
| posts | ALL  | NULL | 997279 | Using filesort |
+-------+------+------+--------+----------------+

OFFSET が小さいうちは、MySQL はインデックスを後ろから読みます。OFFSET が大きくなると「インデックスをたどって本体を何万回も読むより、全件を読んで並べかえたほうが安い」と判断し、全件スキャンに切り替えます。EXPLAIN ANALYZE で測った時間は次のとおりです。

ページ OFFSET 読み方 読んだ行 時間
1ページ目 0 インデックスを後ろから 20 0.16 ミリ秒
100ページ目 1,980 インデックスを後ろから 2,000 2.3 ミリ秒
1,000ページ目 19,980 全件を読んで並べかえ 1,000,000 664 ミリ秒
50,000ページ目 999,980 全件を読んで並べかえ 1,000,000 723 ミリ秒

浅いページは速くても、あるところから急に遅くなります。後ろのページを開く人やクローラーが多いサービスでは、ここがデータベースの負荷になります。

SEEK方式(キーセットページング)で解決する

SEEK 方式は「前のページの最後の行の値を使って、次のページを絞り込む」方法です。OFFSET を使わないので、何ページ目でもインデックスの途中から20件だけ読みます。

1ページ目は OFFSET なしの LIMIT で取得します。

SELECT id, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
+---------+---------------------+
| id      | created_at          |
+---------+---------------------+
| 1000000 | 2024-08-19 11:33:00 |
|  999999 | 2024-08-19 11:33:00 |
|  999998 | 2024-08-19 11:32:00 |
...(中略)
|  999982 | 2024-08-19 11:27:00 |
|  999981 | 2024-08-19 11:27:00 |
+---------+---------------------+

最後の行は id = 999981, created_at = '2024-08-19 11:27:00' です。2ページ目は、この2つの値を WHERE に渡します。

SELECT id, user_id, title, created_at
FROM posts
WHERE created_at < '2024-08-19 11:27:00'
   OR (created_at = '2024-08-19 11:27:00' AND id < 999981)
ORDER BY created_at DESC, id DESC
LIMIT 20;

「created_at が前のページより古い行、または created_at が同じで id が小さい行」という条件です。EXPLAIN ANALYZE では、インデックスの範囲を後ろから読み、20行だけで終わっていました。

-> Limit: 20 row(s)  (actual time=0.282..0.286 rows=20 loops=1)
    -> Index range scan on posts using idx_created over (created_at < '2024-08-19 11:27:00') OR (created_at = '2024-08-19 11:27:00' AND id < 999981) (reverse) ...  (actual time=0.281..0.284 rows=20 loops=1)

同じ書き方で、5万ページ目あたり(created_at = '2024-01-01 06:40:00', id = 1200 の続き)を取っても 0.9 ミリ秒でした。OFFSET 999980 の 723 ミリ秒とは大きな差です。

ID だけで並べる場合は、条件がもっと単純になります。

-- IDの降順ページングの例
SELECT id, user_id, title, created_at
FROM posts
WHERE id < 999981
ORDER BY id DESC
LIMIT 20;

よくある落とし穴と注意点

行の比較 (created_at, id) < (…) は深いページで遅い

同じ条件は WHERE (created_at, id) < ('2024-08-19 11:27:00', 999981) と短く書けます。結果は同じですが、MySQL 8.0 ではこの形がインデックスの範囲の検索になりませんでした。インデックスを先頭(いちばん新しい行)から読み、条件に合わない行を1行ずつ捨てます。

続きを取る位置 OR で書いた条件 (created_at, id) < (…)
2ページ目 0.29 ミリ秒(20行) 0.11 ミリ秒(40行)
5万ページ目あたり 0.9 ミリ秒(20行) 6,414 ミリ秒(998,821行)

浅いページでは差が見えないので、開発中は気づきにくい点に注意してください。SEEK 方式の条件は OR の形で書きます。

並べる列の組み合わせを一意にする

ページの境目を正しく決めるには、並べる列の組み合わせが行ごとに一意である必要があります。created_at は複数の行が同じ値を持つので(今回のデータでは3行ずつ)、必ず id を2つ目の列に足します。インデックスも (created_at, id) の順にします。

-- NG: created_atが重複するとページの境目で行が抜ける・重なる
ORDER BY created_at DESC

-- OK: idを加えて一意にする
ORDER BY created_at DESC, id DESC

複合インデックスの列の順番の考え方は「MySQLの複合インデックス設計:左端マッチとカラム順の基本」で説明しています。

ページ番号へのジャンプはできない

SEEK 方式では「50ページ目に直接移動」はできません。「次へ・前へ」や無限スクロールには向いていますが、ページ番号を並べる画面とは相性がよくありません。

画面の作り 向いている方式
無限スクロール SEEK方式
「次へ/前へ」のみ SEEK方式
ページ番号ジャンプあり LIMIT/OFFSET
管理画面・小規模(〜5万件) LIMIT/OFFSET(シンプルさ優先)

LIMIT/OFFSETのまま速くする場合(遅延JOIN)

OFFSET 方式のまま速くしたい場合は、サブクエリで ID だけを取ってから JOIN する「遅延 JOIN」が使えます。

SELECT p.id, p.user_id, p.title, p.created_at
FROM posts p
JOIN (
  SELECT id
  FROM posts
  ORDER BY created_at DESC, id DESC
  LIMIT 20 OFFSET 999980
) AS paged USING (id);

サブクエリはインデックスだけで答えられるので(カバリングインデックス)、並べかえが要らなくなります。OFFSET 999980 で 723 ミリ秒だったものが 304 ミリ秒になりました。ただし 100 万行を読むことは変わらないので、SEEK 方式ほどは速くなりません。

まとめ

LIMIT/OFFSET は、深いページで MySQL が全件を読んで並べかえるようになり、100万件では 0.7 秒ほどかかりました。前のページの最後の値で絞る SEEK 方式なら、どのページも 1 ミリ秒以下です。条件は created_at < ? OR (created_at = ? AND id < ?) の形で書き、行の比較 (created_at, id) < (?, ?) は使わないでください。「次へ」だけの画面なら、まず SEEK 方式を選びます。

参考リンク

アイキャッチ画像: Photo by Jantine Doornbos on Unsplash

タイトルとURLをコピーしました