Web 後端的一生之敵:分頁器
- 2022 年 5 月 18 日
- 筆記
分頁器是 Web 開發中常見的功能,看似簡單的卻經常隱藏著各種奇怪的坑,堪稱 WEB 後端開發的一生之敵。
常見問題
邊翻頁邊寫入導致內容重複
某位用戶正在瀏覽我的部落格,他看到第一頁最後一篇文章是 《Redis 快取更新一致性》:
在他瀏覽第一頁的過程中,我發布了一篇新文章。他繼續瀏覽,發現第二頁的第一篇文章仍然是 《Redis 快取更新一致性》:
部落格園使用的是時間倒序排列和limit..offset分頁器,用 SQL 來描述就是:
select * from posts where user_id = ? order by publish_time desc limit 10 offset 10;
在用戶瀏覽第一頁時《Redis 快取更新一致性》按時間倒序排列在第 10 位,當發布新文章後它被擠到了第 11 位。讀者使用 limit 10 offset 10
查詢第二頁時它便會再次出現。
上述情況只是在瀏覽過程中在頭部追加了新的數據,在搜索引擎這類條件很多、排序演算法複雜的場景中,第一次查詢和第二次查詢的順序可能完全不同,分頁器也難以實現。
後置過濾
一般情況下我們可以使用 where 語句過濾出我們需要的記錄,然而在開發工作中也經常碰到 MySQL 不能完成所有過濾的情況。比如我們需要在展示結果前調用一下 rpc 介面來查詢一下其中是否存在違規內容,並把違規內容刪除掉。
這種實現會遇到一種問題,客戶端向我們請求 10 篇文章而服務端只返回了 8 篇,甚至某一頁可能一篇不剩。這些情況可能在客戶端導致一些會被用戶注意到的體驗問題,比如上滑瀏覽 feed 流時出現卡頓、閃爍。
聰明的讀者可能會想這個問題好辦,如果請求 10 篇文章過濾後只剩下 8 篇,那我們再從資料庫中取出 10 篇只要過濾後剩下 2 篇以上是不是就可以滿足客戶端的請求了?
好了現在問題又來了,客戶端請求第一頁 10 篇文章而我們已經從資料庫中取出了 14 行記錄,當客戶端請求第二頁時 offset 應為 14, 請求第三頁時 offset 應為 26。。。。根據客戶端發來的頁碼找到的 offset 是幾乎不可能的事情。
分頁介面通常需要告知客戶端結果總數或者總頁數,以便客戶端判斷是否到達最後一頁。而使用了後置過濾的查詢幾乎不可能查出結果總數。
深度分頁帶來的性能消耗
MySQL 深度分頁的性能問題以及使用自增主鍵優化深度分頁已經廣為人知,這裡我們不再討論。
與此類似,查詢客戶端結果總數或者總頁數同樣是很耗時的操作。在移動互聯網時代,像部落格園這樣顯示頁碼的場景已經不多,更多的是各種流,客戶端並不需要知道有多少頁,只需要知道是否到達最後一頁即可。
解決方案
解決分頁器麻煩最好的方案就是避免分頁(手動滑稽
當然大多數情況無法避免分頁,所以我們還是需要研究一下怎麼解決上面提到的各種問題
游標分頁器
游標分頁器的思路和 MySQL 使用自增主鍵優化深度分頁相同,我們不再使用 offset 表示拉取進度而是使用上次返回的最後一條結果的自增id作為游標。以上文中提到的部落格重複的問題為例,若 post 表使用自增主鍵 id, 那麼我們可以使用如下SQL 查詢:
select * from posts where id < ? order by id desc limit 10;
用戶瀏覽第一頁時記住最後一篇文章《Redis 快取更新一致性》的 id=233, 在拉取第二頁時只需要進行查詢:
select * from posts where id < 233 order by id desc limit 10;
游標分頁器也可以解決上文提到的後置過濾的問題。客戶端請求第一頁 10 條內容,我們實際上從資料庫中取出了 14 條,只需要將從資料庫中取出的最後一條的 id 作為游標發給客戶端。查詢下一頁時只要查詢 id < cursor (升序排列時為 id > cursor) 即可。
除了自增 id 外只要是不重複的排序欄位都可以作為游標,比如時間戳也可以作為游標。在無法保證時間戳不重複時我們可以使用時間戳作為整數部分、id 作為小數部分的方法來進行排序。如下面的示例程式碼:
// 對於時間戳相同的 post 我們並不關心誰前誰後,我們只要求排序穩定
// 若 post1.CreatedAt == post2.CreatedAt,查詢第一頁時 post1 在前 post2 在後,查詢第二頁時變成了 post2 在前 post1 在後,那麼 post1 會出現兩次,post2 會被漏掉
// 所以我們需要查詢結果是穩定的,post1 始終在 post2 之前或者 post2 始終在 post1 之前
func GetUniqueTime(post *Post) float64 {
intPart := strconv.FormatInt(post.CreatedAt.Unix(), 10)
decimalPart := strconv.FormatUint(post.ID, 10) // 只要求 ID 唯一,並不要求 ID 有序
str := intPart + "." + decimalPart
f, _ := strconv.ParseFloat(str, 64)
return f
}
能使用游標分頁器的資料庫也不僅限於 MySQL 等關係型資料庫,Redis 的 SortedSet 或者 ElasticSearch 的 search_after 都可以使用游標分頁器。
游標分頁器中不再有具體的頁碼概念也不再需要總頁數,只需要知道當前是否為最後一頁即可。我們在查詢資料庫時可以將 limit 加 1 來方便地判斷當前是否是最後一頁。 比如客戶端請求 10 篇文章,我們查詢資料庫時 limit 設為 11,若資料庫返回 11 條記錄說明還有下一頁,若資料庫返回 10 條或 10 條以下的記錄則說明當前已到最後一頁。
limit 加 1 的目的是為了避免最後一頁恰好有 10 條記錄的情況,若 limit = 10 且資料庫返回 10 條記錄我們會認為還有下一頁,當客戶端繼續查詢下一頁時只能返回空結果。這不僅會空耗資源更重要的是可能會出現一些體驗上的問題,比如客戶端提示「上滑載入更多」而用戶上滑後並無新內容出現的尷尬局面。
游標分頁器只適用於元素之間的相對順序(即A始終在B前)不會發生改變,結果集中只會插入新元素或刪除部分元素的情況。
快照
對於搜索引擎這種兩次查詢中相對順序可能發生改變的場景,游標分頁器也無能為力。若無法避免分頁則只能採取快照的方式,在搜索完畢後將整個搜索結果快取下來,拉取後續內容時不重新搜索而是拉取快照的剩餘內容。
使用快照的典型的例子是 ElasticSearch 的 Scroll API:
POST /twitter/_search?scroll=1m
{
"size": 100,
"query": {
"match" : {
"title" : "elasticsearch"
}
}
}
在查詢時創建一個有效期為 1m 的快照,使用返回的 scroll id 獲取下一頁:
GET /_search/scroll
{
"scroll_id" : "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAAD4WYm9laVYtZndUQlNsdDcwakFMNjU1QQ=="
}
ES 真是分頁器的老受害者了