[ADP-312] 分頁
概述
RESTful API 中的分頁是一種將大型資料集分割成更小、更易管理的塊或"頁面"的技術。這有助於提高性能、減少服務器負載並提供更好的用戶體驗。
分頁主要有兩種方法:基於偏移的分頁(offset-based)和基於游標的分頁(cursor-based)。何時使用何者,詳見下方設計考量。
基於偏移的分頁
基於偏移的分頁 API 會使用 offset 來指定資料集合的起始點,並使用限制來指定要檢索的項目數。
示例
GET /resources?offset=20&limit=10 HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
Link: </resources?offset=20&limit=10>; rel="self", </resources?offset=30&limit=10>; rel="next", </resources?offset=10&limit=10>; rel="prev", </resources?offset=0&limit=10>; rel="first", </resources?offset=90&limit=10>; rel="last"
X-Total-Count: 100
{
"resources": [ /* 從偏移量20到29的資源 */ ]
}基於游標的分頁
基於游標的分頁使用游標(通常是唯一標識符)來獲取下一組結果。
示例
GET /resources?cursor=abc123&limit=10 HTTP/1.1HTTP/1.1 200 OK
Content-Type: application/json
Link: </resources?cursor=abc123&limit=10>; rel="self", </resources?cursor=def456&limit=10>; rel="next"
X-Total-Count: 256
{
"resources": [ /* 從游標abc123之後開始的資源 */ ]
}TIP
業界對 cursor 這個值有不同命名:Google AIP-158 用 page_token / next_page_token、Twitter API v2 用 pagination_token / next_token、Atlassian Jira 用 nextPageToken、GitHub 用 after / before、Elasticsearch 用 search_after。也有不另設參數的做法 — Stripe 直接用最後一個物件的 ID 透過 starting_after / ending_before 帶下一頁。這些命名指的都是同一個概念:一個告訴 server「從哪裡接續」的不透明字串。本文件統一使用 cursor。
指導原則
應該(SHOULD)在需求與成本之間取捨,選擇分頁策略。詳見下方設計考量。
資料量大時若仍想使用 offset 分頁策略,應該(SHOULD)對最大可達 offset 設上限,避免無上限的 deep-scan(server 為了找一頁要從頭逐筆掃過大量列)。
TIP
GitHub Search API 把總結果數限制在 1,000 筆就是基於這個考量。
應該(SHOULD)使用常見的查詢參數:
cursor、offset、limit。應該(SHOULD)使用標準 Link 標頭依 RFC 8288 提供分頁資訊。
rel="next"的存在表示還有更多結果,不存在表示結果結束。不要將分頁連結嵌入回應主體。httpHTTP/1.1 200 OK Content-Type: application/json Link: </resources?cursor=abc123&limit=10>; rel="self", </resources?cursor=def456&limit=10>; rel="next", </resources?cursor=xyz789&limit=10>; rel="prev" X-Total-Count: 256 { "resources": [ /* 資源項目 */ ] }應該(SHOULD)僅在計數精確且運算成本低時提供
X-Total-Count,其餘情況省略。TIP
在多數後端,每個分頁回應都算
COUNT(*)是 O(N) 操作,資料量大或無上限時會撐不住。詳見設計考量。應該(SHOULD)使用註冊過的自定義標頭來提供分頁 metadata。這些標頭必須(MUST)在組織的內部註冊表中註冊:
X-Total-Count:可用項目的總數(僅在精確且運算成本低時使用)X-Page-Count:可用頁面的總數(僅適用於 offset 分頁)X-Current-Page:當前頁碼(僅適用於 offset 分頁)
設計考量
Offset 與 Cursor 的比較
Offset 適合小型資料集、需要隨機跳頁(例如帶頁碼按鈕的 UI),以及客戶端可收藏的穩定 URL。offset=N 的成本隨 N 線性增加,這個前提只在資料量小時成立。一旦資料量變大,offset 查詢會變慢,無上限的 offset 查詢還可能拖垮資料庫。GitHub Search API 把總結果數限制在 1,000 筆,就是基於這個考量。
Cursor 適合大型或無上限的資料集、需要從頭到尾讀完整份資料的工作(例如資料匯出、產生報表)、infinite scroll,以及在分頁過程中會有資料新增或刪除的情境。Cursor 只能拿著前一頁的結尾要下一頁,不能直接跳到任意頁。
Atlassian 在 2025 年把 Jira 搜尋從 offset 遷移到 cursor 是一個值得參考的案例。Atlassian 在 RFC-61 中說明的理由是:offset 分頁要求每頁都計算所有前置列,這「對使用者很慢、對我們很貴、無法擴展」。他們的遷移文件顯示,變更後 99 百分位延遲(P99)從 1502 ms 降到 358 ms。
為何 Cursor 無法跳至第 N 頁
Cursor 代表的是查詢結果中某一筆的位置 — 這裡的「查詢」可以帶過濾條件,也可以是「給我整份列表」這種無過濾的列出 — 通常用上一筆回傳項目的排序鍵值來表達。它本身沒有頁碼的概念。要產生第 47 頁的 cursor,server 必須先走過第 1 到第 46 頁,等於把 cursor 分頁原本想避開的 deep scan 成本又拉回來了。
需要頁碼 UI 的 API 應使用 offset(並設上限)。不需要的 API 應拿掉頁碼概念,改用 infinite scroll、「載入更多」按鈕,或用 filter 直接跳轉(依日期、狀態或排序鍵)。
總筆數:是否提供
在多數後端,算出符合 query 的總筆數是 O(N) 操作。PostgreSQL COUNT(*) 必須做順序掃描,因為 MVCC(多版本並行控制)要求逐筆判斷可見性,公開 benchmark 顯示每百萬列約 85 ms。改用 INSERT/DELETE trigger 維護一個精確計數器可以消除讀取成本,但會讓 INSERT 在典型工作負載下慢約 50×。
Stripe 與 Atlassian 都在 2024–2025 年因為這個成本拿掉了 per-page total count。Stripe 在 API version 2025-03-31.basil deprecate 了 total_count expansion,要求 client 改用 has_more。Atlassian 在 Jira 新版搜尋端點移除了 total,改提供獨立的 approximate-count endpoint。
當服務有 client 真的需要 count 但代價昂貴時,業界做法分歧:
- 完全不給 count。 Stripe(2025-03-31 起)只回
has_more,client 須自行取最後一個物件的 ID 透過starting_after拼出下一頁;Twitter API v2 在meta.next_token回傳 token,client 用pagination_token帶回。兩者都把計數需求推出 API contract 之外。 - 透過 request header opt-in。 PostgREST 的
Prefer: count=exact|planned|estimated讓 client 自選精度等級(exact = 全掃描;planned = 用 PostgreSQL 統計;estimated = threshold 以下 exact,超過用 planned)。Zalando 用Prefer: return=total-count(boolean opt-in)。兩者都依 RFC 7240,server 可逕自不予採用,無須告知 client。 - 獨立的計數 resource。 Atlassian 的
POST /rest/api/3/search/approximate-count把 count 當獨立 endpoint 提供,可獨立 cache 與限流。
相較於上述 opt-in 做法,Microsoft Azure 訂了 SHOULD NOT 等級的明文規範:不該預設返回集合的總數,因為計算成本可能很高。
是否提供 count、用什麼方式提供,是需求(client 要不要)、實作(怎麼算)、成本(每種實作的代價)三者拉扯的決策。沒有通用的答案。先問 client 是否真的需要 count。多數情況其實不需要,不做就是最省事的決定。真的有需求時,主要看 count 多常被呼叫和每次算多貴的取捨。挑能同時處理這兩件事的最簡單做法,把決定寫進服務的 ADR。
計數怎麼算取決於要多精確、寫入多頻繁、資料多大。
TIP
大資料下常見的實作技術:
- Trigger 或 counter table — 精確值。同步 trigger 在每次寫入時順便更新 counter,會讓寫入慢約 50×(Citus benchmark)。改用非同步背景同步機制(CDC,監聽 DB 寫入紀錄)更新 counter,寫入維持原速,但 counter 會延遲幾秒。
- DB 統計值估算(Postgres
pg_class.reltuples、MySQLINFORMATION_SCHEMA.TABLES)— 近即時,精度依統計值的更新時點而定(由 PostgreSQL 的ANALYZE指令重算);適合「約 N 筆」這類粗估 UI。 - HyperLogLog (HLL) — 機率式,誤差約 0.5–2%,幾十億筆只需 KB 級記憶體;BigQuery、Snowflake、Presto、ClickHouse 內建。
- Search engine capped count(Elasticsearch
track_total_hits: N)— 上限 N 內精確、超過顯示「N+」;超過後 short-circuit。
示例
OpenAPI 示例
openapi: 3.1.0
info:
title: 示例 API
version: 1.0.0
paths:
/resources:
get:
summary: 獲取資源列表
parameters:
- name: cursor
in: query
description: 分頁的游標
required: false
schema:
type: string
- name: limit
in: query
description: 每頁項目數
required: false
schema:
type: integer
default: 20
maximum: 100
- name: filter
in: query
description: 過濾條件
required: false
schema:
type: string
- name: sort
in: query
description: 排序順序
required: false
schema:
type: string
responses:
'200':
description: 分頁的資源列表
headers:
Link:
description: |
依 RFC 8288 的分頁連結。`rel="next"` 的存在表示還有更多結果;
其不存在表示結果結束。
schema:
type: string
example: </resources?cursor=def456&limit=10>; rel="next", </resources?cursor=xyz789&limit=10>; rel="prev"
X-Total-Count:
description: |
可用項目的總數。僅在計數精確且運算成本低時使用,否則省略。
schema:
type: integer
example: 256
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object參考
規範參考
設計參考
- GitHub REST API: Pagination
- GitHub REST API: Search — offset 分頁,總結果數上限 1,000
- Stripe API: Pagination — cursor 分頁配
has_more - Stripe Changelog: deprecate total_count expansion (2025-03-31)
- PostgREST: Pagination and Count —
Prefer: count=exact|planned|estimated協商 - Elasticsearch: Search API —
track_total_hits、search_after - Zalando RESTful API Guidelines: Pagination — 透過
Prefer: return=total-count提供 opt-in count - Microsoft Azure REST API Guidelines: Collections — 預設不給 count 的 SHOULD NOT 指引
- Google AIP-158: Pagination —
total_sizefield 慣例(可為估計值) - Atlassian RFC-61: Evolving Search Capabilities (2024-08-29) — Jira 搜尋從 offset 遷移到 cursor 的官方理由
- Avoiding Pitfalls: Migration to Enhanced JQL APIs — Atlassian 公開的 P90/P99 延遲比較
- Citus: Faster PostgreSQL Counting — PostgreSQL 各種計數技術 benchmark
更新紀錄
2026-05-05: 大幅改寫 — 新增設計考量章節(offset/cursor 選擇、總筆數取捨),改寫指導原則對齊「需求 vs 成本」取捨框架,統一 cursor 術語。2025-04-14: 更新為建議使用標準 Link 標頭和自定義標頭來提供分頁 metadata,而非在回應主體中嵌入連結。