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 DESC

Base64 編碼的好處:API 消費者看到的是一個不透明的 token(eyJjcmVhdGVkX2F0Ij...),不會嘗試解析或偽造它;實作改 cursor 格式也不影響 API 介面。


什麼時候選哪個

場景建議
Admin 後台(需要跳頁、看第幾頁)Offset
資料量 < 10 萬,不增刪頻繁Offset(夠用,實作簡單)
無限滾動 / 社群 feedCursor(插入不影響翻頁)
資料量 > 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
  }
}

延伸閱讀