水龍頭比排水管出水快的時候,把浴缸換大一點,並不會讓水不淹出來——它只是讓淹出來的那一刻晚一點到,而且到的時候會更猛。軟體系統前面那條 queue,就是那個更大的浴缸。
加 queue 救不了過載——為什麼緩衝只是把崩潰往後延,以及該做什麼
服務開始變慢的時候,最直覺的反應是「在前面加一條 queue 把尖峰吸收掉」。這篇文章會說明,為什麼這個直覺在「過載」這個特定情境下不只是沒用,而是有害——它把一個會立刻暴露的容量問題,換成一個會在最糟的時間點、以最糟的方式爆炸的延遲炸彈。讀完之後你會有三樣東西:一條能用紙筆驗證「queue 為什麼一定發散」的公式(Little's Law),一個能解釋「為什麼變慢會自己加速」的回饋模型(latency death spiral),以及一套真正解決問題的機制(load shedding 與 backpressure),最後用 pmbanugo 的 actor 框架 Tina 的 bounded mailbox 把這套機制落到具體的 API 上——.ok / .mailbox_full / .pool_exhausted 三種 send 結果各代表什麼、什麼時候該讓系統 crash。
先把用詞對齊。本文講的「過載」(overload)指的是一個很具體的狀態:到達率(arrival rate,記作 λ)持續地、不是瞬間地,超過系統的處理能力(service rate,記作 μ)。注意「持續地」這三個字——如果只是一秒鐘的尖峰,queue 確實能吸收,那是 queue 的正當用途。問題出在當 λ > μ 不是一個尖峰、而是一個趨勢的時候。這兩種情況看起來在 dashboard 上很像(都是 queue 變長),但物理本質天差地別。把它們混為一談,正是「加 queue」這個錯誤決策的源頭。
浴缸不會因為變大就不溢出——流入大於流出時,緩衝只是延後溢出
原文用了一個非常乾淨的類比,值得逐字引用:「If water is coming out of the tap (faucet) faster than it can go down the drain, making the bathtub bigger does not prevent a flood. It just delays it.」水龍頭出水比排水管排水快的時候,把浴缸換大不會避免淹水,只是延後。
把這個類比的三個角色對到系統上。水龍頭是到達率 λ——進來的 request;排水管是處理能力 μ——你的 worker 把 request 做完的速率;浴缸是 buffer,也就是那條 queue。原文的圖說把這件事釘死:「When arrival rate (the faucet) exceeds processing rate (the narrow pipe), the buffer (the bathtub) eventually overflows.」
這個類比之所以重要,是因為它把一個關於「容量」的迷思戳破了。直覺上我們以為 queue 是容量——「我有一條十萬深的 queue,所以我能撐十萬個 request」。錯。queue 不是容量,queue 是時間。一條深度 D 的 queue,在到達率 λ、處理速率 μ 的情況下,能撐的時間大約是 D / (λ − μ) 秒。只要 λ > μ,這個時間是有限的——把 D 加倍,撐的時間加倍,但終究是有限。你買到的不是「不會崩」,是「晚幾分鐘崩」。而且那幾分鐘裡,每一個進到 queue 的 request 都在累積延遲,所以崩的時候,整缸水是滾燙的。
下面這個 widget 把浴缸實際模擬出來。你可以調水龍頭(流入)與排水管(流出,有上限——這就是 μ)的速率,按 play 看水位怎麼變。當流入持續大於流出時,無論你把浴缸(buffer 容量)設多大,水位都會單調爬升到溢出;只有把流入壓回流出以下,水位才會回落。這就是 queue 在過載下的全部故事,沒有別的。
排水管 μ 有實體上限,水龍頭 λ 沒有
只要流入 λ 持續大於流出 μ,queue 無論多大都終將溢出,唯有把 λ 壓回 μ 以下才能阻止。
玩過一輪你會發現一件反直覺的事:當 λ 只比 μ 高一點點(比如 110 對 100),水位爬得慢,慢到你會以為「再撐一下就過去了」。這正是過載最危險的地方——它不是斷崖,是緩坡,而緩坡會騙你不去處理它。等到你發現的時候,水位已經逼近缸頂,而且後面那一整缸水的延遲已經高到沒救。下一節我們用 Little's Law 把這個「水位一定會爬到無限」的直覺變成一個可以算的不等式。
Little's Law——L = λW 怎麼證明 queue 深度會趨向無限
排隊理論裡有一條極其簡單、卻被低估的恆等式,叫 Little's Law。它的形式只有三個字母:
L = λ × W
L = 系統裡的平均項目數(queue 深度 + 正在處理的)
λ = 平均到達率(requests per second)
W = 平均每個項目待在系統裡的時間(seconds)
這條定律的美在於它幾乎不需要假設——它對任何穩定的排隊系統都成立,不管到達分布是 Poisson 還是別的、不管服務時間是固定還是隨機、不管你有幾個 worker。它是個會計恆等式:穩態下「系統裡有多少東西」等於「東西進來的速率」乘上「每個東西待多久」。
關鍵字是「穩定」(stable)。Little's Law 成立的前提是系統達到 steady state——進出平衡,L 收斂到一個有限值。而系統能達到 steady state 的條件,正是 λ < μ。當 λ < μ,每個進來的 request 平均等待時間 W 是有限的,於是 L = λW 是有限的,queue 有個穩定的深度。
現在把 λ 推過 μ。原文講得很直白:當每秒有 5,000 個 request 進來、但只有 1,000 個能被處理時,「Little's Law dictates that the number of items in your queue (L) must grow toward infinity」——queue 裡的項目數 L 必須趨向無限。為什麼?因為當 λ > μ,系統不再有 steady state。每個新進的 request 前面排的人比上一個更多,所以它的 W 比上一個更長;W 沒有上界,於是 L = λW 也沒有上界。L 不是「變大」,是「發散」——它沒有收斂的終點,只有 buffer 容量這個人為的天花板。撞到天花板那一刻,就是溢出。
下面這個 widget 讓你直接操弄 λ 與 W,看 L = λW 在 utilization ρ = λ/μ 跨過 1 的時候怎麼從一條溫和的曲線變成一面牆。把 λ 拉到 μ 以下,L 是個矮矮的有限值;把 λ 推過 μ,曲線在 ρ=1 附近垂直起飛——這就是排隊理論裡著名的「knee」,p99 latency 在系統利用率逼近 100% 時爆炸的數學根源。
μ(單一 worker 處理能力)固定為 1000 req/s
Little's Law 說明當 ρ = λ/μ ≥ 1 時 queue 深度 L 發散趨向無限,ρ=0.9 時延遲已是低負載的九倍。
這裡有個容易被忽略的細節:曲線在 ρ=0.8、ρ=0.9 的時候已經開始翹了,不是等到 ρ=1 才爆。M/M/1 排隊的 L = ρ/(1−ρ) 告訴你,利用率 80% 時 L=4、90% 時 L=9、95% 時 L=19、99% 時 L=99。也就是說,你以為「還有 20% 餘裕」的系統,延遲其實已經是低負載時的好幾倍。這就是為什麼 SRE 圈子有「別讓系統跑到 80% 利用率以上」這條經驗法則——不是 80% 之後就會炸,而是 80% 之後每多 1% 的利用率,p99 latency 的代價急速上升。Little's Law 是這條法則背後的數學。
到這裡,「為什麼 queue 救不了過載」已經有了靜態的答案:因為 λ > μ 時 L 在數學上發散,buffer 只決定撞牆的時間點,不決定撞不撞。但真實系統比這更糟——它不只是被動地等水位爬到溢出,它會主動地把 λ 推得更高、把 μ 壓得更低,自己加速自己的死亡。這個正回饋迴圈,就是下一節的主角。
latency death spiral——變慢為什麼會自己加速
前兩節假設 λ 與 μ 是你給定的外部參數。真實系統不是這樣。當 queue 開始變長、延遲開始上升,這件事本身會改變 λ 與 μ——而且是往更糟的方向改。原文把這條鏈描述得很完整,我把它拆成六個環節,每一環都餵養下一環。
第一環:queue 累積。如同原文所說,當「arrival rate (λ) exceeds your processing capacity」,queue 開始堆積。第二環:等到你的 worker 終於處理到某個 request 時,「the client who sent it most likely timed out」——送這個 request 的 client 早就 timeout 了。第三環:client timeout 之後做什麼?它「refreshed the page」,而「That refresh just added another request to the back of the queue」——重整或自動 retry,等於在 queue 尾巴又塞一個 request 進去。
第四環是整條鏈裡最惡毒的一段:系統現在「spending expensive CPU cycles processing dead requests」——花昂貴的 CPU 週期在處理已經死掉的 request(client 已經不在等了)。每處理一個死掉的 request,就是把 μ 浪費掉一份;原文說「your effective processing time (W) worsens. The queue grows faster because of the retries」——有效處理時間 W 惡化,queue 因為 retry 長得更快。第五環:記憶體壓力。queue 越長佔的記憶體越多,「memory pressure triggers garbage collection pauses, which slows down your processing rate」——記憶體壓力觸發 GC 暫停,進一步壓低處理速率 μ。
第六環是迴圈閉合:μ 被 GC 拖慢、被死 request 浪費,同時 λ 被 retry 推高。回到 Little's Law,分子 λ 變大、分母(μ 隱含在 W 裡)變小,L 發散得比沒有回饋時更快。原文那句總結最傳神:「the drain gets clogged while the faucet opens wider」——排水管被堵住的同時,水龍頭開得更大。這就是 latency death spiral:它不是線性惡化,是指數惡化,因為每一環都在放大下一環。
下面把這六環攤成一個鏈。在桌機看是一條帶箭頭的回饋環,在手機看是可點開的卡片——點任一環看它具體怎麼餵養下一環、以及它對 λ 還是 μ 下手。注意最後一條箭頭從第六環指回第一環:這是一個閉合的正回饋迴圈,不是一條有終點的鏈。
點任一環看它怎麼餵養下一環 · 6 環閉合迴圈
latency death spiral——六環正回饋,每一環餵養下一環
六環構成閉合正回饋
過載觸發六環正回饋:retry 推高 λ、處理死 request 與 GC 暫停壓低 μ,系統以指數速度自我加速崩潰。
環 3 那個「retry 推高 λ」值得單獨量化,因為它的放大效果比直覺強得多。假設真實到達率是 λ,每個失敗的 request 會以機率 r 觸發一次 retry(client 自動重試、或使用者重整)。在穩態下,有效到達率不是 λ,而是一個幾何級數的和:λ_eff = λ·(1 + r + r² + r³ + …) = λ / (1 − r)。下面的 widget 讓你拖動 retry 機率 r,看有效到達率怎麼被放大——r=0.5 時 λ 直接翻倍,r=0.8 時放大五倍。原本只是「略微超載」的系統,會被 retry 放大成「嚴重超載」,這就是 death spiral 在環 3 把線性問題變成爆炸問題的數學。
retry 把「想用服務的人數」放大成「實際打到後端的 request 數」
retry 機率 r=0.5 時有效到達率翻倍,r=0.8 時放大五倍,遵循 λ/(1−r) 幾何級數,是 death spiral 的放大器。
death spiral 最殘酷的性質是它的不可逆性。一旦進入螺旋,就算外部的真實負載(真正想用你服務的人)已經回到正常,系統也不會自己恢復——因為此刻 queue 裡塞滿的是 retry 產生的死 request,系統忙著處理它們、忙著 GC,自己餵自己。要打破螺旋,往往得手動把 queue 清空(drop 掉所有積壓)、重啟服務、或暫時拒絕所有流量讓系統喘口氣。這就是為什麼「加更大的 queue」是反解——它讓螺旋啟動前能積累的死 request 更多,螺旋一旦啟動就更難打破。
load shedding 與 backpressure——立刻、明確地拒絕,把容量限制同步告訴送方
既然問題是 λ 持續大於 μ,而你無法增加 μ(worker 就那麼快),唯一能做的就是控制 λ——在源頭把多出來的工作擋下來。這有兩個互補的機制:load shedding(卸載)與 backpressure(背壓)。
load shedding 的核心原則,原文一句話:「when capacity is exceeded, excess is shed immediately」——容量被超過時,多出來的工作立刻被丟棄。注意「immediately」。關鍵不是「拒絕」這件事,而是「立刻」這個時間性質。一個 request 進來,系統若已滿載,必須在 O(1) 時間內回一個明確的「我滿了」,而不是把它收進一條無界 queue 裡讓它慢慢爛、最後讓 client timeout。原文:「If the system is at capacity, it must reject new work immediately.」
backpressure 是 load shedding 的「溝通」面。光是丟棄不夠——你得讓送方知道你丟了,而且要立刻知道。原文:「The sender must be told instantly so it can make a policy decision.」送方被同步告知「我滿了」之後,它能做出明智的決定:稍後 retry(帶 backoff)、優雅降級(degraded mode,回快取的舊資料)、或 fail fast(直接告訴使用者現在不行)。這三條都比「讓下游 timeout」好得多——因為 timeout 是最壞的失敗模式:送方等了整整 timeout 的時間才知道失敗,這段時間它的資源也被佔著,而且它沒有任何資訊能判斷該怎麼反應。
把這兩個機制對照「往 queue 塞」這個反模式,差別是「明確 vs. 沉默」。無界 queue 是沉默地接受——它對送方說「收到了」,但其實這個 request 永遠不會被及時處理。Backpressure 是明確地拒絕——它對送方說「我滿了,你自己決定怎麼辦」。沉默的接受製造了容量的假象(illusion of capacity),明確的拒絕保留了送方的決策權。一個系統在過載下最有價值的行為,不是假裝還能吃,而是誠實地說不。
下面這個 before/after 把同一個過載瞬間並排:左邊是無界 queue 的「沉默接受」,右邊是 bounded mailbox + backpressure 的「明確拒絕」。拖動中間的分隔線,看同一波超過容量的流量在兩種設計下的下場——左邊全部收下、queue 無限長大、最終所有 client timeout;右邊收到容量為止、其餘立刻回 .mailbox_full,送方拿到訊號去做 retry/降級/fail-fast。
拖分隔線 · 無界 queue vs bounded mailbox
互動圖表
無界 queue 對每筆請求回「收到了」卻讓 L 趨無窮,bounded mailbox 滿後即刻回 .mailbox_full,送方同步拿到訊號。
具體怎麼落地?答案是 bounded queue 加 timeout。把每條 queue(mailbox)設一個嚴格的上限,滿了就同步回拒;對可靠性關鍵的操作,強制設一個毫秒級 timeout,避免無限等待。下一節我們看 pmbanugo 的 actor 框架 Tina 怎麼把這套原則變成具體的 API——三種 send 結果、強制 timeout 的 Effect_Call。
Tina 的 bounded mailbox——把「立刻拒絕」變成型別系統強制的三種 send 結果
Tina 是一個 actor 模型框架(Isolate 是它的 actor 單位,Shard 是承載 Isolate 的記憶體池)。它把上一節的原則做成了一個不給你犯錯空間的設計:mailbox 嚴格有界,沒有「unbounded 模式」這個選項。
原文對 mailbox 的描述很具體:「A standard Isolate mailbox holds exactly 256 messages by default (a configurable value)」——標準 mailbox 預設正好容納 256 則訊息,可設定。更關鍵的是它的記憶體承諾:「no dynamic allocation (or malloc) during operation, and there is no 'unbounded' mode」——運作期間沒有動態配置、沒有 malloc、沒有無界模式。這一句把「無界 queue 製造容量假象」這個問題從根上拔掉了:你不可能不小心做出一條會無限長大的 queue,因為框架根本不提供那個選項。
當有人想往一個滿的 mailbox 送訊息,會發生什麼?原文:「the system does not allocate a hidden buffer... It rejects the message in O(1) time」——系統不會偷偷配一個 buffer,它在 O(1) 時間內拒絕。這就是「load shedding immediately」的字面實作。而拒絕的方式,是透過 ctx_send 的同步回傳值——三種結果,每一種對應一個明確的語意,逼著你在 call site 就處理掉它:
點欄位 header 排序 · 3 種 send 結果 × 3 欄
| send 結果 | 語意(原文) | 送方該做什麼 |
|---|---|---|
.ok |
Message successfully enqueued——訊息成功入列。 | 正常路徑,繼續。mailbox 還有空間。 |
.mailbox_full |
The destination is overwhelmed——目的地已飽和,必須 shed load。 | backpressure 觸發。送方自己決定:retry with backoff、降級、或 fail fast。不要硬塞。 |
.pool_exhausted |
The Shard's memory pool is fully saturated——整個記憶體池飽和。Let it crash. | 系統級飽和,不是單一 mailbox 的問題。放手讓它 crash,由 supervisor 重啟。 |
.mailbox_full 是「這個收件人忙」(局部背壓,可恢復);.pool_exhausted 是「整台機器的記憶體池滿了」(全局飽和,crash 比硬撐安全)。把兩者分開,讓你對局部過載做優雅降級、對全局飽和做 fail-fast crash。三種結果的層級不同:.mailbox_full 是「這個收件人忙」(局部背壓,可恢復);.pool_exhausted…
Tina 的 .mailbox_full 是局部背壓,.pool_exhausted 是全局飽和——兩層拒絕語意逼送方在 call site 做明確決定。
注意這個設計的哲學。最戲劇性的是 .pool_exhausted 對應的「Let it crash」——這是 Erlang/OTP 一脈相承的 supervisor 哲學:當系統進入一個它無法安全處理的狀態(記憶體池整個飽和),最安全的反應不是硬撐(硬撐會進 death spiral),而是讓這個 process crash,由上層 supervisor 在一個乾淨的 state 重啟。crash 是一種 backpressure——它是對整個系統最強烈、最不容忽視的「我滿了」。把它跟 .mailbox_full 分開,等於把「局部忙」與「全局垮」這兩件本質不同的事,在型別層面就區分開,逼你分別處理。
send 結果處理的是「往別人的 mailbox 塞」這個方向。但還有另一個方向的問題:你呼叫別人、等別人回,這個等待本身可能無限長。Tina 對此的答案是強制 timeout 的 Effect_Call。原文給的形狀是這樣:
// 對可靠性關鍵的操作,timeout 是必填欄位,不是 optional
return tina.Effect_Call{
to = billing_handle,
message = transform_request_to_message(request),
timeout = 5000, // Mandatory timeout in milliseconds
}
關鍵字是「Mandatory」。timeout 不是一個你可以忘記設、忘記設就退化成「等到天荒地老」的 optional 欄位——它是必填的。原文描述 timeout 到期時的行為:「you do not wait forever. The mandatory timeout fires, the scheduler wakes your Isolate with a TAG_CALL_TIMEOUT message, and you handle the failure.」——你不會永遠等下去;強制 timeout 觸發,scheduler 用一個 TAG_CALL_TIMEOUT 訊息喚醒你的 Isolate,你處理這個失敗。
把這兩個機制放回 Little's Law 的框架看,它們各自關掉了 death spiral 的一個放大器。bounded mailbox + .mailbox_full 同步回拒,從源頭壓住 λ——多出來的 request 不進 queue,所以 retry 沒有累積的地方,環 3 的放大器失效。強制 timeout 則砍掉 W 的尾巴——一個 call 最多等 5000ms,不會有 request 在 queue 裡爛到 client 早就走了的程度,環 2 與環 4 的「死 request」無從產生。兩個機制合起來,把一個會發散的系統,按回到 ρ < 1 的穩定區。
下面用偽碼把「收到 .mailbox_full 該怎麼反應」收束成一個能直接套用的決策骨架——這就是 backpressure 從原則變成程式碼的樣子:
// backpressure 的決策骨架:拿到拒絕後,由送方做 policy decision
result = ctx_send(downstream, msg)
match result {
.ok => continue, // 正常路徑
.mailbox_full => { // 局部背壓——三選一,別硬塞
if request.is_idempotent {
schedule_retry(msg, backoff_ms) // 稍後重試
} else if has_cached_fallback {
return degraded(cached_value) // 優雅降級
} else {
return fail_fast(503) // 快速失敗,告訴使用者現在不行
}
}
.pool_exhausted => panic(), // 全局飽和——let it crash,由 supervisor 重啟
}
這段骨架裡沒有一條路徑是「把 msg 收進一條更大的 queue 然後祈禱」。每一條都是立刻、明確的決定。這正是過載處理與「加 queue」的根本分野:前者承認容量有限並誠實地把限制傳回去,後者用一層緩衝假裝限制不存在,直到限制以最暴力的方式重新出現。
最後值得一提的是這套設計的可組合性。當每一層都用 bounded mailbox + backpressure,背壓會沿著呼叫鏈自然地往上游傳播:最底層的 worker 滿了回 .mailbox_full,它的上游收到後也會更快地滿、也回 .mailbox_full,一路傳到最前端的 load balancer,後者直接回 503 給真正的 client。整條鏈在過載下不是某一點崩潰、其餘還在傻傻接收,而是協調一致地、立刻地把「我滿了」這個訊號傳到能做決定的地方——也就是最前端,那個唯一知道「這個 request 對使用者到底重不重要」的地方。這才是過載時你要的行為:不是更大的浴缸,是一個會在水龍頭那端就把水關小的系統。
Take-away:queue 不是容量,是時間——λ > μ 時 Little's Law 保證 L 發散,buffer 只決定撞牆的時刻而非撞不撞;過載下唯一有效的動作是 load shedding 與 backpressure,立刻、明確地拒絕多出來的工作,把容量限制同步告訴送方,讓它在還有選擇的時候做選擇——而不是讓它 timeout。