<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Pagination on Tarragon</title><link>https://tarrragon.github.io/blog/tags/pagination/</link><description>Recent content in Pagination on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 03 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/pagination/index.xml" rel="self" type="application/rss+xml"/><item><title>11.7 集合介面設計</title><link>https://tarrragon.github.io/blog/backend/11-api-design/collection-interface-design/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/collection-interface-design/</guid><description>&lt;p>集合介面的設計難點在規模是變數：同一個 list endpoint、資料一千筆時各方案差異可忽略、一億筆加高頻寫入時、當初的分頁決策變成效能與一致性問題。本章處理三個規模敏感的介面模式：分頁、批次、長時操作。&lt;/p>
&lt;h2 id="分頁offset-的兩個失效模式">分頁：offset 的兩個失效模式&lt;/h2>
&lt;p>offset 分頁（&lt;code>?page=3&amp;amp;limit=50&lt;/code>）的介面直觀、失效有明確的機制。Slack 的工程紀錄給了一手描述：其一、&lt;code>LIMIT / OFFSET&lt;/code> 深頁掃描 — 資料庫要掃過並丟棄前面所有列、頁數越深成本越高；其二、page window 漂移 — 高頻寫入下、兩次請求之間有新資料插入、消費者會看到跳項或重複（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/" data-link-title="11.C37 Slack：offset 到 opaque cursor 的分頁遷移" data-link-desc="深頁掃描與 page window 漂移兩個失效模式、opaque cursor 把分頁狀態表示權留在 server 端">11.C37&lt;/a>）。&lt;/p>
&lt;p>Slack 的解法是遷移到 opaque cursor：介面收斂為 &lt;code>cursor&lt;/code> 加 &lt;code>limit&lt;/code>、回傳 &lt;code>next_cursor&lt;/code>、cursor 內容 Base64 編碼、消費者不可解析。opaque 這個性質是設計重點 — &lt;strong>分頁狀態的表示權留在 server 端&lt;/strong>、消費者不能解析就不能依賴內部格式、server 可以自由更換底層策略（keyset、shard 位置、甚至混合）而不動介面。同一份紀錄明列了付出的代價：失去 total count 與跳頁能力 — 這是明示的產品決策、選 cursor 前要跟產品端確認「第 N 頁」跟「共幾筆」是不是真需求。&lt;/p>
&lt;p>判準：資料量小、寫入頻率低、產品要跳頁 — offset 合理且便宜；資料量大或寫入頻繁 — cursor、並從第一版就 opaque（先給透明 cursor 再收緊、又是一次 breaking change）。offset、cursor、keyset 的完整交鋒與「cursor 不透明性算承諾還是逃生門」的爭議、收在掛本章的分頁爭論文章 backlog（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁&lt;/a>）。&lt;/p>
&lt;h2 id="批次操作部分失敗是預設不是例外">批次操作：部分失敗是預設、不是例外&lt;/h2>
&lt;p>批次介面（一次建立 100 筆）的核心設計問題是部分失敗語意：第 37 筆驗證失敗、前 36 筆算什麼。三種可承諾的語意、各有成立情境：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>全有全無&lt;/strong>：包成一個 transaction、任一筆失敗全部回滾。語意最乾淨、消費者重試最簡單（整包重送）；成本是 server 端要撐住大 transaction、且單筆失敗導致整批回滾的體驗、在大批次下代價過高。&lt;/li>
&lt;li>&lt;strong>獨立處理、逐筆回報&lt;/strong>：回應是跟請求等長的結果陣列、每筆自己的成功或錯誤。務實預設、但消費者的重試邏輯變複雜 — 要能只重送失敗子集、這又要求逐筆操作冪等（&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8&lt;/a> 的主題）。&lt;/li>
&lt;li>&lt;strong>fail-fast&lt;/strong>：處理到第一個錯誤即停、回報已處理數。適合順序有意義的批次（匯入）、消費者從斷點續傳。&lt;/li>
&lt;/ul>
&lt;p>判準是消費者的重試能力與資料的順序性；唯一的反模式是不宣告 — 文件沒寫部分失敗語意的批次介面、消費者只能拿 production 事故來逆向工程。&lt;/p>
&lt;h2 id="長時操作把進行中實體化">長時操作：把「進行中」實體化&lt;/h2>
&lt;p>超過請求逾時預算的操作（報表、匯入、佈建）、介面要回的是「工作的身分」而非結果。Google AIP-151 是這個模式的系統化規範：長時方法回傳 Operation resource、client 輪詢其 &lt;code>done&lt;/code> / &lt;code>response&lt;/code> / &lt;code>error&lt;/code> 狀態、回應型別事先宣告、operation 約 30 天過期（見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/longrun-google-aip151/" data-link-title="11.C44 Google AIP-151：長時操作實體化成 Operation resource" data-link-desc="202 &amp;#43; 輪詢模式的系統化版本：回應型別先宣告、client 統一寫一套 polling、operation 生命週期明訂">11.C44&lt;/a>）。比起裸的 202 加 Location、Operation resource 的增量價值在統一：所有長時操作共用同一個查詢介面、client 寫一套 polling 邏輯到處用；&lt;code>done=true&lt;/code> 直接回的 validate-only 條款、示範了用同一個介面模式涵蓋同步捷徑的手法（C44 判讀）。&lt;/p>
&lt;p>設計時要明訂的三件事：operation 的生命週期（查詢結果保留多久 — AIP 選 30 天）、輪詢的節奏指引（配合 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9&lt;/a> 的限流語意、避免消費者用 while-true 打爆查詢端點）、以及完成通知的替代路徑（webhook 回呼、屬 styles/realtime 的 backlog 範圍）。&lt;/p>
&lt;h2 id="常見設計錯誤">常見設計錯誤&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>透明 cursor&lt;/strong>：消費者 decode 後依賴內部欄位、底層換策略即斷 — 從第一版就 opaque。&lt;/li>
&lt;li>&lt;strong>批次語意未宣告&lt;/strong>：部分失敗行為靠消費者猜。&lt;/li>
&lt;li>&lt;strong>長時操作同步等&lt;/strong>：把 5 分鐘的工作掛在一條 HTTP 連線上、逾時、重試、重複執行三連發。&lt;/li>
&lt;li>&lt;strong>list 端點無上限&lt;/strong>：&lt;code>limit&lt;/code> 沒有 max、一個請求拉全表 — 上限是集合介面的基本流量防線（完整語意見 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9&lt;/a>）。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>批次重試的前提：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計&lt;/a>&lt;/li>
&lt;li>深頁掃描背後的資料庫機制：&lt;a href="https://tarrragon.github.io/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&amp;#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 Query 反模式&lt;/a>&lt;/li>
&lt;li>案例原文：&lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>集合介面的設計難點在規模是變數：同一個 list endpoint、資料一千筆時各方案差異可忽略、一億筆加高頻寫入時、當初的分頁決策變成效能與一致性問題。本章處理三個規模敏感的介面模式：分頁、批次、長時操作。</p>
<h2 id="分頁offset-的兩個失效模式">分頁：offset 的兩個失效模式</h2>
<p>offset 分頁（<code>?page=3&amp;limit=50</code>）的介面直觀、失效有明確的機制。Slack 的工程紀錄給了一手描述：其一、<code>LIMIT / OFFSET</code> 深頁掃描 — 資料庫要掃過並丟棄前面所有列、頁數越深成本越高；其二、page window 漂移 — 高頻寫入下、兩次請求之間有新資料插入、消費者會看到跳項或重複（見 <a href="/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/" data-link-title="11.C37 Slack：offset 到 opaque cursor 的分頁遷移" data-link-desc="深頁掃描與 page window 漂移兩個失效模式、opaque cursor 把分頁狀態表示權留在 server 端">11.C37</a>）。</p>
<p>Slack 的解法是遷移到 opaque cursor：介面收斂為 <code>cursor</code> 加 <code>limit</code>、回傳 <code>next_cursor</code>、cursor 內容 Base64 編碼、消費者不可解析。opaque 這個性質是設計重點 — <strong>分頁狀態的表示權留在 server 端</strong>、消費者不能解析就不能依賴內部格式、server 可以自由更換底層策略（keyset、shard 位置、甚至混合）而不動介面。同一份紀錄明列了付出的代價：失去 total count 與跳頁能力 — 這是明示的產品決策、選 cursor 前要跟產品端確認「第 N 頁」跟「共幾筆」是不是真需求。</p>
<p>判準：資料量小、寫入頻率低、產品要跳頁 — offset 合理且便宜；資料量大或寫入頻繁 — cursor、並從第一版就 opaque（先給透明 cursor 再收緊、又是一次 breaking change）。offset、cursor、keyset 的完整交鋒與「cursor 不透明性算承諾還是逃生門」的爭議、收在掛本章的分頁爭論文章 backlog（見 <a href="/blog/backend/11-api-design/" data-link-title="模組十一：API 設計與對外契約" data-link-desc="整理 API 風格選型、資源建模、錯誤模型、版本與相容策略、冪等與對外流量語意的設計判準；主流做法與各流派的深度論證分層收錄">模組頁</a>）。</p>
<h2 id="批次操作部分失敗是預設不是例外">批次操作：部分失敗是預設、不是例外</h2>
<p>批次介面（一次建立 100 筆）的核心設計問題是部分失敗語意：第 37 筆驗證失敗、前 36 筆算什麼。三種可承諾的語意、各有成立情境：</p>
<ul>
<li><strong>全有全無</strong>：包成一個 transaction、任一筆失敗全部回滾。語意最乾淨、消費者重試最簡單（整包重送）；成本是 server 端要撐住大 transaction、且單筆失敗導致整批回滾的體驗、在大批次下代價過高。</li>
<li><strong>獨立處理、逐筆回報</strong>：回應是跟請求等長的結果陣列、每筆自己的成功或錯誤。務實預設、但消費者的重試邏輯變複雜 — 要能只重送失敗子集、這又要求逐筆操作冪等（<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8</a> 的主題）。</li>
<li><strong>fail-fast</strong>：處理到第一個錯誤即停、回報已處理數。適合順序有意義的批次（匯入）、消費者從斷點續傳。</li>
</ul>
<p>判準是消費者的重試能力與資料的順序性；唯一的反模式是不宣告 — 文件沒寫部分失敗語意的批次介面、消費者只能拿 production 事故來逆向工程。</p>
<h2 id="長時操作把進行中實體化">長時操作：把「進行中」實體化</h2>
<p>超過請求逾時預算的操作（報表、匯入、佈建）、介面要回的是「工作的身分」而非結果。Google AIP-151 是這個模式的系統化規範：長時方法回傳 Operation resource、client 輪詢其 <code>done</code> / <code>response</code> / <code>error</code> 狀態、回應型別事先宣告、operation 約 30 天過期（見 <a href="/blog/backend/11-api-design/cases/longrun-google-aip151/" data-link-title="11.C44 Google AIP-151：長時操作實體化成 Operation resource" data-link-desc="202 &#43; 輪詢模式的系統化版本：回應型別先宣告、client 統一寫一套 polling、operation 生命週期明訂">11.C44</a>）。比起裸的 202 加 Location、Operation resource 的增量價值在統一：所有長時操作共用同一個查詢介面、client 寫一套 polling 邏輯到處用；<code>done=true</code> 直接回的 validate-only 條款、示範了用同一個介面模式涵蓋同步捷徑的手法（C44 判讀）。</p>
<p>設計時要明訂的三件事：operation 的生命週期（查詢結果保留多久 — AIP 選 30 天）、輪詢的節奏指引（配合 <a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9</a> 的限流語意、避免消費者用 while-true 打爆查詢端點）、以及完成通知的替代路徑（webhook 回呼、屬 styles/realtime 的 backlog 範圍）。</p>
<h2 id="常見設計錯誤">常見設計錯誤</h2>
<ul>
<li><strong>透明 cursor</strong>：消費者 decode 後依賴內部欄位、底層換策略即斷 — 從第一版就 opaque。</li>
<li><strong>批次語意未宣告</strong>：部分失敗行為靠消費者猜。</li>
<li><strong>長時操作同步等</strong>：把 5 分鐘的工作掛在一條 HTTP 連線上、逾時、重試、重複執行三連發。</li>
<li><strong>list 端點無上限</strong>：<code>limit</code> 沒有 max、一個請求拉全表 — 上限是集合介面的基本流量防線（完整語意見 <a href="/blog/backend/11-api-design/external-traffic-semantics/" data-link-title="11.9 對外流量語意" data-link-desc="rate limit 對消費者承諾什麼、429 與 Retry-After 怎麼設計、配額 header 該不該信 — 限流作為契約的語意設計">11.9</a>）。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>批次重試的前提：<a href="/blog/backend/11-api-design/api-idempotency-design/" data-link-title="11.8 API 層冪等設計" data-link-desc="idempotency key 誰生成、存多久、replay 回什麼、衝突怎麼回 — 對外冪等契約的條款設計與無標準現況">11.8 API 層冪等設計</a></li>
<li>深頁掃描背後的資料庫機制：<a href="/blog/backend/01-database/query-anti-patterns/" data-link-title="1.13 應用層查詢反模式與 Query 預算" data-link-desc="整理 N&#43;1、select *、缺索引、ORM lazy load、long transaction 等查詢反模式與每請求的 query 預算判讀">1.13 Query 反模式</a></li>
<li>案例原文：<a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a></li>
</ul>
]]></content:encoded></item><item><title>11.C37 Slack：offset 到 opaque cursor 的分頁遷移</title><link>https://tarrragon.github.io/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/11-api-design/cases/pagination-slack-cursor-migration/</guid><description>&lt;p>這個案例的核心責任是提供 pagination 決策最乾淨的工程紀錄：明示 tradeoff 的產品決策。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>Slack 記錄 offset 的兩個失效模式：&lt;code>LIMIT / OFFSET&lt;/code> 深頁掃描的丟棄成本、高寫入頻率下 page window 漂移造成跳項或重複。遷移到 Base64 opaque cursor（受 Relay GraphQL spec 啟發）、介面收斂為 &lt;code>cursor&lt;/code> 加 &lt;code>limit&lt;/code>、回傳 &lt;code>next_cursor&lt;/code>；opaque 編碼允許各 endpoint 底層策略不同、甚至在單一 cursor 內編多個 shard 的位置。明列 tradeoff：失去 total count 與跳頁能力。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;p>opaque cursor 的核心價值是「把分頁狀態的表示權留在 server 端」— client 不能解析就不能依賴內部格式、這是介面演化自由度的直接來源。「犧牲跳頁換一致性與效能」是明示的產品決策、不是技術妥協。&lt;/p>
&lt;h2 id="對應大綱">對應大綱&lt;/h2>
&lt;p>11.7 集合介面設計（anchor）、分頁爭論文章。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;p>回 &lt;a href="https://tarrragon.github.io/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫&lt;/a>。&lt;/p>
&lt;h2 id="引用源">引用源&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://slack.engineering/evolving-api-pagination-at-slack/">Evolving API Pagination at Slack（Slack engineering blog）&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是提供 pagination 決策最乾淨的工程紀錄：明示 tradeoff 的產品決策。</p>
<h2 id="觀察">觀察</h2>
<p>Slack 記錄 offset 的兩個失效模式：<code>LIMIT / OFFSET</code> 深頁掃描的丟棄成本、高寫入頻率下 page window 漂移造成跳項或重複。遷移到 Base64 opaque cursor（受 Relay GraphQL spec 啟發）、介面收斂為 <code>cursor</code> 加 <code>limit</code>、回傳 <code>next_cursor</code>；opaque 編碼允許各 endpoint 底層策略不同、甚至在單一 cursor 內編多個 shard 的位置。明列 tradeoff：失去 total count 與跳頁能力。</p>
<h2 id="判讀">判讀</h2>
<p>opaque cursor 的核心價值是「把分頁狀態的表示權留在 server 端」— client 不能解析就不能依賴內部格式、這是介面演化自由度的直接來源。「犧牲跳頁換一致性與效能」是明示的產品決策、不是技術妥協。</p>
<h2 id="對應大綱">對應大綱</h2>
<p>11.7 集合介面設計（anchor）、分頁爭論文章。</p>
<h2 id="下一步路由">下一步路由</h2>
<p>回 <a href="/blog/backend/11-api-design/cases/" data-link-title="模組十一案例庫：API 設計與對外契約" data-link-desc="API 風格流派、版本與相容、介面語意、規範治理的已驗證公開案例集；含反例與覆蓋缺口標明">模組十一案例庫</a>。</p>
<h2 id="引用源">引用源</h2>
<ul>
<li><a href="https://slack.engineering/evolving-api-pagination-at-slack/">Evolving API Pagination at Slack（Slack engineering blog）</a></li>
</ul>
]]></content:encoded></item></channel></rss>