深入游標分頁:當交易紀錄一邊翻頁一邊插入新資料時,OFFSET 為什麼會壞掉
後端工程師,設計過交易所成交紀錄等大資料量列表 API。
那天客服轉來的截圖
我先講那個讓我下定決心重寫分頁的早上。
那是某個交易所專案,使用者在 App 上翻自己的成交紀錄。她截了兩張圖給客服:第一頁最底下那筆成交,跟第二頁最上面那筆,是一模一樣的同一筆交易。同一個訂單號、同一個成交價、同一個時間戳,出現了兩次。她的問題很合理:「我到底是不是被重複扣款了?」
當然沒有重複扣款,重複的只是「顯示」。但對一個拿真金白銀在交易的人來說,看到自己的成交紀錄出現幽靈,那種恐慌是真實的。客服壓不住,case 直接升到我這邊。
我看了一眼後端,分頁是這樣寫的,每頁 20 筆,標準到不能再標準的 OFFSET 寫法:
SELECT * FROM trades WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 20;
問題不在 SQL 寫錯,問題在於這套寫法天生就跟「即時插入的資料」八字不合。我那天花了一整個下午把整個分頁機制換成游標(cursor / keyset)分頁,那之後我再也沒回去用過 OFFSET 翻深列表。這篇就是把當時想清楚的東西完整寫一遍,因為我在另一篇談 API 設計的文章裡欠了大家一篇「專門講游標分頁」的文章,這篇就是還債。
OFFSET 為什麼會壞:先看那筆幽靈成交怎麼來的
OFFSET 的本質是「跳過前面 N 筆,再拿 M 筆」。它的座標系是「位置」,不是「資料」。而位置這個東西,在一個會一直插入新資料的表上,是會漂移的。
把那個早上的時間軸攤開來看。使用者打開成交紀錄,這是她的第一頁請求:
OFFSET 0 LIMIT 20,拿到第 1 到第 20 筆,最新的在最上面。
她看了幾秒,這幾秒之內,她又成交了一筆新單(或者系統幫她的掛單撮合了一筆)。這筆新成交,因為是最新的,插進了整個排序的最前面,變成新的第 1 筆。原本的第 1 到第 20 筆,每一筆的「位置」全部往後挪了一格,原本第 20 筆現在變成第 21 筆。
接著她按了下一頁:
OFFSET 20 LIMIT 20,意思是「跳過前 20 筆」。但現在的前 20 筆,是「新插入的那筆」加上「原本的第 1 到第 19 筆」。所以跳過這 20 筆之後,拿到的第 21 筆,正是原本的第 20 筆——也就是她在第一頁最底下已經看過的那筆。
幽靈成交就是這樣生出來的。不是 bug,是 OFFSET 的數學必然。
反過來,如果在她翻頁的空檔有一筆資料被刪掉(例如某筆掛單被取消、某筆紀錄被風控撤銷),位置會往前縮,於是 OFFSET 20 會跳過原本該在第 20、21 之間的那筆,造成「漏資料」。一個使用者在快速捲動的 feed 上漏掉一則貼文,通常不會發現;但一個在對帳的使用者漏掉一筆成交,那就是工單了。
重複跟跳過,是同一個病的兩種症狀:你用「位置」當座標,但位置在動。
第二個病:深分頁會越翻越慢
幽靈成交是正確性問題,還有一個是效能問題,而且更隱蔽,因為它平常不會發作。
OFFSET 在資料庫裡的執行方式,很多人有誤解。它不是「直接跳到第 N 筆」,資料庫做不到那件事。它的實際動作是:把符合條件的列「一筆一筆掃出來、排好序、數過去」,數到第 OFFSET 筆之前的全部丟掉,從第 OFFSET+1 筆開始才真正回傳。
也就是說,OFFSET 100000 LIMIT 20,資料庫要先實際處理十萬零二十筆,再把前十萬筆扔進垃圾桶。你付了十萬筆的代價,只拿到二十筆。
我當時實測過一張大約三千萬列的成交表。第一頁,OFFSET 0,大概兩三毫秒,飛快。OFFSET 50000,跳到十幾毫秒。等翻到 OFFSET 2000000,單一查詢要 1.8 秒到 2 秒上下,而且這還是在有索引、機器不忙的情況。深分頁的成本是線性往上爬的,越往後翻越慢。
平常你不會注意到,因為真人使用者很少手動翻到第十萬頁。真正會去翻深分頁的,是爬蟲、是匯出工具、是某個寫了 while loop 一直要下一頁的對接方。我那次就是有個資料同步服務在背景一頁一頁拉歷史成交,OFFSET 一路往上加,加到後面每個查詢都要兩秒,幾十個這種查詢同時打進來,DB 的 CPU 直接被這些「掃了一大堆又丟掉」的查詢吃滿。
游標分頁:把座標從「位置」換成「資料」
游標分頁(cursor pagination,底層原理常被叫做 keyset pagination)的核心轉念只有一句話:不要記「我翻到第幾筆」,要記「我上次看到的最後一筆是哪一筆」。
座標從「位置」變成「資料本身的值」。位置會漂移,但一筆已經存在的成交,它的 created_at 跟它的 id 是不會變的。你用這個不變的值當作下一頁的起點,新插入的資料插在哪都跟你無關,因為你問的不是「第 21 筆」,而是「比我手上這筆更舊的,再給我 20 筆」。
具體來說,第一頁照常拿:
SELECT * FROM trades WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;
拿到 20 筆之後,記住最後一筆的 created_at,假設是 2026-06-06 10:00:00。下一頁不要用 OFFSET,改成這樣:
SELECT * FROM trades WHERE user_id = 123 AND created_at < '2026-06-06 10:00:00' ORDER BY created_at DESC LIMIT 20;
差別就在那個 created_at < 條件。你不是跳過前面 20 筆,你是直接告訴資料庫「從這個時間點往前找」。如果這時候最上面插了五十筆新成交,完全不影響——它們的時間都比 10:00:00 新,根本不會落進 created_at < '10:00:00' 的範圍。沒有漂移,所以沒有重複,也沒有跳過。
效能上更是另一個世界。只要 (user_id, created_at) 上有複合索引,created_at < 某值 這個條件可以直接走索引定位,資料庫從索引上那個點開始往後拿 20 筆就收工,完全不需要掃過前面那一大堆再丟掉。我前面那張三千萬列的表換成游標之後,不管你翻到多深,每一頁都是穩定的兩三毫秒,跟第一頁一樣快。從線性退化變成常數時間,這是游標分頁最甜的地方。
處理同分:為什麼單一 timestamp 不夠
上面那個寫法有個陷阱,我當時就踩過,而且踩得很難看。
問題出在 created_at 不是唯一的。在高頻撮合的場景,同一毫秒成交好幾筆是常態。我見過同一個 created_at 時間戳上同時掛著七八筆成交。這時候 created_at < '某時刻' 這個條件就會出事:假設第一頁最後一筆的時間是 10:00:00,但這個時刻其實有三筆成交,第一頁只拿到其中一筆,剩下兩筆還沒拿。下一頁用 created_at < '10:00:00',嚴格小於,那兩筆跟它同時刻的成交就被你跳過了,永遠拿不到。如果改成 created_at <= '10:00:00',那第一頁拿過的那筆又會重複出現。怎麼設都不對。
解法是用「複合游標」:排序跟比較都不能只靠 created_at,要再加一個能打破平手的欄位,通常就是主鍵 id。排序變成先比時間、時間一樣再比 id:
ORDER BY created_at DESC, id DESC
游標也從「只記 created_at」變成「記 created_at 加 id」。下一頁的條件變成這個樣子,這是 keyset 分頁的標準寫法:
SELECT * FROM trades WHERE user_id = 123 AND (created_at < '2026-06-06 10:00:00' OR (created_at = '2026-06-06 10:00:00' AND id < 上一頁最後一筆的 id)) ORDER BY created_at DESC, id DESC LIMIT 20;
白話講就是:「時間比它舊的全要;時間跟它一樣的,只要 id 比它小的。」這樣同一個時刻的那幾筆成交,會被 id 切成一條全序,不會重複也不會漏。對應的索引也要建成 (user_id, created_at, id),整條條件才能完全走索引。
這個「時間加上一個 tie-breaker 主鍵」是游標分頁的標準配方。任何「排序欄位可能有重複值」的場景都適用,不只是時間,你用價格排、用金額排,道理一樣,後面永遠補一個唯一的 id 收尾。
游標 token:不要把 created_at 跟 id 裸奔丟給前端
到這裡功能上已經正確了,但還有一個我認為很多人會忽略的點:游標長什麼樣子、怎麼交給前端。
最直覺的做法是直接把 created_at 跟 id 兩個值塞進回應,前端下次原樣帶回來。能動,但我不喜歡,有幾個現實的理由。
第一,它把你的內部實作細節漏給外面了。前端跟任何看得到流量的人都會發現,原來你的游標就是時間戳加自增 id。自增 id 裸奔本身就會洩漏業務量,別人靠連續兩次請求的 id 差,就能推估你一段時間內成交了多少筆。第二,它可以被竄改。既然是明文,使用者可以自己捏一個游標,塞一個別人的 id 進來試探邊界,或者塞一個畸形值讓你的查詢爆炸。第三,今天你的游標是 created_at 加 id,哪天你要改排序欄位、加欄位,這個格式就綁死了,因為前端已經依賴它的結構。
我的做法是把游標當成一個「不透明 token」(opaque cursor)。對使用者來說它就是一串看不懂的字串,你不該、也不需要去理解它的內容,你只要把它原樣傳回來就好。
實作上,我把要記的狀態(排序欄位的值、id、還有排序方向)先序列化成一小段 JSON,然後做兩件事。一是編碼,至少 base64url 一下,讓它在 URL 上乾乾淨淨,也順手讓它「看起來不像」內部資料。但光編碼不防竄改,base64 誰都能解。所以第二件事是簽章:用一把只有伺服器知道的密鑰,對這段內容算一個 HMAC,把簽章一起塞進 token。下次拿回來先驗章,驗不過就直接當作非法游標拒絕。這樣使用者改不動內容,因為他沒有密鑰,算不出對的簽章;你也不必擔心有人塞畸形值進來打你的 SQL,因為驗章那關就先把它擋掉了。
要更講究的話,可以在 token 裡放一個查詢指紋(例如把 user_id、排序規則一起算進去),讓 A 使用者的游標拿去 B 使用者的列表會直接失效,避免有人拿別人的游標跨界翻資料。也可以放一個簽發時間,給游標一個保鮮期,太舊的游標讓它過期,避免使用者拿一個放了三個月的游標回來翻一個早就面目全非的列表。
關鍵心態是:游標是「狀態的快照」,是伺服器發出去、要能驗證真偽的東西,不是讓前端自由拼湊的查詢參數。
刪除、修改,跟那個你跳不到第 N 頁的代價
游標分頁不是萬靈丹,老實講幾個它的限制跟邊角。
刪除其實是游標最擅長的場景。前面說過 OFFSET 遇到刪除會漏資料,游標完全沒這問題:你的游標記的是「上次最後那筆的時間跟 id」,就算那筆後來被刪了,created_at < 那個值 這個條件照樣成立,它只是一個比較基準,那筆資料還在不在都不影響下一頁。這反而是游標比 OFFSET 強的地方。
修改要看你改到的是不是排序欄位。如果你只是改了成交的備註欄位,排序鍵沒動,毫無影響。但如果你的列表是按「更新時間」排序,而某筆資料的更新時間在你翻頁的途中被改了,它的位置就跳走了,這時候它有可能跑到你前面、被你重複看到,或跳到你後面、被你漏掉。這不是 bug,是「拿一個會變動的值當座標」的固有後果。我的建議是,分頁的排序鍵盡量用不可變的欄位,成交紀錄就按成交時間排,別按一個會被改的欄位排。
最後是那個最常被拿來嫌的限制:游標分頁沒辦法「直接跳到第 87 頁」。因為它的座標是資料,不是頁碼,你手上沒有第 86 頁最後那筆,就生不出第 87 頁的游標。它天生只能一頁接一頁地走,上一頁下一頁,沒有頁碼跳轉。
但我做久了之後想通一件事:真正需要「跳到第 N 頁」的情境,比你以為的少很多。一個在無限捲動的 feed、一個在翻自己成交紀錄的人、一個在拉歷史資料的同步服務——沒有一個需要頁碼。需要頁碼的,通常是後台管理介面那種要給人「看到第幾頁、總共幾頁」的列表,而那種列表資料量通常不大、也不會即時插入,繼續用 OFFSET 反而最省事。所以我現在的判準很簡單:對外的、即時的、大資料量的列表用游標;對內的、靜態的、要頁碼的後台用 OFFSET。兩個都留著,看場景挑。
至於雙向翻頁,順著前面的設計其實很自然。往後翻是 created_at < 游標 加 DESC,往前翻就是 created_at > 游標 加 ASC,把往前那批結果在記憶體裡反轉回來給前端就行。我那時候在 token 裡記了排序方向這個欄位,就是為了讓同一個游標格式能同時表達「從這裡往新的翻」跟「從這裡往舊的翻」兩個方向。
寫在最後
回頭看,那筆幽靈成交其實是一份禮物。它逼我把一個我用了好幾年、從沒認真想過的東西攤開來重看:原來 LIMIT OFFSET 這個我閉著眼睛都會寫的句子,骨子裡是用「位置」在描述資料,而位置在一個活著的、一直在動的系統裡,是最不該拿來當座標的東西。游標分頁說穿了沒什麼高深,它只是把座標從「第幾筆」換成「哪一筆」,從一個會漂移的相對位置,換成一個釘死的絕對值。
我現在寫任何一個要對外的列表 API,預設就是游標,連想都不太需要想。但我也學會不要矯枉過正,後台那種要頁碼的小列表,OFFSET 依然是最務實的選擇,硬上游標反而是給自己找麻煩。工程上沒有哪個方案是全對的,只有放對地方的方案。那筆讓使用者半夜恐慌的幽靈成交,最後變成我判斷「這個列表該用哪種分頁」的那把尺,這大概是那張客服截圖留給我最值錢的東西。