Skip to content
ADP
API Design PrincipleBETA

[ADP-312] 分頁

概述

RESTful API 中的分頁是一種將大型資料集分割成更小、更易管理的塊或"頁面"的技術。這有助於提高性能、減少服務器負載並提供更好的用戶體驗。

分頁主要有兩種方法:基於偏移的分頁(offset-based)和基於游標的分頁(cursor-based)。何時使用何者,詳見下方設計考量

基於偏移的分頁

基於偏移的分頁 API 會使用 offset 來指定資料集合的起始點,並使用限制來指定要檢索的項目數。

示例

http
GET /resources?offset=20&limit=10 HTTP/1.1
http
HTTP/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的資源 */ ]
}

基於游標的分頁

基於游標的分頁使用游標(通常是唯一標識符)來獲取下一組結果。

示例

http
GET /resources?cursor=abc123&limit=10 HTTP/1.1
http
HTTP/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)使用常見的查詢參數:cursoroffsetlimit

  • 應該(SHOULD)使用標準 Link 標頭依 RFC 8288 提供分頁資訊。rel="next" 的存在表示還有更多結果,不存在表示結果結束。不要將分頁連結嵌入回應主體。

    http
    HTTP/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、MySQL INFORMATION_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 示例

yaml
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

參考

規範參考

設計參考

更新紀錄

  • 2026-05-05: 大幅改寫 — 新增設計考量章節(offset/cursor 選擇、總筆數取捨),改寫指導原則對齊「需求 vs 成本」取捨框架,統一 cursor 術語。
  • 2025-04-14: 更新為建議使用標準 Link 標頭和自定義標頭來提供分頁 metadata,而非在回應主體中嵌入連結。