後端

破壞性讀取將剖析失敗轉為永久性掛起

某個 worker 使用 GETDEL 讀取工作結果,因此任何它無法解析的酬載都會永久遺失。若將此情況視為「尚未就緒」,就會導致無限期的等待。

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

一個任務在 18:28 失敗了。使用者在 20:30 之前都看到「進行中」的狀態。失敗通知已準時送達,且 worker 也已讀取。只是它沒有理解通知的內容。

這個錯誤只是一行控制流程的問題。一個等待帶外 (out-of-band) 結果的 worker 將所有擷取錯誤都視為相同情況處理,因此,「結果尚未出現」和「結果顯示任務失敗」都意味著繼續等待。光是這樣就已經會是個緩慢的錯誤了。讓問題變成永久性的是讀取本身:worker 使用了破壞性讀取 (destructive read),所以在它誤讀失敗通知的那一刻,該通知就不復存在了。

如果你有一個非同步 worker 會輪詢 (poll) 一個鍵 (key) 來取得結果,這篇文章就是要談論,是哪兩件事會將一個小小的分類錯誤變成數小時的停滯,以及如何建構 consumer 來避免這種情況發生。

消費者必須區分的兩種狀態

這種設定很常見。後端將工作提交給運算服務,並立即取回一個 ID。運算服務執行其工作,並將結果寫入一個共享鍵,然後發布一個通知。後端訂閱該通知,同時也透過一個計時器輪詢該鍵,因為發布/訂閱的傳遞不保證成功。

消費者迴圈大致如下所示:

for {
    select {
    case <-ctx.Done():
        return rescueOrError(ctx.Err())

    case msg := <-notifyCh:
        result, err := fetchResult(ctx, key)
        if err != nil {
            slog.Warn("fetch after notify failed", "error", err)
            continue          // keep waiting
        }
        return result, nil

    case <-ticker.C:
        result, err := fetchResult(ctx, key)
        if err == nil {
            return result, nil
        }
        // no result yet, keep waiting
    }
}

再看一下那個 continue。當計算服務明確告知我們任務失敗時,就會執行到它。消費者會記錄一則警告,然後返回休眠狀態。

一個等待中的消費者必須區分至少三種結果,而這個迴圈將它們簡化成了兩種:

  • 尚未就緒。 鍵(key)不存在。繼續等待。這是唯一應該繼續等待的情況。
  • 暫時性的基礎設施錯誤。 鍵儲存區短暫無法連線或正在載入。繼續等待,因為結果可能尚未被消費。
  • 終端狀態。 結果已送達,而它要不是明確的失敗,就是我們無法解讀的東西。立即停止。

修復方法不是「處理錯誤情況」。而是要讓第三種類別成為迴圈可以識別的型別,並將每個分支都導向同一個述詞(predicate):

type StepError struct{ Step, Server, Message string }      // service said: failed
type ProtocolError struct{ Step, Server, Reason, Raw string } // we cannot parse it

func isTerminal(err error) bool {
    var se *StepError
    if errors.As(err, &se) { return true }
    var pe *ProtocolError
    return errors.As(err, &pe)
}

有兩個細節比表面上看起來更重要。請將這些作為指標回傳,因為 errors.As 在比對 *StepError 目標時,會靜默地無法匹配實值型別。並且使用 %w 進行包裝,絕不使用 %v,否則錯誤鏈會在第一個包裝器處中斷,且每個下游的檢查都會悄悄地回傳 false。

為何破壞性讀取會提高風險

消費者以原子性的讀取並刪除(Redis GETDEL)操作來讀取結果。這個選擇本身是站得住腳的:它能防止兩個工作者取用相同的結果,並且無需依賴 TTL 就能保持鍵空間的整潔。

但這會將交付保證轉換為至多一次,而這也改變了剖析失敗所代表的意義。

非破壞性讀取 GET key 解析失敗 值仍然存在 稍後重試或檢查 破壞性讀取 GETDEL key 解析失敗 值已消失 解析是唯一機會

使用單純的 GET 時,格式錯誤的酬載很惱人。你會將其記錄下來,把值留在原處,然後稍後再決定如何處理。而使用 GETDEL 時,值在查看的當下就被消耗掉了。因此,規則變得絕對:

一旦破壞性讀取返回的不是「鍵值不存在」,那麼在那之後的任何失敗都是終端性的。

無效的 JSON 是終端性的。遺失狀態欄位是終端性的。狀態欄位是數字而非字串也是終端性的。這些問題都不會因為等待而解決,因為已經沒有東西可以等待了。最初的迴圈將所有這些情況都視為「繼續等待」,因此每個問題都保證會造成數小時的停滯,直到某個外部的安全計時器觸發為止。

這裡有一個真正的權衡取捨。如果你保留破壞性讀取,你就得接受消費者在讀取和提交之間崩潰會導致結果遺失。如果你改用 GET,並在自己的狀態提交後再執行刪除,你就能實現冪等性,但必須處理重複消費的問題。兩種做法都可以。不可以的是,使用破壞性讀取,卻又撰寫假設可以再次查看資料的消費者程式碼。

正面表列,而非負面檢查

在新增終端機類型時,我發現在同一個函式中還隱藏著第二個錯誤,比我正在修復的那個還要舊:

var status string
if s, ok := raw["status"]; ok {
    json.Unmarshal(s, &status)   // error ignored
}
if status == "error" {
    return nil, errors.New("service reported failure")
}
return &Result{Status: "done", Content: raw}, nil   // everything else

這會檢查一種錯誤值,並將其餘所有情況都視為成功。讓我們來看看這代表什麼意思:

負載 舊有行為 正確行為
{"status":"done", ...} 完成 完成
{"status":"error", ...} 失敗,然後被呼叫端忽略 終端失敗
{} 完成 終端協定錯誤
{"status":123} 完成 (unmarshal 錯誤被丟棄) 終端協定錯誤
{"status":"failed"} 完成 終端協定錯誤
完全不是 JSON 錯誤,然後被呼叫端忽略 終端協定錯誤

其中有三列是無聲的資料毀損。一個空的物件被回報為成功的任務,而隨之而來的任何部分內容,最終都被寫入了紀錄中。沒有人注意到這點,因為生產者恰好總是傳送格式正確的負載。合約是靠運氣而非強制執行來遵守的。

反過來做。明確指出您接受的值,並將其他所有情況都視為您能看見的錯誤:

switch status {
case "done":
    return &Result{Status: "done", Content: raw}, nil
case "error":
    return nil, &StepError{Step: step, Message: sanitize(msg)}
default:
    return nil, &ProtocolError{Step: step, Reason: "unknown_status:" + status}
}

同樣的原則也適用於你本來要忽略的解組錯誤。如果 status 存在但不是字串,這就違反了生產者的合約,而你應該要讓這個錯誤明確地顯現,而不是將其轉換成一個假的成功狀態。

重試預算需要自己的鍵作用域

一旦失敗立即浮現,你可能就會想要重試。計算端的暫時性記憶體不足錯誤值得再試一次,而不良的輸入則不值得,但你通常無法從酬載中區分它們。

我最初的嘗試是重複使用一個現有的「我們已嘗試過的伺服器」陣列作為預算計數器。兩位審核者接連否決了它,而第二個理由是有趣的那個。

第一個問題是差一錯誤。附加目前的伺服器然後檢查 len(servers) >= 1 會在第一次嘗試時就讓工作失敗,所以重試永遠不會發生。一旦說出來就很明顯了。

第二個問題是當你修復第一個問題後會發生什麼事。將上限提高到 2,每當同一個工作者重新拾起該工作時,陣列就會停止增長,因為附加操作有防止重複的保護。沒有增長意味著沒有終止條件。該工作會永遠重新排入佇列。更糟的是,通常的逃生口並不存在:這個路徑不會增加通用的重試計數器,而且工作在每個週期都乾淨地完成,所以過期工作監視器從未將其視為卡住。

同樣的結構對於相鄰的錯誤類別之所以安全,是因為有斷路器。提交失敗會計入其中,所以在幾次失敗後,斷路器會打開,工作者進入閒置狀態,這意外地中斷了迴圈。我正在做的變更明確地移除了那個意外。

所以預算需要是一個單調遞增的計數器,而其鍵的作用域需要深思熟慮:

// key: retry:{job_id}:{step}
const incrWithTTL = `
local v = redis.call("INCR", KEYS[1])
if v == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end
return v`

在確定此方案前,我犯了三個錯誤:

  • 以工作為範疇,而非實體。 以父實體加上步驟名稱作為鍵值看起來很自然,但如果使用者可以針對同一個實體和步驟觸發數個獨立的工作,這些工作會共用一份預算並相互排擠。每個佇列項目使用獨立的 ID 可以將它們分開,而內部重新排入佇列的操作會保留此 ID。
  • 區分次級步驟。 在同一個工作記錄中執行的前置處理步驟,絕不能耗用主要步驟的預算。應使用實際的運算步驟名稱,而非工作宣告的步驟。
  • 讓 INCR 和 EXPIRE 成為原子操作。 INCR 會建立沒有 TTL 的鍵值。如果程序在執行 EXPIRE 之前終止,你就會得到一個永不消失的計數器,未來該 ID 的每一次失敗都會立即致命,且無法自然恢復。一個 Lua 指令碼可以消除這個時間空窗。

應根據嘗試之間可能的最長間隔來設定 TTL,而非平均值。我最終設定的值是單次等待時間硬性上限的兩倍。我最初猜測的兩個值(一小時,然後是六小時)都比單次嘗試可能運行的時間還短,這會讓計數器在重試中途過期並重設預算。這又是另一種形式的無限重新排入佇列。

不要將領域失敗混入你的斷路器中

最後一部分是關於這些失敗應在何處計入生產者的健康狀況。

斷路器的存在是為了解答一個問題:這個端點是否生病了?一個明確的失敗回應證明了它是健康的。它接受了請求、執行了工作、產生了結構化的答案,並透過結果通道傳遞了它。工作失敗了,但伺服器沒有。

因此,明確的失敗不應被計為斷路器失敗。否則,少數幾個不良的輸入就會開啟斷路器,將一個完全正常的節點從輪替中移除,這會將不相關的工作推入重新排隊的佇列中。

協定錯誤則相反。一個你無法解析的酬載暗示著版本不一致、序列化錯誤或未完成的部署。這是一個節點問題,應該被計入。

這裡有一個細微之處,讓我多花了一個後續的 commit。如果你只是跳過失敗呼叫,你可能也會跳過成功呼叫,而如果你的斷路器在半開狀態下追蹤一個飛行中的探測槽,那個槽就永遠不會被釋放。然後,該工作單元就會卡在一個永遠不會打開的閘門後面,直到程序重啟。我需要一個明確的釋放:

func (cb *Breaker) ReleaseProbeAsSuccess() {
    cb.mu.Lock()
    defer cb.mu.Unlock()
    if cb.state == "half-open" {
        cb.state = "closed"     // the server answered, it is reachable
        cb.failures = 0
    }
    cb.inFlight = 0
}

該方法有兩項防護措施。請勿在斷路器處於閉合狀態時發起的請求中呼叫它,因為斷路器通常在 goroutines 之間共享,而你可能會在沒有實際探測成功的情況下,關閉了其他人的探測。並且,絕不要在上下文取消時呼叫它,因為關閉操作無法證明任何關於遠端主機的狀態。

仍未涵蓋的內容

誠實面對邊界情況,因為它們就是下一個錯誤的來源:

  • 單一工作的並行消費者。 如果兩個 worker 不知何故處理了同一個工作,其中一個可能會在計數為 1 時將其重新排入佇列,而另一個則在計數為 2 時放棄,導致一個已經失敗的工作又回到生產者端。要防止這種情況,需要在重新排入佇列時對工作狀態進行比較並設定 (compare-and-set),而不僅僅是使用一個計數器。
  • 缺乏多樣性的重試。 計數器限制了總嘗試次數,但並未將重試導向不同的節點。如果失敗是節點本機的問題,盲目重試會浪費預算。
  • 生產者合約本身。 所有這些都是消費者為了防禦模糊性所做的努力。真正的修復方法是在酬載 (payload) 中指明失敗是否可重試,這樣消費者就不必猜測。

部署後,同一個失敗的工作在兩個節點上嘗試了兩次,並在 2 分 19 秒內解決,生產者本身的錯誤訊息也保留在紀錄中。先前的路徑花了超過六個小時,並用一個通用的逾時標籤覆蓋了失敗原因。

常見問答

破壞性讀取是個錯誤嗎?

不。這是一種保證單次消費的合理方式。它只是轉移了責任:一旦你消費了,解析就是你最後的機會,所以每次讀取後的失敗都必須是終端性的。錯誤在於將破壞性讀取與假設可以再次查看的消費者程式碼配對。

為什麼不在解析錯誤時直接重試擷取?

因為在破壞性讀取下,已經沒有東西可以擷取了。重試會看到一個空的鍵值,並得出「尚未就緒」的結論,然後等待。這正是解析錯誤如何變成數小時停滯的原因。

終端性失敗應該嘗試幾次?

對我來說,重試一次是正確的,因為有相當一部分的失敗是計算端的暫時性資源耗盡,而最壞情況下的成本是多執行一次該步驟。如果你的生產者會回報錯誤是否可重試,請使用該資訊,而不是一個固定的數字。一個固定的數字是你所沒有的資訊的替代品。

協定錯誤和明確的失敗都應該重試嗎?

我兩者都重試,因為一個低成本的統一策略比一個我無法驗證的分類法更容易理解。它們的不同之處在於如何影響斷路器,而不是它們獲得的嘗試次數。

如何確保生產者的錯誤文本可以安全儲存?

在截斷之前進行正規化,而不是之後。修正無效的 UTF-8、移除控制字元和雙向覆寫、遮蔽內部路徑和 URL,然後再截斷至指定長度。先截斷會留下半個路徑或 URL,使其不再符合你的遮蔽模式。並且,不要讓未經解析的原始酬載出現在任何會觸及使用者的欄位中,因為它是整個系統中最不可信的字串。