用於「先退款後恢復」重試機制的冪等信用分類帳
失敗工作的點數會被退還。然後重試成功。收回點數而不重複收費,需要冪等的分類帳和原子防護。
一個按用量計費的系統會在工作失敗時退還點數。棘手的情況是,當同樣的工作稍後在重試時恢復並成功。現在,這筆退款是錯誤的,你必須收回點數,同時不能重複收費。解決方法是一個僅供附加的反向操作對分類帳,其中每一次退款和每一次收回都是一次根據目前狀態進行保護的單一條件式寫入。這篇文章展示了分類帳的結構、阻止重複退款和重複收費的原子保護機制,以及重試佇列如何在不陷入無限循環的情況下為其提供資料。
先退款後復原的問題
在計量系統中,使用者操作會消耗點數:例如渲染影片、執行轉錄、生成圖片。常見的設計是預先收費、執行工作,並在失敗時退款,這樣使用者就不會為他們從未收到的成果付費。而麻煩就從那筆退款開始。
分散式工作不會乾淨地失敗。一個工作單元在 30 秒時逾時,將工作標記為失敗,並退還 40 點數。但正在執行工作的 GPU 實際上在 32 秒時完成了,且結果在逾時觸發兩秒後存入儲存空間。這項工作並未失敗,它已復原。帳本現在顯示了一筆退款,但其對應的成果卻存在且將會交付。
簡單的實作方式會讓情況更糟。如果失敗處理和成功處理是兩個沒有共享狀態的獨立程式碼路徑,你會遇到兩種錯誤之一。同一工作的兩個失敗信號(一次逾時加上後來的錯誤回呼)各自觸發退款,因此一個 40 點數的工作,使用者卻收到了 80 點數。或者,一次退款後接著一次復原,然後第二次復原信號又各自重新收費,導致使用者為一項工作被收費兩次。這兩者在一個正確性就是一切的領域中,都是正確性的失敗。
根本原因與大多數分散式系統的錯誤相同:一個先讀取狀態、然後再寫入狀態的操作,分成兩個獨立的步驟,中間存在一個間隙,讓另一個工作單元可以根據相同的過時讀取資料進行操作。
將每次變更都模型化為一對反向操作
第一個決定是絕不修改或刪除先前的帳本條目。帳本是僅供附加的 (append-only)。一個被收費、退款、然後再收回的任務會產生三個不可變的資料列:
- charge: -40
- refund: +40
- reclaim: -40
使用者的餘額是這些資料列的總和,而不是一個你會覆寫的可變計數器。每個條目都帶有 job_id、type、amount 和 created_at,因此每筆信用的完整歷史都是可稽核的。當客服詢問為何某位使用者有這樣的餘額時,你只需重播這些資料列即可。
退款 (refund) 和收回 (reclaim) 互為反向操作,這正是讓恢復 (recovery) 變得可表達的原因。只有在退款先發生且尚未被收回的情況下,收回操作才有意義。這個條件不是你在應用程式碼中用 if 來檢查的東西。它就是寫入操作本身的防護機制。
阻止雙重退款的原子防護機制
雙重退款 bug 是個典型的「檢查時到使用時」(time-of-check-to-time-of-use)競爭條件。工作單元 A 讀取到 is_refunded = false 並決定退款。在其寫入前,工作單元 B 也讀取到相同的 is_refunded = false 並同樣決定退款。兩者都進行了寫入。使用者便會收到兩次退款。
要彌補這個間隙,就意味著檢查和寫入必須是單一且不可分割的操作。在具有條件式更新功能的文件儲存庫上,這會是單一的 UpdateOne 操作,其篩選器即為檢查,而其更新即為寫入。該旗標存在於工作文件中,而且只有將其從 false 翻轉為 true 的工作單元,才被允許附加帳本資料列。
// Refund only if this job has not already been refunded.
// The filter and the $set are one atomic operation, so two concurrent
// failure signals cannot both win.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
bson.M{"_id": jobID, "is_refunded": false},
bson.M{"$set": bson.M{
"is_refunded": true,
"refunded_at": now,
}},
)
if err != nil {
return err
}
if res.ModifiedCount == 1 {
// We won the guard. Append the ledger row exactly once.
appendLedger(ctx, jobID, "refund", cost, now)
}
ModifiedCount 回傳 1 的工作者取得了守衛並附加退款。所有其他工作者都會看到 ModifiedCount 回傳 0,因為一旦旗標被設定,篩選條件就不再匹配,因此不做任何事。資料庫會為你序列化條件式寫入,所以無論多高的並行性都不會產生兩筆退款。
當工作復原時收回點數
復原會使用鏡像守衛。當一個已退款的工作後來成功時,您便會收回點數。條件是該工作已被退款 (is_refunded = true) 且尚未被收回 (is_reclaimed = false),同樣地,檢查與寫入是同一個操作。
// Reclaim only if the job was refunded and not yet reclaimed.
now := time.Now()
res, err := jobs.UpdateOne(ctx,
bson.M{"_id": jobID, "is_refunded": true, "is_reclaimed": false},
bson.M{"$set": bson.M{
"is_reclaimed": true,
"reclaimed_at": now,
}},
)
if err != nil {
return err
}
if res.ModifiedCount == 1 {
appendLedger(ctx, jobID, "reclaim", cost, now)
}
其精妙之處在於一般成功路徑上的處理方式。首次嘗試即成功的任務從未被退款,因此 is_refunded 仍為 false,篩選器無法匹配,ModifiedCount 為 0,且不會寫入任何回收資料列。這完全正確:因為沒有任何退款,所以沒有什麼可回收的。成功處理常式會無條件地執行相同的回收呼叫,並由守衛來決定是否適用。無論是否曾發生退款,也無論是因為重複傳遞而執行一次還是五次,成功路徑都是冪等的。
請注意,回收金額來自於任務上記錄的 cost,而非來自重試回報的任何內容。將金額鎖定在原始費用上,意味著一個復原的任務會回收與其退款完全相同的金額,絕不會是不同的數字。
計費工作的狀態轉換
兩個布林標記 is_refunded 和 is_reclaimed 定義了一個小型狀態機。每條通過它的有效路徑都會保持分類帳的平衡,而上述的每個守衛都是這個機器的其中一個邊緣。
| 狀態 | is_refunded | is_reclaimed | 淨分類帳影響 | 有效的下一個轉換 |
|---|---|---|---|---|
| 已收費,執行中 | false | false | -cost | 成功(停留),或失敗(退款) |
| 首次嘗試成功 | false | false | -cost | 終端 |
| 失敗,已退款 | true | false | 0 | 恢復(回收),或重試 |
| 已恢復,已回收 | true | true | -cost | 終端 |
兩個終端狀態最終都達到 -cost 的淨影響,這是正確的:使用者只為該工作支付了一次費用。「已退款但未回收」的狀態最終達到 0,這對於確實失敗且從未交付的工作是正確的。沒有任何路徑會達到 -2 倍成本,或在交付後達到 0,因為每個邊緣都由一個只有單一寫入者能翻轉的標記所控制。
使用退避機制重新排入失敗的工作佇列
一個失敗的工作並非總是就此結束。如果失敗是暫時性的相依性問題(例如 GPU 節點掉線、儲存呼叫逾時),該工作會回到重試佇列,而不是進入死信佇列。使用 RPush 將工作推送到 Redis 列表的尾部,提供了一個簡單的先進先出(FIFO)重試通道,且每次重新排入佇列都會帶有一個遞增的嘗試次數以及一個「不早於」的時間戳記,如此一來,損壞的相依性就不會被密集迴圈重複敲打。
// Re-queue a failed job with exponential backoff, capped.
attempt := job.Attempts + 1
backoff := time.Duration(
math.Min(
float64(baseDelay)*math.Pow(2, float64(attempt)),
float64(maxDelay),
),
)
job.Attempts = attempt
job.NotBefore = time.Now().Add(backoff)
payload, _ := json.Marshal(job)
rdb.RPush(ctx, "jobs:retry", payload)
消費者會跳過任何 NotBefore 在未來的工作,並在不執行工作的情況下將其重新排入佇列,因此無需單獨的延遲佇列機制即可實現延遲。以 maxDelay 為上限的指數級增長意味著首次重試會很迅速,但持續失敗的相依性會退避為緩慢的輪詢,而不是一個會耗盡其正在等待的資源的忙碌迴圈。
死信防護機制可停止無限重試
僅有退避機制無法停止一個永遠不會成功的任務。若沒有上限,一個永久損壞的任務會永遠在退款、重新排入佇列、失敗、退款的循環中,而且因為退款防護是冪等的,它不會造成財務損失,但它會浪費工作者並使佇列變得雜亂。嘗試次數也是這個上限。
// Terminal guard: past the cap, dead-letter instead of retrying.
if job.Attempts >= maxAttempts {
rdb.RPush(ctx, "jobs:dead-letter", payload)
return
}
死信清單是供人工檢查的暫存區,而非自動重試通道。沒有任何東西會定時消費它。同樣重要的是,相同的 is_reclaimed 和 is_refunded 旗標可作為防止重複傳遞的冪等性鍵。如果一個工作從重試佇列中被傳遞了兩次(至少一次傳遞保證了這種情況會發生),第二次傳遞會命中一個其旗標已反映其終端狀態的工作,而受保護的寫入將不匹配任何項目。重新處理一個已經結算的工作無法改變餘額。佇列可以傳遞一則訊息任意次數,而分類帳仍會保持正確,因為正確性存在於條件式寫入中,而非傳遞保證中。
權衡:帳本複雜度與正確性
簡單模型是一行邏輯:失敗即退款,完成。較少的狀態、較少的程式碼、無需推理。但在失敗並非最終結果的那一刻,它就錯了,而這在分散式系統中很常見。每個與緩慢成功競爭的 worker 逾時、每個重複的回呼、每次成功恢復的重試,都會悄悄地破壞這個簡單模型,而無聲的計費錯誤是代價高昂的那種。
帳本模型會讓你付出兩個旗標、一對反向操作、一個帶有退避機制的重試佇列,以及一個死信路徑的代價。這是真正的複雜性,而且要將其記在腦中並非沒有成本。問題在於該領域是否值得如此投入。對於一個免費層級的功能,其中算錯少數點數不會傷害任何人,那就省略所有這些,並接受偶爾的偏差。對於付費點數,其餘額具有金錢的形式,且使用者注意到重複收費會成為一個支援工單和信任問題,那麼在你已經退款的工作首次死而復生時,僅供附加的稽核軌跡和原子防護就會值回票價。讓整件事運作的規則很小:讓每個帳本操作都具有冪等性,並為每個狀態轉換設置一個只有單一寫入者能通過的原子防護。