👋
ページングのベストプラクティス:オフセット 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