為何原子計數器會漂移以及對帳如何修正此問題
以原子性增量維護的計數器,可能會與實際的資料列計數慢慢出現分歧。一個定期的對帳作業會重新計算真實值,並將其收斂回來。
一個透過原子性增量維護的衍生計數器,將會與其真實來源產生偏差。這並非因為增量有誤,而是因為原子性只保證單一操作,而非與另一份獨立紀錄的一致性。崩潰、重試和部分失敗都會使數字產生些微偏差,而這些錯誤會不斷累積。解決方法並非使用一個更大的鎖,而是將計數器視為一個快速快取,並執行一個定期任務,從真實來源重新計算出真正的值再寫回。如此一來,即使數字曾短暫出錯,最終仍會是正確的。
何謂資料漂移,以及為何原子性無法防止它
假設您將留言儲存為資料列,並且希望在每篇貼文上顯示留言計數,而不需要在每次頁面載入時掃描整個留言集合。最直接的優化方法是使用衍生計數器:在貼文中保留一個 comment_count 欄位,並在每次建立留言時增加其數值。
// Fast path: bump the derived counter whenever a comment is created.
// This single update is atomic for this one document. Nothing here ties
// it to the actual number of comment rows that exist.
_, err := counters.UpdateOne(ctx,
bson.M{"_id": postID},
bson.M{"$inc": bson.M{"comment_count": 1}},
options.Update().SetUpsert(true),
)
$inc 具有原子性。兩個並行的遞增操作不會像「讀取-修改-寫入」那樣遺失更新。因此,人們很容易得出計數器是正確的結論。但事實並非如此,原因在於對原子性所能帶來的好處存在範疇謬誤。
原子性是單一操作的屬性。它指的是這一次遞增要麼完全發生,要麼完全不發生,不會有任何交錯操作來損壞其值。它並未說明遞增序列是否與另一個集合中的資料列數量相符。計數器和評論資料列是兩個獨立的狀態,由兩個獨立的操作更新。它們之間的一致性並非這兩個操作中任何一個所具備的屬性。任何時候,只要這兩次寫入不屬於同一個原子單元(在文件儲存和不同集合之間,它們通常都不是),它們之間就可能出現不一致。這種不一致就是漂移。
偏差的來源
偏差並非單一的錯誤。它是一系列在資料列寫入和計數器寫入之間的小間隙所造成的,而每一個間隙都會將計數推向一個可預測的方向。了解這些來源可以讓你知道對帳作業需要修正什麼,以及當真正的錯誤出現時,指標應該會是什麼樣子。
| 原因 | 機制 | 方向 |
|---|---|---|
| 在兩次寫入之間發生崩潰 | 資料列已插入,但在 $inc 完成前程序終止 |
計數過低 |
| 以相反順序發生崩潰 | 計數器已遞增,但隨後資料列插入失敗或回滾 | 計數過高 |
| 至少一次重試 | 一則訊息被傳遞了兩次,同一個事件讓計數器遞增了兩次 | 計數過高 |
| 遺漏遞減 | 一筆資料列被刪除或軟刪除,但沒有對應的遞減操作執行 | 計數過高 |
| 回填或手動修復 | 直接在資料庫中匯入或修復資料列,繞過了遞增路徑 | 計數過低 |
| 軟刪除與還原的順序錯亂 | 刪除時遞減,但稍後的還原忘了遞增,或反之亦然 | 兩者皆可能 |
有兩點值得注意。首先,這些錯誤不會相互抵銷。一個既會遺漏某些遞增,又會重複套用其他遞增的系統,並不會平均下來得到正確的結果。它最終會落在某個錯誤的數值上。其次,錯誤的方向是一種診斷指標。一個只會偏高的計數器指向重複處理或遺漏遞減。一個只會偏低的計數器則指向在資料列提交後失敗的寫入路徑。修正數值的對帳作業也能告訴你你遇到的是哪種情況,前提是你在彌補差距前有先記錄下來。
將計數器視為快取,而非真實來源
要讓這一切變得可管理的心智模型,就是停止將計數器視為一個事實。它是一個事實的快取。這個事實是留言資料列的集合。計數器是一個預先計算好的答案,用來回答一個你不想在每次頁面載入時都執行的問題。
一旦將計數器視為快取,就會遵循兩條規則。快取允許是過時的,所以短暫的錯誤計數是可接受的,而非一場危機。而且,快取必須有辦法從其快取的對象重建,因為一個無法重新生成的快取,就只是不可靠的主要資料。真實來源保持其權威性。它是直接查詢的資料列集合,完全忽略計數器。任何時候你需要一個可以據此做決策的數字時,你就去計算資料列。計數器是用於廉價的讀取,在這種情況下,微小的短暫錯誤不會造成任何代價。
這種重新詮釋使得整個方法變得誠實。你不是在承諾一個永遠正確的計數器,然後卻悄悄地無法兌現。你承諾的是一個快速的近似計數器,加上一個你隨時可以回退的真實來源,以及一個讓兩者保持接近的工作。
重新計算並覆寫的協調作業
對帳是一項排程作業,它不在熱路徑上,會從資料列中重新計算真實計數,並將其寫入計數器中。因為它是在計時器上執行,而不是在每個請求上執行,所以它被允許執行熱路徑所避免的昂貴操作,也就是實際進行計數。
最簡單直觀的版本是三行:計算資料列、寫入數字、完成。
// Naive reconciliation: recompute from the source of truth and overwrite.
// Correct in isolation, but see the race in the next section.
trueCount, err := comments.CountDocuments(ctx, bson.M{
"post_id": postID,
"deleted_at": bson.M{"$exists": false},
})
if err != nil {
return err
}
_, err = counters.UpdateOne(ctx,
bson.M{"_id": postID},
bson.M{"$set": bson.M{
"comment_count": trueCount,
"reconciled_at": time.Now(),
}},
)
這會一次性地校正所有種類的偏差,無論是過高還是過低,因為它完全不信任舊有的值。它會從資料列中重新推導這個數字。無論舊計數器的值是多少,是對是錯,都會被捨棄。
這存在一個風險,這也是為什麼這個天真的版本不是最終版本的原因。在 CountDocuments 回傳的時刻與 $set 生效的時刻之間,可能會有新的留言送達。熱路徑會為它們遞增計數器。然後 $set 會用一個在那些遞增操作存在前就計算出來的數字覆寫計數器,而那些遞增操作就遺失了。這個本意是為了修復偏差的對帳過程,卻反而製造了新的偏差。
根據浮水印進行調節,讓進行中的寫入得以保留
避免覆蓋並行寫入的簡潔方法是,只調節資料中已穩定的前綴部分,並保留近期的寫入。這需要對計數器的儲存方式做一個小小的改變。將它分成兩個欄位:一個由調節程序擁有的 base_count,以及一個由熱路徑擁有的 live_delta。顯示的數字是它們的總和。
熱路徑不再碰觸已調節的值。它只會遞增即時的 delta。
// Hot path now only touches live_delta. Reconciliation never overwrites
// this field, so a concurrent increment can never be clobbered.
_, err := counters.UpdateOne(ctx,
bson.M{"_id": postID},
bson.M{"$inc": bson.M{"live_delta": 1}},
options.Update().SetUpsert(true),
)
讀取會將這兩個欄位相加:
displayed := doc.BaseCount + doc.LiveDelta
核對作業會挑選一個水位線,也就是一個夠久以前的時間戳記,確保不會有任何新的資料列以更舊的建立時間寫入。在實務上,這意味著比您的寫入可見性延遲更舊,也比最舊的未完成交易更舊,所以幾秒鐘通常就綽綽有餘了。它會計算直到該水位線為止的真實值,將其設定為新的基準,並從即時差異中,精確地移除現在已由基準計入的增量。所有這些寫入操作都在單一的原子更新中發生,因此欄位永遠不會不一致。
// Settled boundary: no new row can appear with a timestamp older than this.
watermark := time.Now().Add(-30 * time.Second)
baseTrue, err := comments.CountDocuments(ctx, bson.M{
"post_id": postID,
"deleted_at": bson.M{"$exists": false},
"created_at": bson.M{"$lte": watermark},
})
if err != nil {
return err
}
// Atomic pipeline update. Install the recomputed base, and subtract from
// live_delta the number of rows the base has just absorbed (baseTrue minus
// the old base_count). Increments for rows after the watermark stay in
// live_delta untouched.
_, err = counters.UpdateOne(ctx,
bson.M{"_id": postID},
bson.A{
bson.M{"$set": bson.M{
"live_delta": bson.M{"$subtract": bson.A{
"$live_delta",
bson.M{"$subtract": bson.A{baseTrue, "$base_count"}},
}},
"base_count": baseTrue,
"reconciled_at": "$$NOW",
}},
},
)
這樣做之所以安全,是因為協調與熱路徑現在寫入的是互不相交的欄位。基底是從資料列重新推導出來的,因此舊基底中累積的任何漂移都會被清除。即時差異只持有最近的增量,一旦這些增量也超過了水位線,下一個週期就會將它們併入基底中。在協調期間的並行寫入會寫入即時差異,並且永遠不會在覆寫的路徑上。計數器無需鎖定也無需暫停寫入即可收斂。
如果你想要最簡單的版本,並且能容忍稍微較大的暫時性錯誤,請保留單一欄位覆寫,並接受與協調競爭的寫入可能會短暫遺失,然後在下一次執行時被修正。對於寫入率非常低的計數器來說,這是一個合理的選擇。當寫入率高到覆蓋問題變得重要時,你才會需要採用水位線和分割欄位。
測量漂移以浮現真正的錯誤
調節過程所捨棄的數字,比它寫入的數字更有價值。在您安裝新的基準之前,您知道舊值和真實值。這個差異就是漂移,而它是您寫入路徑健康狀況的直接證據。
drift := baseTrue - oldBaseCount
metrics.Observe("counter_drift", float64(drift),
"collection", "comments")
將其作為 gauge 或 histogram 發出,並以計數器類型標記。現在你有了一個信號,而它會講述一個故事。在零附近徘徊並保持在那裡的漂移,意味著 hot path 基本正確,而校正只是在清理罕見的崩潰。在每次運行之間增長的漂移,意味著增量正在以比你想像中更快的速度遺失或被重複應用,而其正負號會告訴你是哪一種情況。一個突然的尖峰會與某次部署或事件的時間點對應,並直接指向那個破壞了寫入路徑的變更。
如果沒有這個,校正會隱藏你的錯誤。它每晚都會悄悄地掩蓋一個損壞的增量路徑,而你永遠不會知道該路徑已損壞,因為到了早上,顯示的數字總是看起來沒問題。這個指標將無聲的修正轉變為一個警報。這個任務仍然修復了症狀,但現在它也報告了病因。如果漂移超過了你所關心的閾值,就呼叫某人,因為到那時,計數器的漂移速度已經超過了夜間任務可以安全掩蓋的速度。
當近似計數器不足以應付時
這整個方法是以即時的準確性換取廉價的讀取和自我修復。這種取捨對於一大類的計數器是正確的,但對於某一特定類別則是錯誤的,而它們之間的界線就是金錢。
對於統計數據、排名、顯示計數、觀看總數、留言計數、按讚總數、追蹤者人數,一個在幾分鐘內有少許誤差的計數對使用者來說是看不出來的,且不會產生任何成本。在分散式系統中,強迫這些計數做到精確且即時,意味著要在最繁忙的寫入熱點路徑上進行交易或上鎖,而其成本會隨著流量增長。一個快速的近似計數器搭配定期調節是務實的解答,而「最終正確」是個足夠強大的保證。
對於任何計數會影響帶有實際後果的決策的情況,考量就完全相反了。錢包餘額、您執行的付費配額、可能超賣的庫存、絕不能變負數的信用分類帳:這些都不能是延遲調節的快取。對於這些情況,真實來源應該放在讀取路徑上。您在做決策時計算或讀取權威的餘額,或者您讓減量操作本身根據約束條件進行交易式處理。一個短暫錯誤的計數器對於按讚總數來說沒問題,但對於金額來說是不可接受的。
還有一個需要權衡的營運成本。計算資料列不是免費的,而一個重新計算龐大集合中每個計數器的調節工作,本身就可能成為一個負載問題。通常的解決方案是只調節自上次執行以來有變動的文件、按時間將掃描分窗、錯開執行以避免一次性計算所有東西,以及執行的頻率要足夠高以保持誤差小,但又要足夠低以保持掃描的成本低廉。這種調校是運行調節計數器的真正工作所在。設計很簡單。隨著資料增長,如何保持這項工作的成本可負擔,才是工程技術的用武之地。