Offset Pagination 的問題
Offset pagination 是最常見的寫法:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 100;// Request: GET /posts?page=6&page_size=20
const offset = (page - 1) * pageSize;
const posts = await Post.findAll({ limit: pageSize, offset });
const total = await Post.count();
res.json({
data: posts,
pagination: {
page,
page_size: pageSize,
total,
total_pages: Math.ceil(total / pageSize),
}
});問題一:OFFSET 效能
OFFSET 100000 的實際工作:資料庫掃過 100,000 筆記錄,然後丟掉,只回傳接下來的 20 筆。這個掃描是真實發生的,資料量越大越慢。posts 表有 100 萬筆,第 5 萬頁的查詢要掃 100 萬筆。
問題二:Phantom Page
用戶在第 2 頁,這時候有人刪了第 1 頁的一篇文章:
原本:第 1 頁 ID [1,2,3,4,5,6,7,8,9,10],第 2 頁 ID [11,12,13...]
刪 ID=5 後:第 1 頁 ID [1,2,3,4,6,7,8,9,10,11],第 2 頁 ID [12,13,14...]
ID=11 從第 2 頁「消失」了,用戶看不到它。新增時反過來——同一筆資料可能在兩頁各出現一次。
Cursor Pagination
Cursor pagination 用「上一筆記錄的 ID 或時間戳」作為下一頁的起點,不用 OFFSET:
-- 向後翻頁:找 created_at < cursor 的記錄
SELECT * FROM posts
WHERE created_at < '2026-04-15T10:30:00Z' -- cursor
ORDER BY created_at DESC
LIMIT 20;// Request: GET /posts?cursor=2026-04-15T10%3A30%3A00Z&limit=20
async function findPosts(cursor?: string, limit = 20) {
const where = cursor ? { created_at: { [Op.lt]: new Date(cursor) } } : {};
const posts = await Post.findAll({
where,
order: [['created_at', 'DESC']],
limit: limit + 1, // 多取一筆來判斷是否有下一頁
});
const hasNextPage = posts.length > limit;
if (hasNextPage) posts.pop();
const nextCursor = hasNextPage
? posts[posts.length - 1].createdAt.toISOString()
: null;
return {
data: posts,
pagination: {
next_cursor: nextCursor,
has_next_page: hasNextPage,
}
};
}
// Response
{
"data": [...],
"pagination": {
"next_cursor": "2026-04-14T08:15:00Z",
"has_next_page": true
}
}
// 下一個 request: GET /posts?cursor=2026-04-14T08%3A15%3A00Z為什麼解決了 OFFSET 問題:
- 效能:
WHERE created_at < cursor用 index 直接跳到對的位置,不掃描前面的記錄 - 穩定性:cursor 是固定點,不受新增或刪除影響
Cursor 的問題:
- 不能跳頁(不能直接去第 50 頁)
- 不知道總頁數(無法顯示「第 X 頁,共 Y 頁」)
- 雙向翻頁(上一頁)實作較複雜
Cursor 的格式:時間戳 vs Opaque Token
時間戳 cursor(上面的例子):直觀,但有問題——如果多筆記錄的 created_at 相同(精度低或批次寫入),cursor 就不精確。
Composite cursor(更穩固):
// cursor = created_at + id(避免時間戳重複的問題)
const cursor = Buffer.from(
JSON.stringify({ created_at: post.createdAt, id: post.id })
).toString('base64');
// 解 cursor
const { created_at, id } = JSON.parse(Buffer.from(cursor, 'base64').toString());
// SQL:先按 created_at 排,相同 created_at 再按 id 排
WHERE (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESCBase64 編碼的好處:API 消費者看到的是一個不透明的 token(eyJjcmVhdGVkX2F0Ij...),不會嘗試解析或偽造它;實作改 cursor 格式也不影響 API 介面。
什麼時候選哪個
| 場景 | 建議 |
|---|---|
| Admin 後台(需要跳頁、看第幾頁) | Offset |
| 資料量 < 10 萬,不增刪頻繁 | Offset(夠用,實作簡單) |
| 無限滾動 / 社群 feed | Cursor(插入不影響翻頁) |
| 資料量 > 100 萬,有效能需求 | Cursor |
| API 給第三方消費(非 UI) | Cursor(更穩定,適合機器消費) |
實務做法:先用 Offset,等真的碰到效能問題或 phantom page 投訴再換 Cursor。換的時候 API 版本通知消費者。
Response 格式標準化
// Offset pagination response
{
"data": [...],
"pagination": {
"page": 3,
"page_size": 20,
"total": 1234,
"total_pages": 62,
"has_prev_page": true,
"has_next_page": true
}
}
// Cursor pagination response
{
"data": [...],
"pagination": {
"next_cursor": "eyJjcmVhdGVkX2F0...",
"prev_cursor": "eyJjcmVhdGVkX2F0...", // 支援往回翻時需要
"has_next_page": true,
"has_prev_page": true
}
}