👋

ページングのベストプラクティス:オフセット vs キーセット

に公開

ページングについてのアーキテクチャを検討する機会があったので、あらためてその基本を整理しておきます。

🚩 オフセットページングとは?

SELECT * FROM Items ORDER BY CreatedAt DESC
OFFSET 100 ROWS FETCH NEXT 20 ROWS ONLY;

✅ 特徴

  • UI的に「ページ番号」が使える
  • どのページでも自由に飛べる

⚠️ デメリット

  • OFFSETが大きくなるほどクエリが重くなる(スキャンコスト)
  • データの追加・削除により「ズレ」が発生しやすい

🚩 キーセットページングとは?

SELECT * FROM Items
WHERE CreatedAt < @lastCreatedAt
ORDER BY CreatedAt DESC
FETCH NEXT 20 ROWS ONLY;

✅ 特徴

  • スクロールUIや無限ロードに最適
  • パフォーマンスが非常に高い(インデックス効率的)
  • 順序保証されていれば安定した表示

⚠️ デメリット

  • ページ番号のUIが作れない
  • 「前に戻る」が難しい(逆順クエリが必要)

C#での実装(EF Core)

オフセット方式

var result = await _db.Items
    .OrderByDescending(x => x.CreatedAt)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

キーセット方式

var result = await _db.Items
    .Where(x => x.CreatedAt < lastCreatedAt)
    .OrderByDescending(x => x.CreatedAt)
    .Take(pageSize)
    .ToListAsync();

Pythonでの実装(SQLAlchemy)

オフセット方式

query = session.query(Item).order_by(Item.created_at.desc())
items = query.offset((page - 1) * page_size).limit(page_size).all()

キーセット方式

query = session.query(Item)
    .filter(Item.created_at < last_created_at)
    .order_by(Item.created_at.desc())
    .limit(page_size)
items = query.all()

TypeScript (prisma)

オフセット方式

const items = await prisma.item.findMany({
  orderBy: { createdAt: 'desc' },
  skip: (page - 1) * pageSize,
  take: pageSize,
});

キーセット方式

const items = await prisma.item.findMany({
  where: { createdAt: { lt: lastCreatedAt } },
  orderBy: { createdAt: 'desc' },
  take: pageSize,
});

✅ 選び方のベストプラクティス

シナリオ 推奨方式
管理画面などで「ページ番号」が必要 オフセット方式
モバイルや無限スクロール系UI キーセット方式
データ量が非常に多い(10万件以上) キーセット方式(高速)
データ整合性を優先したい(ズレNG) キーセット方式

🧩 ページングメタ情報の付加

どちらの方式でも、レスポンスには以下のような情報を付与すると親切です:

{
  "items": [...],
  "hasNext": true,
  "nextKey": "2024-05-01T12:00:00Z"
}

✅ まとめ

比較項目 オフセット キーセット
UI互換性 ◎(ページ番号あり) △(無限スクロール向き)
パフォーマンス △(遅くなる) ◎(高速)
実装の簡易さ
データ整合性

一覧画面の設計では、「ユーザー体験」と「性能」のバランスを意識してページング方式を選びましょう!

🧭 その他のページング手法

📌 カーソルページング(キーセットの派生/GraphQLなどでよく使う)

  • nextCursor などのトークンを返して、次の位置を指定
  • Base64や暗号化でクエリ情報を隠すことも可能
  • 前後移動や一意キーとの組み合わせがカギ

📌 カウントページング(ページ番号UI補助目的)

  • SELECT COUNT(*) を併用して全ページ数を算出
  • ユーザーに「全10ページ中3ページ目」と示す用途に便利
  • 大量データでは COUNT(*) がボトルネックになることもある

📌 時間ベースページング(ログや履歴データ向き)

  • WHERE CreatedAt BETWEEN start AND end のような時間ウィンドウ指定
  • 日単位や時間単位でのフィルターに向いており、レポート系に最適

Discussion