API・データベース ·

ページネーションはoffsetかcursorか — 件数・並び順・API互換性から決める

  • API設計
  • データベース
  • Laravel

この記事で扱う問題

利用者が自分の注文の一覧を新しい順に見る API を作るとします。最初は、たいてい次のように書きます。

$orders = OrderModel::query()
    ->where('user_id', $userId)
    ->orderByDesc('created_at')
    ->paginate(20);   // ?page=3 なら OFFSET 40 LIMIT 20。総件数のための COUNT(*) も走る

件数が少ないうちは何の問題もありません。ところが、例えば次のことが起こり得ます。

  • 深いページが遅くなる:OFFSET 100000 LIMIT 20 は、DB が 100,020 行を読み、先頭の 100,000 行を捨てる処理です。インデックスがあっても、読み飛ばす行数ぶんの仕事は残ります。総ページ数を出すための COUNT(*) も、条件に合う行をすべて数えます
  • ページをめくる間に行が重複する・抜ける:利用者が1ページ目を見ている間に新しい注文が1件増えると、2ページ目の先頭には、1ページ目の最後に見た注文がもう一度出ます。逆に1件消えると、1件読み飛ばされます。無限スクロールのクライアントでは、同じ注文が2回並びます
  • データが変わらなくても、ページの境目がずれる:created_at が同じ秒の注文が複数あると、その中の並び順は DB が保証しません。同じ注文が1ページ目と2ページ目の両方に出たり、どちらにも出なかったりします
  • あとから方式を変えられない:?page=3 で作った API を公開した後で cursor に切り替えると、クライアントにとっては互換性のない変更です

3つ目は、offset か cursor かに関係なく起きます。まずそこから決めます。

判断

前提:並び順は、最後に主キーを足して一意にする

どちらの方式でも、並び順の列の組み合わせが、行を1つに決められるようにします。created_at だけでは同じ値の行がありうるので、最後に主キーを足します。

->orderByDesc('created_at')
->orderByDesc('id')   // created_at が同じ行どうしの順序を決める

並び順に使う列は、NOT NULL で、後から変わらない値にします。Laravel のマイグレーションの timestamps() は NULL を許す列を作るので、並び順に使うなら、NULL の行が残っていないか確かめるか、NOT NULL の専用の列(placed_at など)を持ちます。NULL を並べる位置は DB によって違い(昇順なら MySQL は先頭、PostgreSQL は末尾)、後で書く「前のページの最後の行より後ろ」という条件も、NULL とは比べられません。

使い分け:画面の番号付きページは offset、API とスクロールは cursor

offset(?page=3) cursor(?cursor=...)
深いページの性能 読み飛ばす行数に比例して遅くなる どのページも同じ速さ
めくる間の追加・削除 重複・抜けが起きる 起きない(後で書く条件つき)
任意のページへ移動 できる できない(前後に進むだけ)
総件数 出しやすい(COUNT(*) のコストは別) 方式としては持たない
並び順の種類を増やす ORDER BY を変えるだけ 並び順ごとにインデックスとカーソルの中身が要る

私は、次のように分けています。

  • 管理画面の番号付きの一覧:offset。担当者が「5ページ目」に飛びたい画面で、絞り込みで件数も抑えられることが多いからです。ただし、ページ番号に上限を付けます
  • 外部に公開する API、モバイルアプリの無限スクロール、他システムとの連携で全件を順に読む処理:cursor。件数が増え続けるもの、めくる間にデータが変わるもの、クライアントのコードを後から変えにくいものが、ここに入ります

offset の上限は、例えば次のように決めます。

/** 管理画面の一覧。100ページより先は、絞り込みの条件を足してもらう */
public function rules(): array
{
    return [
        'page' => ['sometimes', 'integer', 'min:1', 'max:100'],
    ];
}

100ページ目まで見て探す担当者は、たいてい絞り込みの手段が足りていません。深いページを速くするより、検索の条件を足すほうが、担当者にとっても DB にとっても早く済みます。

cursor の実装:前のページの最後の行より「後ろ」を読む

cursor の方式(キーセットページネーションとも呼ばれます)は、「何行飛ばすか」ではなく、「前のページの最後の行の並び順の値より後ろ」を条件にして読みます。読み取り専用の一覧なので、Repository ではなく、専用のクエリクラスから DTO を返します。

まず、絞り込みの条件と、1行分の DTO です。OrderId・OrderStatus は既存の記事の定義のまま使います。

final readonly class OrderListFilter
{
    public function __construct(
        public int $userId,
        public ?OrderStatus $status,
    ) {
    }

    /** カーソルに埋め込み、次のページの要求で絞り込みの条件が変わっていないかを確かめるための値 */
    public function fingerprint(): string
    {
        return $this->status?->value ?? 'any';
    }
}

final readonly class OrderListItem
{
    public function __construct(
        public string $id,
        public string $status,
        public string $createdAt,
    ) {
    }
}

final readonly class OrderListPage
{
    /** @param list<OrderListItem> $items */
    public function __construct(
        public array $items,
        public ?OrderListCursor $next,   // 最後のページなら null
    ) {
    }
}

カーソルには、最後の行の並び順の値(created_at と id)と、絞り込みの条件を入れ、クライアントには中身の読めない文字列として渡します(例外クラス InvalidCursor の定義は省略します)。

final readonly class OrderListCursor
{
    private const VERSION = 1;

    public function __construct(
        public string $createdAt,   // DB が返した形式のまま持ち、そのまま条件に渡す
        public OrderId $orderId,
        public string $filterFingerprint,
    ) {
    }

    public static function decode(string $encoded): self
    {
        $json = base64_decode(strtr($encoded, '-_', '+/'), true);
        $data = is_string($json) ? json_decode($json, true) : null;

        if (
            ! is_array($data)
            || ($data['v'] ?? null) !== self::VERSION
            || ! is_string($data['c'] ?? null)
            || preg_match('/\A\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?\z/', $data['c']) !== 1
            || ! is_string($data['i'] ?? null)
            || ! is_string($data['f'] ?? null)
        ) {
            throw new InvalidCursor('カーソルの形式ではありません');
        }

        try {
            return new self($data['c'], new OrderId($data['i']), $data['f']);
        } catch (InvalidOrderId) {
            throw new InvalidCursor('カーソルの形式ではありません');
        }
    }

    public function encode(): string
    {
        $json = json_encode(
            ['v' => self::VERSION, 'c' => $this->createdAt, 'i' => $this->orderId->value, 'f' => $this->filterFingerprint],
            JSON_THROW_ON_ERROR,
        );

        return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');   // URL にそのまま載せられる形にする
    }
}

created_at の形式の確認は、MySQL の DATETIME を Query Builder で読んだときの形式に合わせています。PostgreSQL の timestamptz のようにタイムゾーンの付いた形式で返る列なら、その形式に合わせます。

クエリクラスは、1件多く読んで次のページがあるかを判断し、最後の行からカーソルを作ります。

use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use InvalidArgumentException;

final class OrderListQuery
{
    public const MAX_LIMIT = 100;

    public function __construct(private ConnectionInterface $db)
    {
    }

    public function fetch(OrderListFilter $filter, ?OrderListCursor $after, int $limit): OrderListPage
    {
        if ($limit < 1 || $limit > self::MAX_LIMIT) {
            throw new InvalidArgumentException("limit は 1〜" . self::MAX_LIMIT . " で指定する: {$limit}");
        }

        if ($after !== null && $after->filterFingerprint !== $filter->fingerprint()) {
            throw new InvalidCursor('カーソルを発行したときと、絞り込みの条件が違います');
        }

        // インデックスは (user_id, created_at, id)。status で絞るなら (user_id, status, created_at, id) も検討する
        $query = $this->db->table('orders')
            ->where('user_id', $filter->userId)
            ->orderByDesc('created_at')
            ->orderByDesc('id')
            ->limit($limit + 1);   // 1件多く読み、次のページがあるかを COUNT(*) なしで判断する

        if ($filter->status !== null) {
            $query->where('status', $filter->status->value);
        }

        if ($after !== null) {
            // 降順なので「後ろ」は小さい側。(created_at, id) < (前のページの最後の行) を展開して書く
            $query->where(function (Builder $q) use ($after): void {
                $q->where('created_at', '<', $after->createdAt)
                    ->orWhere(function (Builder $q) use ($after): void {
                        $q->where('created_at', '=', $after->createdAt)
                            ->where('id', '<', $after->orderId->value);
                    });
            });
        }

        $rows = $query->get(['id', 'status', 'created_at']);
        $pageRows = $rows->take($limit);

        $items = $pageRows
            ->map(fn (object $row): OrderListItem => new OrderListItem($row->id, $row->status, $row->created_at))
            ->values()
            ->all();

        $last = $pageRows->last();
        $next = $rows->count() > $limit
            ? new OrderListCursor($last->created_at, new OrderId($last->id), $filter->fingerprint())
            : null;

        return new OrderListPage($items, $next);
    }
}

利用者の ID はカーソルに入れず、毎回、認証した利用者から条件に入れています。カーソルを書き換えられても、自分の注文の中で読み始める位置が変わるだけで、他人の注文は読めません。カーソルに署名を付けていないのはそのためです。カーソルの中身で絞り込みの範囲が決まるような作りにするなら、署名か暗号化が要ります。

条件は (created_at, id) < (?, ?) という行の値の比較でも書けます。PostgreSQL では、この形のほうが複合インデックスをそのまま使えます。どちらの書き方がインデックスを効率よく使うかは DB とバージョンによって違うので、私は実際のデータ量で EXPLAIN を見て確かめます。並び順の列の向きがそろっていること(ここではどちらも降順)も前提です。created_at は降順、id は昇順のように向きを混ぜると、行の値の比較では書けず、インデックスも向きを合わせて作る必要があります。

入口:カーソルの誤りは、入力の誤りとして返す

形式の検証は FormRequest に置き、カーソルの中身の確認はカーソルのクラスに任せます。入力検証の分担と同じ考え方で、カーソルの誤りは項目のエラーとして 422 で返します。

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class ListOrdersRequest extends FormRequest
{
    /** @return array<string, list<mixed>> */
    public function rules(): array
    {
        return [
            'cursor' => ['sometimes', 'string', 'max:512'],
            'limit' => ['sometimes', 'integer', 'min:1', 'max:' . OrderListQuery::MAX_LIMIT],
            'status' => ['sometimes', Rule::enum(OrderStatus::class)],
        ];
    }
}
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;

final class ListOrdersController
{
    public function __construct(private OrderListQuery $query)
    {
    }

    public function __invoke(ListOrdersRequest $request): JsonResponse
    {
        $filter = new OrderListFilter(
            $request->user()->id,
            $request->has('status') ? OrderStatus::from($request->validated('status')) : null,
        );

        try {
            $after = $request->has('cursor') ? OrderListCursor::decode($request->validated('cursor')) : null;
            $page = $this->query->fetch($filter, $after, (int) $request->validated('limit', 20));
        } catch (InvalidCursor) {
            throw ValidationException::withMessages(['cursor' => 'カーソルが正しくありません。一覧を最初から読み直してください。']);
        }

        return response()->json([
            'data' => $page->items,   // 公開する項目の形は、API のレスポンスとして別に決める(省略)
            'next_cursor' => $page->next?->encode(),
        ]);
    }
}

レスポンスは、データと「次のカーソル」だけにします。総件数は返しません。クライアントは next_cursor が null になるまで、それを付けて次を要求します。

なぜそう判断したか

cursor でページの重複・抜けが起きない理由と、その条件

offset は「先頭から何行目か」で位置を表すので、前に行が増えたり消えたりすると、位置がずれます。cursor は「この値より後ろ」で位置を表すので、前のページの最後の行が消えても、その後に新しい注文が増えても、次のページは同じ境目から始まります。新しい注文は、一覧の先頭(すでに読み終えた側)に増えるだけで、利用者が一覧を開き直したときに見えます。

ただし、これは並び順の値が後から変わらない場合に限ります。updated_at の順に並べると、利用者がめくっている間に更新された行は、境目をまたいで前に移ります。まだ読んでいなかった行なら読み飛ばされます。「更新された順」の一覧が必要な場合でも、並び順の値が動くことを前提に、どこまでの抜けを許容するかを先に決めます。

「前回以降に変わったもの」を cursor で読ませない理由

連携先が「前回の続きから、更新された注文を読む」ために updated_at のカーソルを使うと、もう1つ問題があります。updated_at の値は、トランザクションの中で時刻を取った時点の値です。コミットの順序とは一致しません。時刻の早いトランザクションが遅れてコミットされると、連携先がすでにその時刻より後ろまで読んだ後に現れ、二度と読まれません。

この用途には、ページネーションではなく、変更の記録を順番に読ませる仕組みが向いていると考えています。このあたりはイベント駆動の設計の領域で、私自身まだ深めている途中ですが、少なくとも「一覧の API の cursor をそのまま差分の取得に流用しない」ことは決めています。

Laravel の cursorPaginate() をそのまま公開しなかった理由

Laravel にも、同じ方式の cursorPaginate() があります。並び順の列が一意になる組み合わせであること、NULL を含む列を使えないことなど、前提はこの記事と同じです。社内の画面から呼ぶだけなら、これで十分です。

公開する API で自分で書いているのは、カーソルの中身とレスポンスの形を、自分たちの API の契約として持っておきたいからです。cursorPaginate() のレスポンスには path や next_page_url などフレームワークの形式の項目が並び、カーソルの中身もフレームワークが決めます。この記事のカーソルには形式の版(v)を入れているので、中身を変えるときは新しい版を発行しつつ、古い版もしばらく読めるようにする、という段階を自分で踏めます。

API の方式を最初に決める理由

offset から cursor への切り替えは、リクエストのパラメータもレスポンスの形も変わるので、クライアントを壊さない変更として段階を踏む必要があります。モバイルアプリが古いバージョンのまま残るなら、両方の方式を長く併存させることになります。

逆に、最初から cursor にしておけば、件数が増えても API の形は変わりません。総件数や任意のページへの移動が後から必要になった場合は、項目やパラメータの追加で済むかを、その時点で考えます。公開する API では、後から変えにくい方を最初に選んでおく、という判断です。

どこから設計過剰になるか

  • 件数が増えない一覧:都道府県や、利用者ごとに数十件で頭打ちになる設定の一覧なら、ページネーション自体が要りません。全件を返します
  • 管理画面を cursor にする:担当者が任意のページに飛べなくなり、総件数も出しにくくなります。上限を付けた offset で困らない画面は、そのままにします
  • 並び順の選択肢を増やしすぎる:cursor で「価格順」「人気順」を選べるようにすると、並び順ごとに一意にする列の組み合わせ、インデックス、カーソルの中身が要ります。人気順のように値が頻繁に変わる並び順は、上で書いたとおり重複と抜けが避けられません。公開する API で選べる並び順は、本当に要るものに絞ります
  • 前のページへ戻る機能:無限スクロールなら、読んだページはクライアントが持っているので要りません。必要なら、並び順を逆にして「前のページの最初の行より前」を読み、結果を並べ直すことになり、カーソルも前後の2種類が要ります。要件として確かめてから作ります
  • 総件数を正確に出す:「1,234件」と正確に出す必要がなければ、上限まで数えて「1,000件以上」と表示する、という手もあります。正確な件数が要るなら、集計した値を別に持つことを検討します

まとめ

  • 並び順は最後に主キーを足して一意にし、NULL を含まず後から変わらない列で並べる。これは offset でも cursor でも前提
  • 任意のページへ飛びたい管理画面は上限付きの offset、公開する API や無限スクロールは「前のページの最後の行より後ろ」を読む cursor にする
  • カーソルは中身の読めない文字列にして版と絞り込みの条件を入れ、レスポンスはデータと次のカーソルだけにする。公開する API の方式は、後から変えにくいので最初に決める

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。