OFFSETを使わない高速なページネーションの実現

# 概要

SQLでページネーションと言えばこんな処理が定番かと思われますが…

```sql
SELECT * FROM samples ORDER BY id LIMIT 5 OFFSET 30 
SELECT * FROM samples ORDER BY id LIMIT 30, 5
```

`OFFSET`を使ってしまうと，毎回`OFFSET`以降に加えて**先頭から`OFFSET`までの検索も行う**ため，奥に進むにつれてどんどん効率が悪くなってきます。そこで，以下のような解決策を提案します。

- `OFFSET`の代わりに`PRIMARY KEY`(インデックスの効いたキー)で範囲を絞り込む
- 常に1件手前と1件奥を余分に取得し，前のページおよび次のページがあるかどうか判定可能にする

## `id`が連続

`BETWEEN`を使って両端の`id`で挟み撃ちしてやればスパッと抜けます。

```sql:奥に進むとき
SELECT * FROM samples WHERE id BETWEEN 30-1 AND 30+5 ORDER BY id
```

```sql:前に戻るとき
SELECT * FROM samples WHERE id BETWEEN 30-5 AND 30+1 ORDER BY id
```

## `id`が不連続

`LIMIT`制限下で両方向に探索した後，結果を`UNION ALL`でマージします。`UNION`対象の集合に直接`ORDER BY`を使うことは出来ないので，もう一重`SELECT`に被せてます。

```sql:奥に進むとき
SELECT * FROM (
    SELECT * FROM samples WHERE id < 30 ORDER BY id DESC LIMIT 1
) x
UNION ALL
SELECT * FROM (
    SELECT * FROM samples WHERE id >= 30 ORDER BY id ASC LIMIT 5+1
) x
```

```sql:前に戻るとき&#32;(逆順なのでarray_reverse適用必須)
SELECT * FROM (
    SELECT * FROM samples WHERE id > 30 ORDER BY id ASC LIMIT 1
) x
UNION ALL
SELECT * FROM (
    SELECT * FROM samples WHERE id <= 30 ORDER BY id DESC LIMIT 5+1
) x
```

# サンプル

`id`の連続・不連続を問わず汎用的に使えます。

```php
<?php

try {

    /* SQLite3のテンポラリデータベースに接続およびモード設定 */
    $pdo = new \PDO('sqlite::memory:');
    $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
    $pdo->setAttribute(\PDO::ATTR_EMULATE_PREPARES, true);

    /*
        スキーマとレコードの生成
        idは絞り込み等で番号が飛んでいることを想定
    */
    $pdo->exec('CREATE TABLE samples(
        id INTEGER PRIMARY KEY NOT NULL,
        name TEXT NOT NULL
    )');
    array_map(
        [$pdo->prepare("INSERT INTO samples VALUES (?, ?)"), 'execute'],
        [
            [3, '安藤'], [6, '伊藤'], [7, '上田'], [10, '江口'], [32, '小野田'],
            [33, '柏木'], [43, '木村'], [73, '黒木'], [75, '慶野'], [89, '小林'],
            [91, '澤田'], [101, '城山'], [107, '鈴木'], [123, '瀬川'], [137, '薗部'],
            [155, '田中'], [199, '千代田'],
        ]
    );

    /*
        $_GET['left'], $_GET['right'], $_GET['count'] が1以上の整数かどうか検証する
        更に $_GET['count'] は 1〜10を強制し、該当しない場合は5とする
    */
    $left = filter_input(INPUT_GET, 'left', FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1],
    ]);
    $right = filter_input(INPUT_GET, 'right', FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1],
    ]);
    $count = filter_input(INPUT_GET, 'count', FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1, 'max_range' => 10],
    ]) ?: 5;

    /*
        レコード取得
        SQL文に変数直埋め込みやってますが整数バリデーション通してるので安全です
        ($_GETや$_POSTをそのままぶち込むのは厳禁！)
    */
    if (is_int($left)) {

        // 左端が指定されたとき
        $rows = $pdo->query("
            SELECT * FROM (
                SELECT * FROM samples WHERE id < $left ORDER BY id DESC LIMIT 1
            ) x
            UNION ALL
            SELECT * FROM (
                SELECT * FROM samples WHERE id >= $left ORDER BY id ASC LIMIT $count+1
            ) x
        ")->fetchAll();

        // 余分な先頭レコードを取り出す
        $prev_right = isset($rows[0]) && $rows[0]['id'] < $left ? $rows[0]['id'] : null;
        $rows = array_slice($rows, (int)($prev_right !== null));

        // 余分な末端レコードを取り出す
        $next_left = isset($rows[$count]) ? $rows[$count]['id'] : null;
        $rows = array_slice($rows, 0, $count);

    } elseif (is_int($right)) {

        // 右端が指定されたとき (逆順)
        $rows = $pdo->query("
            SELECT * FROM (
                SELECT * FROM samples WHERE id > $right ORDER BY id ASC LIMIT 1
            ) x
            UNION ALL
            SELECT * FROM (
                SELECT * FROM samples WHERE id <= $right ORDER BY id DESC LIMIT $count+1
            ) x
        ")->fetchAll();

        // 余分な末端レコードを取り出す
        $next_left = isset($rows[0]) && $rows[0]['id'] > $right ? $rows[0]['id'] : null;
        $rows = array_slice($rows, (int)($next_left !== null));

        // 余分な先頭レコードを取り出す
        $prev_right = isset($rows[$count]) ? $rows[$count]['id'] : null;
        $rows = array_slice($rows, 0, $count);

        // 逆順を元に戻す
        $rows = array_reverse($rows);

    } else {

        // 何も指定されなかった時
        $rows = $pdo->query("
            SELECT * FROM samples ORDER BY id ASC LIMIT $count+1
        ")->fetchAll();

        // 余分な先頭レコードは存在しない
        $prev_right = null;

        // 余分な末端レコードを取り出す
        $next_left = isset($rows[$count]) ? $rows[$count]['id'] : null;
        $rows = array_slice($rows, 0, $count);

    }

} catch (\PDOException $e) {

    /* 異常発生 */
    header('Content-Type: text/plain; charset=UTF-8', true, 500);
    exit($e->getMessage());

}

function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

header('Content-Type: text/html; charset=UTF-8');

?>
<!DOCTYPE html>
<title>OFFSETを使わない高速なページネーションの実現</title>
<?php if ($rows): ?>
<?php if ($prev_right !== null): ?>
<a href="<?=h("?right=$prev_right&count=$count")?>">前の<?=h($count)?>件</a>
<?php else: ?>
前の<?=h($count)?>件
<?php endif; ?>
<?php if ($next_left !== null): ?>
<a href="<?=h("?left=$next_left&count=$count")?>">次の<?=h($count)?>件</a>
<?php else: ?>
次の<?=h($count)?>件
<?php endif; ?>
<ul>
<?php foreach ($rows as $row): ?>
    <li><?=h("$row[id]: $row[name]")?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
```

# FAQ 

## 主キー以外も含めてソートしたい場合は？

**ソート方向が共通** であって **その順序どおりの複合インデックス** が貼られている場合，今回のテクニックを応用できます。以下に日付とIDの複合条件で検索する例を示します。更新日時の新しい順であることに注意してください。


```sql:想定するインデックス生成クエリ
CREATE INDEX index_updated_at_and_id ON posts(updated_at, id);

/* 
  ※ 2つ目のインデックスが主キーなので，以下のように書いても同じ効果が得られます。
    
    CREATE INDEX index_updated_at ON posts(updated_at);
*/
```

```sql:更新日時の新しい順に，奥に進む例
SELECT * FROM (
    SELECT * FROM posts
    WHERE updated_at = '2017-01-01 00:00:00' AND id > 30
       OR updated_at > '2017-01-01 00:00:00'
    ORDER BY updated_at ASC, id ASC
    LIMIT 1
) x
UNION ALL
SELECT * FROM (
    SELECT * FROM posts
    WHERE updated_at = '2017-01-01 00:00:00' AND id <= 30
       OR updated_at < '2017-01-01 00:00:00'
    ORDER BY updated_at DESC, id DESC
    LIMIT 5+1
) x
```

```sql:更新日時の新しい順に，前に戻る例&#32;(逆順なのでarray_reverse適用必須)
SELECT * FROM (
    SELECT * FROM posts
    WHERE updated_at = '2017-01-01 00:00:00' AND id < 30
       OR updated_at < '2017-01-01 00:00:00'
    ORDER BY updated_at DESC, id DESC
    LIMIT 1
) x
UNION ALL
SELECT * FROM (
    SELECT * FROM posts
    WHERE updated_at = '2017-01-01 00:00:00' AND id >= 30
       OR updated_at > '2017-01-01 00:00:00'
    ORDER BY updated_at ASC, id ASC
    LIMIT 5+1
) x
```

## `GROUP BY`したい場合は？

これはもうどうしようもないです。この用途ではむしろ`OFFSET`を使うほうがいいかもしれません。

## 全体件数および総ページ数を求めたい場合は？

### `id`が連続

あらかじめ全体件数を格納するテーブルを作っておくべきでしょう。追加や削除があった場合にはこのテーブルも同時に更新することになります。追加や削除に時間をかけてもいいので検索をより高速にするのがセオリーです。

### 絞り込みが必要

分かりません。大手検索エンジンはどんなロジックを使っているんでしょうか…？詳しい人教えて下さい。