Web 後端的一生之敵:分頁器

分頁器是 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 接口來查詢一下其中是否存在違規內容並把違規內容過濾掉, 或者有朋友在 mysql 中存儲了 json 字符串而且使用的是 MySQL 5.7 之前的版本,只能在業務邏輯中解析 json 並進行過濾了。

後置過濾會遇到一種問題,客戶端向我們請求 10 篇文章而服務端過濾後只剩下了 8 篇甚至某一頁可能一篇不剩。這可能會在客戶端導致一些會被用戶注意到的體驗問題,比如上滑瀏覽 feed 流時出現卡頓、閃爍。

聰明的讀者可能會想這個問題好解決,如果請求 10 篇文章過濾後只剩下 8 篇,那我們再從數據庫中取出 10 篇只要過濾後剩下 2 篇以上是不是就可以滿足客戶端的請求了?

ok, 我們照此實現,於是問題又來了。客戶端請求第一頁 10 篇文章而我們已經從數據庫中讀到了第 14 行,所以客戶端請求第二頁時 offset 應爲 14。 依次類推請求第 3 頁時 offset 應爲 26, 第 4 頁的 offset 應爲 44。。。。根據客戶端發來的頁碼找到的 offset 是幾乎不可能的事情。

另一個問題是分頁接口通常需要告知客戶端結果總數或者總頁數以便客戶端判斷是否到達最後一頁,而使用了後置過濾的查詢幾乎不可能查出結果總數,emmm

深度分頁帶來的性能消耗

MySQL 深度分頁的性能問題以及使用自增主鍵優化深度分頁已經廣爲人知,這裏我們不再討論。

與此類似,查詢客戶端結果總數或者總頁數同樣是很耗時的操作。在移動互聯網時代像博客園這樣顯示頁碼的場景已經不多,更多的是各種樣式的信息流。客戶端並不需要知道有多少頁只需要知道是否到達最後一頁即可, 這爲我們優(tao)化(ke)留下很大空間。

解決方案

解決分頁器麻煩最好的方案就是避免分頁(手動滑稽

當然大多數情況無法避免分頁,所以我們還是需要研究一下怎麼解決上面提到的各種問題

遊標分頁器

遊標分頁器的思路和 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 真是分頁器的老受害者了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章