資料庫

為何在計量型資料庫中,游標分頁勝過 Skip 分頁

`skip(N)` 會讀取並捨棄它跳過的每一份文件,而按讀取次數收費的資料庫會針對所有這些文件向您收費。以下是其算術運算、取代它的查詢,以及維持順序穩定的決勝條件。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

位移分頁(Offset pagination)感覺上是免費的,直到帳單來臨。在一個根據讀取文件數量收費的資料庫中,skip(N) 並不是一個能直接跳轉的捷徑。它會讀取並捨棄其跳過的每一份文件,而你必須為每一份文件付費。這篇文章將透過實際算術呈現成本差異、展示完全取代位移的基於游標的查詢、說明保持排序完整性的「打破平局」(tie-breaker)機制,以及在何種情況下,單純使用 skip 仍然是正確的選擇。

skip 實際上做了什麼

skip(N) 看起來像是傳送到第 N 個結果,但資料庫在沒有走訪前 N 個文件的情況下,無法知道第 N 個文件在哪裡。對於任意的篩選器和排序,並沒有一個現成的索引可以直接指向「第 380 個符合條件的資料列」。所以引擎會從有序結果集的開頭開始,數過 N 個文件,然後才開始向您回傳資料列。

在固定的集合上,您從不進行深度分頁,所以沒有人會注意到。當兩件事同時成立時,成本就會顯現出來:集合很大,且使用者深入其中。在頁面大小為 20 的第 20 頁,skip(380).limit(20) 會讀取 400 個文件,以交給您 20 個。在您的頁面開始之前,另外 380 個文件會被讀取、計數,然後丟棄。您沒有要求它們,也看不到它們,而在計量資料庫中,您卻需要為所有這些文件付費。

成本的數字呈現

將這兩種策略並列比較。最重要的欄位是最後一欄,因為它顯示了在實際流量下,帳單上會出現的數字。

方法 第 3 頁的讀取次數 第 20 頁的讀取次數 一萬次深層分頁瀏覽的讀取次數
skip 60 400 ~1,200,000
cursor 20 20 ~200,000

skip 這一列的數字會隨著頁面深度增加而增長。cursor 這一列則不會。無論您在第 3 頁還是第 3,000 頁,它每次都只會讀取與頁面大小完全相同的數量。而且隨著網站成長,兩者之間的比例不會縮小,反而會擴大,因為集合越大,人們就越會進行深層分頁。skip 的最壞情況,恰好發生在您擁有最多流量、會因此損失最多金錢的時候。

延遲的情況也與成本的情況如出一轍。讀取並捨棄 380 份文件不僅會被收費,速度也很慢,而且頁面越深,速度就越慢。游標分頁的延遲是固定的,因為它每一頁所做的工作量是恆定的。

取而代之的游標查詢

您不用偏移量,而是沿用當前頁面上最後一項的排序鍵,下一頁則請求排在其後的資料列。完全不需要計數。資料庫會直接在索引中尋找游標位置並讀取該頁面。

// Carry the last item's sort key forward. Each page reads exactly what it
// returns, no matter how deep you are.
filter := bson.M{"published_at": bson.M{"$lt": cursor}}
opts := options.Find().
	SetSort(bson.D{{"published_at", -1}}).
	SetLimit(20)

rows, err := coll.Find(ctx, filter, opts)

游標不是頁碼。它是您顯示的最後一列的排序欄位值,在此為 published_at。下一個請求會說「給我比這個時間戳記更早的最新 20 列」,而且因為 published_at 上有索引,引擎會直接跳到那個點並向前讀取。頁面深度與所做的工作無關。

決定總排序的決勝標準

第一個查詢中隱藏著一個錯誤,而這也是大家會遇到的第二個問題。如果兩個文件共享相同的 published_at(在真實系統中這種情況確實會發生),那麼「比此時間戳更舊」的說法就會變得模棱兩可。對時間戳使用嚴格的 $lt 可能會跳過與游標時間戳相同的資料列,而非嚴格的 $lte 則可能會重複你剛顯示過的資料列。無論哪種方式,分頁的邊界都是錯誤的,而使用者會看到重複或遺失的項目,正好出現在頁與頁之間的接縫處。

解決方法是透過加入一個獨一無二的決勝標準,使排序順序成為總排序,這樣就永遠不會有兩個文件在比較時相等。_id 是個自然的選擇,如果 id 本身就能有意義地排序,那就更好了,就像 ULID 一樣。游標變成了一對值,而比較也變成了複合比較。

// Compound cursor: (published_at, _id). No two rows compare equal, so the
// page boundary is exact. Works because _id is unique and ULIDs sort by time.
filter := bson.M{
	"$or": []bson.M{
		{"published_at": bson.M{"$lt": cur.PublishedAt}},
		{"published_at": cur.PublishedAt, "_id": bson.M{"$lt": cur.ID}},
	},
}
opts := options.Find().
	SetSort(bson.D{{"published_at", -1}, {"_id", -1}}).
	SetLimit(20)

可以解讀為「所有嚴格來說更舊的項目,加上在相同時間戳內,所有 id 較小的項目。」因為 _id 是獨一無二的,這個組合排序是總排序,所以每一列都有一個確切的位置,而第 N 頁和第 N+1 頁之間的邊界也只會落在一個確切的地方。接縫處不會有重複的項目,也不會有遺漏的資料列。這就是為什麼一個能提供可排序、獨一無二 id 的資料庫,與游標分頁如此契合。如果你的 id 是隨機的 ULID 或衍生的 ULID,你就已經擁有所需的決勝標準了。關於穩定、可排序 id 的推導,在 確定性 ULID 中有涵蓋。

此查詢所需的索引

只有在排序有索引支援的情況下,資料指標分頁才會快速,而複合資料指標需要一個欄位順序與排序相同的複合索引。若沒有它,引擎會退而使用掃描,而你只是將一個計費問題換成了延遲問題。

// The sort is (published_at desc, _id desc), so the index must match.
{Keys: bson.D{{"published_at", -1}, {"_id", -1}}, Name: "published_at_id"}

規則是索引鍵的順序必須對應排序鍵的順序。若對齊正確,查詢就會是一次索引搜尋,接著是對恰好一個頁面的循序讀取。若弄錯了,資料庫會靜默地進行掃描,這在計量引擎上正是你想避免的昂貴操作,只是換了一張不同的面具。

您會犧牲什麼,以及如何解決

游標分頁並非沒有權衡取捨。最大的權衡是您無法跳到任意頁面。沒有「前往第 47 頁」這種功能,因為第 47 頁的游標是第 46 頁的最後一列,而除非您逐頁走到那裡,否則您不會有這個游標。編號的頁面連結,也就是傳統的「1 2 3 ... 47」分頁器,不適用於此模型。

對於大多數現代介面來說,這不成問題,因為主流的模式是下一頁和無限滾動,這兩種模式都只需要緊接著下一頁的游標。當您確實需要向後移動時,您會為目前頁面的第一列保留第二個游標,並執行一個比較和排序都相反的鏡像查詢。兩個游標,一前一後,涵蓋了下一頁和上一頁,這正是實際的分頁 UI 所使用的。

您真正失去的是廉價的總計數,因此也失去了廉價的「第 X 頁,共 Y 頁」功能。在大型集合中,計算所有符合條件的列本身就是一項昂貴的查詢,無論是否計量。通常,誠實的做法是從 UI 中移除總數,因為「第 3 頁,共 2,847 頁」是一個沒有人會據此行動的數字,而且在每次頁面載入時顯示它都需要進行一次完整的計數。

何時 skip 仍然是正確的工具

這一切並不代表偏移分頁在任何地方都是錯的。對於一個你永遠不會深入分頁的小型、有邊界的集合,例如一個幾百列的管理員表格、一個設定列表,skip 的簡單性勝出,而且成本可以忽略不計,因為 N 永遠不會變得很大。在這種情況下,使用複合游標和匹配的索引是過度設計。

經驗法則是關於深度,而不僅僅是大小。如果分頁深度有界且小,skip 就可以,而且更簡單。如果分頁深度無界,意味著使用者可以且將會深入瀏覽,就使用游標,因為這正是偏移成本無限制增長,且計量資料庫會將這種增長變成一個帳單項目的情況。根據人們實際深入的程度,為每個集合做決定,你就會在每個工具真正更便宜的地方使用它。