後端

為填充過快而無法清空的工作者佇列施加背壓

當生產者的產出速度超過工作者時,無界佇列並不會吸收負載,它只是延後了崩潰。以下說明背壓(backpressure)如何讓系統屹立不倒。

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

生產者與工作者之間的佇列,感覺就像一個避震器。工作以叢發方式到達,佇列會將其暫存,而工作者則以自己的步調消耗它。這個直覺只有在平均到達率低於平均消耗率時才成立。一旦生產者的速度長時間超過工作者,無界佇列就會停止吸收任何東西。它會無限制地增長,記憶體或佇列儲存空間會耗盡,然後整個系統會瞬間崩潰。解決方法不是一個更大的佇列。而是背壓:一個有界佇列,它會將超載推回給生產者,而不是將其隱藏直到系統崩潰。這篇文章將展示為何無界佇列會失敗、在 Go 中背壓是什麼樣子,以及學會拒絕所帶來的取捨。

為何無邊界佇列是延後而非吸收崩潰

把佇列想像成一個水桶,水會流入也會流出。如果平均來說,流出的速度至少和流入的速度一樣快,水位就會維持在一定範圍內,突發的流量也會被平滑化。如果平均來說,流入的速度稍微快一點,水位就會無止盡地上升。沒有任何大小的水桶可以解決永久性的失衡,只有大小能改變你等待它溢出前的時間長短。

無邊界佇列移除了溢出點,這聽起來像個功能,但實際上是個失敗。失衡依然存在,所以待辦項目會不斷增長,直到耗盡所有可用記憶體,或將佇列儲存區填滿至其硬性上限。在那之前,有兩件事會先出問題,而且兩者都比乾淨地拒絕請求更糟。

第一是延遲。一個進入佇列的工作,若前面有一百萬個項目,就必須等待那一百萬個項目被處理完。等到工作者處理到它時,創建它的請求通常已經逾時,使用者已經離開,或是結果已經過時。你現在正耗費稀少的工作者容量,去計算沒有人在等待的答案,這會進一步減慢處理速度,並加深待辦項目的積壓。這是一個會自行惡化的回饋循環。

第二是連鎖反應。當佇列儲存區最終耗盡記憶體時,它不會客氣地失敗。它可能會拖垮託管它的行程、阻擋所有試圖將項目加入佇列的生產者,並癱瘓共用同一台機器或同一個連線池的無關服務。一個緩慢的工作者會演變成全系統的中斷。無邊界佇列並未防止崩潰,它只是延後了崩潰,並使其規模變得更大。

背壓是一個會反推的有界佇列

背壓是一種特性,當下游階段跟不上時,壓力會向上游傳遞給生產者,而不是在中間無形地堆積起來。建構它最簡單的方法是使用一個有硬性容量的佇列。當佇列滿了,生產者無法將項目入列,並且必須當下做出決定。那個決定就是重點所在。這就是系統選擇如何有目的地卸載負載,而不是任由負載將其壓垮的地方。

在 Go 中,其基本元件已經具備了背壓感知能力。一個緩衝通道就是一個有界佇列,而向一個滿載的通道發送資料會阻塞發送者,直到某個工作者騰出空間為止。這種阻塞就是最直接形式的背壓。

// A buffered channel of capacity N is a bounded queue. When it holds N jobs,
// the next send blocks until a worker receives one. The block is the
// backpressure: the producer cannot outrun the workers.
jobs := make(chan Job, 256)

// Producer. This send parks the goroutine if the buffer is full, so the
// producer's own rate is capped by how fast workers drain.
jobs <- job

當生產者可以承受放慢速度時,例如另一端沒有使用者在等待的內部批次載入器,阻塞式傳送就是正確的行為。減慢生產者的速度以匹配工作者的速度正是您想要的,因為這兩個速率現在是耦合的,且待辦事項不會增長超過緩衝區。

但當有使用者或有截止時間的上游呼叫者時,阻塞式傳送就是錯誤的行為。因為工作者落後而讓一個 HTTP 處理程式無限期地阻塞,只是將無限制的等待從佇列轉移到您的連線池中,然後您會耗盡連線而不是記憶體。當有人在等待時,您會希望快速拒絕而不是阻塞,這樣呼叫者就能立即得知系統已飽和,並且可以退避或容錯移轉。

// Non-blocking enqueue for request paths. If the buffer is full, do not wait.
// Reject now so the caller gets a fast, honest "try later" instead of a hang.
select {
case jobs <- job:
    // accepted
default:
    return errQueueFull // surface as HTTP 429 or 503 with Retry-After
}

這兩個選項,阻塞生產者或拒絕任務,是背壓的兩個面向。阻塞會耦合速率。拒絕則會捨棄多餘的部分。每個生產者要選擇哪一種,取決於是否有東西在等待,而一個真實的系統通常會在不同的地方同時使用這兩種方法。

限制並行性,使工作者不會淹沒下游

限制佇列可以保護您免受生產者的影響。您也必須限制工作者,因為工作者對於其下的任何東西(通常是資料庫、GPU 或其他服務)來說,也是生產者。一個常見的錯誤是為每個任務生成一個 goroutine。這樣做看起來很簡潔,並且完全移除了佇列,但它只是將無限制的增長轉移到別處。一萬個傳入的任務變成一萬個 goroutine 同時猛烈衝擊資料庫,而資料庫現在就成了倒下的那個。

一個固定的工作者池就是這個上限。您啟動一個已知數量的工作者,每個工作者都從有界佇列中提取任務,而這個數量就是將會衝擊下游資源的最大並行性。工作者池的大小變成一個您根據下游容量刻意設定的限制,而不是因應傳入流量多寡而產生的意外結果。

// Fixed worker pool. poolSize is the hard ceiling on concurrent work hitting
// the downstream resource. It does not grow with load, which is the point.
func startPool(poolSize int, jobs <-chan Job) {
    var wg sync.WaitGroup
    for i := 0; i < poolSize; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                process(job) // the only place downstream load is generated
            }
        }()
    }
    wg.Wait()
}

現在這兩個界限一起作用。緩衝通道限制了可以等待的工作量,而池則限制了可以執行的工作量。下游的總負載最多是 poolSize 個並行操作,加上一個最多為緩衝區容量的佇列,而且當流量尖峰到來時,這兩個數字都不會變動。尖峰流量會衝擊到入隊路徑,在那裡被拒絕或減慢,永遠不會像洪水一樣到達資料庫。請根據下游的容量來選擇池的大小,例如資料庫的連線池限制或您擁有的 GPU 數量,而不是根據一個感覺上很大方的整數。

捨棄已經太舊而無關緊要的工作

限制佇列和池的大小能讓系統維持運作,但在持續超載的情況下,當 worker 處理到那些確實進入的任務時,它們仍然可能變得過時。如果一個請求有五秒的預算,而一個任務已經等待了八秒,那麼執行它純粹是浪費。你會花費 worker 的時間去產生一個呼叫者早已放棄的結果,這會從那些仍有機會的較新任務中竊取容量。

為每個任務設定一個截止期限可以解決這個問題。在任務上標記它必須完成的時間,並讓 worker 在開始耗費資源的部分之前檢查那個截止期限。如果截止期限已過,就捨棄該任務並繼續處理下一個。在 Go 中,慣用的載體是帶有截止期限的 context,這也讓你可以取消已經在進行中的工作。

type Job struct {
    Payload  []byte
    Deadline time.Time
}

func process(job Job) {
    // Skip work that can no longer be useful. Under overload this is what
    // keeps workers spending their time on jobs someone still wants.
    if time.Now().After(job.Deadline) {
        metrics.Expired.Inc()
        return
    }
    ctx, cancel := context.WithDeadline(context.Background(), job.Deadline)
    defer cancel()
    doExpensiveWork(ctx, job.Payload)
}

捨棄過時的工作是一種反壓的形式,其目標是時間而非數量。它承認有些工作已經無望,並拒絕讓它們繼續消耗資源。在尖峰期間,這能讓一個飽和的系統繼續服務那些仍然有效的請求,而不是去費力處理積壓的無效工作。

無邊界佇列與背壓的並列比較

這兩種設計之間的差異,在關鍵情況下最容易看出,也就是持續超載,即抵達速率在一段有意義的時間內持續高於處理速率。

持續超載下的行為 無邊界佇列 帶有背壓的有邊界佇列
佇列深度 無限制地增長 上限為緩衝區大小
記憶體或佇列儲存空間 填滿直到耗盡 保持平穩
已接受工作的延遲 無限制地增加 受限於緩衝區加上服務時間
生產者得知的情況 什麼都不知道,直到崩潰 立即透過阻塞或拒絕得知
故障形態 全面崩潰,一次性發生 部分故障,某些工作被拒絕
下游(資料庫、GPU) 隨著 goroutine 的產生而淹沒 上限為池大小
尖峰過後的恢復 必須先處理完龐大的積壓工作 已經接近穩定狀態

無邊界佇列那一欄並不是一個能處理更多負載的系統。它是一個處理相同負載,但將故障隱藏起來直到災難性後果發生的系統。有邊界佇列那一欄會更早、更小規模且更清晰地發生故障,而這正是你希望從故障中得到的。

優先級、隔離以及不會放大問題的重試機制

還有兩個部分讓背壓機制變得實用而非粗暴。首先,並非所有工作都具有同等的重要性。如果健康檢查和大量匯入共用一個佇列,大量的匯入請求將會排擠掉健康檢查,導致您的監控系統在最需要的時候失靈。使用獨立的佇列搭配獨立的資源池來隔離重要的工作,這樣一條通道的超載就不會淹沒另一條。一個專為關鍵任務設置的小型專用資源池,即使在大量處理通道拒絕所有請求時,也能保持關鍵任務的流動。

其次是重試機制。重試被拒絕的工作是合理的,但一個天真的重試循環會讓背壓演變成一場重試風暴。當系統因飽和而拒絕工作時,立即重試會給已經飽和的系統增加負載,如果每個客戶端都這樣做,僅僅是重試就可能讓系統在最初的流量高峰過去很久之後仍然無法恢復。重試需要一個每次嘗試都會增加的退避(backoff)時間,並對嘗試次數設定硬性上限,這樣一次拒絕會導致下一次嘗試變慢,而不是立即進行第二次打擊。背壓和退避是應用在兩端的相同概念。伺服器透過拒絕來施加推力,而客戶端則透過放慢速度而非更猛烈地敲擊來尊重這個推力。

可觀察性將這一切聯繫在一起。佇列深度和等待時間是您的早期預警信號。一個趨於滿載的佇列,或是一個逐漸逼近截止時間的等待時間,都告訴您在任何請求被拒絕之前,不平衡就已經開始了。針對這兩個數字設定警報,您就可以按照自己的計畫增加工作單元或減慢生產者,而不是從一次服務中斷中才發現問題。

權衡:部分失敗是維持運作的代價

背壓並非沒有代價,而誠實的做法是說出其成本。有界佇列會拒絕任務。有上限的池會服務較少的並行請求。期限會丟棄那些已投入實際心力產出的工作。這些每一個都是一種部分失敗,且會有人將其體驗為一個未被處理的請求。如果你將「永不拒絕任何事物」視為成功,那麼背壓看起來就像是一種退步。

重要的比較,並非將背壓與一個能接受所有事物的完美系統相比。一旦生產者的速度超過了工作者的處理能力,這種系統便不存在。比較的是部分失敗與完全失敗。無界佇列會接受每一個任務,直到它再也無法接受任何任務的那一刻,並連帶使整個程序崩潰。有界佇列會持續地拒絕一小部分的任務,並繼續服務其餘的任務。這就是優雅降級(graceful degradation),而正是這個特性,區分了在負載下搖搖欲墜的系統與那些會直接崩潰的系統。

接受所有工作然後再處理的本能,在低負載時是沒問題的,而大多數系統在大部分時間都處於低負載狀態。穩定性來自於處於極限時的行為,也就是當負載高且不斷上升時,在那種情況下,有用的技能是知道如何拒絕。一個懂得拒絕的佇列是在保護自己。背壓並非系統的失敗。而是系統在選擇要承受哪些小的失敗,以便永遠不必承受那一個大的失敗。