12k
All articles

モダンAPIのためのページネーションパターン

現代のAPI向けに、offset、cursor、keyset、page token、Relay GraphQLのページネーションをTypeScriptとSQL例で比較します。

OpenReplay Team
OpenReplay Team
モダンAPIのためのページネーションパターン

新規のリストエンドポイントには、キーセットクエリを基盤とするカーソルページネーションをデフォルトとして採用してください。このアプローチはデータセットのサイズに関わらずほぼ一定のクエリ時間を実現し、ページ取得中に行が挿入・削除されても安定した動作を維持します。オフセットページネーションを使用するのは、ユーザーが任意のページ番号に直接ジャンプする必要がある場合、データセットが小規模かつ更新頻度が低い場合、またはUIの要件として総ページ数の表示が必須である場合に限定してください。この判断の根拠となるのは、オフセットページネーションが構造的に抱える2つの障害モードです。深いオフセットでのリニアな速度低下と、データ変更時に項目の重複やスキップが発生するウィンドウシフトバグです。本記事では、オフセット、カーソル、キーセット、ページトークン、RelayスタイルのGraphQL接続といったページネーションパターンファミリーのすべてを、実行可能なTypeScriptコードと各パターンを支えるSQLとともに解説します。

重要なポイント

  • カーソルとキーセットは同義語ではありません。カーソルはAPIがクライアントに返す不透明なトークンであり、キーセットはSQLのテクニックです。キーセットは複合 WHERE (sort_col, id) < (val, id) 述語を用いて、データベースがインデックスシークを行えるようにすることで、行をスキャンして破棄する代わりに高速なカーソルページネーションを実現します。
  • OFFSET 500000 LIMIT 20 というクエリは500,000行をスキップするわけではありません。PostgreSQLは20行を返す前にそれらの行を読み込んで破棄するため、ソート列にインデックスが存在するかどうかに関わらず、クエリ時間はオフセット値に比例して増加します。
  • ページ取得の間に行が挿入されると、オフセットページネーションはウィンドウをシフトさせます。クライアントは重複した項目を受け取り、別の項目をサイレントにスキップしますが、レスポンスにはエラーが表示されません。
  • すべてのレスポンスで total_count を返すと、別途 COUNT(*) が必要になります。代わりに limit + 1 行を取得して余分な行が存在するかどうかを確認することで安価に導出できる has_next_page を優先してください。
  • カーソルは特定の順序付き・フィルタリング済み結果セット内の位置をエンコードするため、ソートやフィルターパラメータが変更された際には必ず破棄して最初のページにリセットする必要があります。

ページネーション戦略が重要な理由:2つの障害モード

ページネーション戦略が重要なのは、単純なデフォルト実装である LIMITOFFSET が、大規模なデータや並行書き込みが発生した場合にのみ顕在化する2つの構造的な障害モードを持つからです。1つ目はパフォーマンスの問題で、深いオフセットはデータベースに行を読み込んで破棄させます。2つ目は一貫性の問題で、オフセットは位置ベースであるため、ページ取得間に挿入や削除が発生するとウィンドウがシフトして結果セットが破損します。どちらのバグも小規模な静的テーブルでは現れないため、オフセットページネーションは開発環境では問題なく動作しても、本番環境で障害を引き起こします。

以下に、本記事全体を通じて説明する各パターンの比較表を示します:

パターンランダムページアクセス書き込みに対する安定性深さにおけるDBパフォーマンス総件数最適なユースケース
オフセット / リミット不可リニアに低下比較的安価(追加クエリ)小規模な静的データ、管理テーブル、ページ番号が必要な検索UI
カーソル(APIサーフェス)不可キーセット使用時は一定省略;has_more を使用無限スクロール、フィード、公開リストAPI
キーセット(DBテクニック)不可一定(インデックスシーク)困難カーソルAPIの実装基盤
ページトークン不可実装依存任意戦略を隠蔽・進化させたいAPI
Relay接続不可キーセット使用時は一定任意Connections仕様を使用するGraphQLクライアント

パターンを区別する2つの重要な特性は、パフォーマンスと安定性です。以下でその理由を詳しく説明します。

オフセットページネーション:ウィンドウシフトバグの仕組み

オフセットページネーションは pagelimit(または offsetlimit)を使用して数値的な位置を計算します:offset = (page - 1) * limit。これは任意のページ番号への直接ジャンプをサポートする唯一のパターンであり、ユーザーが「7ページ目」を直接クリックする管理テーブルや検索結果に適しています。その弱点はいずれも、この位置ベースのアドレッシングに起因します。

パフォーマンスの弱点は実装上の問題ではなく、仕様として文書化された動作です。PostgreSQLのLIMITとOFFSETに関するドキュメントによると、「OFFSET句でスキップされる行はサーバー内で計算される必要がある」とされています。OFFSET 500000 LIMIT 20 というクエリは500,000行をスキップするわけではなく、データベースは20行を返す前にそれらを読み込んで破棄します。そのため、ソート列にインデックスが存在するかどうかに関わらず、クエリ時間はオフセット値に比例して増加します。この仕様は最近のPostgreSQLメジャーバージョン(16から18)で変わっていません。

一貫性の弱点はウィンドウシフトバグです。ページ取得の間にフィードの先頭に行が挿入されると、オフセットページネーションはウィンドウをシフトさせます。N+1ページ目の offset 位置にある項目は、Nページ目の offset - 1 位置にあった項目となるため、クライアントは重複した項目を受け取り、境界からはみ出した項目はサイレントにスキップされ、レスポンスにはエラーが表示されません。削除の場合は逆の現象が起き、ウィンドウが縮小して項目がスキップされます。

以下は、Node 24(Active LTS)上で動作する、TypeScriptとExpressおよびpg 8.22.xを使用した正しいオフセットエンドポイントの実装です:

// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
import express from "express";
import { Pool } from "pg";

const pool = new Pool();
const app = express();

interface OffsetPage<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    has_next_page: boolean;
  };
}

function clampLimit(raw: unknown): number {
  const n = Number(raw) || 20;
  return Math.min(Math.max(Math.trunc(n), 1), 100); // 1〜100にクランプ
}

app.get("/posts", async (req, res) => {
  const limit = clampLimit(req.query.limit);
  const page = Math.max(Number(req.query.page) || 1, 1);
  const offset = (page - 1) * limit;

  // COUNTクエリなしでhas_next_pageを判定するためlimit + 1行を取得
  const { rows } = await pool.query(
    `SELECT id, title, created_at
       FROM posts
       ORDER BY created_at DESC, id DESC
       LIMIT $1 OFFSET $2`,
    [limit + 1, offset]
  );

  const has_next_page = rows.length > limit;
  const data = rows.slice(0, limit);

  const body: OffsetPage<typeof data[number]> = {
    data,
    pagination: { page, limit, has_next_page },
  };
  res.json(body);
});

ここで重要な点が2つあります。clampLimit 関数はページサイズを100に制限し、クライアントが limit=1000000 のような過大なリクエストでサーバーを枯渇させることを防ぎます。また、limit + 1 行を取得することで、2回目の COUNT(*) クエリなしに has_next_page を報告できます。これは後述のカーソル実装でも使用するテクニックです。余分な行が存在すれば、少なくとも1ページ以上残っていることがわかります。

カーソルページネーション:APIサーフェス

カーソルページネーションは数値オフセットを、結果セット内の固定位置を示す不透明なトークンに置き換えます。クライアントはトークンを送り返して次のページを取得します。トークンは件数ではなく行の実際のソート値に基づいて位置を固定するため、テーブル内の他の場所での挿入や削除によってウィンドウが破損することがありません。トレードオフはランダムページアクセスの喪失です。「7ページ目」という概念はなく、「このカーソルの次のページ」のみが存在します。

カーソルとキーセットは同義語ではなく、この混同はページネーションの解説で最もよく見られる誤りです。カーソルはAPIがクライアントに返す不透明なトークンであり、ページネーション戦略を隠蔽します。キーセットはSQLのテクニックであり、複合 WHERE (sort_col, id) < (val, id) 述語を使用して、データベースが行をスキャンして破棄する代わりにインデックスシークを使用できるようにすることで、カーソルページネーションを高速化します。カーソルAPIの内部にオフセットクエリを使用することもできますが(深いオフセットの速度低下を引き継ぐため推奨しません)、キーセットクエリを使用することもできます(こちらを推奨します)。カーソルはインターフェースであり、キーセットは効率的なエンジンです。

トークン自体はエンコードされた位置情報に過ぎません。通常はソート列とタイブレーカーをbase64エンコードしたもので、クライアントがそれを不透明なものとして扱い、内部構造に依存しないようにします。内部形式を変更してもクライアントのトラバーサルを中断させないよう、不透明な状態を維持してください。

// Node 24 (Active LTS), pg 8.22.x, PostgreSQL 18
interface CursorPage<T> {
  data: T[];
  pagination: {
    next_cursor: string | null;
    has_more: boolean;
  };
}

type Cursor = { created_at: string; id: number };

function encodeCursor(c: Cursor): string {
  return Buffer.from(JSON.stringify(c)).toString("base64url");
}

function decodeCursor(raw: string): Cursor {
  return JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
}

app.get("/feed", async (req, res) => {
  const limit = clampLimit(req.query.limit);
  const cursor = req.query.cursor
    ? decodeCursor(String(req.query.cursor))
    : null;

  // キーセット述語:ソートタプルに対する複合行比較
  const { rows } = cursor
    ? await pool.query(
        `SELECT id, title, created_at
           FROM posts
          WHERE (created_at, id) < ($1, $2)
          ORDER BY created_at DESC, id DESC
          LIMIT $3`,
        [cursor.created_at, cursor.id, limit + 1]
      )
    : await pool.query(
        `SELECT id, title, created_at
           FROM posts
          ORDER BY created_at DESC, id DESC
          LIMIT $1`,
        [limit + 1]
      );

  const has_more = rows.length > limit;
  const data = rows.slice(0, limit);
  const last = data[data.length - 1];

  const body: CursorPage<typeof data[number]> = {
    data,
    pagination: {
      has_more,
      next_cursor:
        has_more && last
          ? encodeCursor({ created_at: last.created_at, id: last.id })
          : null,
    },
  };
  res.json(body);
});

このエンドポイントはカーソルを (created_at, id) タプルにデコードし、それに対してキーセットクエリを実行し、limit + 1 行を取得して has_more を計算し、最後に返された行から次のカーソルをエンコードします。COUNT(*) なし、オフセットなし、破棄行のスキャンなしです。

キーセットページネーション:複合述語

キーセットページネーションはカーソルページネーションを高速化するデータベーステクニックです。オフセットの代わりに、複合述語でソートキー自体をフィルタリングします。PostgreSQLは行値比較をサポートしているため、(created_at, id) < ($1, $2) はタプルを辞書順に比較し、対応する複合インデックスが境界に直接シークできるようにします。

タイブレーカーは省略できません。created_at 単独では一意性が保証されません。2つの投稿がミリ秒単位で同じタイムスタンプを持つ可能性があり、非一意の列でのソートは非決定論的な順序を生み出します。つまり、created_at のみに基づくカーソルは、その値を共有する行をスキップまたは繰り返す可能性があります。主キー(id)を追加することでソートタプルが一意になり、境界が決定論的になります。

-- PostgreSQL 18
-- ソートタプルとその方向に合わせた複合インデックス
CREATE INDEX idx_posts_cursor ON posts (created_at DESC, id DESC);

-- キーセットクエリ:複合述語でインデックスをシーク
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

インデックスの方向は重要です。PostgreSQLのインデックスとORDER BYに関するドキュメントによると、B-treeインデックスはどちらの方向にもスキャンできますが、インデックスの宣言された順序をクエリの ORDER BY と一致させることで、プランナーが単一のインデックススキャンで述語とソートの両方を満たせるようになります。大規模なテーブルで両方の形式に対して EXPLAIN ANALYZE を実行すると、実行計画に構造的な違いが現れます。OFFSET 500000 のクエリでは先頭行の計算と破棄が示されるのに対し、キーセットクエリでは低い起動コストで必要な行のみを返すインデックススキャンが示されます。このコストの差異は偶発的なものではなく、上記で説明した OFFSET の仕様から直接導かれる構造的な差異です。

ページトークン:戦略の隠蔽

ページトークンはカーソルページネーションの不透明戦略バリアントであり、GoogleのCloud API設計ガイドラインで最もクリーンな形で採用されています。AIP-158では、リクエストに page_sizepage_token を含め、レスポンスに next_page_token を返し、ページトークンはクライアントに対して不透明であることが仕様で要求されています。完全なページネーション状態をサーバー側でエンコードすることで、サーバーはトラバーサル中のクライアントを中断させることなく、内部戦略をオフセットからキーセットなどに変更できます。

Stripeもここでよく引き合いに出されますが、両者は同じメカニズムではなく、この違いを正確に理解することが重要です。Stripeのページネーションドキュメントによると、Stripeのリスト APIはオブジェクトIDカーソル(starting_afterending_beforelimithas_more フラグ)を使用しており、不透明なページトークンではありません。不透明な next_page トークンを返すのはStripeの検索 APIです。正確に整理すると、Google Cloudは不透明なページトークンを使用し、StripeのリストエンドポイントはオブジェクトIDカーソルを使用し、ShopifyはGraphQL Admin APIでカーソルベースの接続を使用し、GitHubのREST APILink ヘッダーでページネーションを行い、ほとんどのエンドポイントではページ番号ベース、特定のエンドポイントのみカーソルベース(before/after)です。共通点はカーソルスタイルのトラバーサルであり、単一の統一されたメカニズムではありません。

RelayスタイルのGraphQL接続

Relay接続はカーソルページネーションのGraphQL特化版であり、スキーマ内のすべてのリストフィールドが同じ方法でページネーションを行えるよう標準化されています。Relay Cursor Connections仕様では、接続を edges のリストとして定義しています。各エッジには node(項目)と cursor(不透明な位置)があり、hasNextPagehasPreviousPagestartCursorendCursor を公開する pageInfo オブジェクトが付属します。

query {
  posts(first: 10, after: "cursor123") {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

エッジごとのカーソルはRESTセクションと同じ不透明なトークンの概念であり、pageInfo.hasNextPage は1件余分に取得するテクニックから導出される has_more フラグと同じものです。この構造はシンプルな { data, pagination } エンベロープよりも重いですが、クライアントに統一されたコントラクトを提供します。first/after を渡して pageInfo を読み取ることで、あらゆる接続をページネーションできます。内部実装では、同じパフォーマンス上の理由から、Relay接続もキーセットクエリを基盤とすべきです。

パターンの選択と一般的な落とし穴の回避

新規のリストエンドポイントにはキーセットを基盤とするカーソルページネーションをデフォルトとし、オフセットは特定の要件によって必要性が正当化される場合の例外として扱ってください。判断マトリクス:

ユースケースパターン
無限スクロールフィード、アクティビティストリーム、通知カーソル + キーセット
後で再実装する可能性がある公開リストAPIページトークン(不透明)
GraphQLリストフィールドRelay接続(内部はキーセット)
大規模テーブルの一括エクスポートカーソル + キーセット
「Nページ目にジャンプ」機能付き管理テーブルオフセット
ページ番号を表示する検索UIオフセット
小規模(1万行未満)かつ変更頻度が低い参照データオフセット(どちらでも可)

本番環境でこれらすべてを正しく機能させるためのベストプラクティスを以下に示します:

  • ページサイズを制限する。 limit を固定の最大値(一般的な上限は100)にクランプし、クライアントが無制限のページをリクエストできないようにする。
  • ソートタプル全体にインデックスを作成する。 複合インデックスは ORDER BY の両列と方向に一致している必要があります。一致しない場合、キーセットクエリはスキャンにフォールバックします。
  • 総件数より has_next_page を優先する。 すべてのレスポンスで total_count を返すと、別途 COUNT(*) が必要になります。大規模テーブルでのそのコストはテーブルの膨張と可視性マップに依存します。代わりに limit + 1 行から has_next_page を安価に導出し、総件数は本当に必要なUIのためにのみ使用してください。
  • カーソルを不透明に保つ。 クライアントが内部形式に依存できないようにエンコードし、基盤となる戦略を自由に変更できるようにする。
  • フィルターやソートの変更時にカーソルをリセットする。 カーソルは特定の順序付き・フィルタリング済み結果セット内の位置をエンコードしています。ユーザーがソート順を変更したり新しいフィルターを適用したりすると、そのカーソルは新しいクエリに対して無効となるため、ソートやフィルターパラメータが変更された際には必ず最初のページにリセットしてください。クライアント側では、フィルター状態にカーソル状態を紐付けることで、フィルター変更時に蓄積されたページをクリアして再取得するようにします。

ウィンドウシフトバグと深いオフセットバグはテスト中に見落としやすいです。なぜなら、これらは並行書き込みや深いオフセットが発生した場合にのみ現れ、エラーログに記録されないことが多いからです。ウィンドウシフトバグはサーバーログには現れません。レスポンスは正しい件数の項目を持つ有効な200レスポンスですが、セッションリプレイでは特定のレンダリングアーティファクトとして確認できます。すでにビューポートに表示されていた項目が次のバッチで再びレンダリングされ、前の位置のすぐ隣に表示されます。深いオフセットの速度低下も同様に現れます。タイムアウトや4xxエラーとしてではなく、クライアントがまだ待っているスロー200レスポンスで「もっと読み込む」スピナーが永遠に解決しないという形で現れます。これらは個別の失敗したリクエストではなく、インタラクションシーケンスの症状であるため、スクロール、フェッチ、レンダリングの完全なシーケンスの中に存在し、セッションリプレイがこれらを発見する有効な手段となることが多いです。

まとめ

次にリストエンドポイントを構築する際は、キーセットクエリを基盤とするカーソルページネーションを採用し、オフセットは引き継ぐデフォルトではなく正当化が必要な例外として扱ってください。深いオフセットのリニアなコストとウィンドウシフトの不安定性は構造的なものであり、チューニングで解決できるものではありません。ソートタプルに複合インデックスを追加し、総件数の代わりに has_next_page を返し、カーソルを不透明に保つことで、破壊的な変更なしに内部エンジンを入れ替えられるようにしてください。

FAQ

既存のオフセットベースAPIにカーソルページネーションを追加しても、既存のクライアントを壊さないようにできますか?

はい、ただし単純な置き換えではなく新しいパラメータとして扱ってください。既存の page と limit パラメータと並行してカーソルクエリパラメータを受け付け、カーソルを含むリクエストはキーセットクエリにルーティングし、ページベースのリクエストはオフセットパスに残します。内部形式を自由に変更できるようカーソルを不透明に保ち、大規模または書き込みが多いエンドポイントのオフセットパスは即座に削除するのではなく非推奨として文書化してください。

カーソルが指す行が削除された場合はどうなりますか?

何も壊れません。キーセットカーソルは行の参照ではなくソート値をエンコードするからです。複合述語 (created_at, id) < (val, id) はその境界タプルより後に順序付けられたすべての行を選択するため、正確なアンカー行が存在しなくなっても次のページは正しく返されます。これがオフセットページネーションに対する核心的な優位点です。オフセットページネーションでは削除によってその後のすべての位置がシフトし、クライアントがサイレントに項目をスキップしてしまいます。

カーソルにタイムスタンプとIDを直接送信する代わりに、base64エンコードされたJSONを使用する理由は何ですか?

エンコードによってカーソルが不透明になり、トークンを手動で解析・構築することを意図していないことをクライアントに示します。クライアントが生のタイムスタンプとIDを読み取ると、内部のページネーション戦略に依存することになり、変更した際に壊れてしまいます。不透明なbase64トークンを使用することで、クライアント側の変更なしにフィールドの追加、ソートキーの変更、キーセットからページトークンへの移行が可能になります。エンコードによる意味のあるオーバーヘッドはありません。

カーソルページネーションはcreated_at以外の列、例えば人気スコアでソートする場合にも機能しますか?

はい、カーソルがクエリの ORDER BY と同じソートタプルをエンコードし、一意のタイブレーカーで終わる限り機能します。人気スコアでソートする場合、述語とインデックスは (score, id) をカバーし、カーソルは両方の値を持つ必要があります。スコアはタイムスタンプよりもはるかに頻繁に衝突するからです。一意の id タイブレーカーがない場合、同じスコアを持つ行は非決定論的な順序を生み出し、ページをまたいで項目がスキップまたは繰り返される可能性があります。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.