分頁查詢的穩定性陷阱與根治方案
在后端開發中,分頁查詢是最基礎也最常用的功能之一。無論是運營后臺各模塊的列表,還是APP上商品列表、帖子列表、關注/粉絲列表等,幾乎都離不開「按時間倒序+分頁」的查詢組合。但就是這個看似簡單的需求,卻因“錨點不穩定”而暗藏數據重復、丟失的陷阱,輕則影響體驗,重則導致資損。本文將揭示這一問題的根本原因,對比分析三種主流解決方案的優劣,并最終給出可落地的工程規范,幫助你一勞永逸地解決分頁穩定性問題。
案例
首先我們基于一些案例來感受一下,什么是“分頁錨點不穩定”問題。
案例1:社交場景展示的帖子列表按「最新時間排序」,當有新帖子發布時,用戶在翻頁過程中會發現同一條帖子重復出現在不同的頁碼。
案例2:運營人員正在批量發放優惠券,此時新增了一批優惠券數據。發放完成后發現,部分用戶收到了多張相同的優惠券,造成資損。
案例3:某支付流水查詢頁面,因為排序字段不唯一,導致數據展示順序混亂,甚至出現數據丟失,給用戶帶來困擾。
當我們使用LIMIT offset, size(MySQL)或from + size(ES)時,分頁的依據是「當前查詢結果集的行數偏移」。而新數據插入或舊數據的刪除會直接改變結果集的行數,進而導致下一頁的偏移量失效。
案例1只是導致用戶體驗類的bug,而另外兩個案例則影響更加嚴重,很容易出現資損。
分析
我們從案例1入手分析。假設用戶A正在瀏覽帖子列表,操作流程如下:
第1步:加載第1頁
執行SQL:select * from t order by create_time desc LIMIT 0,10
返回帖子 P1~P10(P1 為最新,P10 為第10新的帖子)。
第2步:加載第2頁
此時,其他用戶發布了一條新帖子 P0(時間比 P1 更新)。
用戶A繼續滑動,執行SQL:select * from t order by create_time desc LIMIT 10,10
按理應該返回 P11~P20,但由于新插入了1條數據,整個結果集向后偏移,實際返回的是 P10~P19。
結果:P10 在第1頁和第2頁都出現了,造成數據重復。
更極端的情況是,如果新數據插入量大于等于頁大小,用戶可能會遇到「連續多頁顯示相同數據」,甚至「永遠無法看到后續數據」的問題。
解決方案
方案 1:「時間戳 + 唯一鍵」做「游標分頁」
這是目前最主流、最徹底的方案,核心是放棄「偏移量(offset)」,改用「上一頁最后一條數據的標記」作為分頁錨點,徹底擺脫結果集變化的影響。
實現原理
- 確定「唯一排序鍵」:必須包含「時間字段(如create_time)+ 唯一鍵(如id)」,確保排序唯一(解決場景 3 的順序混亂)。
- 分頁時不傳遞offset,而是傳遞「上一頁最后一條數據的create_time和id」。
- 下一頁查詢用「大于 / 小于」條件過濾,替代LIMIT offset, size。
SQL示例
- 第 1 頁查詢(無錨點,取最新 10 條)
SELECT id, title, create_time FROM posts
ORDER BY create_time DESC, id DESC
LIMIT 10;假設第 1 頁最后一條數據為create_time='2024-05-20 14:30:00',id=100。
- 第 2 頁查詢(用錨點過濾):
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 14:30:00' -- 時間早于上一頁最后一條
AND id < 100 -- 時間相同則id更小
ORDER BY create_time DESC, id DESC
LIMIT 10;方案優勢
- 徹底解決重復/跳過:錨點是具體數據標記,不受新數據插入、舊數據刪除影響。
- 性能優異:where 條件可創建聯合索引(create_time, id),避免全表掃描。
- 兼容性強:同時解決排序不唯一問題。
方案劣勢
- 不支持直接跳頁:無法像LIMIT 40,10那樣直接跳轉到第 5 頁,僅支持上一頁/下一頁或滑動加載。
適用場景
- 所有C端滑動加載場景(帖子、商品、評論列表等)。
- 數據量較大(萬級以上),需優化分頁性能的場景。
- 同樣適合定時任務通過此方法遍歷全表刷歷史數據。
方案 2:時間戳過濾
由于方案1無法支持自由分頁,可通過「固定查詢時間范圍」減少新數據影響,核心是讓每次分頁查詢的「時間窗口」固定,避免新數據進入結果集。
實現原理
- 第一次查詢時,記錄「當前時間」作為max_create_time。
- 后續分頁查詢均加create_time <= max_create_time條件,新插入數據不滿足條件被排除;
- 用戶刷新頁面時,重新獲取最新max_create_time,更新時間窗口。
SQL示例
- 第 1 頁查詢(記錄時間窗口):
-- 假設當前時間為2024-05-20 15:00:00
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 15:00:00' -- 固定時間窗口
ORDER BY create_time DESC, id DESC
LIMIT 0,10;- 第 2 頁查詢(沿用時間窗口):
SELECT id, title, create_time FROM posts
WHERE create_time <= '2024-05-20 15:00:00' -- 不更新時間
ORDER BY create_time DESC, id DESC
LIMIT 10,10; -- 正常取第二頁數據即可,區別**方案1**方案優勢
- 實現簡單,成本低,只需記錄首次查詢時間,無需修改核心邏輯;首次查詢的時間可以傳給客戶端,后續分頁查詢讓客戶端把首次查詢的時間傳給服務端即可,不需要服務器暫存此參數。
- 快速解決新數據重復:新數據被排除在時間窗口外,結果集穩定。
方案劣勢
- 無法解決數據刪除導致的跳過:若時間窗口內數據被刪除,會導致部分數據被跳過。
- 數據滯后,用戶滑動分頁時看不到新數據,需刷新頁面或者重新查詢才能更新。
- 深分頁性能問題,比如LIMIT 1000000, 10。
適用場景
- 所有C端滑動加載場景(帖子、商品、評論列表等)。
- 特別適合只增不刪的場景,比如查看訪客記錄。
- 適合不存在深分頁的業務場景,用戶手動翻頁一般很少翻到100頁往后。
方案 3:適用于Elasticsearch的專屬優化方案
上面講到的方案1和2同樣適用于Elasticsearch,參考MySQL的實現方式,可以在Elasticsearch手動實現。但方案2在深分頁場景下,Elasticsearch默認限制查詢結果窗口大小為10000條記錄,超過該值會觸發錯誤提示“Result window is too large”。
為解決此類問題,Elasticsearch 提供 “滾動查詢(Scroll)” 和“Search_after”功能。
- Search_after:分頁時需要排序,原理是從上一次的排序值開始,查詢下一頁數據,不需要指定偏移量from,直接取前size條即可。官方推薦使用的方式。和方案1實現原理類似。
- Scroll:原理將排序后的文檔ID形成快照,保存在內存,后續分頁基于快照查詢,不受數據更新影響。官方已經不推薦使用。
代碼示例(search_after方式)
// 第1頁查詢
SearchRequest request = new SearchRequest("posts");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.matchAllQuery());
// 按create_time(降序)、id(降序)排序
sourceBuilder.sort("create_time", SortOrder.DESC);
sourceBuilder.sort("id", SortOrder.DESC);
sourceBuilder.size(10);
request.source(sourceBuilder);
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 獲取第1頁最后一條數據的排序值(作為下一頁的游標)
SearchHit lastHit = response.getHits().getHits()[response.getHits().getHits().length - 1];
// [1716215400000(create_time的時間戳), "100"(id)]
Object[] lastSortValues = lastHit.getSortValues();
// 第2頁查詢(用Search After)
sourceBuilder.searchAfter(lastSortValues); // 傳入上一頁的游標
sourceBuilder.size(10);
request.source(sourceBuilder);
SearchResponse page2Response = client.search(request, RequestOptions.DEFAULT);方案優勢
- 適合ES海量數據的獲取:避免from + size在from較大時的性能問題(ES 會將前 N 條數據加載到內存)。
- 兼容性強:同時解決排序不唯一問題。
方案劣勢
- 不支持直接跳頁:無法像LIMIT 40,10那樣直接跳轉到第 5 頁,僅支持上一頁/下一頁或滑動加載。
適用場景
- ES 大數據量全量導出(如導出近 1 個月日志、批量導出 Excel)
- 同方案1列舉的場景
總結
- 問題本質:分頁重復/跳過源于「錨點不穩定」(用offset易受數據增刪影響)和「排序不唯一」(單一字段排序規則不固定),解法是用「數據標記錨點」(如游標)和「唯一排序組合」(如create_time + id)。
- 方案選擇邏輯:按「是否需跳頁→數據量→更新頻率」決策,如B端需跳頁且數據量小用LIMIT + 時間戳,C 端滑動加載且數據量大用游標分頁,ES 批量導出用Search_after。
- 規范價值:技術方案解決單次問題,建立工程規范(需求 - 編碼 - 測試 - 監控)將個人經驗轉化為團隊標準,CR分頁查詢代碼重點關注排序項是否唯一、是否游標分頁、分頁是否有防重措施等,避免重復踩坑,保障分頁功能穩定,提升用戶體驗與業務營收。
關于作者,張叢叢,俠客匯Java開發工程師。



























