<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Overload on Tarragon</title><link>https://tarrragon.github.io/blog/tags/overload/</link><description>Recent content in Overload on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 15 Oct 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/overload/index.xml" rel="self" type="application/rss+xml"/><item><title>9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/load-path-analysis/</guid><description>&lt;p>這篇整理沿著一個會改變狀態的請求（購票、下單、委託、建立短網址）從用戶端走到資料庫的路徑，分析負載時共用的概念。讀取請求（短網址的轉址、商品頁、座位圖上的剩餘數量）本身的快取設計不在本篇的範圍內，只有保護為了寫入路徑而把讀取移到邊緣的那一段（座位圖的剩餘數量由 CDN 合併回源）在範圍內。概念以 &lt;a href="https://tarrragon.github.io/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統&lt;/a> 的短網址、售票搶購、電商 checkout 與交易撮合舉例；完整走過一條路徑的案例是 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑&lt;/a>。負載超過容量之後服務呈現哪一種結果（變慢、請求失敗、無回應、程序終止、停在失效），在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現&lt;/a>。&lt;/p>
&lt;h2 id="一次一筆的處理與它的互斥範圍">一次一筆的處理與它的互斥範圍&lt;/h2>
&lt;p>一個服務的需求裡，通常有一件事必須一次只處理一筆（業界常稱為 serialization point）：同一張票不能同時賣給兩個人，同一筆訂單不能同時被付款與取消，同一個交易對的委託要依單一順序撮合，同一個短代碼不能同時分配給兩個長網址。這件事要靠鎖、單一執行緒或條件更新依序處理，所以不論前面有多少台伺服器，抵達這件處理的請求都要逐一通過，負載最後集中在這件處理上。&lt;/p>
&lt;p>決定有多少請求要依序等候的，是&lt;strong>互斥範圍&lt;/strong>：哪些請求彼此必須輪流處理。互斥範圍有三個來源：需求、資料的表示方式，以及把多個範圍放進同一個依序處理的執行者。&lt;/p>
&lt;p>售票的需求要互斥的只是同一張票；全場庫存若寫成一個計數欄，所有購票請求都更新資料表的同一列，整場的請求依序等候同一把鎖，這種情形叫熱點列（hot row）。改成每張票一列，而且認領的寫法讓每個請求各自挑到不同的列（例如跳過別的交易已經鎖住的列，寫法在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑&lt;/a> 的〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉），只有搶同一張票（同一個座位）的請求才需要輪流；認領時若每個請求都挑「第一張還沒賣出的票」，所有請求仍會撞在同一列上。表示方式造成的互斥範圍，在實作上表現為鎖的粒度（lock granularity），可以靠改寫法縮小。熱點列的排隊機制與分散寫入的做法在 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總&lt;/a>。&lt;/p>
&lt;p>撮合的需求要互斥的是整本委託簿（同一個交易對上還沒成交的買賣委託），因為一筆委託是否成交取決於簿上所有其他委託的價格與時間；這是需求要求的下限，換表示方式也縮不小。撮合引擎以單一執行緒依序處理、沒有用鎖，而同一條執行緒或同一份依序日誌裡放的交易對越多，互斥範圍就越大：LMAX 把所有市場放在一條執行緒上，這時範圍是整個交易平台；依交易對分開撮合引擎，是把範圍縮回一本簿（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">Single Writer Model&lt;/a>；兩種做法在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑&lt;/a> 的〈撮合引擎：單一寫入者與記憶體內的委託簿〉）。&lt;/p>
&lt;p>需求本身可以協商時，互斥範圍也能縮小。廣告平台允許預算小幅超支，就可以把一個廣告活動的預算預先切成多份額度、分給各台伺服器各自扣，定期同步，互斥範圍從整個活動縮到一台伺服器上的那一份，代價是超支有上限而不是零。撮合沒有這種空間：不論撮合規則是價格與時間優先，還是依比例分配，一筆委託的成交都取決於整本簿，所以在一本簿之內，撮合能做的只有讓依序處理的那一條路徑變快。&lt;/p>
&lt;h2 id="能在一次一筆的處理之前回應的請求">能在一次一筆的處理之前回應的請求&lt;/h2>
&lt;p>需要經過一次一筆處理（必須依序、不能並行處理的那件事）的請求（購票、下單、委託、建立短網址）裡，有三類在進入那件處理之前就能回應：格式、權限或帳戶狀態不符的請求（例如餘額不足）在入口就能拒絕，結果已經確定的請求可以直接告訴它結果（票賣完之後的購票請求），以及重送的請求可以回傳上一次的結果。同一段時間內，這三類請求的筆數在所有需要經過一次一筆處理的請求筆數裡所佔的比例（分母包含這三類本身），下文稱&lt;strong>可提早回應的比例&lt;/strong>；這個比例越高，前面的元件擋下的越多，一次一筆的處理承受的越少。&lt;/p>
&lt;p>提早回應的元件讀的是一次一筆的處理所持有狀態的副本（售票的剩餘數量、帳戶餘額），副本會落後。一旦確定就不再改變的結果可以安全地提早回應；會回頭的狀態（帳戶餘額、付款期限到期時被放回的票），提早回應的只是暫時的結果，而副本落後造成的錯有兩個方向，補救各不相同。副本把請求判成不行（已售完、餘額不足），可能是錯拒，所以要讓使用者能再試；副本把請求判成可以而放行，副本可能已經過時，所以通過提早檢查的請求一律要在一次一筆的處理裡再檢查一次，否則餘額會透支、票會超賣。&lt;/p>
&lt;p>可提早回應的比例由需求的性質決定。售票的成功請求總數有上限（票數），票數之外的購票請求結果在售完那一刻就確定（直到付款期限到期、有票被放回為止），所以比例高；交易撮合沒有這種上限，一筆委託的結果要撮合當下才知道，只有無效的委託能提早回應，比例低。成功請求的總數有上限，不保證比例高：拍賣的成功請求總數是一（只有一個得標者），而結標之前每一次出價都要處理、誰得標事先不知道。&lt;/p>
&lt;p>比例高的系統，負載保護的重心在一次一筆的處理之前：排隊、提早回應，把請求擋在資料庫前面。比例低時，比對同一個互斥範圍每秒到達的請求數與依序處理一個範圍的處理量。到達的請求數低於處理量時（checkout 的每一筆訂單、一般帳戶的轉帳），一次一筆的處理不是瓶頸，負載先耗盡的是別的資源（連線數、資料庫的總寫入量），見〈每一段保護把負載轉到哪裡〉。到達的請求數高於處理量、而範圍能靠改表示方式縮小時（熱門商家的收款帳戶改成只新增入帳紀錄），回到〈一次一筆的處理與它的互斥範圍〉的熱點列。範圍縮不小時（一本委託簿、結標前的一件拍品），同一個範圍的全部請求都要通過同一個依序處理的執行者，重心才落在那件處理本身：記憶體內的狀態、單一寫入者、處理路徑上沒有資料庫，以及事先配置的容量。&lt;/p>
&lt;h2 id="多出來的請求排隊拒絕或請使用者重新確認">多出來的請求：排隊、拒絕或請使用者重新確認&lt;/h2>
&lt;p>扣掉能提早回應的請求，抵達一次一筆的處理（例如扣庫存或撮合）的請求仍可能超過它的處理量，多出來的那些要決定怎麼處置，而決定的依據是&lt;strong>延遲是否改變請求的價值&lt;/strong>。售票在不劃位、固定票價時，排隊幾分鐘之後買到的仍是同一種票、同一個價格，多出來的請求可以排隊（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/admission-control/" data-link-title="Admission Control（准入控制）" data-link-desc="要決定湧入的使用者誰能進入、什麼時候進入時，例如開賣的虛擬等候室、waitlist、事前註冊與抽籤；和負載卸除、限流的分工，以及和 Kubernetes admission controller 的區分">Admission Control&lt;/a>），放行的速率設在後端實測的處理量。連續撮合的交易所，一筆委託晚一秒送進委託簿，市場已經變了，市價單以偏離預期的價格成交，排隊讓請求失去下單時的意義。所以委託到達時以佇列長度除以處理速率預估要等多久，預估超過委託延遲目標就在接受之前明確拒絕（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-shedding/" data-link-title="Load Shedding" data-link-desc="說明服務過載時如何主動拒絕低優先工作以保護核心能力">Load Shedding&lt;/a>；9.0.2 的閘道以佇列長度的上限做這件事）。&lt;/p>
&lt;p>延遲改變價值、所以要拒絕，還有一個前提：請求在處理時照送出當下的條件直接執行，不再經使用者確認。處理之前會讓使用者重新確認的請求，延遲的代價只是多一次確認：checkout 的價格在使用者排隊期間變了，下單時以新價格重算並請使用者確認，訂單照樣成立。市場的規則也會改變這個判斷：集合競價是把一段時間內的委託集中起來、以單一價格一次撮合的規則，以固定間隔做集合競價的市場，等到下一次撮合本來就是規則的一部分。&lt;/p>
&lt;p>被拒絕的用戶端會重送時，負載以重送的形式回來：用戶端是程式時往往立刻重送，重送的請求疊在原本的需求上，讓進來的請求率高於真實的需求，這是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm&lt;/a>。所以拒絕的回應要帶重試的時機（&lt;code>Retry-After&lt;/code> 這類欄位，或約定加上隨機延遲的退避間隔，見 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">Jitter&lt;/a>），排隊的回應要讓使用者看得到自己的位置，拒絕與排隊的回應都在讓用戶端不要用重送去確認結果。逾時就作廢的用戶端（廣告的即時競價）不會重送，拒絕本身就是結果。&lt;/p>
&lt;h2 id="每一段保護把負載轉到哪裡">每一段保護把負載轉到哪裡&lt;/h2>
&lt;p>把請求擋下或延後的機制（邊緣快取合併回源、速率限制、排隊放行、Redis 閘門、機器人偵測），本篇統稱&lt;strong>保護&lt;/strong>，實作它的程式或服務是&lt;strong>保護元件&lt;/strong>。每一段保護擋下或延後某一類請求（可以提早回應的、還沒輪到的），被擋下的請求並沒有消失，改由保護元件承受，或轉給保護後面的下一個元件，承受的那一方會先耗盡某種資源；保護也會讓下游出現新的上限，或讓同一個請求被處理不只一次（去重的做法在〈佇列、使用者重按與回呼帶來的重送與去重〉）。邊緣快取合併回源與機器人偵測把負載留在邊緣，速率限制、排隊放行與 Redis 閘門把它轉到保護元件自己的狀態與連到它的連線上，排隊放行另外帶來位置查詢；保護把請求順利送到下游之後，負載轉到最慢的接收端與第三方業者的配額。&lt;/p>
&lt;p>&lt;strong>保護元件自己的狀態&lt;/strong>。排隊的順序、已發出的進入資格與速率限制的計數，要存在保護元件的程序以外，重啟之後才接得下去（〈保護元件故障時的輸出：停止放行、全部放行、保守的固定上限與預設值〉）。每一次入列、位置查詢與計數都是對那個儲存的一次寫入或讀取，而它承受的是全部到達的請求，不只放行之後的請求，所以它的容量要以到達的請求率規劃（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Rate Limit&lt;/a> 的計數儲存是同一件事）。&lt;/p>
&lt;p>&lt;strong>連線數&lt;/strong>。連到一個元件的連線數，等於應用程式這一側同時在執行的單位數（程序、執行緒或協程）乘以每個單位各開的連線數，或在用連線池的執行環境裡，等於每個程序的連線池大小乘以程序數。把扣庫存或計數從資料庫移到 Redis 這類記憶體儲存，資料庫的鎖等待少了，連到新儲存的連線數卻成為新的上限。PHP-FPM 這類每個請求佔一個 worker 的執行環境，連線數的上限是全部主機的 worker 總數（每台的 &lt;code>pm.max_children&lt;/code> 乘以主機數），為了尖峰把主機擴到幾十台時，總數可能超過 Redis 預設的 &lt;code>maxclients&lt;/code>（一萬；Redis 行程的檔案描述元上限不夠時，實際的上限是那個上限減 32，可能低很多）。持久連線（phpredis 的 &lt;code>pconnect&lt;/code>）讓每個 worker 長期保留一條連線，閒置的 worker 也佔著一條，省下的是每次請求的握手，連線數不會變少；要讓連線數少於執行單位數，要在中間放一層連線代理（例如 twemproxy，它對後端維持少數的持久連線）。沒有持久連線時，每次新開連線要付 TCP 握手的成本，用完的連線在主動關閉的那一端（通常是應用程式這一端）停留在 TIME_WAIT 狀態，佔住應用程式主機的本機連接埠。資料庫連線的同一件事在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/connection-pool-amplification/" data-link-title="9.14 連線池放大解法（PgBouncer / RDS Proxy / ProxySQL）" data-link-desc="水平擴展應用層時 DB 連線池放大問題的具體解法、connection pooler 三大選項對比、解 9.13 提出但未深入的隱性成本">9.14 連線池放大解法&lt;/a>。&lt;/p>
&lt;p>&lt;strong>輪詢&lt;/strong>。排隊把請求從資料庫移開，排隊中的使用者卻會反覆查自己的位置，每秒的查詢數是等候人數除以查詢的間隔（9.0.1 的假設：十萬人每五秒一次，每秒兩萬個），落在排隊服務上。讓每一次查詢便宜（位置資訊放在記憶體或邊緣），讓查詢變少（由伺服器在回應裡指定下一次查詢的間隔），或改成伺服器推送；改成推送時，等候中的每一條連線都是要規劃的容量。&lt;/p>
&lt;p>&lt;strong>下游的接收端&lt;/strong>。一個元件若把輸出直接送給每一個接收端、等接收端收下才處理下一筆，最慢的那一個接收端決定了整個元件的處理速度，負載以 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backpressure&lt;/a> 的形式傳回來。把輸出先寫進一份日誌，由另外的發布服務送給各個接收端，接收端跟不上時，延遲的只是送到它那裡的資料。&lt;/p>
&lt;p>&lt;strong>外部的配額&lt;/strong>。第三方業者（金流、簡訊、雲端 API）對每個商家的速率限制會成為下一個上限。這個上限由業者設定，所以只能事先查到數字、在尖峰之前向業者申請提高；超過時業者回錯誤（例如 HTTP 429）或變慢，所以要在自己這一側排隊，以不超過配額的速率送出；呼叫業者的請求若和其他流量共用同一個連線池，業者變慢時等待中的請求會佔住其他請求也要用的連線，所以呼叫業者用獨立的連線池與逾時（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/bulkhead/" data-link-title="Bulkhead" data-link-desc="說明 bulkhead 如何用資源分艙限制故障擴散">Bulkhead&lt;/a>）。&lt;/p>
&lt;h2 id="佇列使用者重按與回呼帶來的重送與去重">佇列、使用者重按與回呼帶來的重送與去重&lt;/h2>
&lt;p>把請求放進佇列延後處理，會讓同一個請求被處理不只一次。佇列在消費端以確認（ack）模式使用時保證的是至少送達一次，消費端處理到一半當機、佇列重送，同一個請求會被處理兩次。請求在進入佇列之前要有一個識別碼（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key&lt;/a>），處理那一段以它辨認重送：辨認重送與狀態改變寫在同一個交易裡（以識別碼當唯一鍵寫入結果），識別碼至少保存到用戶端不會再重送為止；先查一個快取、再寫資料庫的寫法，在兩步之間當機或兩個重送同時到達時仍會重複。識別碼若在使用者那一次操作時就由用戶端產生（表單載入時產生、重按時沿用），使用者重按也辨認得出；識別碼在入列時才由伺服器產生時，使用者重按產生的是新的請求，佇列分不出來，要以業務規則限制（例如一個帳號同時只能有一筆在排隊，在進入佇列時檢查）。排程服務與第三方的回呼同樣可能把同一件事送兩次，條件更新（&lt;code>UPDATE ... WHERE status = 'pending'&lt;/code>）讓第二次執行更新零列；對外的動作（寄信、出貨、呼叫退款 API）要在更新了一列之後才做，寫在更新之前、或不看更新了幾列就執行的動作，仍會做兩次。只靠「之後才做」還會漏做：更新提交之後、對外動作完成之前當機，重送時更新零列，動作就被跳過。所以待執行的對外動作和狀態改變寫在同一個交易裡，由另一個程序讀出、至少送一次（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">Outbox Pattern&lt;/a>），呼叫業者時帶業者的冪等鍵（例如 Stripe 的 &lt;code>Idempotency-Key&lt;/code>），讓至少一次的送出在業者那一側只生效一次；業者不支援冪等鍵時，以對帳找出重複或漏做的動作。&lt;/p>
&lt;h2 id="保護元件故障時的輸出停止放行全部放行保守的固定上限與預設值">保護元件故障時的輸出：停止放行、全部放行、保守的固定上限與預設值&lt;/h2>
&lt;p>保護元件故障時的處置有三種：停止放行（fail closed）、全部放行（fail open），以及介於兩者之間、退回一個保守的固定上限放行。選擇時先分出保護擋的是什麼。權限檢查、餘額檢查、詐欺評分與法規要求的檢查擋的是不該進來的請求，放過去就違反不能讓步的需求，而停止放行的代價是整個服務停擺，所以這一類再依它在檢查順序裡的位置分開處理。之後沒有其他檢查的權限與法規檢查，故障時停止放行，全部放行的結果是越權或跳過法規要求的檢查。後面還有權威檢查的前置檢查（閘道上的餘額檢查，最終檢查在一次一筆的處理裡），故障時可以放行給權威檢查。權限檢查可以先用快取的最近決定繼續放行，代價是權限被撤銷之後，要等快取到期才生效。詐欺評分漏掉的損失有上限、事後可以經由拒付追回部分金額，故障時放行多少是業務決定的損失上限，例如低金額放行、高金額暫停、事後審查。&lt;/p>
&lt;p>只用來擋流量的保護（排隊、速率限制、閘門），依下游在故障那一刻要承受的流量選。保護的資源一定處理不了全部請求時（售票的排隊，保護的是扣庫存那一段），全部放行等於拿掉保護，全部請求直接打到必須依序處理的扣庫存，選停止放行，使用者看到的是暫停。保護的資源處理得了全部合法請求、保護只用來擋濫用時（邊緣依來源位址的速率限制），停止放行等於整個服務停擺，選全部放行。保護的資源處理得了全部合法請求、原本會選全部放行的那一類保護，常常正是因為它要擋的那股負載而故障（速率限制的計數儲存在攻擊流量下過載），這時全部放行是在負載最大的時候拿掉保護，所以退回保守的固定上限，它取代的是全部放行，不取代排隊這類該停止放行的保護：共用的限流服務故障時，呼叫它的每一個 API 實例改以本機的計數、每秒放行固定的請求數，這個數字是下游實測的總處理量除以實例數，全部實例加起來不超過下游處理得了的量；實例數會因自動擴充而變時，以最大實例數計算，或在故障期間停止擴充。代價有三個：每個實例只有一個總計數，一個重度呼叫者就能吃光一個實例的額度，所以本機計數也依呼叫者分開；負載平衡不均時有的實例拒絕、有的閒著，實際放行量低於下游處理得了的量；實際實例數遠少於最大實例數時，放行量只剩一小部分，接近停止放行。這個折衷是從機制推出的，只用在擋流量的保護上；擋不該進來的請求的保護照上一段的分法處理。停止放行之後要能在恢復後照原本的順序繼續，保護元件的狀態（排隊的順序、已發出的資格）要存在它的程序以外，只存在記憶體的狀態在重啟時就丟了。&lt;/p>
&lt;p>選了全部放行之後，還要確認下游讀到的值代表放行：保護元件故障時，保護元件自己或呼叫它的那一方的備援邏輯會給下游一個值，這個值必須是下游判成放行的那個值。&lt;a href="https://blog.cloudflare.com/18-november-2025-outage/">Cloudflare 2025 年 11 月 18 日的事故報告&lt;/a> 寫到，機器人偵測讀到一份因為重複的列而變大的設定檔，舊版代理引擎上的所有流量都拿到零分的機器人分數。那套評分的正常範圍是 1 到 99，分數越低越像機器人，零分代表沒有評估（&lt;a href="https://developers.cloudflare.com/bots/concepts/bot-score/">Cloudflare 的 bot score 文件&lt;/a>）；客戶的規則寫成低於某個分數就擋，代表沒有評估的零分落在擋的那一側，設了擋機器人規則的客戶因此擋掉了大量的真實流量。保護元件的故障也不只停掉一種，讀到壞掉的規則或設定檔、輸出錯的判斷，是同樣要事先決定處置的情形；那時保護元件還在運作，監控上每一層都顯示正常，這一類異常要靠 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">4.0.1 監控鏈事前偵測不到的異常&lt;/a> 裡的業務結果發現。保護元件是購買的服務（業者提供的等候室、WAF）時，故障時放行還是不放行由業者的行為決定，要事先查清楚業者的預設，以及它能改成什麼。&lt;/p></description><content:encoded><![CDATA[<p>這篇整理沿著一個會改變狀態的請求（購票、下單、委託、建立短網址）從用戶端走到資料庫的路徑，分析負載時共用的概念。讀取請求（短網址的轉址、商品頁、座位圖上的剩餘數量）本身的快取設計不在本篇的範圍內，只有保護為了寫入路徑而把讀取移到邊緣的那一段（座位圖的剩餘數量由 CDN 合併回源）在範圍內。概念以 <a href="/blog/backend/case-systems/" data-link-title="Backend 範例系統：短網址、售票搶購、電商 checkout 與交易撮合的功能、需求與規模假設" data-link-desc="Backend 教材幾篇以具體系統走完整條請求路徑的文章共用的四個範例系統：每個系統提供的功能與請求種類、不能讓步與可以讓步的需求、規模假設與起點架構，以及四個系統在成功請求的總數是否有上限、延遲是否改變請求的價值、同一個請求被處理兩次的後果上的差別">Backend 範例系統</a> 的短網址、售票搶購、電商 checkout 與交易撮合舉例；完整走過一條路徑的案例是 <a href="/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑</a> 與 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a>。負載超過容量之後服務呈現哪一種結果（變慢、請求失敗、無回應、程序終止、停在失效），在 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a>。</p>
<h2 id="一次一筆的處理與它的互斥範圍">一次一筆的處理與它的互斥範圍</h2>
<p>一個服務的需求裡，通常有一件事必須一次只處理一筆（業界常稱為 serialization point）：同一張票不能同時賣給兩個人，同一筆訂單不能同時被付款與取消，同一個交易對的委託要依單一順序撮合，同一個短代碼不能同時分配給兩個長網址。這件事要靠鎖、單一執行緒或條件更新依序處理，所以不論前面有多少台伺服器，抵達這件處理的請求都要逐一通過，負載最後集中在這件處理上。</p>
<p>決定有多少請求要依序等候的，是<strong>互斥範圍</strong>：哪些請求彼此必須輪流處理。互斥範圍有三個來源：需求、資料的表示方式，以及把多個範圍放進同一個依序處理的執行者。</p>
<p>售票的需求要互斥的只是同一張票；全場庫存若寫成一個計數欄，所有購票請求都更新資料表的同一列，整場的請求依序等候同一把鎖，這種情形叫熱點列（hot row）。改成每張票一列，而且認領的寫法讓每個請求各自挑到不同的列（例如跳過別的交易已經鎖住的列，寫法在 <a href="/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑</a> 的〈扣庫存：資料庫逐張認領與加入 Redis 閘門的條件〉），只有搶同一張票（同一個座位）的請求才需要輪流；認領時若每個請求都挑「第一張還沒賣出的票」，所有請求仍會撞在同一列上。表示方式造成的互斥範圍，在實作上表現為鎖的粒度（lock granularity），可以靠改寫法縮小。熱點列的排隊機制與分散寫入的做法在 <a href="/blog/backend/01-database/high-frequency-counting/" data-link-title="1.18 高頻計數的寫入與彙總：熱點列的鎖、只新增的事件、可以重跑的彙總與重送的事件" data-link-desc="熱點列更新的吞吐量上限與它的成因、拆成多列／只新增事件／先在記憶體裡合併的差別、只新增事件搬去哪裡的成本、累加與重算在什麼條件下漏算或重複計算、讓彙總排程互斥的建議鎖與連線池的交互，以及重送事件的冪等寫入、推導事實的存放位置，以及 Redis 承擔即時計數、跨小時不重複數、去重與互斥時各自的邊界">1.18 高頻計數的寫入與彙總</a>。</p>
<p>撮合的需求要互斥的是整本委託簿（同一個交易對上還沒成交的買賣委託），因為一筆委託是否成交取決於簿上所有其他委託的價格與時間；這是需求要求的下限，換表示方式也縮不小。撮合引擎以單一執行緒依序處理、沒有用鎖，而同一條執行緒或同一份依序日誌裡放的交易對越多，互斥範圍就越大：LMAX 把所有市場放在一條執行緒上，這時範圍是整個交易平台；依交易對分開撮合引擎，是把範圍縮回一本簿（<a href="/blog/backend/knowledge-cards/single-writer-model/" data-link-title="Single Writer Model" data-link-desc="說明單寫者模型如何序列化寫入，並成為系統的容量邊界">Single Writer Model</a>；兩種做法在 <a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 的〈撮合引擎：單一寫入者與記憶體內的委託簿〉）。</p>
<p>需求本身可以協商時，互斥範圍也能縮小。廣告平台允許預算小幅超支，就可以把一個廣告活動的預算預先切成多份額度、分給各台伺服器各自扣，定期同步，互斥範圍從整個活動縮到一台伺服器上的那一份，代價是超支有上限而不是零。撮合沒有這種空間：不論撮合規則是價格與時間優先，還是依比例分配，一筆委託的成交都取決於整本簿，所以在一本簿之內，撮合能做的只有讓依序處理的那一條路徑變快。</p>
<h2 id="能在一次一筆的處理之前回應的請求">能在一次一筆的處理之前回應的請求</h2>
<p>需要經過一次一筆處理（必須依序、不能並行處理的那件事）的請求（購票、下單、委託、建立短網址）裡，有三類在進入那件處理之前就能回應：格式、權限或帳戶狀態不符的請求（例如餘額不足）在入口就能拒絕，結果已經確定的請求可以直接告訴它結果（票賣完之後的購票請求），以及重送的請求可以回傳上一次的結果。同一段時間內，這三類請求的筆數在所有需要經過一次一筆處理的請求筆數裡所佔的比例（分母包含這三類本身），下文稱<strong>可提早回應的比例</strong>；這個比例越高，前面的元件擋下的越多，一次一筆的處理承受的越少。</p>
<p>提早回應的元件讀的是一次一筆的處理所持有狀態的副本（售票的剩餘數量、帳戶餘額），副本會落後。一旦確定就不再改變的結果可以安全地提早回應；會回頭的狀態（帳戶餘額、付款期限到期時被放回的票），提早回應的只是暫時的結果，而副本落後造成的錯有兩個方向，補救各不相同。副本把請求判成不行（已售完、餘額不足），可能是錯拒，所以要讓使用者能再試；副本把請求判成可以而放行，副本可能已經過時，所以通過提早檢查的請求一律要在一次一筆的處理裡再檢查一次，否則餘額會透支、票會超賣。</p>
<p>可提早回應的比例由需求的性質決定。售票的成功請求總數有上限（票數），票數之外的購票請求結果在售完那一刻就確定（直到付款期限到期、有票被放回為止），所以比例高；交易撮合沒有這種上限，一筆委託的結果要撮合當下才知道，只有無效的委託能提早回應，比例低。成功請求的總數有上限，不保證比例高：拍賣的成功請求總數是一（只有一個得標者），而結標之前每一次出價都要處理、誰得標事先不知道。</p>
<p>比例高的系統，負載保護的重心在一次一筆的處理之前：排隊、提早回應，把請求擋在資料庫前面。比例低時，比對同一個互斥範圍每秒到達的請求數與依序處理一個範圍的處理量。到達的請求數低於處理量時（checkout 的每一筆訂單、一般帳戶的轉帳），一次一筆的處理不是瓶頸，負載先耗盡的是別的資源（連線數、資料庫的總寫入量），見〈每一段保護把負載轉到哪裡〉。到達的請求數高於處理量、而範圍能靠改表示方式縮小時（熱門商家的收款帳戶改成只新增入帳紀錄），回到〈一次一筆的處理與它的互斥範圍〉的熱點列。範圍縮不小時（一本委託簿、結標前的一件拍品），同一個範圍的全部請求都要通過同一個依序處理的執行者，重心才落在那件處理本身：記憶體內的狀態、單一寫入者、處理路徑上沒有資料庫，以及事先配置的容量。</p>
<h2 id="多出來的請求排隊拒絕或請使用者重新確認">多出來的請求：排隊、拒絕或請使用者重新確認</h2>
<p>扣掉能提早回應的請求，抵達一次一筆的處理（例如扣庫存或撮合）的請求仍可能超過它的處理量，多出來的那些要決定怎麼處置，而決定的依據是<strong>延遲是否改變請求的價值</strong>。售票在不劃位、固定票價時，排隊幾分鐘之後買到的仍是同一種票、同一個價格，多出來的請求可以排隊（<a href="/blog/backend/knowledge-cards/admission-control/" data-link-title="Admission Control（准入控制）" data-link-desc="要決定湧入的使用者誰能進入、什麼時候進入時，例如開賣的虛擬等候室、waitlist、事前註冊與抽籤；和負載卸除、限流的分工，以及和 Kubernetes admission controller 的區分">Admission Control</a>），放行的速率設在後端實測的處理量。連續撮合的交易所，一筆委託晚一秒送進委託簿，市場已經變了，市價單以偏離預期的價格成交，排隊讓請求失去下單時的意義。所以委託到達時以佇列長度除以處理速率預估要等多久，預估超過委託延遲目標就在接受之前明確拒絕（<a href="/blog/backend/knowledge-cards/load-shedding/" data-link-title="Load Shedding" data-link-desc="說明服務過載時如何主動拒絕低優先工作以保護核心能力">Load Shedding</a>；9.0.2 的閘道以佇列長度的上限做這件事）。</p>
<p>延遲改變價值、所以要拒絕，還有一個前提：請求在處理時照送出當下的條件直接執行，不再經使用者確認。處理之前會讓使用者重新確認的請求，延遲的代價只是多一次確認：checkout 的價格在使用者排隊期間變了，下單時以新價格重算並請使用者確認，訂單照樣成立。市場的規則也會改變這個判斷：集合競價是把一段時間內的委託集中起來、以單一價格一次撮合的規則，以固定間隔做集合競價的市場，等到下一次撮合本來就是規則的一部分。</p>
<p>被拒絕的用戶端會重送時，負載以重送的形式回來：用戶端是程式時往往立刻重送，重送的請求疊在原本的需求上，讓進來的請求率高於真實的需求，這是 <a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>。所以拒絕的回應要帶重試的時機（<code>Retry-After</code> 這類欄位，或約定加上隨機延遲的退避間隔，見 <a href="/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">Jitter</a>），排隊的回應要讓使用者看得到自己的位置，拒絕與排隊的回應都在讓用戶端不要用重送去確認結果。逾時就作廢的用戶端（廣告的即時競價）不會重送，拒絕本身就是結果。</p>
<h2 id="每一段保護把負載轉到哪裡">每一段保護把負載轉到哪裡</h2>
<p>把請求擋下或延後的機制（邊緣快取合併回源、速率限制、排隊放行、Redis 閘門、機器人偵測），本篇統稱<strong>保護</strong>，實作它的程式或服務是<strong>保護元件</strong>。每一段保護擋下或延後某一類請求（可以提早回應的、還沒輪到的），被擋下的請求並沒有消失，改由保護元件承受，或轉給保護後面的下一個元件，承受的那一方會先耗盡某種資源；保護也會讓下游出現新的上限，或讓同一個請求被處理不只一次（去重的做法在〈佇列、使用者重按與回呼帶來的重送與去重〉）。邊緣快取合併回源與機器人偵測把負載留在邊緣，速率限制、排隊放行與 Redis 閘門把它轉到保護元件自己的狀態與連到它的連線上，排隊放行另外帶來位置查詢；保護把請求順利送到下游之後，負載轉到最慢的接收端與第三方業者的配額。</p>
<p><strong>保護元件自己的狀態</strong>。排隊的順序、已發出的進入資格與速率限制的計數，要存在保護元件的程序以外，重啟之後才接得下去（〈保護元件故障時的輸出：停止放行、全部放行、保守的固定上限與預設值〉）。每一次入列、位置查詢與計數都是對那個儲存的一次寫入或讀取，而它承受的是全部到達的請求，不只放行之後的請求，所以它的容量要以到達的請求率規劃（<a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Rate Limit</a> 的計數儲存是同一件事）。</p>
<p><strong>連線數</strong>。連到一個元件的連線數，等於應用程式這一側同時在執行的單位數（程序、執行緒或協程）乘以每個單位各開的連線數，或在用連線池的執行環境裡，等於每個程序的連線池大小乘以程序數。把扣庫存或計數從資料庫移到 Redis 這類記憶體儲存，資料庫的鎖等待少了，連到新儲存的連線數卻成為新的上限。PHP-FPM 這類每個請求佔一個 worker 的執行環境，連線數的上限是全部主機的 worker 總數（每台的 <code>pm.max_children</code> 乘以主機數），為了尖峰把主機擴到幾十台時，總數可能超過 Redis 預設的 <code>maxclients</code>（一萬；Redis 行程的檔案描述元上限不夠時，實際的上限是那個上限減 32，可能低很多）。持久連線（phpredis 的 <code>pconnect</code>）讓每個 worker 長期保留一條連線，閒置的 worker 也佔著一條，省下的是每次請求的握手，連線數不會變少；要讓連線數少於執行單位數，要在中間放一層連線代理（例如 twemproxy，它對後端維持少數的持久連線）。沒有持久連線時，每次新開連線要付 TCP 握手的成本，用完的連線在主動關閉的那一端（通常是應用程式這一端）停留在 TIME_WAIT 狀態，佔住應用程式主機的本機連接埠。資料庫連線的同一件事在 <a href="/blog/backend/09-performance-capacity/connection-pool-amplification/" data-link-title="9.14 連線池放大解法（PgBouncer / RDS Proxy / ProxySQL）" data-link-desc="水平擴展應用層時 DB 連線池放大問題的具體解法、connection pooler 三大選項對比、解 9.13 提出但未深入的隱性成本">9.14 連線池放大解法</a>。</p>
<p><strong>輪詢</strong>。排隊把請求從資料庫移開，排隊中的使用者卻會反覆查自己的位置，每秒的查詢數是等候人數除以查詢的間隔（9.0.1 的假設：十萬人每五秒一次，每秒兩萬個），落在排隊服務上。讓每一次查詢便宜（位置資訊放在記憶體或邊緣），讓查詢變少（由伺服器在回應裡指定下一次查詢的間隔），或改成伺服器推送；改成推送時，等候中的每一條連線都是要規劃的容量。</p>
<p><strong>下游的接收端</strong>。一個元件若把輸出直接送給每一個接收端、等接收端收下才處理下一筆，最慢的那一個接收端決定了整個元件的處理速度，負載以 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">Backpressure</a> 的形式傳回來。把輸出先寫進一份日誌，由另外的發布服務送給各個接收端，接收端跟不上時，延遲的只是送到它那裡的資料。</p>
<p><strong>外部的配額</strong>。第三方業者（金流、簡訊、雲端 API）對每個商家的速率限制會成為下一個上限。這個上限由業者設定，所以只能事先查到數字、在尖峰之前向業者申請提高；超過時業者回錯誤（例如 HTTP 429）或變慢，所以要在自己這一側排隊，以不超過配額的速率送出；呼叫業者的請求若和其他流量共用同一個連線池，業者變慢時等待中的請求會佔住其他請求也要用的連線，所以呼叫業者用獨立的連線池與逾時（<a href="/blog/backend/knowledge-cards/bulkhead/" data-link-title="Bulkhead" data-link-desc="說明 bulkhead 如何用資源分艙限制故障擴散">Bulkhead</a>）。</p>
<h2 id="佇列使用者重按與回呼帶來的重送與去重">佇列、使用者重按與回呼帶來的重送與去重</h2>
<p>把請求放進佇列延後處理，會讓同一個請求被處理不只一次。佇列在消費端以確認（ack）模式使用時保證的是至少送達一次，消費端處理到一半當機、佇列重送，同一個請求會被處理兩次。請求在進入佇列之前要有一個識別碼（<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a>），處理那一段以它辨認重送：辨認重送與狀態改變寫在同一個交易裡（以識別碼當唯一鍵寫入結果），識別碼至少保存到用戶端不會再重送為止；先查一個快取、再寫資料庫的寫法，在兩步之間當機或兩個重送同時到達時仍會重複。識別碼若在使用者那一次操作時就由用戶端產生（表單載入時產生、重按時沿用），使用者重按也辨認得出；識別碼在入列時才由伺服器產生時，使用者重按產生的是新的請求，佇列分不出來，要以業務規則限制（例如一個帳號同時只能有一筆在排隊，在進入佇列時檢查）。排程服務與第三方的回呼同樣可能把同一件事送兩次，條件更新（<code>UPDATE ... WHERE status = 'pending'</code>）讓第二次執行更新零列；對外的動作（寄信、出貨、呼叫退款 API）要在更新了一列之後才做，寫在更新之前、或不看更新了幾列就執行的動作，仍會做兩次。只靠「之後才做」還會漏做：更新提交之後、對外動作完成之前當機，重送時更新零列，動作就被跳過。所以待執行的對外動作和狀態改變寫在同一個交易裡，由另一個程序讀出、至少送一次（<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">Outbox Pattern</a>），呼叫業者時帶業者的冪等鍵（例如 Stripe 的 <code>Idempotency-Key</code>），讓至少一次的送出在業者那一側只生效一次；業者不支援冪等鍵時，以對帳找出重複或漏做的動作。</p>
<h2 id="保護元件故障時的輸出停止放行全部放行保守的固定上限與預設值">保護元件故障時的輸出：停止放行、全部放行、保守的固定上限與預設值</h2>
<p>保護元件故障時的處置有三種：停止放行（fail closed）、全部放行（fail open），以及介於兩者之間、退回一個保守的固定上限放行。選擇時先分出保護擋的是什麼。權限檢查、餘額檢查、詐欺評分與法規要求的檢查擋的是不該進來的請求，放過去就違反不能讓步的需求，而停止放行的代價是整個服務停擺，所以這一類再依它在檢查順序裡的位置分開處理。之後沒有其他檢查的權限與法規檢查，故障時停止放行，全部放行的結果是越權或跳過法規要求的檢查。後面還有權威檢查的前置檢查（閘道上的餘額檢查，最終檢查在一次一筆的處理裡），故障時可以放行給權威檢查。權限檢查可以先用快取的最近決定繼續放行，代價是權限被撤銷之後，要等快取到期才生效。詐欺評分漏掉的損失有上限、事後可以經由拒付追回部分金額，故障時放行多少是業務決定的損失上限，例如低金額放行、高金額暫停、事後審查。</p>
<p>只用來擋流量的保護（排隊、速率限制、閘門），依下游在故障那一刻要承受的流量選。保護的資源一定處理不了全部請求時（售票的排隊，保護的是扣庫存那一段），全部放行等於拿掉保護，全部請求直接打到必須依序處理的扣庫存，選停止放行，使用者看到的是暫停。保護的資源處理得了全部合法請求、保護只用來擋濫用時（邊緣依來源位址的速率限制），停止放行等於整個服務停擺，選全部放行。保護的資源處理得了全部合法請求、原本會選全部放行的那一類保護，常常正是因為它要擋的那股負載而故障（速率限制的計數儲存在攻擊流量下過載），這時全部放行是在負載最大的時候拿掉保護，所以退回保守的固定上限，它取代的是全部放行，不取代排隊這類該停止放行的保護：共用的限流服務故障時，呼叫它的每一個 API 實例改以本機的計數、每秒放行固定的請求數，這個數字是下游實測的總處理量除以實例數，全部實例加起來不超過下游處理得了的量；實例數會因自動擴充而變時，以最大實例數計算，或在故障期間停止擴充。代價有三個：每個實例只有一個總計數，一個重度呼叫者就能吃光一個實例的額度，所以本機計數也依呼叫者分開；負載平衡不均時有的實例拒絕、有的閒著，實際放行量低於下游處理得了的量；實際實例數遠少於最大實例數時，放行量只剩一小部分，接近停止放行。這個折衷是從機制推出的，只用在擋流量的保護上；擋不該進來的請求的保護照上一段的分法處理。停止放行之後要能在恢復後照原本的順序繼續，保護元件的狀態（排隊的順序、已發出的資格）要存在它的程序以外，只存在記憶體的狀態在重啟時就丟了。</p>
<p>選了全部放行之後，還要確認下游讀到的值代表放行：保護元件故障時，保護元件自己或呼叫它的那一方的備援邏輯會給下游一個值，這個值必須是下游判成放行的那個值。<a href="https://blog.cloudflare.com/18-november-2025-outage/">Cloudflare 2025 年 11 月 18 日的事故報告</a> 寫到，機器人偵測讀到一份因為重複的列而變大的設定檔，舊版代理引擎上的所有流量都拿到零分的機器人分數。那套評分的正常範圍是 1 到 99，分數越低越像機器人，零分代表沒有評估（<a href="https://developers.cloudflare.com/bots/concepts/bot-score/">Cloudflare 的 bot score 文件</a>）；客戶的規則寫成低於某個分數就擋，代表沒有評估的零分落在擋的那一側，設了擋機器人規則的客戶因此擋掉了大量的真實流量。保護元件的故障也不只停掉一種，讀到壞掉的規則或設定檔、輸出錯的判斷，是同樣要事先決定處置的情形；那時保護元件還在運作，監控上每一層都顯示正常，這一類異常要靠 <a href="/blog/backend/04-observability/monitoring-outside-the-chain/" data-link-title="4.0.1 監控鏈事前偵測不到的異常：業務結果、資料正確性、到期事件、排程與背景工作、備份、安全、第三方依賴與成本的監看" data-link-desc="監控鏈各層都正常時仍會發生的幾類異常：業務停了而程式回應正常、資料寫錯、TLS 憑證與網域到期、排程工作與佇列消費端沒跑、備份還原不回來、以帳號正常操作進行的攻擊、第三方業者停掉與帳單失控，各自要先定義的預期、檢查它的規則與承接的專章">4.0.1 監控鏈事前偵測不到的異常</a> 裡的業務結果發現。保護元件是購買的服務（業者提供的等候室、WAF）時，故障時放行還是不放行由業者的行為決定，要事先查清楚業者的預設，以及它能改成什麼。</p>
<h2 id="依賴變慢部分失效與恢復時的第二波湧入">依賴變慢、部分失效與恢復時的第二波湧入</h2>
<p>保護元件與它保護的依賴，失效時也不只停掉或輸出錯的值。<strong>依賴變慢</strong>時，每一個等待中的請求在逾時之前都佔著呼叫方的 worker 與連線，所以變慢通常比停掉更早耗盡呼叫方，見 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a> 的〈無回應：程序還在，請求等到逾時也沒有結果〉。<strong>部分失效</strong>時只有一部分副本連不到依賴（某一個可用區的網路出口壞了），讓那幾個副本下線是對的，而剩下的副本要承接全部流量，容量不夠時接著變慢（<a href="/blog/backend/knowledge-cards/partial-failure/" data-link-title="Partial Failure" data-link-desc="說明分散式系統中部分依賴失效時如何保留整體可用性">Partial Failure</a>；Kubernetes 上的例子在 <a href="/blog/backend/04-observability/monitoring-inventory-k8s-checkout/" data-link-title="4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout：叢集內的監控、託管資料庫與第三方金流各自的失效域，以及佇列消費端與外部依賴的缺口" data-link-desc="把一個跑在託管 Kubernetes 上、有訊息佇列消費端、託管資料庫與第三方金流的電商 checkout 現有的監控放進監控鏈的各層，逐層判讀缺口與補法；另寫付款服務的 readiness 納入金流業者時對付款回呼的影響、失效域從主機換成叢集與雲端帳號之後觀察者要放在哪裡，以及業務結果、對帳、到期事件、消費端、備份、安全、第三方依賴與成本的逐類盤點">4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout</a> 的〈付款服務的 readiness 納入金流業者時的後果〉）。<strong>恢復時的第二波湧入</strong>：依賴恢復的那一刻，故障期間累積的請求與重試一起到達，讓剛恢復的依賴再過載一次，這是 9.16〈尖峰過去或應對切換之後仍持續的情形：停在失效、擺盪與第二波湧入〉的第二波湧入。第二波湧入本身是一次尖峰，累積的請求送完就結束；重試若把過載維持下去，它就轉成另一種情形：負載退去之後服務仍停在失效（<a href="/blog/backend/knowledge-cards/metastable-failure/" data-link-title="Metastable Failure（負載退去後仍停在失效狀態）" data-link-desc="流量已經回到平常的量而服務仍然大量逾時或出錯、或要判斷一次過載為什麼沒有自己恢復時，查 metastable failure 的定義、讓它停在失效狀態的持續效應、偵測它要比對的訊號與脫離它的做法">Metastable Failure</a>）。委託簿這種狀態會隨時間失去價值的系統，恢復時還要決定處理的順序，交易所停機後先只收取消、再以集合競價重開的流程在 9.0.2 的〈撮合引擎：單一寫入者與記憶體內的委託簿〉。</p>
<h2 id="延伸閱讀售票搶購與交易撮合的負載路徑">延伸閱讀：售票搶購與交易撮合的負載路徑</h2>
<p><a href="/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑</a> 從全場庫存寫成一個計數欄時熱點列與連線池的耗盡順序起算，走過 CDN 合併回源、排隊放行、資料庫逐張認領與付款期限，示範互斥範圍怎麼靠改表示方式從整場縮到一張票、可提早回應的比例高時排隊服務怎麼提早告訴後面的人買不到，以及什麼條件下才值得在扣庫存前面加一道 Redis 閘門。<a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a> 走過閘道、依序日誌、撮合引擎與行情發布，示範互斥範圍沒辦法縮小、可提早回應的比例低時，負載保護的重心怎麼移到處理路徑本身與事先配置的容量，以及延遲改變請求的價值時，過載與停機後的處置為什麼與售票相反。</p>
]]></content:encoded></item><item><title>9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層</title><link>https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/</link><pubDate>Wed, 14 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/</guid><description>&lt;p>這一篇整理負載超過容量時，服務實際呈現的幾種結果：每一種怎麼判斷、通常由什麼造成、怎麼應對，以及服務端監控的哪一層看得到它。負載落在飽和曲線的哪一段（利用率與 linear、knee、cliff）在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery&lt;/a>；卸除、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/admission-control/" data-link-title="Admission Control（准入控制）" data-link-desc="要決定湧入的使用者誰能進入、什麼時候進入時，例如開賣的虛擬等候室、waitlist、事前註冊與抽籤；和負載卸除、限流的分工，以及和 Kubernetes admission controller 的區分">准入控制&lt;/a>、重試預算這些應對手段的做法在 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量&lt;/a>；監控各層的做法在 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層&lt;/a>。這一篇處理它們之間的對應：同一次過載，在使用者那一側是哪一種結果，在監控上由哪一層看到，應對又該挑哪一種手段。&lt;/p>
&lt;p>例子以服務端監控的分層（4.0）裡的短網址服務為底：一台主機上的入口 Nginx、後端程式、PostgreSQL 與 Valkey，訪客點短網址時由後端查出原本的網址並轉址。這一篇另外假設它把點擊事件寫進佇列做統計，談到負載平衡器時假設後端跑成多個實例；4.0 的版本兩者都沒有。&lt;/p>
&lt;h2 id="負載的位置與服務的結果兩條分開量測的軸">負載的位置與服務的結果：兩條分開量測的軸&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/saturation-point/" data-link-title="Saturation Point" data-link-desc="說明系統從線性穩態進入 latency 指數成長區的關鍵流量點">Saturation Point&lt;/a> 卡與 9.4 Saturation Discovery 以利用率把負載分成 linear、knee、cliff 三段：利用率低時吞吐量隨負載線性上升，到 knee 之後延遲開始上升而吞吐量的成長變慢，到 cliff 之後吞吐量下降、延遲失控。這三段量的是輸入那一側：負載壓在系統上的位置。服務呈現的結果是輸出那一側：使用者拿到的是一個慢的回應（變慢）、一個錯誤（請求失敗）、一個逾時（無回應，或請求沒有抵達），還是連線被拒（程序終止，連接埠上沒有程式在接受連線）。這是用戶端直接連服務時的樣子；前面有反向代理時，代理把無回應與程序終止轉成 504 與 502，見〈無回應：程序還在，請求等到逾時也沒有結果〉。&lt;/p>
&lt;p>兩條軸要分開寫，理由是同一個位置會走到不同的結果。進入 cliff 之後，一個連線池設了等待上限的服務呈現請求失敗，同一個服務把等待上限拿掉就呈現無回應，記憶體先耗盡則呈現程序終止。只寫「進入 cliff」說不出使用者看到什麼，也說不出該用哪一種應對；只寫「回應變慢」說不出離 cliff 還有多遠。描述一次過載時兩條軸都寫：負載在哪一段、呈現哪一種結果。各種結果的應對不同，所以挑應對之前要先判出是哪一種結果；從監控上判的方法見〈各監控層看得到哪一種結果〉，先耗盡的資源怎麼決定結果見〈變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限〉。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/load-testing/#saturation-%e8%88%87%e7%93%b6%e9%a0%b8%e5%ae%9a%e4%bd%8d" data-link-title="6.2 load test" data-link-desc="把 production 流量結構轉成可重播壓力情境，定位 saturation 轉折與容量邊界">6.2 load test&lt;/a> 的〈Saturation 與瓶頸定位〉另把越過 knee（6.2 稱為 inflection point）之後的走法分成逐漸退化型與崩落型：前者延遲緩慢爬升，後者延遲在某一點突然跳升數倍。兩型的差別在走到結果的速度：逐漸退化型會在變慢停留較久，崩落型多半由連線池或執行緒池耗盡造成，很快走到請求失敗或無回應，所以要在比延遲跳升點更低的負載就開始限流。&lt;/p>
&lt;h2 id="變慢延遲超過門檻請求仍然完成">變慢：延遲超過門檻，請求仍然完成&lt;/h2>
&lt;p>變慢指請求仍然完成，而完成所需的時間超過服務承諾的門檻。判斷標準寫在 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">SLO&lt;/a> 上：延遲的某個&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/percentile/" data-link-title="Percentile" data-link-desc="說明 p95 與 p99 如何描述長尾延遲與使用者體驗">百分位數&lt;/a>（p95、p99）在一段時間內超過門檻。看平均值會把&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">尾端延遲&lt;/a>蓋掉，看單次請求會被偶發的慢請求誤導；百分位數也要依影響範圍（租戶、分區、端點）分開算，理由與錯誤率相同，見〈請求失敗〉。&lt;/p>
&lt;p>非同步的工作沒有回應時間可量，它的變慢是工作被接受、完成時間延後：短網址服務把點擊事件寫進佇列、由背景程式彙總成統計，過載時統計頁上的數字晚了半小時（數字是示意），而每一次點擊都轉址成功。非同步的變慢要量佇列裡最舊的一筆等了多久或消費者落後多少（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue-depth/" data-link-title="Queue Depth" data-link-desc="說明 queue 中等待處理的訊息數如何反映 backlog 與容量壓力">Queue Depth&lt;/a>），量請求延遲看不到它。&lt;/p>
&lt;p>變慢的成因是排隊：請求在某個瓶頸前面等，瓶頸可能是 CPU、資料庫的鎖、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">連線池&lt;/a>。CPU 不足時所有請求一起變慢，鎖競爭或單一資源不足時只有用到它的請求變慢。一個請求扇出到多個後端並等全部回來時，任一個後端的尾端延遲都成為這個請求的延遲，所以一個後端過載會讓扇出到它的每一個請求變慢（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">Fan-out&lt;/a>）。應對是先找出瓶頸在哪一層（&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程&lt;/a>），再擴充瓶頸所在的元件（&lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型&lt;/a>、9.15〈自動擴展（autoscaling）的反應時間與緩衝〉），或關掉非核心功能讓核心請求先過。瓶頸是單一 key 或分區時，擴充整個元件不會分散它的負載，那個 key 仍落在同一組節點上；應對是降低打到它的並行量，例如合併同一個 key 的重複讀取（request coalescing），或把 key 拆開。&lt;/p>
&lt;h2 id="請求失敗抵達後被拒或失敗與根本沒有抵達">請求失敗：抵達後被拒或失敗，與根本沒有抵達&lt;/h2>
&lt;p>請求失敗指一部分請求得到錯誤，其餘仍正常完成。它有兩種形態，監控上看到的地方完全不同。&lt;/p>
&lt;p>&lt;strong>請求抵達服務之後被拒或失敗&lt;/strong>。一種是服務主動拒絕：入口或後端依負載卸除或限流規則回 429 或 503，讓被拒的請求立即得到錯誤回應、其餘請求維持原本的延遲，這是設計負載卸除（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-shedding/" data-link-title="Load Shedding" data-link-desc="說明服務過載時如何主動拒絕低優先工作以保護核心能力">Load Shedding&lt;/a>）時刻意選擇的結果。另一種是資源耗盡造成的失敗：請求拿不到資料庫連線、等待到了上限而失敗，依賴的服務逾時，佇列已滿而寫不進去。拒絕也可能發生在後端之前：負載平衡器的佇列已滿時，請求到了負載平衡器而沒有到後端，由負載平衡器自己回 503 並計數（例如 AWS Classic Load Balancer 的 &lt;code>SpilloverCount&lt;/code>），這些錯誤不在後端的錯誤率裡，要看負載平衡器的指標。&lt;/p>
&lt;p>判斷標準是錯誤率在一段時間內超過門檻，主動拒絕與資源耗盡造成的失敗都算請求失敗。錯誤率除了算全體，也要依影響範圍（租戶、分區、端點）分開算：只佔總請求一小部分的範圍全部失敗時，全體的錯誤率仍然低於門檻。&lt;a href="https://aws.amazon.com/message/5467D2/">DynamoDB 2015 年 9 月的事故說明&lt;/a> 寫到，主要問題在週日解決之後，殘留的整體錯誤率在 0.15% 到 0.25% 之間，部分客戶的錯誤率卻不成比例地高；隔天陸續有客戶開支援案件，AWS 寫到沒有及早察覺這個落差。要分開的是兩者的成因：429 是卸除與限流規則回的拒絕，503 則兩種成因都有，卸除規則與資源耗盡（例如等資料庫連線等到上限）都可能回 503。所以卸除與限流規則回的每一個拒絕，不論狀態碼是 429 還是 503，都在 log 欄位或指標標籤上標出拒絕它的規則，和其他 5xx 分開計算，才分得出拒絕是卸除在作用，還是服務本身出了錯。&lt;/p>
&lt;p>&lt;strong>請求根本沒有抵達服務&lt;/strong>。入口的網路頻寬塞滿、雲端主機超過每秒封包數的上限而默默丟包（9.4〈Resource saturation 的六個維度〉），這些請求不會進到後端程式；用戶端那一側看到的是封包重送之後的連線逾時，和無回應長得一樣。兩者的差別在入口記錄到的請求：沒有抵達時，入口的請求率比平常低；無回應時，請求照常抵達，進行中的請求數上升而完成的請求數下降。兩種情形下程序都還在執行，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check/" data-link-title="Health Check" data-link-desc="說明服務如何對外提供可供平台判斷狀態的健康回應">健康檢查&lt;/a>不經過卡住的資源時也都成功，所以從程序與健康檢查分不出兩者。這些請求不在後端錯誤率的分子，也不在分母（分母是後端收到的請求總數，見 4.0〈應用層訊號：請求指標、log 與 trace〉）。判斷標準換成入口的請求率（每秒進來的請求數）比平常低，或從外面發出的探測失敗；這一篇說的「平常」都指同一時段的歷史值（例如上週同一天同一個小時）。主機網卡驅動的計數也看得到丟包（AWS ENA 在 &lt;code>ethtool -S&lt;/code> 裡的 &lt;code>pps_allowance_exceeded&lt;/code>、&lt;code>bw_in_allowance_exceeded&lt;/code>）。&lt;/p></description><content:encoded><![CDATA[<p>這一篇整理負載超過容量時，服務實際呈現的幾種結果：每一種怎麼判斷、通常由什麼造成、怎麼應對，以及服務端監控的哪一層看得到它。負載落在飽和曲線的哪一段（利用率與 linear、knee、cliff）在 <a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a>；卸除、<a href="/blog/backend/knowledge-cards/admission-control/" data-link-title="Admission Control（准入控制）" data-link-desc="要決定湧入的使用者誰能進入、什麼時候進入時，例如開賣的虛擬等候室、waitlist、事前註冊與抽籤；和負載卸除、限流的分工，以及和 Kubernetes admission controller 的區分">准入控制</a>、重試預算這些應對手段的做法在 <a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a>；監控各層的做法在 <a href="/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層</a>。這一篇處理它們之間的對應：同一次過載，在使用者那一側是哪一種結果，在監控上由哪一層看到，應對又該挑哪一種手段。</p>
<p>例子以服務端監控的分層（4.0）裡的短網址服務為底：一台主機上的入口 Nginx、後端程式、PostgreSQL 與 Valkey，訪客點短網址時由後端查出原本的網址並轉址。這一篇另外假設它把點擊事件寫進佇列做統計，談到負載平衡器時假設後端跑成多個實例；4.0 的版本兩者都沒有。</p>
<h2 id="負載的位置與服務的結果兩條分開量測的軸">負載的位置與服務的結果：兩條分開量測的軸</h2>
<p><a href="/blog/backend/knowledge-cards/saturation-point/" data-link-title="Saturation Point" data-link-desc="說明系統從線性穩態進入 latency 指數成長區的關鍵流量點">Saturation Point</a> 卡與 9.4 Saturation Discovery 以利用率把負載分成 linear、knee、cliff 三段：利用率低時吞吐量隨負載線性上升，到 knee 之後延遲開始上升而吞吐量的成長變慢，到 cliff 之後吞吐量下降、延遲失控。這三段量的是輸入那一側：負載壓在系統上的位置。服務呈現的結果是輸出那一側：使用者拿到的是一個慢的回應（變慢）、一個錯誤（請求失敗）、一個逾時（無回應，或請求沒有抵達），還是連線被拒（程序終止，連接埠上沒有程式在接受連線）。這是用戶端直接連服務時的樣子；前面有反向代理時，代理把無回應與程序終止轉成 504 與 502，見〈無回應：程序還在，請求等到逾時也沒有結果〉。</p>
<p>兩條軸要分開寫，理由是同一個位置會走到不同的結果。進入 cliff 之後，一個連線池設了等待上限的服務呈現請求失敗，同一個服務把等待上限拿掉就呈現無回應，記憶體先耗盡則呈現程序終止。只寫「進入 cliff」說不出使用者看到什麼，也說不出該用哪一種應對；只寫「回應變慢」說不出離 cliff 還有多遠。描述一次過載時兩條軸都寫：負載在哪一段、呈現哪一種結果。各種結果的應對不同，所以挑應對之前要先判出是哪一種結果；從監控上判的方法見〈各監控層看得到哪一種結果〉，先耗盡的資源怎麼決定結果見〈變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限〉。</p>
<p><a href="/blog/backend/06-reliability/load-testing/#saturation-%e8%88%87%e7%93%b6%e9%a0%b8%e5%ae%9a%e4%bd%8d" data-link-title="6.2 load test" data-link-desc="把 production 流量結構轉成可重播壓力情境，定位 saturation 轉折與容量邊界">6.2 load test</a> 的〈Saturation 與瓶頸定位〉另把越過 knee（6.2 稱為 inflection point）之後的走法分成逐漸退化型與崩落型：前者延遲緩慢爬升，後者延遲在某一點突然跳升數倍。兩型的差別在走到結果的速度：逐漸退化型會在變慢停留較久，崩落型多半由連線池或執行緒池耗盡造成，很快走到請求失敗或無回應，所以要在比延遲跳升點更低的負載就開始限流。</p>
<h2 id="變慢延遲超過門檻請求仍然完成">變慢：延遲超過門檻，請求仍然完成</h2>
<p>變慢指請求仍然完成，而完成所需的時間超過服務承諾的門檻。判斷標準寫在 <a href="/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">SLO</a> 上：延遲的某個<a href="/blog/backend/knowledge-cards/percentile/" data-link-title="Percentile" data-link-desc="說明 p95 與 p99 如何描述長尾延遲與使用者體驗">百分位數</a>（p95、p99）在一段時間內超過門檻。看平均值會把<a href="/blog/backend/knowledge-cards/tail-latency/" data-link-title="Tail Latency" data-link-desc="說明 p99 / p999 等長尾延遲為何比平均延遲更能反映 saturation">尾端延遲</a>蓋掉，看單次請求會被偶發的慢請求誤導；百分位數也要依影響範圍（租戶、分區、端點）分開算，理由與錯誤率相同，見〈請求失敗〉。</p>
<p>非同步的工作沒有回應時間可量，它的變慢是工作被接受、完成時間延後：短網址服務把點擊事件寫進佇列、由背景程式彙總成統計，過載時統計頁上的數字晚了半小時（數字是示意），而每一次點擊都轉址成功。非同步的變慢要量佇列裡最舊的一筆等了多久或消費者落後多少（<a href="/blog/backend/knowledge-cards/queue-depth/" data-link-title="Queue Depth" data-link-desc="說明 queue 中等待處理的訊息數如何反映 backlog 與容量壓力">Queue Depth</a>），量請求延遲看不到它。</p>
<p>變慢的成因是排隊：請求在某個瓶頸前面等，瓶頸可能是 CPU、資料庫的鎖、<a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">連線池</a>。CPU 不足時所有請求一起變慢，鎖競爭或單一資源不足時只有用到它的請求變慢。一個請求扇出到多個後端並等全部回來時，任一個後端的尾端延遲都成為這個請求的延遲，所以一個後端過載會讓扇出到它的每一個請求變慢（<a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">Fan-out</a>）。應對是先找出瓶頸在哪一層（<a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a>），再擴充瓶頸所在的元件（<a href="/blog/backend/09-performance-capacity/capacity-planning/" data-link-title="9.6 容量規劃模型" data-link-desc="peak forecast、headroom budget、growth curve、autoscaling sizing">9.6 容量規劃模型</a>、9.15〈自動擴展（autoscaling）的反應時間與緩衝〉），或關掉非核心功能讓核心請求先過。瓶頸是單一 key 或分區時，擴充整個元件不會分散它的負載，那個 key 仍落在同一組節點上；應對是降低打到它的並行量，例如合併同一個 key 的重複讀取（request coalescing），或把 key 拆開。</p>
<h2 id="請求失敗抵達後被拒或失敗與根本沒有抵達">請求失敗：抵達後被拒或失敗，與根本沒有抵達</h2>
<p>請求失敗指一部分請求得到錯誤，其餘仍正常完成。它有兩種形態，監控上看到的地方完全不同。</p>
<p><strong>請求抵達服務之後被拒或失敗</strong>。一種是服務主動拒絕：入口或後端依負載卸除或限流規則回 429 或 503，讓被拒的請求立即得到錯誤回應、其餘請求維持原本的延遲，這是設計負載卸除（<a href="/blog/backend/knowledge-cards/load-shedding/" data-link-title="Load Shedding" data-link-desc="說明服務過載時如何主動拒絕低優先工作以保護核心能力">Load Shedding</a>）時刻意選擇的結果。另一種是資源耗盡造成的失敗：請求拿不到資料庫連線、等待到了上限而失敗，依賴的服務逾時，佇列已滿而寫不進去。拒絕也可能發生在後端之前：負載平衡器的佇列已滿時，請求到了負載平衡器而沒有到後端，由負載平衡器自己回 503 並計數（例如 AWS Classic Load Balancer 的 <code>SpilloverCount</code>），這些錯誤不在後端的錯誤率裡，要看負載平衡器的指標。</p>
<p>判斷標準是錯誤率在一段時間內超過門檻，主動拒絕與資源耗盡造成的失敗都算請求失敗。錯誤率除了算全體，也要依影響範圍（租戶、分區、端點）分開算：只佔總請求一小部分的範圍全部失敗時，全體的錯誤率仍然低於門檻。<a href="https://aws.amazon.com/message/5467D2/">DynamoDB 2015 年 9 月的事故說明</a> 寫到，主要問題在週日解決之後，殘留的整體錯誤率在 0.15% 到 0.25% 之間，部分客戶的錯誤率卻不成比例地高；隔天陸續有客戶開支援案件，AWS 寫到沒有及早察覺這個落差。要分開的是兩者的成因：429 是卸除與限流規則回的拒絕，503 則兩種成因都有，卸除規則與資源耗盡（例如等資料庫連線等到上限）都可能回 503。所以卸除與限流規則回的每一個拒絕，不論狀態碼是 429 還是 503，都在 log 欄位或指標標籤上標出拒絕它的規則，和其他 5xx 分開計算，才分得出拒絕是卸除在作用，還是服務本身出了錯。</p>
<p><strong>請求根本沒有抵達服務</strong>。入口的網路頻寬塞滿、雲端主機超過每秒封包數的上限而默默丟包（9.4〈Resource saturation 的六個維度〉），這些請求不會進到後端程式；用戶端那一側看到的是封包重送之後的連線逾時，和無回應長得一樣。兩者的差別在入口記錄到的請求：沒有抵達時，入口的請求率比平常低；無回應時，請求照常抵達，進行中的請求數上升而完成的請求數下降。兩種情形下程序都還在執行，<a href="/blog/backend/knowledge-cards/health-check/" data-link-title="Health Check" data-link-desc="說明服務如何對外提供可供平台判斷狀態的健康回應">健康檢查</a>不經過卡住的資源時也都成功，所以從程序與健康檢查分不出兩者。這些請求不在後端錯誤率的分子，也不在分母（分母是後端收到的請求總數，見 4.0〈應用層訊號：請求指標、log 與 trace〉）。判斷標準換成入口的請求率（每秒進來的請求數）比平常低，或從外面發出的探測失敗；這一篇說的「平常」都指同一時段的歷史值（例如上週同一天同一個小時）。主機網卡驅動的計數也看得到丟包（AWS ENA 在 <code>ethtool -S</code> 裡的 <code>pps_allowance_exceeded</code>、<code>bw_in_allowance_exceeded</code>）。</p>
<p>應對依形態而定。資源耗盡造成的失敗，先找出耗盡的是哪一種資源，見〈變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限〉；主動拒絕本身就是應對，要確認被拒的是低優先的請求（9.15〈負載卸除與限流的分工、請求的優先等級〉）；用戶端的重試會讓失敗的請求再送一次、加重負載，所以要配重試預算與退避（<a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>），寫入路徑的重試還要帶冪等鍵（見〈資料上的結果：重複寫入、接受後遺失與讀到舊資料〉）。沒有抵達的請求，服務端能做的有限，要在入口之前擴充頻寬或交給邊緣網路清洗（9.15〈體積型攻擊（DDoS）的處置：流量清洗與邊緣網路〉）。</p>
<h2 id="無回應程序還在請求等到逾時也沒有結果">無回應：程序還在，請求等到逾時也沒有結果</h2>
<p>無回應指服務的程序仍在執行，而請求在用戶端設定的逾時之內等不到任何回應。判斷時用發出那個請求的用戶端自己的逾時：瀏覽器、上游服務與健康檢查各有各的逾時，健康檢查的逾時通常比較短，所以服務變慢時健康檢查可能先逾時：readiness 檢查（決定是否接流量）逾時，負載平衡器把實例移出；liveness 檢查（決定程序要不要重啟）逾時，平台重啟容器，見〈程序終止：被系統結束、當機或反覆重啟〉。健康檢查不經過卡住的那個資源（例如不查資料庫）時情形相反：轉址請求全部逾時，健康檢查仍然成功。無回應與請求失敗的差別在等待由哪一方結束：請求失敗是服務端在用戶端的逾時之前回了一個錯誤，無回應是用戶端的逾時先到、由用戶端自己放棄。</p>
<p>用戶端與服務之間有反向代理時，每一跳各自判一次：入口 Nginx 等後端等到 <code>proxy_read_timeout</code>（預設 60 秒）就回 504，後端連線被拒或連線被中途關閉時回 502。所以後端的無回應在瀏覽器那一側是 504 的請求失敗，後端的程序終止是 502；入口存取紀錄裡 502 與 504 的數量，是分辨後端這兩種結果的第一個訊號（各層逾時的大小關係見 <a href="/blog/backend/05-deployment-platform/load-balancer-contract/" data-link-title="5.3 load balancer 合約" data-link-desc="整理 idle timeout、draining 與 health check">Load Balancer 契約</a>）。入口後面有多個後端實例時，Nginx 預設（<code>proxy_next_upstream error timeout</code>）會把 GET 這類冪等請求改送下一台，最終狀態碼可能是 200，每一次嘗試的狀態碼記在 <code>$upstream_status</code>，要從那個欄位數。</p>
<p>成因多半是沒有上限的等待：連線池、鎖或對依賴的呼叫沒有設逾時，每一個請求都卡在等待裡，執行緒或 goroutine 被佔滿之後，新的請求連被處理的機會都沒有。GC 壓力逐漸加重時，延遲緩慢爬升而請求仍然完成，呈現變慢（<a href="/blog/backend/06-reliability/load-testing/" data-link-title="6.2 load test" data-link-desc="把 production 流量結構轉成可重播壓力情境，定位 saturation 轉折與容量邊界">6.2</a> 稱為逐漸退化型）；記憶體將滿、GC 連續執行時，程序大部分時間停在回收上，延遲超過發出請求那一方的逾時時就呈現無回應；記憶體繼續上升到上限就被 <a href="/blog/linux/dotfile/knowledge-cards/oom-exit-code-137/" data-link-title="OOM Killer and Exit Code 137（OOM killer 與退出碼 137）" data-link-desc="程序或 container 被無預警砍掉、退出碼是 137、或編譯 / 測試在記憶體吃緊時突然死掉、要判斷是不是記憶體不足時回來讀">OOM</a> 終止，所以同一次過載常是先無回應、再程序終止。</p>
<p>無回應會改變影響範圍。負載平衡器以健康檢查判斷實例能不能接流量，一台實例無回應時被移出，剩下的實例分到更多負載；<a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a> 的〈負載卸除與限流的分工、請求的優先等級〉引用 Amazon 的說法，健康檢查回應不及時，機器被移出，機群因此變小。剩下的實例如果也接近各自的上限，多分到的負載會讓它們跟著進入無回應。</p>
<p>應對的第一步是由服務端替自己的等待加上限（<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a>、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">Deadline</a>）：等資料庫連線、等鎖、等依賴回應都設上限，到上限就由服務端回錯誤，這個錯誤在用戶端的逾時之前送到，結果從無回應變成請求失敗。使用者得到錯誤而不是乾等，佔著的資源被釋放，健康檢查能在逾時內回應。加上限的同時，呼叫端的重試要受重試預算限制，否則逾時的請求會再送一次，成為停在失效的持續效應（<a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>）。依賴已經無回應時，用 <a href="/blog/backend/knowledge-cards/circuit-breaker/" data-link-title="Circuit Breaker" data-link-desc="說明下游持續失敗時如何暫停呼叫並保護系統">Circuit Breaker</a> 停止呼叫它，不再讓每一個請求等到逾時。重啟不改變負載：重啟回來的實例接到的負載和重啟前一樣，負載沒降下來時，它很快回到無回應。</p>
<h2 id="程序終止被系統結束當機或反覆重啟">程序終止：被系統結束、當機或反覆重啟</h2>
<p>程序終止指服務的程序結束：被作業系統以記憶體不足（OOM）終止、遇到錯誤當機，接著被容器平台或 systemd 重啟，在負載沒有下降時一再重啟。在 Kubernetes 上，被 OOM 終止的容器終止原因記成 <code>OOMKilled</code>、退出碼 137，反覆重啟時狀態顯示 <code>CrashLoopBackOff</code>。</p>
<p>最常見的成因是記憶體。記憶體的佔用來自兩處：進行中的請求（請求與回應的物件、連線的緩衝區），排隊的請求越多佔得越多；以及服務持有的狀態（快取、session、監控後端的時間序列），隨狀態的數量成長。任一處到了上限，程序就被終止。</p>
<p>主機的記憶體耗盡時另有兩種走法。主機有 swap 時，記憶體耗盡先表現為大量換頁，整台主機的請求一起變慢到無回應，OOM killer 很晚才介入。主機層級的 OOM killer 依各程序用掉的記憶體（常駐記憶體加 swap）佔可用記憶體的比例評分，再加上 <code>oom_score_adj</code>，結束分數最高的程序；同一台主機上的資料庫或快取常駐記憶體大，常先被挑中，後端看到的是依賴失敗，也就是請求失敗（<a href="/blog/devops/operations/05-capacity-planning/shared-host-contention/" data-link-title="共用主機的資源競爭：容量估算、故障範圍、隔離層級與監控的位置" data-link-desc="多個服務、資料庫與監控放在同一台主機（或同一台實體機上的多台虛擬機）時，資源競爭在容量估算、故障範圍、隔離的做法與監控的位置上各自要考慮什麼：共用發生在哪些層、量到的數字什麼時候能互相比較、哪些服務適合放在一起、從同機加限制到專用實體機的隔離層級與各層的觸發條件">共用主機的資源爭用</a>）。</p>
<p>另一個成因是平台的存活檢查：Kubernetes 的 liveness probe 連續逾時達到門檻時，kubelet 重啟容器；過載讓 probe 回應變慢，程序沒有壞也會被重啟，重啟期間其餘實例分到更多負載而跟著被重啟。終止原因沒有 <code>OOMKilled</code>、事件裡有 liveness probe 失敗，就是這一種。應對是讓 liveness 只檢查程序內部是否卡死、不經過依賴，逾時設得比過載時的延遲寬（<a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">Liveness</a>、<a href="/blog/devops/operations/04-service-health/liveness-vs-readiness/" data-link-title="Liveness 與 Readiness" data-link-desc="分不清該用哪種探針、或探針失敗後平台重啟了不該重啟的服務時，回來釐清 liveness、readiness、startup 三種探針各自宣告什麼、失敗後平台做什麼">Liveness 與 Readiness</a>〈探針靈敏度是雙向的取捨〉）。</p>
<p>重啟讓程序回來，而負載沒有變。重啟期間其他實例分到這一台的流量；重啟回來時程序內的快取是空的，原本由它回應的讀取全部打到資料庫，所以在負載下重啟常讓其他實例與資料庫跟著過載。應對依佔用的來源而定：進行中的請求佔掉的記憶體與進行中的請求數成正比，要限制<a href="/blog/backend/knowledge-cards/in-flight/" data-link-title="In-Flight Work" data-link-desc="目前已接收但尚未完成處理的工作量">進行中的請求</a>數（上限可以固定，也可以依量到的延遲自動調整，即 adaptive concurrency limit）並卸除負載，讓重啟回來的程序不會再被推到記憶體上限；狀態佔掉的記憶體要限制狀態的數量（例如時間序列數的上限），限制進行中的請求數不會讓它下降。平台的自動重啟本身不會通知任何人，所以連續重啟要由一條告警看到（4.0〈容器與程序的自檢〉）。重啟回來的實例快取是空的，readiness 等快取預熱完成才回報就緒（<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">Readiness</a>、<a href="/blog/backend/knowledge-cards/cache-warmup/" data-link-title="Cache Warmup" data-link-desc="說明服務啟動或活動前如何預先建立快取資料">Cache Warmup</a>），負載平衡器加回它時讓流量分段增加（slow start）。</p>
<h2 id="尖峰過去或應對切換之後仍持續的情形停在失效擺盪與第二波湧入">尖峰過去或應對切換之後仍持續的情形：停在失效、擺盪與第二波湧入</h2>
<p>變慢、請求失敗、無回應與程序終止，在負載下降之後通常會跟著消失；以下三種情形在尖峰過去、依賴恢復或應對切換之後仍然持續。</p>
<p><strong>停在失效</strong>：觸發（流量尖峰、依賴故障、重新部署）已經消失、使用者發起的請求率回到平常，服務仍然大量逾時或出錯，原因是過載本身產生了額外的工作（重試、處理沒人在等的舊請求、快取變空之後改打資料庫的讀取），額外的工作又維持過載；這種讓過載自我維持的機制叫持續效應（sustaining effect），停在失效就是文獻裡的 <a href="/blog/backend/knowledge-cards/metastable-failure/" data-link-title="Metastable Failure（負載退去後仍停在失效狀態）" data-link-desc="流量已經回到平常的量而服務仍然大量逾時或出錯、或要判斷一次過載為什麼沒有自己恢復時，查 metastable failure 的定義、讓它停在失效狀態的持續效應、偵測它要比對的訊號與脫離它的做法">Metastable Failure</a>。9.15〈負載卸除與限流的分工、請求的優先等級〉描述的「過載因此變成穩定狀態」就是這一種。</p>
<p>停在失效的判斷標準是比對兩組數字：使用者發起的請求率（扣掉重試）已經回到平常，而每秒成功完成的請求數（goodput）沒有回到平常、錯誤率仍高；總請求率可能因為重試而高於平常，重試請求佔全部請求的比例也比平常高。瀏覽器的重新整理、入口反向代理的改送與多數自己寫的重試沒有標記，送到服務時和新請求長得一樣，所以扣掉重試的請求率要從入口分得出來的量取：不同使用者或工作階段的數量，或帶冪等鍵的請求以鍵去重後的數量；gRPC 的重試帶 <code>grpc-previous-rpc-attempts</code>，Envoy 開啟 <code>include_request_attempt_count</code> 後帶 <code>x-envoy-attempt-count</code>，有這些標記時可以直接扣掉。另外服務的資源要仍然飽和：等待連線的請求數、CPU 或資料庫讀取量高於平常；自己量得到的資源沒有飽和時，先看依賴回的錯誤，依賴的配額被重試用完時（第三方 API 回 429），飽和的是對方的配額，持續效應一樣存在（<a href="/blog/backend/09-performance-capacity/bottleneck-localization/" data-link-title="9.5 瓶頸定位流程" data-link-desc="從 app 到 DB / cache / broker / 第三方 quota 的逐層瓶頸定位">9.5 瓶頸定位流程</a>〈逐層定位流程〉的外部 API 配額）。自己的資源沒有飽和、依賴也沒有回 429 時，goodput 低落的成因不是過載（例如資料庫切換後應用程式還連著舊的位址），要回到部署、設定與依賴的變更去找。</p>
<p>判斷之前還要排除觸發仍在的情形：依賴的錯誤率與延遲回到平常，被移出或終止的實例已經補回，部署已經完成或回滾。觸發是依賴故障或容量減少時，使用者的請求率可能一直在平常的範圍，請求率平常、goodput 低、資源飽和這三項在觸發仍在時同樣成立；那時是正在進行的過載，應對照它呈現的結果處理，並處理觸發本身（故障的依賴、減少的容量）。新部署的版本讓每個請求的成本變高時，三項也都成立而觸發仍在：處置是回滾；只把負載壓低再放回，放回之後每個請求仍然一樣貴，服務會再度過載。分辨的方法是看每個成功請求消耗的資源（每請求的 CPU 時間、每請求的資料庫查詢數）是否比觸發前高，而且高出的部分不是來自重試或快取未命中。</p>
<p>Slack 2021 年 1 月 4 日的事故可以拿來對照觸發仍在的過載：假期後第一個上班日流量上升，服務之間的託管網路元件（AWS Transit Gateway）來不及擴展而丟包，錯誤一直持續到網路恢復，那段期間要處理的是網路元件，不是持續效應。</p>
<p>確認是停在失效之後，應對是把負載壓到平常的量以下，讓持續效應停止（重試停下、舊請求清掉、快取預熱），佇列在過載時改成後進先出或依排隊時間丟棄，讓舊請求不再佔用處理時間，再逐步放回流量，做法見 Metastable Failure 卡。</p>
<p>非同步的消費端也會停在失效：入列速率回到平常，消費端成功處理的速率仍低於入列速率，落後量持續增加（<a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Consumer Lag</a>）；先進先出的佇列先處理已經過期的舊訊息時最容易這樣。</p>
<p><strong>擺盪</strong>：卸除或擴縮的切換太快，服務在兩個狀態之間來回：卸除一啟動負載就下降，卸除解除後又過載；或擴充之後利用率下降、隨即縮減、延遲又超過門檻。9.15 引用 Stripe 的經驗寫到卸除削得太快會擺盪，<a href="/blog/devops/operations/02-horizontal-scaling/scaling-triggers/" data-link-title="擴展的觸發與縮回" data-link-desc="設計自動擴縮時，釐清擴展前要先窮盡哪些低成本手段、觸發訊號怎麼分層、以及縮回為什麼比擴容難、要先 drain 才能縮">擴展的觸發與縮回</a> 以冷卻時間避免擴縮的抖動。應對是讓卸除與恢復都分段進行，擴縮設冷卻時間，縮減的門檻低於擴充的門檻，讓兩個切換點之間留出間隔。健康檢查也會造成擺盪：實例過載被移出，負載降下來後恢復、被加回，加回後又過載；應對是加回時讓流量分段增加（負載平衡器的 slow start），移出與加回用不同的連續次數門檻。</p>
<p><strong>恢復時的第二波湧入</strong>：一個依賴故障期間，呼叫它的請求在用戶端或佇列裡累積；依賴恢復的那一刻，累積的請求與重試一起到達，讓剛恢復的依賴再過載一次（<a href="/blog/devops/operations/07-burst-traffic/burst-classification/#%e4%b8%8d%e5%8f%af%e9%a0%90%e6%9c%9f%e7%aa%81%e7%99%bc" data-link-title="突發流量的分類" data-link-desc="突發流量依能不能事前知道分成兩類：各自的來源、倍率與持續時間，可預期突發的準備要提前多久由什麼決定、collector 在活動前要準備的項目，以及 collector 遇到 error storm 時的資料保留">突發流量的分類</a> 的〈不可預期突發〉）。第二波湧入本身是一次尖峰，累積的請求送完就結束；再過載之後如果重試維持住過載，就轉成停在失效。應對是讓累積的請求分散到達：用戶端的重試加退避與隨機延遲（<a href="/blog/backend/knowledge-cards/jitter/" data-link-title="Jitter" data-link-desc="說明重試或排程加入隨機偏移如何降低同步尖峰">jitter</a>）、寫入的重試帶冪等鍵，<a href="/blog/backend/knowledge-cards/circuit-breaker/" data-link-title="Circuit Breaker" data-link-desc="說明下游持續失敗時如何暫停呼叫並保護系統">Circuit Breaker</a> 在半開狀態只放少量請求試探，佇列的消費端恢復後逐步提高消費速率，已經過了 deadline 的累積請求直接丟掉。</p>
<h2 id="降級回應回應成功而內容縮減">降級回應：回應成功而內容縮減</h2>
<p>降級回應是應對選出來的結果，過載本身不會產生它：服務在過載時關掉推薦、回舊的快取資料、改成唯讀，回應照樣成功、延遲照樣正常。它把原本會走到請求失敗或無回應的情形，換成使用者拿得到、內容比較少的回應。使用者在過載期間拿到的是它，所以監控上也要分得出它（<a href="/blog/backend/knowledge-cards/degradation/" data-link-title="Degradation" data-link-desc="說明服務部分能力失效時如何保留核心功能與控制風險">Degradation</a>）。</p>
<p>延遲與錯誤率都看不到它，所以降級的程式路徑要自己計數：每一次回應走了哪一條降級路徑，記成一個計數指標，降級時才看得到有多少回應是縮減過的，以及降級在負載退去之後有沒有解除。降級也可能發生在服務之外：CDN 依 <code>stale-if-error</code> 在源站出錯時回舊內容（各家 CDN 的支援不同），這些回應要看 CDN 記錄裡的快取狀態（<a href="/blog/backend/knowledge-cards/stale-if-error/" data-link-title="Stale-If-Error" data-link-desc="HTTP cache-control directive、origin 出錯時用舊版頂著、確保使用者拿到有效回應">Stale-If-Error</a>）。</p>
<h2 id="資料上的結果重複寫入接受後遺失與讀到舊資料">資料上的結果：重複寫入、接受後遺失與讀到舊資料</h2>
<p>變慢、請求失敗、無回應、程序終止與降級回應，是按使用者拿到的回應分類；過載在資料上另有三種結果，回應碼與延遲都看不出來。</p>
<p><strong>重複寫入</strong>：寫入在用戶端放棄之後才完成，或服務端的 deadline 在資料庫已經提交之後才觸發，使用者拿到錯誤而寫入已經發生，用戶端重試就寫第二次。服務端替等待加上限（〈無回應〉的應對）會讓「回了錯誤而資料庫已經提交」更常發生，所以寫入路徑的重試要帶冪等鍵，服務端以鍵判斷是否已經處理過（<a href="/blog/backend/knowledge-cards/idempotency-key/" data-link-title="Idempotency Key（冪等鍵）" data-link-desc="同一操作重送時該由誰生成識別碼、存多久、衝突怎麼回——冪等性質的對外契約落地機制">Idempotency Key</a>、<a href="/blog/backend/11-api-design/consumer-retry-decision/" data-link-title="接收方的重試決策：從單一請求到 retry 風暴" data-link-desc="收到錯誤之後重不重試：從單請求的 status 加冪等合判、集體的去同步責任、到 retry 預算與斷路閘門">接收方的重試決策</a>）。訊號是同一個冪等鍵被重複送達的次數；還沒有冪等鍵時，訊號是業務上的重複（同一使用者、同一內容在短時間內寫進兩筆）。</p>
<p><strong>接受後遺失</strong>：非同步路徑把事件放進程序內的緩衝就回應成功（API 回 202；短網址照常回轉址），程序被 OOM 終止時，緩衝裡還沒寫進佇列的事件跟著消失。短網址的點擊事件如果先放在後端程序內再批次寫入佇列，就是這一種。訊號是入口存取紀錄的請求數（或其他不在同一個程序裡的計數）與寫進佇列的事件數之間的差；接受數由同一個程序計數時，程序被終止的那一段會跟著消失。應對是讓回應在事件寫進持久的佇列之後才送出，或接受遺失並在統計上標明。</p>
<p><strong>讀到舊資料</strong>：讀取走資料庫複本時，主庫的寫入量超過複本套用的速度，複寫延遲變長，剛建立的短網址從複本讀不到而回 404。錯誤率只算 5xx 時看不到它，延遲也正常；端點的 404 比例上升與複寫延遲是它的訊號，應對是剛寫入的資料從主庫讀，或讀取時帶上寫入的版本（<a href="/blog/backend/knowledge-cards/replication-lag/" data-link-title="Replication Lag" data-link-desc="說明資料副本落後正式來源多久，以及它如何影響讀取正確性">Replication Lag</a>、<a href="/blog/backend/knowledge-cards/read-after-write/" data-link-title="Read-After-Write Consistency" data-link-desc="說明寫入後能否立即讀到該筆寫入的一致性保證">Read-After-Write</a>）。</p>
<h2 id="變慢之後走向哪一種結果先耗盡的資源與等待是否設有上限">變慢之後走向哪一種結果：先耗盡的資源與等待是否設有上限</h2>
<p>過載時，多數資源在耗盡前都先讓請求排隊，先出現的是變慢；入口頻寬與每秒封包數例外，超過上限的封包直接被丟掉。變慢持續下去會轉成哪一種結果，由最先耗盡的資源，以及等待那個資源時是否設有上限決定。先耗盡的資源指請求在這個服務裡等待的那一個；它的上游成因（例如服務之間的網路丟包）另外寫。下表是常見的對應：</p>
<table>
  <thead>
      <tr>
          <th>先耗盡的資源</th>
          <th>等待是否設上限</th>
          <th>等待到頭之後呈現的結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CPU</td>
          <td>不適用</td>
          <td>變慢：所有請求一起變慢</td>
      </tr>
      <tr>
          <td>CPU 配額（容器的 CPU 上限，cgroup 的 <code>cpu.max</code>）</td>
          <td>不適用</td>
          <td>變慢，而且是週期性的停頓：配額在一個週期（預設 100 毫秒）內用完，剩下的時間整個容器暫停，主機的 CPU 不一定滿；延遲尾端跳升，健康檢查也可能逾時</td>
      </tr>
      <tr>
          <td>連線池、執行緒池</td>
          <td>有上限，且短於呼叫方的逾時</td>
          <td>請求失敗：等到上限就得到錯誤</td>
      </tr>
      <tr>
          <td>連線池、執行緒池</td>
          <td>有上限，但長於呼叫方的逾時</td>
          <td>無回應：呼叫方先放棄，服務端仍佔著資源等到上限</td>
      </tr>
      <tr>
          <td>連線池、執行緒池</td>
          <td>沒有上限</td>
          <td>無回應：請求一直等，佔滿之後新請求進不來</td>
      </tr>
      <tr>
          <td>記憶體</td>
          <td>不適用</td>
          <td>程序終止：容器設了記憶體上限時被 OOM 終止、接著重啟；主機有 swap 時先大量換頁、整台主機變慢到無回應；主機層級的 OOM killer 常先結束同機的資料庫或快取，後端看到依賴失敗</td>
      </tr>
      <tr>
          <td>檔案描述子</td>
          <td>依連線的方向</td>
          <td>程序開不了新的連線與檔案：對外的連線（例如連資料庫）立即失敗，是請求失敗；進來的連線多半停在 accept 佇列裡等不到程序接受，用戶端等到逾時，是無回應；健康檢查也可能失敗而被移出</td>
      </tr>
      <tr>
          <td>listen socket 的 accept 佇列（上限是 listen() 的 backlog 與 <code>somaxconn</code> 取小者）</td>
          <td>依用戶端的連線逾時</td>
          <td>佇列滿時核心丟掉新連線（預設不回 reset），用戶端重送後逾時；應用程式沒有紀錄，經過同一個連接埠的健康檢查一起失敗；計數在 <code>nstat</code> 的 <code>TcpExtListenOverflows</code></td>
      </tr>
      <tr>
          <td>入口頻寬、每秒封包數</td>
          <td>不適用</td>
          <td>請求失敗（沒有抵達）：應用程式沒有任何錯誤紀錄</td>
      </tr>
      <tr>
          <td>服務之間的網路（託管路由元件、交換器的頻寬與丟包）</td>
          <td>依呼叫端的等待是否設上限</td>
          <td>呼叫依賴變慢，等待落在呼叫端的執行緒池或連線池，結果照〈連線池、執行緒池〉幾列依等待上限判；請求已經抵達，應用程式有紀錄</td>
      </tr>
      <tr>
          <td>依賴的服務（資料庫、外部 API）</td>
          <td>依呼叫是否設逾時</td>
          <td>呼叫設了逾時是請求失敗，沒有設是無回應</td>
      </tr>
  </tbody>
</table>
<p>先耗盡的是哪一個資源，看元件指標的飽和度（<a href="/blog/backend/knowledge-cards/use-method/" data-link-title="USE Method" data-link-desc="Brendan Gregg 提出的資源層 Utilization / Saturation / Errors 三維度量測法">USE Method</a>、9.5 瓶頸定位流程）；容器的 CPU 要看被暫停的時間（<code>container_cpu_cfs_throttled_seconds_total</code>，見 <a href="/blog/backend/knowledge-cards/cgroup/" data-link-title="cgroup（控制群組）" data-link-desc="設定容器的 CPU 與記憶體上限、讀 cAdvisor 的指標、或查容器為什麼被暫停與結束時，查 cgroup 是什麼、v1 與 v2 的差別，以及 Docker 與 Kubernetes 的參數各自對應到它的哪個控制檔">cgroup</a>），只看主機的 CPU 會把它排除。CPU（含配額）與記憶體標為不適用，是因為等 CPU 的排程佇列與記憶體配置都沒有應用程式能設的等待上限。</p>
<p>同一個機制可以走到不同的結果。<a href="/blog/devops/operations/07-burst-traffic/queue-buffering/" data-link-title="Queue 緩衝" data-link-desc="在 ingestion 和 processing 之間加 message queue 做 burst 緩衝 — Kafka / NATS / Redis Streams 的選型和引入條件">Queue 緩衝</a> 寫的「goroutine 積壓 → 記憶體上升 → 最終 OOM 或 response timeout」就是兩條路：記憶體先到上限就是程序終止，用戶端的逾時先到就是無回應。<a href="/blog/backend/09-performance-capacity/connection-pool-amplification/" data-link-title="9.14 連線池放大解法（PgBouncer / RDS Proxy / ProxySQL）" data-link-desc="水平擴展應用層時 DB 連線池放大問題的具體解法、connection pooler 三大選項對比、解 9.13 提出但未深入的隱性成本">9.14 連線池放大解法</a> 的〈連線池放大的物理本質〉也是三種結果並存：記憶體耗盡而被 OOM 終止、行程切換變多而變慢、超過資料庫最大連線數而連線建立失敗。壓測時除了記下在多少負載越過 SLO，還要記下先耗盡的是哪一個資源、呈現的是哪一種結果，9.4〈Saturation 文件化：容量地圖〉的清單裡列了這一項。</p>
<h2 id="影響範圍從單一-key-到依賴與上游">影響範圍：從單一 key 到依賴與上游</h2>
<p>結果之外，另一個要寫的是範圍：受影響的是一個 key 或分區、一個租戶、一個優先等級、一個端點、一個實例、同一台主機上的幾個服務、整個服務，還是已經擴散到依賴與呼叫它的上游（<a href="/blog/backend/knowledge-cards/cascading-failure/" data-link-title="Cascading Failure" data-link-desc="說明局部故障如何透過等待、重試與資源耗盡擴散到整個系統">Cascading Failure</a>、<a href="/blog/backend/knowledge-cards/blast-radius/" data-link-title="Blast Radius" data-link-desc="說明事故影響面如何估算與隔離">Blast Radius</a>）。</p>
<p>範圍與結果會在時間上互相推動。一台實例無回應而被移出，剩下的實例負載上升，範圍從一個實例擴大到整個服務；後端無回應時，呼叫它的上游也在等待中耗盡自己的連線池，範圍擴散到上游。縮小範圍的手段是隔離：依租戶或功能分開資源池（<a href="/blog/backend/knowledge-cards/bulkhead/" data-link-title="Bulkhead" data-link-desc="說明 bulkhead 如何用資源分艙限制故障擴散">Bulkhead</a>）、依優先等級卸除，讓過載停在一個範圍裡。</p>
<p>觀察者自己也在範圍之內。監控程式與服務共用一個過載的資源時（同一台主機、同一段網路路徑、同一個資料庫），監控的抓取先逾時、資料先缺，判斷結果所需的訊號在最需要的時候先失真，見 <a href="/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">4.27 主機過載時的監控告警</a>。</p>
<h2 id="各監控層看得到哪一種結果">各監控層看得到哪一種結果</h2>
<p>服務端監控的分層（4.0）把監控分成自檢、服務網路內的探測、元件指標、應用層訊號、告警鏈的監看與合成監控等層，每一層看得到的結果不同：</p>
<table>
  <thead>
      <tr>
          <th>結果</th>
          <th>看得到它的監控層</th>
          <th>訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>變慢（同步）</td>
          <td>應用層訊號、服務網路內的探測、使用者路徑的合成監控</td>
          <td>請求耗時的直方圖（<a href="/blog/backend/knowledge-cards/histogram/" data-link-title="Histogram" data-link-desc="說明 histogram 如何用分桶統計延遲、大小與分布">histogram</a>）百分位數（不含入口排隊時間，見表下）；探測耗時與慢探測的比例</td>
      </tr>
      <tr>
          <td>變慢（非同步）</td>
          <td>應用層訊號、元件指標</td>
          <td>佇列最舊一筆的等待時間、消費者落後量</td>
      </tr>
      <tr>
          <td>請求失敗（抵達後）</td>
          <td>應用層訊號；負載平衡器回的部分看入口的請求指標；應用層訊號與服務共用過載的資源時，由使用者路徑的合成監控接手</td>
          <td>錯誤率，卸除與限流的拒絕（以標籤區分）與其他 5xx 分開；對每個依賴的請求錯誤率；負載平衡器的拒絕計數</td>
      </tr>
      <tr>
          <td>請求失敗（沒有抵達）</td>
          <td>入口的請求指標與存取紀錄、使用者路徑的合成監控</td>
          <td>入口的請求率比平常低；外部探測失敗；網卡驅動的丟包計數</td>
      </tr>
      <tr>
          <td>無回應</td>
          <td>容器與程序的自檢、服務網路內的探測、使用者路徑的合成監控</td>
          <td>健康檢查逾時（健康檢查經過卡住的資源時）；探測逾時；應用層看到完成的請求數下降、錯誤率不升；入口回的 504 計數</td>
      </tr>
      <tr>
          <td>程序終止</td>
          <td>容器與程序的自檢、元件指標；整台主機停掉或終止的是監控程式自己時，是告警鏈的監看（dead man&rsquo;s switch）與合成監控</td>
          <td>重啟計數（<code>kube_pod_container_status_restarts_total</code>、<code>node_systemd_service_restart_total</code>）；終止原因（<code>kube_pod_container_status_last_terminated_reason</code> 的 <code>reason</code> 標籤、node_exporter 的 <code>node_vmstat_oom_kill</code>）；入口回的 502 計數</td>
      </tr>
      <tr>
          <td>停在失效（觸發消失、請求率回到平常之後仍不恢復）</td>
          <td>沒有單一層看得到</td>
          <td>比對使用者發起的請求率與 goodput（每秒成功完成的請求數）；重試請求的比例；資源仍飽和；依賴與容量已經恢復</td>
      </tr>
      <tr>
          <td>擺盪（卸除或擴縮反覆切換）</td>
          <td>應用層訊號、元件指標</td>
          <td>請求率與錯誤率週期性來回；卸除或擴縮的狀態反覆切換</td>
      </tr>
      <tr>
          <td>第二波湧入（依賴恢復時累積的請求一起到達）</td>
          <td>入口的請求指標、應用層訊號</td>
          <td>依賴恢復那一刻的請求率尖峰；重試請求的比例</td>
      </tr>
      <tr>
          <td>降級回應</td>
          <td>應用層訊號（要自己計數）</td>
          <td>降級路徑的計數指標</td>
      </tr>
      <tr>
          <td>資料上的結果（重複寫入、接受後遺失、讀到舊資料）</td>
          <td>應用層訊號、元件指標（要自己計數）</td>
          <td>同一冪等鍵重複送達的次數；接受與寫進佇列的事件數差；複寫延遲</td>
      </tr>
      <tr>
          <td>前兆（還沒有任何結果）</td>
          <td>元件指標</td>
          <td><a href="/blog/backend/knowledge-cards/use-method/" data-link-title="USE Method" data-link-desc="Brendan Gregg 提出的資源層 Utilization / Saturation / Errors 三維度量測法">USE Method</a> 的飽和度：排隊長度、等待連線的請求數；每個請求的大小或工作量的趨勢（回應大小、清單類資料的大小）</td>
      </tr>
  </tbody>
</table>
<p>表裡變慢（同步）與停在失效兩列的訊號要另外補。histogram 只量得到請求進到程式之後的時間，請求在入口或系統的佇列裡排隊的時間不在裡面，所以過載時應用層的延遲會低報，主機過載時的監控告警那一篇（4.27）改用從外面發的慢探測比例補上這一段。停在失效這一種沒有任何一層直接看得到，要另外寫一條比對規則：使用者發起的請求率已經回到平常的範圍、依賴與容量已經恢復，而 goodput 仍低於平常、元件指標的飽和度高於平常，而且 goodput 在這段時間內沒有上升趨勢（快取逐漸回熱、goodput 一路上升的是自己在恢復），持續一段時間就送出。另外，程序終止那一列的 <code>node_systemd_service_restart_total</code> 由 node_exporter 的 systemd collector 產生，那個 collector 預設關閉，重啟計數另有開關（<code>--collector.systemd.enable-restarts-metrics</code>），沒開過的主機上查不到這個指標。</p>
<h2 id="描述一次過載時要寫出的內容">描述一次過載時要寫出的內容</h2>
<p>壓測報告、事故紀錄與容量地圖描述一次過載時，「撐不住」只說了有一個上限被越過，讀的人不知道使用者看到什麼、哪一個資源先用完、該用哪一種應對。改寫成五項：負載量與它的量法（每秒多少請求、哪一種請求組合、每個請求的大小或工作量）、呈現的結果（使用者拿到的是變慢、請求失敗、無回應、程序終止還是降級回應；觸發消失之後是否仍然持續，是停在失效、擺盪還是第二波湧入；資料上有沒有重複寫入、接受後遺失或讀到舊資料）、先耗盡的資源、影響範圍、監控從哪一層看到。例如：「每秒轉址請求超過壓測的上限之後，資料庫連線池先耗盡，連線池設了等待上限，所以呈現的是約一成的轉址請求失敗（503），其餘延遲不變；應用層的錯誤率告警先送出，健康檢查一直正常。」（這個例子的數字是示意。）實際記錄時，數字取自壓測或事故本身並寫明出處。</p>
<h2 id="延伸閱讀飽和曲線應對手段與監控層">延伸閱讀：飽和曲線、應對手段與監控層</h2>
<ul>
<li>負載在飽和曲線上的位置、knee 與 cliff 的量法、容量地圖：<a href="/blog/backend/09-performance-capacity/saturation-discovery/" data-link-title="9.4 Saturation Discovery" data-link-desc="找出 throughput plateau 與 latency knee 的方法">9.4 Saturation Discovery</a></li>
<li>沿著請求路徑分析負載時共用的概念（一次一筆的處理與互斥範圍、可提早回應的比例、保護把負載轉到哪裡、保護元件故障時的處置）：<a href="/blog/backend/09-performance-capacity/load-path-analysis/" data-link-title="9.0 請求路徑上的負載分析：一次一筆的處理、在它之前能回應的請求、多出來的請求怎麼處置、保護把負載轉到哪裡、重送與去重，以及保護元件與依賴故障時的處置" data-link-desc="沿著一個會改變狀態的請求從用戶端到資料庫的路徑分析負載時共用的幾個概念：必須一次只處理一筆的那件事與它的互斥範圍、需要經過它的請求裡結果事先已確定的比例、多出來的請求要排隊、拒絕還是請使用者重新確認、每一段保護把負載轉給了哪個元件（保護元件自己的狀態、連線數、輪詢、下游接收端、外部配額）、佇列與重按帶來的重送怎麼去重、保護元件故障或輸出錯的值時下游讀到什麼，以及依賴變慢、部分失效與恢復時的第二波湧入">9.0 請求路徑上的負載分析</a></li>
<li>負載卸除、准入控制、重試預算、擴展跟不上時的退路：<a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a></li>
<li>熱點列與連線池的耗盡順序、等待上限與呼叫方逾時的關係，在售票系統上的完整例子：<a href="/blog/backend/09-performance-capacity/ticketing-load-path/" data-link-title="9.0.1 售票搶購的負載路徑：熱點列與連線池、CDN 合併回源、排隊放行的速率、資料庫認領與加入 Redis 閘門的條件、付款期限的條件更新" data-link-desc="以售票搶購為例，沿著一張票從座位圖到付款記帳的路徑，寫出全部請求更新同一個庫存計數時熱點列與連線池的耗盡順序，邊緣快取、排隊放行、資料庫逐張認領與加入 Redis 扣庫存各自把負載轉給了哪一段、自己出錯時由哪一段補救，以及成功請求的總數有上限、延遲不改變請求的價值、先到先得這幾個前提讓哪些做法可以簡化、哪些需求無法簡化">9.0.1 售票搶購的負載路徑</a>；請求晚一點處理就失去價值、過載時要明確拒絕的撮合系統：<a href="/blog/backend/09-performance-capacity/matching-engine-load-path/" data-link-title="9.0.2 交易撮合的負載路徑：閘道的帳戶限流與明確拒絕、依序日誌的複寫延遲、單一寫入者的撮合引擎、行情的合併送出與重放驗證容量" data-link-desc="以交易撮合為例，沿著一筆委託從入口閘道到成交回報的路徑，寫出撮合之前結果無法確定、處理順序決定成交、延遲改變請求的價值這些性質讓哪些負載保護不能用，閘道、依序日誌、單一寫入者的撮合引擎與行情發布各自先耗盡的資源與出錯時的補救，過載時明確拒絕超量委託與以重放驗證容量的做法，並與售票搶購對照">9.0.2 交易撮合的負載路徑</a></li>
<li>監控各層的做法、各層看不到的範圍與接手的層：<a href="/blog/backend/04-observability/monitoring-layers/" data-link-title="4.0 服務端監控的分層：容器與程序的自檢、服務網路內的探測、元件指標、應用層訊號、告警規則與分送、告警鏈的監看與使用者路徑的合成監控" data-link-desc="以每一層監控檢查的對象、看不到的範圍與接手的那一層，把服務端監控串成一條監控鏈，以及拿它盤點一個服務時怎麼判讀缺口的性質（檢查範圍、位置、讀取與訊號失真），涵蓋自架主機、Kubernetes 與雲端託管的情形；使用者路徑那一層另寫合成監控與真實使用者監測的差別，以及盤點時各種缺口的補法">4.0 服務端監控的分層</a></li>
<li>可用性與延遲分成兩個告警、過載時監控自身的失真：<a href="/blog/backend/04-observability/host-overload-alerting/" data-link-title="4.27 主機過載時的監控告警：可用與變慢分開判斷、依原因抑制、監控自身的等待" data-link-desc="多個服務共用一台主機而主機過載時，各服務的探測與告警會一起失敗：可用性與延遲分成兩個判斷的寫法、間歇的條件怎麼持續成立、以主機飽和抑制各服務變慢的規則與它的取捨、以合成告警驗證抑制、抑制藏起各服務受影響程度的差別、探測與 Prometheus 自己也在等 CPU 時的訊號，以及讓監控不比服務先失效的資源隔離；以一次開發環境的過載為例">4.27 主機過載時的監控告警</a></li>
<li>負載退去之後仍停在失效狀態的機制與脫離方式：<a href="/blog/backend/knowledge-cards/metastable-failure/" data-link-title="Metastable Failure（負載退去後仍停在失效狀態）" data-link-desc="流量已經回到平常的量而服務仍然大量逾時或出錯、或要判斷一次過載為什麼沒有自己恢復時，查 metastable failure 的定義、讓它停在失效狀態的持續效應、偵測它要比對的訊號與脫離它的做法">Metastable Failure</a></li>
</ul>
]]></content:encoded></item><item><title>Metastable Failure（負載退去後仍停在失效狀態）</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/metastable-failure/</link><pubDate>Wed, 14 Oct 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/metastable-failure/</guid><description>&lt;p>Metastable failure 指服務在一次觸發（短暫的流量尖峰、一次依賴故障、一次重新部署）讓它過載之後，觸發已經消失、使用者發起的請求量也回到平常，服務仍然停在大量逾時或出錯的狀態。這個名字來自 Bronson 等人在 HotOS 2021 發表的〈Metastable Failures in Distributed Systems〉，論文把讓服務停在失效狀態的機制稱為持續效應（sustaining effect）：過載本身產生了額外的工作，額外的工作又維持過載。Huang 等人在 OSDI 2022 的〈Metastable Failures in the Wild〉整理的公開事故中，超過一半由重試維持（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm&lt;/a>）。Metastable failure 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cascading-failure/" data-link-title="Cascading Failure" data-link-desc="說明局部故障如何透過等待、重試與資源耗盡擴散到整個系統">Cascading Failure&lt;/a> 描述同一次故障的兩個方向：cascading failure 是故障往其他元件擴散，metastable failure 是故障在觸發消失之後仍延續；兩者可以同時發生，論文也寫到持續效應會讓沒碰到觸發的元件跟著進入失效狀態。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>同一個服務平常處理得了的負載，在 metastable failure 期間處理不了，因為服務做的工作比進來的請求多。常見的持續效應：用戶端的重試讓同一個請求被送好幾次（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm&lt;/a>）；伺服器繼續處理已經逾時、沒有人在等結果的舊請求，佔掉處理新請求的資源；快取在故障或重啟後變空，原本由快取擋下的讀取全部打到資料庫（&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-warmup/" data-link-title="Cache Warmup" data-link-desc="說明服務啟動或活動前如何預先建立快取資料">Cache Warmup&lt;/a>）；過載造成的 GC、鎖競爭或 leader 反覆選舉，讓服務每秒處理得了的請求變少。Google《Site Reliability Engineering》的〈Addressing Cascading Failures〉舉的例子是一個在每秒一萬個請求下健康的服務，到每秒一萬一千時因當機開始連鎖失效；把負載降到每秒九千幾乎止不住當機，因為需求集中到剩下的少數伺服器上，假設只剩一成伺服器健康，要降到約每秒一千才能穩定並恢復；同一章也寫到，重試會讓後端在原始的請求率下降之後仍然過載，要大幅降低前端送進來的負載直到重試停止。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>判斷的依據是比對兩組數字：使用者發起的請求率（扣掉重試）已經回到平常的量，而每秒成功完成的請求數（goodput）或錯誤率仍沒有回到平常；總請求率可能因為重試而高於平常。另外兩個訊號是重試流量佔總請求的比例偏高，以及資料庫的讀取量明顯高於平常的同一時段（快取還沒熱）。短網址服務在一次資料庫切換之後，轉址請求的總量回到平常，成功的轉址卻只有平常的一半，log 裡同一個使用者在幾秒內重複送出同一個請求的紀錄大量出現，就是這個樣子（例子的數字是示意）。&lt;/p>
&lt;h2 id="判讀方式">判讀方式&lt;/h2>
&lt;p>流量回到平常而服務仍不恢復時，先確認觸發已經消失：依賴的錯誤率與延遲回到平常、被移出的實例已經補回、新部署的版本已經回滾或確認每個請求的成本沒有變高；觸發是依賴故障或容量減少時，請求率從頭到尾都在平常的範圍，觸發仍在的過載，在請求率、goodput 與資源飽和度上和 metastable failure 一樣。再確認資源仍然飽和（等待連線的請求數、CPU 或資料庫讀取量高於平常），或依賴的配額被重試用完而回 429；自己的資源沒有飽和、依賴也沒有回 429 時，成因不是過載，要回到部署、設定與依賴的變更去找。資源飽和時要處理的是持續效應：等流量自己退不會讓服務恢復，加容量要加到能消化重試與舊請求帶來的額外工作才有效。脫離 metastable failure 要拿掉持續效應：把進入服務的負載壓到平常的量以下（負載卸除、准入控制）、讓用戶端停止重試或拉長退避、丟掉已經逾時的排隊請求、先預熱快取再恢復全部流量。預防的做法是在平常就限制持續效應：重試預算、有上限的佇列與逾時、依優先等級卸除。負載超過容量時服務呈現的各種結果與各自的判斷標準，見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現&lt;/a>；卸除與准入的做法見 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Metastable failure 指服務在一次觸發（短暫的流量尖峰、一次依賴故障、一次重新部署）讓它過載之後，觸發已經消失、使用者發起的請求量也回到平常，服務仍然停在大量逾時或出錯的狀態。這個名字來自 Bronson 等人在 HotOS 2021 發表的〈Metastable Failures in Distributed Systems〉，論文把讓服務停在失效狀態的機制稱為持續效應（sustaining effect）：過載本身產生了額外的工作，額外的工作又維持過載。Huang 等人在 OSDI 2022 的〈Metastable Failures in the Wild〉整理的公開事故中，超過一半由重試維持（<a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>）。Metastable failure 與 <a href="/blog/backend/knowledge-cards/cascading-failure/" data-link-title="Cascading Failure" data-link-desc="說明局部故障如何透過等待、重試與資源耗盡擴散到整個系統">Cascading Failure</a> 描述同一次故障的兩個方向：cascading failure 是故障往其他元件擴散，metastable failure 是故障在觸發消失之後仍延續；兩者可以同時發生，論文也寫到持續效應會讓沒碰到觸發的元件跟著進入失效狀態。</p>
<h2 id="概念位置">概念位置</h2>
<p>同一個服務平常處理得了的負載，在 metastable failure 期間處理不了，因為服務做的工作比進來的請求多。常見的持續效應：用戶端的重試讓同一個請求被送好幾次（<a href="/blog/backend/knowledge-cards/retry-storm/" data-link-title="Retry Storm" data-link-desc="說明大量重試如何把局部故障放大成系統壓力">Retry Storm</a>）；伺服器繼續處理已經逾時、沒有人在等結果的舊請求，佔掉處理新請求的資源；快取在故障或重啟後變空，原本由快取擋下的讀取全部打到資料庫（<a href="/blog/backend/knowledge-cards/cache-warmup/" data-link-title="Cache Warmup" data-link-desc="說明服務啟動或活動前如何預先建立快取資料">Cache Warmup</a>）；過載造成的 GC、鎖競爭或 leader 反覆選舉，讓服務每秒處理得了的請求變少。Google《Site Reliability Engineering》的〈Addressing Cascading Failures〉舉的例子是一個在每秒一萬個請求下健康的服務，到每秒一萬一千時因當機開始連鎖失效；把負載降到每秒九千幾乎止不住當機，因為需求集中到剩下的少數伺服器上，假設只剩一成伺服器健康，要降到約每秒一千才能穩定並恢復；同一章也寫到，重試會讓後端在原始的請求率下降之後仍然過載，要大幅降低前端送進來的負載直到重試停止。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>判斷的依據是比對兩組數字：使用者發起的請求率（扣掉重試）已經回到平常的量，而每秒成功完成的請求數（goodput）或錯誤率仍沒有回到平常；總請求率可能因為重試而高於平常。另外兩個訊號是重試流量佔總請求的比例偏高，以及資料庫的讀取量明顯高於平常的同一時段（快取還沒熱）。短網址服務在一次資料庫切換之後，轉址請求的總量回到平常，成功的轉址卻只有平常的一半，log 裡同一個使用者在幾秒內重複送出同一個請求的紀錄大量出現，就是這個樣子（例子的數字是示意）。</p>
<h2 id="判讀方式">判讀方式</h2>
<p>流量回到平常而服務仍不恢復時，先確認觸發已經消失：依賴的錯誤率與延遲回到平常、被移出的實例已經補回、新部署的版本已經回滾或確認每個請求的成本沒有變高；觸發是依賴故障或容量減少時，請求率從頭到尾都在平常的範圍，觸發仍在的過載，在請求率、goodput 與資源飽和度上和 metastable failure 一樣。再確認資源仍然飽和（等待連線的請求數、CPU 或資料庫讀取量高於平常），或依賴的配額被重試用完而回 429；自己的資源沒有飽和、依賴也沒有回 429 時，成因不是過載，要回到部署、設定與依賴的變更去找。資源飽和時要處理的是持續效應：等流量自己退不會讓服務恢復，加容量要加到能消化重試與舊請求帶來的額外工作才有效。脫離 metastable failure 要拿掉持續效應：把進入服務的負載壓到平常的量以下（負載卸除、准入控制）、讓用戶端停止重試或拉長退避、丟掉已經逾時的排隊請求、先預熱快取再恢復全部流量。預防的做法是在平常就限制持續效應：重試預算、有上限的佇列與逾時、依優先等級卸除。負載超過容量時服務呈現的各種結果與各自的判斷標準，見 <a href="/blog/backend/09-performance-capacity/overload-outcomes/" data-link-title="9.16 負載超過容量時服務的表現：變慢、請求失敗、無回應、程序終止，尖峰過去或應對切換之後仍持續的停在失效、擺盪與第二波湧入，以及應對選出的降級回應，各自的判斷標準、成因、應對與看得到它的監控層" data-link-desc="負載超過容量（過載）時，服務呈現的結果分成變慢、請求失敗（抵達後被拒與根本沒有抵達）、無回應、程序終止，以及尖峰過去、依賴恢復或應對切換之後仍持續的停在失效、擺盪與第二波湧入；每一種的判斷標準、通常的成因與應對，哪一種先出現由先耗盡的資源與等待是否設有上限決定，影響範圍怎麼隨時間擴大，服務端監控的各層各自看得到哪一種，降級回應怎麼計數，過載在資料上造成的重複寫入、接受後遺失與讀到舊資料，以及描述一次過載要寫出的項目">9.16 負載超過容量時服務的表現</a>；卸除與准入的做法見 <a href="/blog/backend/09-performance-capacity/unplanned-traffic-surge/" data-link-title="9.15 無預警瞬時大流量：流量來源辨識、擴展緩衝、請求優先等級、准入控制、體積型攻擊與退路" data-link-desc="沒有準備窗口的瞬時大流量（突發流量、流量暴增）怎麼承接：依流量形狀分辨真人需求、應用層的濫用流量、體積型攻擊（DDoS），自動擴展的反應時間換算成緩衝，入口標記請求優先等級與負載卸除的順序，等候室與事前註冊這類准入控制，把體積型攻擊交給更大的邊緣網路，以及擴展跟不上時的退路">9.15 無預警瞬時大流量</a>。</p>
]]></content:encoded></item></channel></rss>