滿載時的優雅應對
原文由 Nelson Elhage 于 發布,訂閱此部落格
假設我們有一個服務。現在先不談細節,姑且認定它會接收來自外部世界的請求,並做出相應的處理。這些請求可能是 HTTP 請求、RPC,或只是在網路層需要路由的封包。之後我們再談得更具體一點。
那我們能對它的效能說些什麼呢?
目前我們只知道它會接收請求並對其採取行動。光是這樣,就已經能帶出兩個有用的衡量指標:
- 我們可以談 請求速率,也就是單位時間內收到的請求量。我們通常會以「每秒請求數」來衡量,但在某些情況下,資料總量比請求數量更重要,這時我們就會改用(或同時使用)「每秒位元組數」。
- 這些請求中,有一部分(在最理想的情況下是全部)會被成功處理。我們同樣可以談這個速率——單位時間內成功完成的工作量——我們稱之為 吞吐量。
現在正是引入一些具體範例的好時機,後續討論中我們會反覆回到它們。我挑了兩個我熟悉的系統草圖,但它們都是相當常見、你很可能也認得的典型架構。
第一個範例是 HTTP 網頁應用程式。為了具體起見,我們來考慮一個採用特定、但相當常見架構的網路服務:由 nginx 網頁伺服器作為反向代理,後面接著一組 Python 工作行程(worker),每個行程都包含應用程式碼並使用後端資料庫。
對於這樣的服務,我們會把「請求速率」衡量為抵達 nginx 的 HTTP 請求速率,而「吞吐量」則像是「成功請求的速率」,此處的成功指的是例如「收到 HTTP 200 回應」。
我們的第二個案例是 TCP/IP 路由器或防火牆。這個服務位於兩個(或更多)網路之間,在任一介面上接收封包,判斷是否需要轉發以及要往哪裡轉發,再從另一個介面送出去。
對於這個系統,我們同時關心以每秒封包數與每秒位元組數衡量的「請求速率」:在「大量小封包」與「少量大封包」兩種情境下,特性可能會很不一樣。此處的吞吐量則是每秒成功轉發的封包數(或位元組數)。
確立了這兩個指標後,我們就能抽象地把效能談成它們之間的關係:吞吐量作為請求速率的函數。也就是說,當我們收到愈來愈多的流量時,這會如何影響我們實際完成的有用工作量?
在純粹思辨的完美世界裡,每個請求都會被成功處理,我們會得到請求速率與吞吐量之間 1:1 的關係。

然而,在我們這個不完美的現實世界中,系統運行在數量有限的實體硬體上,因此都有其有限的 容量:在既定系統設計與可用硬體下可達到的最大吞吐量。那麼,更務實的目標是:在達到容量上限之前維持線性關係,之後進入 飽和狀態——在這個區間內,額外的請求會失敗,但也不會拖累我們的吞吐量。

值得記住的是,每個系統都有其極限。有時你很幸運,極限大到可以實用上近似為「無限」,但它始終存在。一個沒有文件記載容量限制的系統,並非沒有極限,只是 沒有告訴你極限在哪裡。
然而,現實往往更為殘酷。如果沒有經過仔細的設計與調校,大多數系統的表現會比上圖糟得多;一旦達到容量上限,額外的請求會以某種方式使系統 過載,消耗寶貴的資源卻無法產生有用的吞吐量,於是我們會看到如下的行為:

或者,更糟的情況:

對於系統處於這種模式——接收著很高的請求速率,吞吐量卻遠低於其容量——我們有幾個詞彙可以描述。用哪一個往往取決於失效模式的細節,但我習慣用 壅塞崩潰 來廣泛地指稱這個區間。我不確定這個詞在網路以外的脈絡中是否被廣泛使用,但我發現它通常很容易讓人理解。
要在任何具體系統中解決壅塞崩潰,很大程度上取決於細節與具體問題,但仍有一些反覆出現的通用模式。
資源競爭與准入控制
系統在高負載下表現不佳最常見的原因,是對某個共享資源的 競爭。在這個脈絡下,我指的是任何多個行程試圖同時使用某個資源,而它們之間的互動開銷導致增加並行度反而降低該資源的總有效吞吐量的情況。當系統因這類競爭而效能下降時,我們常稱之為 抖動。
許多資源都會遇到競爭,以下是一些常見的例子:
- 如果同時運行的行程數遠多於實體 CPU 數量,我們會產生更多的上下文切換與排程器開銷,吞吐量因而下降。
- 如果我們有一個快取(例如資料庫的記憶體內磁碟快取),其大小恰好足以快取
k個並行請求所需的相關資料,那麼當我們試圖同時運行超過k個並行請求時,效能會急遽下降,因為快取會在不同請求的資料之間來回抖動,命中率大幅下滑。 - 許多 樂觀並行控制系統——包含許多無鎖演算法——在高負載下會嚴重退化,最糟的情況下會進入沒有任何客戶端能取得進展的模式,原因在於交易彼此衝突。我們把這種最糟的失效情況——每個客戶端都在做大量工作,卻沒有任何一方能前進——稱為 活鎖。
以我們的 nginx→python→database 架構為例,有幾個地方經常會出現棘手的競爭問題。
就我所知,「壅塞崩潰」一詞 最初是在網路的脈絡下被創造出來的,所以網路路由器容易出現這種失效並不令人意外。幾個具體的機制包括:
解決競爭的基本策略,是刻意將並行度限制在不會造成過度競爭的水準。大致來說,我們可以透過加入一個用來暫存輸入請求的請求佇列,以及一個 准入控制器來實現——只有當有可用容量時,控制器才允許請求離開佇列並開始處理。「准入控制」1 一詞在資料庫與通訊系統中大致就是這個意思,但我傾向將它泛化,用來指稱任何符合這種廣泛模式的技術。

准入控制的策略各不相同;最簡單的做法是計算出一個固定的最大並行度數值,並用 號誌來限制處理中的請求數量;在另一個極端,我們也可以有一個精密的閘控代理,它同時對系統中的瓶頸資源以及每個輸入請求所需的資源建有模型。
對於 Python 的 HTTP 服務來說,或許最簡單的准入控制形式,就是設定每台主機只運行固定 N 個工作行程。如果 N 選取得當,這樣的限制大多就能解決 CPU 與記憶體抖動的問題。
在更精巧的設計中,每個節點可以向負載平衡器回報自身的健康狀態;如果這個判斷是基於本地可用資源,我們也可以將其視為一種准入控制的形式。
無論是哪種情況,我們通常不會想到在應用服務前有一個明確的佇列,但負載平衡器往往會在內部 維護一個佇列,而網路的監聽佇列與通訊端緩衝區也會作為待處理請求的隱式佇列。
在網路系統中,我們最常把准入控制想成是對網路實體層的保護。傳輸媒介,無論是空氣、銅線還是光纖,都有一定的最大容量,而硬體的職責就是僅以媒介能夠承載的速率來傳送封包。為了吸收超過此容量的突發流量,網路卡與路由器上的軟體都會維護封包佇列,待容量釋出時再將佇列中的封包送上網路。
競爭現象至少有一個重要的涵義:如果你的系統在某個關鍵資源上已經達到容量上限,增加並行度很可能會 損害吞吐量而非提升它。最糟的情況下,如果我們對非瓶頸的資源增加容量,反而可能 降低效能——因為這麼做會加劇有限資源上的競爭。具體來說,如果應用程式的瓶頸在資料庫,增加並行行程的數量可能會讓資料庫被進一步壓垮,反而降低整體吞吐量,而不是改善情況。
佇列不是空的就是滿的
透過適當的准入控制,我們可以在任何請求速率下限制競爭、維持內部的高吞吐量。然而,問題並未就此結束。如果輸入請求的速率持續高於我們的最大吞吐量,這些請求預設會在請求佇列中無限制地累積。

對於這種狀況,我們可以做幾點觀察:
- 此時的吞吐量與佇列長度無關。無論佇列是短是長(只要永遠不為空),我們從佇列中取出訊息的速率是一樣的,處理訊息的速率也是一樣的。這一點很重要:佇列無法提升峰值容量。
- 隨著佇列變長,整體延遲也隨之增加;若佇列中有 N 個訊息,就需要 N/Y 秒來處理完它們,因此在先進先出(FIFO)的假設下,一個請求從佇列左端走到右端也需要 N/Y 秒。
- 隨著佇列持續增長,終究會發生以下一種或多種情況:
- 佇列會達到設定的最大長度,被迫丟棄訊息。
- 佇列沒有設定最大長度,但會耗盡伺服器上可用的儲存空間(通常是磁碟或記憶體)。
- 隨著延遲升高,上游的客戶端最終會逾時,並開始將請求視為失敗,即使這些請求在我們的系統內部最終仍有可能成功。
在許多系統中,第一個徵兆——因佇列大小限制而導致請求被丟棄——往往是我們在過載時實際觀察到錯誤的第一個地方。當這種情況發生時,很容易會想透過加大佇列來解決眼前的問題;但就我們的問題確實是容量問題——整體吞吐量小於請求速率——而言,這麼做並不會讓系統恢復健康:佇列無法提升峰值容量。
相反地,如果我們遇到的是第三種問題——因高延遲而導致的逾時——我們可能會觀察到,任何在系統中停留時間已超過相關逾時門檻的請求,其實「已經失敗了」,不值得再為它花費更多時間。於是我們可以在請求佇列內部,或是在取出請求時,加入一個明確的檢查,以免在這些請求上再浪費功夫。我們可能會寫出像這樣的邏輯:
request = queue.pop()
if time.time() - request.arrival_time >= REQUEST_LATENCY_BUDGET:
return_error(request, RequestTimedOut()
else:
process(request)這樣的檢查如果做得恰當,確實能在一定程度上改善狀況:它會讓佇列的出隊速率平均上提升到與入隊速率相當,佇列便會停止增長。
然而,這樣的檢查也無法讓佇列縮小到某個程度以下:一旦佇列長到需要花 REQUEST_LATENCY_BUDGET 的時間才能讓請求通過,我們就會停止消化它。如果請求速率持續偏高,佇列就會恰好徘徊在那個大小附近,讓我們處理的每個請求都額外承受一段延遲。在最好的情況下,我們能維持不錯的吞吐量,但代價是多了 REQUEST_LATENCY_BUDGET 的額外延遲;在最糟的情況下,我們預留的餘裕不夠,請求終究還是會逾時。
我們稱這樣的系統具有 常駐佇列。在健康的系統中,佇列是用來吸收暫時性的突波,且會很快被消化;而在這裡,我們有一個在穩態下持續存在的龐大佇列,導致不必要的延遲。
在我們典型的 HTTP 網路服務架構中,高負載下最常見的情況是,最先「察覺」到問題——也就是發出訊號或記錄錯誤——的元件會是前端的 nginx,它對 允許同時處理的請求數量有著嚴格的限制。
然而,如前所述,單純調高這個數字很少能真正解決問題,反而可能只是把問題推到別處。一般來說,如果我在 nginx 的日誌中看到 「worker_connections are not enough」,我的第一直覺永遠是 nginx 背後存在容量瓶頸,而不是真的該去調高那個設定。
nginx 內部以及各種網路緩衝區中的請求佇列,可能非常隱晦,身為維運人員很難直接觀察到。我喜歡的一個做法是讓 nginx 在請求中 加入一個帶有當前時間戳的標頭;當請求抵達應用程式碼時,我們用現在的時間減去 nginx 記錄的時間,就能量測出佇列造成的延遲。
在網路的脈絡中,這種常駐佇列的問題正是惡名昭彰的「緩衝區膨脹」問題。為了避免丟棄封包,加上記憶體成本下降的速度快於網路吞吐量的成長,網路硬體不斷加入愈來愈大的封包佇列來暫存輸入封包,再以網路的容量將其轉發出去。然而,如果網路持續處於超額訂閱的狀態,這些佇列實際上無法讓實體線路變得更快或更粗,若缺乏謹慎的管理,很快就會變成常駐佇列,讓任何被迫在其中等待的封包都增加延遲!
本節的兩個重點,是我最希望你帶走的觀念:
- 佇列可以幫助把突發的負載在時間上攤平,但 無法在總體上提升峰值吞吐量。
- 如果在負載下累積了持續存在的常駐佇列,我們的佇列只是在毫無益處的情況下增加延遲;而且延遲量可能是災難性的。
什麼才是有效的作法
歸根究柢,如果我們持續處於超載狀態,又無法或不願增加容量,那就只有一個解法:想辦法讓需要做的工作變少。
針對這個目標,我通常會想到兩種相關的策略:
- 我們可以設法請客戶端減少請求數量,從而降低輸入的請求負載。
- 我們可以選擇不處理一部分請求,以盡可能低的成本將其丟棄,從而釋出資源來成功處理剩下的請求。
我們把這兩種策略中的任一種或兩者都稱為 背壓;無論哪種情況,我們都是在某種意義上對輸入的工作「施加反壓」,讓客戶端意識到我們的容量限制。在第一種情況下,我們是明確地要求它們放慢速度;在第二種情況下,我們則是讓它們收到錯誤或被丟棄的請求,迫使它們必須以某種方式處理。
作為在乎可用性與使用者體驗的服務設計者與維運者,以這種方式把問題推回給使用者,可能會感覺有違直覺;會覺得像是在承認失敗,或是在推卸責任。沒有人喜歡刻意丟棄一個有效的請求。然而,具韌性的系統不可避免地需要某種形式的背壓,原因有幾個:
- 如前所述,每個系統都有其 極限;當我們真的遇到極限時,我們會希望對接下來發生的事做出有意的決策,並盡可能刻意、優雅地應對。
- 更重要的是,背壓創造了一個 封閉迴路系統。如果一個平時健康的系統發生過載,其直接原因就是有人向我們發送了過多的流量。透過把問題推回給對方,我們讓問題本身與問題的源頭更靠近,從而讓真正的解決成為可能。
流量控制
如果我們能夠與客戶端協定及/或與之互動的客戶端共同設計系統,有時就能以 流量控制機制的形式內建背壓,讓接收端直接告知發送端何謂安全或可支援的流量速率。
大多數低階的通訊原語(例如 TCP 通訊端或 UNIX 管道)都會透過限制串流中已發送但尚未處理的資料量來實現某種形式的流量控制。因此,如果我們的協定主要透過單一或少數這類串流進行通訊,有時就能從底層機制「繼承」一種基本的流量控制。
明確的流量控制機制在 HTTP 網路服務中較少見,但我能想到幾個至少在精神上符合的機制:
- 「429 Too Many Requests」HTTP 狀態碼,約在 2012 年標準化,讓伺服器能明確告知請求因負載或速率限制而被拒絕,並可透過
Retry-After標頭告訴客戶端應暫停多久後再重試。 - 有些 HTTP API 會在每個回應中提供標頭,傳達 速率限制的狀態以及請求者距離被限制還有多近。
負載捨棄
如果我們無法使用流量控制機制,或是客戶端不遵守它,剩下的選項就是 負載捨棄:我們挑選出一部分請求,並盡可能早地將其丟棄(這可能意味著直接忽略它們,或是在成本足夠低的情況下回傳錯誤)。透過這麼做,我們釋出了資源並消化了佇列,讓剩下的請求得以被成功處理。
負載捨棄與 速率限制相關,後者是指限制每個使用者的輸入請求數量,若超過限制就回傳錯誤。一般來說,我所看到的區別在於:「速率限制」指的是針對個別使用者、始終生效且無論整體系統容量如何都會執行的限制,而「負載捨棄」則專指在整體系統達到或接近某個容量上限時才啟動的機制。實務上,在許多情境中,導致超載的常見原因就是某個失控的單一使用者,因此兩者最終密切相關,經常解決重疊的問題。
要決定捨棄哪些請求來卸載負載,我們可以採用多種策略;一些常見的包括:
- 隨機丟棄
- 這種做法簡單且實作成本低:可能完全不需要解析輸入請求,也不需要在多個前端元件之間進行協調。視情境而定,它也可能被視為「公平」的
- 客戶端分級
- 如果你有免費方案與付費方案,就可以優先丟棄免費請求。這可以泛化成各種形式,甚至做到讓請求內含對容量的出價的現貨市場。分級在具有明確 SLA、部分使用者在合約上被保證一定服務水準、而其他人則沒有的系統中特別有效。
- 請求分級
- 你也可以根據 請求類型來決定優先順序,優先丟棄那些較不具時效性、或較容易稍後重試的請求。舉例來說,在 Stripe,對於客戶扣款的請求被視為比單純查詢或列出舊有付款紀錄的請求重要得多;如果 API 服務負載過高,我們會優先處理前者,並刻意丟棄(部分)後者。
- 公平分配
- 均勻隨機丟棄在「每個請求被丟棄的機率相等」的意義上是公平的。但它也會誘使使用者大量發送請求:如果某個使用者占了所有輸入請求的 80%,他就會拿到 80% 的吞吐量。因此,有時根據更細緻的「公平」定義來分配容量是有道理的——例如改為按每個客戶端平均分配可用容量,而非按請求分配。
- 實務上,這有時需要額外的協調,實作起來頗具挑戰,而通常只要實作在負載捨棄器之前就觸發的、針對個別使用者的速率限制,就足以達到類似的目標。
我們在 HTTP 網路服務中可以看到各式各樣的負載捨棄與速率限制技術。
對於「Python 加上資料庫」的應用程式來說,一個常見的模式是請求的處理成本相對較高,無論是需要執行大量應用程式碼,還是需要進行多次或昂貴的資料庫查詢;這種高成本往往會促使我們採用多層次的負載捨棄系統:
- 我們通常會在服務前端加入一個高效能的速率限制器,或許使用 nginx、CDN 或 雲端服務,在流量抵達笨重的應用程式碼之前,就先擋下最劇烈的突發流量(甚至包含明確的 DDoS 攻擊)。
- 接著,一旦流量真的抵達我們的應用程式,我們還可以利用應用程式的驗證及/或路由邏輯來對請求進行分類,實作更細緻的限制。如果我們能在不存取資料庫的情況下完成這一步,其成本仍會遠低於完整處理該請求。
- 或者,我們也可以在應用程式前端使用「API Gateway」,在應用程式碼之外完全實現路由、驗證與感知請求內容的速率限制的某種組合。
我的前同事 Paul Tarjan 曾 在 Stripe 寫過一篇很棒的文章,描述 Stripe 所實作的速率限制與負載捨棄的決策與技術,該公司使用的正是非常類似的前端/應用程式碼/資料庫架構。
TCP 接收端知道自身的容量,可以「直接告訴」發送端放慢速度,但要管理與估算網路容量就困難得多,因為這牽涉到共享同一條鏈路的所有不同流量與節點之間的交互作用。
傳統上,TCP 依賴偵測被丟棄的封包來判斷壅塞。它的假設是,被丟棄的封包意味著網路鏈路已過載,發送端需要放慢速度。這種設計本質上是把一種負載捨棄機制(網路丟棄封包)重新挪用為一種流量控制的形式。
歷史上,網路設備在負載下使用簡單的 「尾端丟棄」演算法來丟棄封包。這個選擇對於管理路由器本身的負載效果很好,但對於整體網路的 TCP 流量控制行為,卻產生了非常糟糕的湧現特性。因此,現代路由器實作了「主動佇列管理」,針對該丟棄哪些封包做出策略性決策,從而決定要向哪些流量發出放慢的訊號。現代的 CoDel 演算法在緩解、甚至可說在很大程度上解決了緩衝區膨脹問題上,功不可沒。
此外,TCP 的 明確壅塞通知 擴充,讓路由器可以在不丟棄資料的情況下設定一個旗標來表示壅塞正在發生。如果網路流量上的所有參與者都支援 ECN,執行主動佇列管理的路由器就可以選擇設定「已發生壅塞」的旗標來通知某個流量放慢速度,而無需實際丟棄封包。
主動佇列管理要求路由器有足夠的剩餘容量來對封包做出審慎的決策。相反地,如果輸入封包的速率高到有可能壓垮路由器自身的處理能力,我們可能還需要在路由器管線中更早的階段就丟棄封包,甚至在 網路卡硬體層級就進行丟棄。
作戰短路
為了正常運作,負載捨棄或速率限制 必須在系統仍然健康、正常運作時——也就是在它變得嚴重過載之前——就開始介入。然而,如果系統仍然健康,它通常還能處理少量額外的邊際請求,因此負載捨棄器無可避免地會拒絕或丟棄一些 假設沒有捨棄的話本來會成功的請求。
這可能是個難以接受的現實:沒有人喜歡刻意丟棄請求。然而,這通常是正確的選擇。以可控的方式在刻意的時機丟棄請求,通常比讓系統進入過載狀態要好得多。
然而,在某些關鍵系統中,有時會認定失效的代價極高,因此在某些情況下,我們可能寧願刻意關閉保護機制、放手一搏,也不願刻意丟棄請求或停用某個子系統。能進入這種模式的開關有時被稱為 「作戰短路」,名稱源自某些軍事裝備在作戰期間以實心銅條旁路或「短路」保險絲的做法。其邏輯在於,在激烈的戰鬥中,失去機動或還擊能力的風險,被判定為高於車輛電力子系統過熱或損壞的風險。
增加容量
我寫這篇文章的主要目的,是探討用來理解與管理過載系統——也就是持續處於或超過某個容量上限的系統——的技術與框架。實務上,如果一個系統經常過載,我們往往會想透過增加容量來解決問題,而不是(或除了)採取讓過載狀態更為優雅的介入措施。在結束之前,我想針對提升系統容量補充幾點說明。
第一,「增加容量」與「優雅地處理過載」往往不是二選一的決定;真實的系統通常兩者兼具才能受益。舉例來說,我們可能會實作自動擴展來增加容量,但同時使用負載捨棄器來確保在等待新容量上線的期間,系統仍能優雅地運作。或者,我們可能會配置足以處理「正常」負載的容量,但仍需要背壓或負載捨棄技術來應付偶爾會向我們發送 10 倍於基準負載、有缺陷或設計不良的程序。
第二,我想強調的是,當我們增加容量時,重要的是辨識出瓶頸資源並對其進行擴展。如果我們擴展了錯誤的資源(例如,對一個瓶頸在資料庫的服務增加更多應用程式的 CPU),有時反而會讓問題更糟!而這背後的原因與本文所討論的競爭與常駐佇列等主題密切相關:系統是由更小的系統以碎形方式組合而成,而各個子系統可能以不同且微妙的方式容易出現這些失效模式。因此,即使我們的目標是持續增加容量直到永遠供過於求,理解這些關於系統在超載時如何運作的教訓,往往仍然是必要的。
結論
「系統在達到或超過其容量時會有何表現?」這個問題在入門的軟體工程資源與初期的系統設計中經常被輕輕帶過,但在我的經驗中,它實際上定義了在大規模下處理複雜系統的工作體驗。
對於任何特定的系統,系統的細節與容量限制都非常重要;但我也發現,只要將這些細節提升幾個抽象層次來看,就會看到許多共通的趨勢與主題,以及許多高層次的相似性。而如果你真的將這些模式內化並能加以辨識,它們就能成為無價的指引,幫助你判斷哪些細節才是關鍵,並將它們放在適當的脈絡中理解。
本文試圖總結我腦中圍繞這個問題的各種主題與概念的全貌,並以可分享的方式闡明其中一些模式與趨勢。我希望它能幫助那些在新應用中首次遇到這類問題的工程師,將問題放進脈絡中,並找到可能有價值的既有研究或概念的指引。如果你覺得有幫助,歡迎告訴我。
維基百科對此詞在網路與互連領域有許多引用;我最初是在資料庫的脈絡中遇到這個詞的,但找不到同樣好的引用;不過你可以在 這篇論文的第 14 頁看到一個使用範例。 ↩︎
與 fighting trousers 有所區別。 ↩︎
隨機一篇部落格
留言
登入後參與討論