這篇整理沿著一個會改變狀態的請求(購票、下單、委託、建立短網址)從用戶端走到資料庫的路徑,分析負載時共用的概念。讀取請求(短網址的轉址、商品頁、座位圖上的剩餘數量)本身的快取設計不在本篇的範圍內,只有保護為了寫入路徑而把讀取移到邊緣的那一段(座位圖的剩餘數量由 CDN 合併回源)在範圍內。概念以 Backend 範例系統 的短網址、售票搶購、電商 checkout 與交易撮合舉例;完整走過一條路徑的案例是 9.0.1 售票搶購的負載路徑 與 9.0.2 交易撮合的負載路徑。負載超過容量之後服務呈現哪一種結果(變慢、請求失敗、無回應、程序終止、停在失效),在 9.16 負載超過容量時服務的表現。

一次一筆的處理與它的互斥範圍

一個服務的需求裡,通常有一件事必須一次只處理一筆(業界常稱為 serialization point):同一張票不能同時賣給兩個人,同一筆訂單不能同時被付款與取消,同一個交易對的委託要依單一順序撮合,同一個短代碼不能同時分配給兩個長網址。這件事要靠鎖、單一執行緒或條件更新依序處理,所以不論前面有多少台伺服器,抵達這件處理的請求都要逐一通過,負載最後集中在這件處理上。

決定有多少請求要依序等候的,是互斥範圍:哪些請求彼此必須輪流處理。互斥範圍有三個來源:需求、資料的表示方式,以及把多個範圍放進同一個依序處理的執行者。

售票的需求要互斥的只是同一張票;全場庫存若寫成一個計數欄,所有購票請求都更新資料表的同一列,整場的請求依序等候同一把鎖,這種情形叫熱點列(hot row)。改成每張票一列,而且認領的寫法讓每個請求各自挑到不同的列(例如跳過別的交易已經鎖住的列,寫法在 9.0.1 售票搶購的負載路徑 的〈扣庫存:資料庫逐張認領與加入 Redis 閘門的條件〉),只有搶同一張票(同一個座位)的請求才需要輪流;認領時若每個請求都挑「第一張還沒賣出的票」,所有請求仍會撞在同一列上。表示方式造成的互斥範圍,在實作上表現為鎖的粒度(lock granularity),可以靠改寫法縮小。熱點列的排隊機制與分散寫入的做法在 1.18 高頻計數的寫入與彙總。

撮合的需求要互斥的是整本委託簿(同一個交易對上還沒成交的買賣委託),因為一筆委託是否成交取決於簿上所有其他委託的價格與時間;這是需求要求的下限,換表示方式也縮不小。撮合引擎以單一執行緒依序處理、沒有用鎖,而同一條執行緒或同一份依序日誌裡放的交易對越多,互斥範圍就越大:LMAX 把所有市場放在一條執行緒上,這時範圍是整個交易平台;依交易對分開撮合引擎,是把範圍縮回一本簿(Single Writer Model;兩種做法在 9.0.2 交易撮合的負載路徑 的〈撮合引擎:單一寫入者與記憶體內的委託簿〉)。

需求本身可以協商時,互斥範圍也能縮小。廣告平台允許預算小幅超支,就可以把一個廣告活動的預算預先切成多份額度、分給各台伺服器各自扣,定期同步,互斥範圍從整個活動縮到一台伺服器上的那一份,代價是超支有上限而不是零。撮合沒有這種空間:不論撮合規則是價格與時間優先,還是依比例分配,一筆委託的成交都取決於整本簿,所以在一本簿之內,撮合能做的只有讓依序處理的那一條路徑變快。

能在一次一筆的處理之前回應的請求

需要經過一次一筆處理(必須依序、不能並行處理的那件事)的請求(購票、下單、委託、建立短網址)裡,有三類在進入那件處理之前就能回應:格式、權限或帳戶狀態不符的請求(例如餘額不足)在入口就能拒絕,結果已經確定的請求可以直接告訴它結果(票賣完之後的購票請求),以及重送的請求可以回傳上一次的結果。同一段時間內,這三類請求的筆數在所有需要經過一次一筆處理的請求筆數裡所佔的比例(分母包含這三類本身),下文稱可提早回應的比例;這個比例越高,前面的元件擋下的越多,一次一筆的處理承受的越少。

提早回應的元件讀的是一次一筆的處理所持有狀態的副本(售票的剩餘數量、帳戶餘額),副本會落後。一旦確定就不再改變的結果可以安全地提早回應;會回頭的狀態(帳戶餘額、付款期限到期時被放回的票),提早回應的只是暫時的結果,而副本落後造成的錯有兩個方向,補救各不相同。副本把請求判成不行(已售完、餘額不足),可能是錯拒,所以要讓使用者能再試;副本把請求判成可以而放行,副本可能已經過時,所以通過提早檢查的請求一律要在一次一筆的處理裡再檢查一次,否則餘額會透支、票會超賣。

可提早回應的比例由需求的性質決定。售票的成功請求總數有上限(票數),票數之外的購票請求結果在售完那一刻就確定(直到付款期限到期、有票被放回為止),所以比例高;交易撮合沒有這種上限,一筆委託的結果要撮合當下才知道,只有無效的委託能提早回應,比例低。成功請求的總數有上限,不保證比例高:拍賣的成功請求總數是一(只有一個得標者),而結標之前每一次出價都要處理、誰得標事先不知道。

比例高的系統,負載保護的重心在一次一筆的處理之前:排隊、提早回應,把請求擋在資料庫前面。比例低時,比對同一個互斥範圍每秒到達的請求數與依序處理一個範圍的處理量。到達的請求數低於處理量時(checkout 的每一筆訂單、一般帳戶的轉帳),一次一筆的處理不是瓶頸,負載先耗盡的是別的資源(連線數、資料庫的總寫入量),見〈每一段保護把負載轉到哪裡〉。到達的請求數高於處理量、而範圍能靠改表示方式縮小時(熱門商家的收款帳戶改成只新增入帳紀錄),回到〈一次一筆的處理與它的互斥範圍〉的熱點列。範圍縮不小時(一本委託簿、結標前的一件拍品),同一個範圍的全部請求都要通過同一個依序處理的執行者,重心才落在那件處理本身:記憶體內的狀態、單一寫入者、處理路徑上沒有資料庫,以及事先配置的容量。

多出來的請求:排隊、拒絕或請使用者重新確認

扣掉能提早回應的請求,抵達一次一筆的處理(例如扣庫存或撮合)的請求仍可能超過它的處理量,多出來的那些要決定怎麼處置,而決定的依據是延遲是否改變請求的價值。售票在不劃位、固定票價時,排隊幾分鐘之後買到的仍是同一種票、同一個價格,多出來的請求可以排隊(Admission Control),放行的速率設在後端實測的處理量。連續撮合的交易所,一筆委託晚一秒送進委託簿,市場已經變了,市價單以偏離預期的價格成交,排隊讓請求失去下單時的意義。所以委託到達時以佇列長度除以處理速率預估要等多久,預估超過委託延遲目標就在接受之前明確拒絕(Load Shedding;9.0.2 的閘道以佇列長度的上限做這件事)。

延遲改變價值、所以要拒絕,還有一個前提:請求在處理時照送出當下的條件直接執行,不再經使用者確認。處理之前會讓使用者重新確認的請求,延遲的代價只是多一次確認:checkout 的價格在使用者排隊期間變了,下單時以新價格重算並請使用者確認,訂單照樣成立。市場的規則也會改變這個判斷:集合競價是把一段時間內的委託集中起來、以單一價格一次撮合的規則,以固定間隔做集合競價的市場,等到下一次撮合本來就是規則的一部分。

被拒絕的用戶端會重送時,負載以重送的形式回來:用戶端是程式時往往立刻重送,重送的請求疊在原本的需求上,讓進來的請求率高於真實的需求,這是 Retry Storm。所以拒絕的回應要帶重試的時機(Retry-After 這類欄位,或約定加上隨機延遲的退避間隔,見 Jitter),排隊的回應要讓使用者看得到自己的位置,拒絕與排隊的回應都在讓用戶端不要用重送去確認結果。逾時就作廢的用戶端(廣告的即時競價)不會重送,拒絕本身就是結果。

每一段保護把負載轉到哪裡

把請求擋下或延後的機制(邊緣快取合併回源、速率限制、排隊放行、Redis 閘門、機器人偵測),本篇統稱保護,實作它的程式或服務是保護元件。每一段保護擋下或延後某一類請求(可以提早回應的、還沒輪到的),被擋下的請求並沒有消失,改由保護元件承受,或轉給保護後面的下一個元件,承受的那一方會先耗盡某種資源;保護也會讓下游出現新的上限,或讓同一個請求被處理不只一次(去重的做法在〈佇列、使用者重按與回呼帶來的重送與去重〉)。邊緣快取合併回源與機器人偵測把負載留在邊緣,速率限制、排隊放行與 Redis 閘門把它轉到保護元件自己的狀態與連到它的連線上,排隊放行另外帶來位置查詢;保護把請求順利送到下游之後,負載轉到最慢的接收端與第三方業者的配額。

保護元件自己的狀態。排隊的順序、已發出的進入資格與速率限制的計數,要存在保護元件的程序以外,重啟之後才接得下去(〈保護元件故障時的輸出:停止放行、全部放行、保守的固定上限與預設值〉)。每一次入列、位置查詢與計數都是對那個儲存的一次寫入或讀取,而它承受的是全部到達的請求,不只放行之後的請求,所以它的容量要以到達的請求率規劃(Rate Limit 的計數儲存是同一件事)。

連線數。連到一個元件的連線數,等於應用程式這一側同時在執行的單位數(程序、執行緒或協程)乘以每個單位各開的連線數,或在用連線池的執行環境裡,等於每個程序的連線池大小乘以程序數。把扣庫存或計數從資料庫移到 Redis 這類記憶體儲存,資料庫的鎖等待少了,連到新儲存的連線數卻成為新的上限。PHP-FPM 這類每個請求佔一個 worker 的執行環境,連線數的上限是全部主機的 worker 總數(每台的 pm.max_children 乘以主機數),為了尖峰把主機擴到幾十台時,總數可能超過 Redis 預設的 maxclients(一萬;Redis 行程的檔案描述元上限不夠時,實際的上限是那個上限減 32,可能低很多)。持久連線(phpredis 的 pconnect)讓每個 worker 長期保留一條連線,閒置的 worker 也佔著一條,省下的是每次請求的握手,連線數不會變少;要讓連線數少於執行單位數,要在中間放一層連線代理(例如 twemproxy,它對後端維持少數的持久連線)。沒有持久連線時,每次新開連線要付 TCP 握手的成本,用完的連線在主動關閉的那一端(通常是應用程式這一端)停留在 TIME_WAIT 狀態,佔住應用程式主機的本機連接埠。資料庫連線的同一件事在 9.14 連線池放大解法。

輪詢。排隊把請求從資料庫移開,排隊中的使用者卻會反覆查自己的位置,每秒的查詢數是等候人數除以查詢的間隔(9.0.1 的假設:十萬人每五秒一次,每秒兩萬個),落在排隊服務上。讓每一次查詢便宜(位置資訊放在記憶體或邊緣),讓查詢變少(由伺服器在回應裡指定下一次查詢的間隔),或改成伺服器推送;改成推送時,等候中的每一條連線都是要規劃的容量。

下游的接收端。一個元件若把輸出直接送給每一個接收端、等接收端收下才處理下一筆,最慢的那一個接收端決定了整個元件的處理速度,負載以 Backpressure 的形式傳回來。把輸出先寫進一份日誌,由另外的發布服務送給各個接收端,接收端跟不上時,延遲的只是送到它那裡的資料。

外部的配額。第三方業者(金流、簡訊、雲端 API)對每個商家的速率限制會成為下一個上限。這個上限由業者設定,所以只能事先查到數字、在尖峰之前向業者申請提高;超過時業者回錯誤(例如 HTTP 429)或變慢,所以要在自己這一側排隊,以不超過配額的速率送出;呼叫業者的請求若和其他流量共用同一個連線池,業者變慢時等待中的請求會佔住其他請求也要用的連線,所以呼叫業者用獨立的連線池與逾時(Bulkhead)。

佇列、使用者重按與回呼帶來的重送與去重

把請求放進佇列延後處理,會讓同一個請求被處理不只一次。佇列在消費端以確認(ack)模式使用時保證的是至少送達一次,消費端處理到一半當機、佇列重送,同一個請求會被處理兩次。請求在進入佇列之前要有一個識別碼(Idempotency Key),處理那一段以它辨認重送:辨認重送與狀態改變寫在同一個交易裡(以識別碼當唯一鍵寫入結果),識別碼至少保存到用戶端不會再重送為止;先查一個快取、再寫資料庫的寫法,在兩步之間當機或兩個重送同時到達時仍會重複。識別碼若在使用者那一次操作時就由用戶端產生(表單載入時產生、重按時沿用),使用者重按也辨認得出;識別碼在入列時才由伺服器產生時,使用者重按產生的是新的請求,佇列分不出來,要以業務規則限制(例如一個帳號同時只能有一筆在排隊,在進入佇列時檢查)。排程服務與第三方的回呼同樣可能把同一件事送兩次,條件更新(UPDATE ... WHERE status = 'pending')讓第二次執行更新零列;對外的動作(寄信、出貨、呼叫退款 API)要在更新了一列之後才做,寫在更新之前、或不看更新了幾列就執行的動作,仍會做兩次。只靠「之後才做」還會漏做:更新提交之後、對外動作完成之前當機,重送時更新零列,動作就被跳過。所以待執行的對外動作和狀態改變寫在同一個交易裡,由另一個程序讀出、至少送一次(Outbox Pattern),呼叫業者時帶業者的冪等鍵(例如 Stripe 的 Idempotency-Key),讓至少一次的送出在業者那一側只生效一次;業者不支援冪等鍵時,以對帳找出重複或漏做的動作。

保護元件故障時的輸出:停止放行、全部放行、保守的固定上限與預設值

保護元件故障時的處置有三種:停止放行(fail closed)、全部放行(fail open),以及介於兩者之間、退回一個保守的固定上限放行。選擇時先分出保護擋的是什麼。權限檢查、餘額檢查、詐欺評分與法規要求的檢查擋的是不該進來的請求,放過去就違反不能讓步的需求,而停止放行的代價是整個服務停擺,所以這一類再依它在檢查順序裡的位置分開處理。之後沒有其他檢查的權限與法規檢查,故障時停止放行,全部放行的結果是越權或跳過法規要求的檢查。後面還有權威檢查的前置檢查(閘道上的餘額檢查,最終檢查在一次一筆的處理裡),故障時可以放行給權威檢查。權限檢查可以先用快取的最近決定繼續放行,代價是權限被撤銷之後,要等快取到期才生效。詐欺評分漏掉的損失有上限、事後可以經由拒付追回部分金額,故障時放行多少是業務決定的損失上限,例如低金額放行、高金額暫停、事後審查。

只用來擋流量的保護(排隊、速率限制、閘門),依下游在故障那一刻要承受的流量選。保護的資源一定處理不了全部請求時(售票的排隊,保護的是扣庫存那一段),全部放行等於拿掉保護,全部請求直接打到必須依序處理的扣庫存,選停止放行,使用者看到的是暫停。保護的資源處理得了全部合法請求、保護只用來擋濫用時(邊緣依來源位址的速率限制),停止放行等於整個服務停擺,選全部放行。保護的資源處理得了全部合法請求、原本會選全部放行的那一類保護,常常正是因為它要擋的那股負載而故障(速率限制的計數儲存在攻擊流量下過載),這時全部放行是在負載最大的時候拿掉保護,所以退回保守的固定上限,它取代的是全部放行,不取代排隊這類該停止放行的保護:共用的限流服務故障時,呼叫它的每一個 API 實例改以本機的計數、每秒放行固定的請求數,這個數字是下游實測的總處理量除以實例數,全部實例加起來不超過下游處理得了的量;實例數會因自動擴充而變時,以最大實例數計算,或在故障期間停止擴充。代價有三個:每個實例只有一個總計數,一個重度呼叫者就能吃光一個實例的額度,所以本機計數也依呼叫者分開;負載平衡不均時有的實例拒絕、有的閒著,實際放行量低於下游處理得了的量;實際實例數遠少於最大實例數時,放行量只剩一小部分,接近停止放行。這個折衷是從機制推出的,只用在擋流量的保護上;擋不該進來的請求的保護照上一段的分法處理。停止放行之後要能在恢復後照原本的順序繼續,保護元件的狀態(排隊的順序、已發出的資格)要存在它的程序以外,只存在記憶體的狀態在重啟時就丟了。

選了全部放行之後,還要確認下游讀到的值代表放行:保護元件故障時,保護元件自己或呼叫它的那一方的備援邏輯會給下游一個值,這個值必須是下游判成放行的那個值。Cloudflare 2025 年 11 月 18 日的事故報告 寫到,機器人偵測讀到一份因為重複的列而變大的設定檔,舊版代理引擎上的所有流量都拿到零分的機器人分數。那套評分的正常範圍是 1 到 99,分數越低越像機器人,零分代表沒有評估(Cloudflare 的 bot score 文件);客戶的規則寫成低於某個分數就擋,代表沒有評估的零分落在擋的那一側,設了擋機器人規則的客戶因此擋掉了大量的真實流量。保護元件的故障也不只停掉一種,讀到壞掉的規則或設定檔、輸出錯的判斷,是同樣要事先決定處置的情形;那時保護元件還在運作,監控上每一層都顯示正常,這一類異常要靠 4.0.1 監控鏈事前偵測不到的異常 裡的業務結果發現。保護元件是購買的服務(業者提供的等候室、WAF)時,故障時放行還是不放行由業者的行為決定,要事先查清楚業者的預設,以及它能改成什麼。

依賴變慢、部分失效與恢復時的第二波湧入

保護元件與它保護的依賴,失效時也不只停掉或輸出錯的值。依賴變慢時,每一個等待中的請求在逾時之前都佔著呼叫方的 worker 與連線,所以變慢通常比停掉更早耗盡呼叫方,見 9.16 負載超過容量時服務的表現 的〈無回應:程序還在,請求等到逾時也沒有結果〉。部分失效時只有一部分副本連不到依賴(某一個可用區的網路出口壞了),讓那幾個副本下線是對的,而剩下的副本要承接全部流量,容量不夠時接著變慢(Partial Failure;Kubernetes 上的例子在 4.0.3 以監控鏈盤點 Kubernetes 上的電商 checkout 的〈付款服務的 readiness 納入金流業者時的後果〉)。恢復時的第二波湧入:依賴恢復的那一刻,故障期間累積的請求與重試一起到達,讓剛恢復的依賴再過載一次,這是 9.16〈尖峰過去或應對切換之後仍持續的情形:停在失效、擺盪與第二波湧入〉的第二波湧入。第二波湧入本身是一次尖峰,累積的請求送完就結束;重試若把過載維持下去,它就轉成另一種情形:負載退去之後服務仍停在失效(Metastable Failure)。委託簿這種狀態會隨時間失去價值的系統,恢復時還要決定處理的順序,交易所停機後先只收取消、再以集合競價重開的流程在 9.0.2 的〈撮合引擎:單一寫入者與記憶體內的委託簿〉。

延伸閱讀:售票搶購與交易撮合的負載路徑

9.0.1 售票搶購的負載路徑 從全場庫存寫成一個計數欄時熱點列與連線池的耗盡順序起算,走過 CDN 合併回源、排隊放行、資料庫逐張認領與付款期限,示範互斥範圍怎麼靠改表示方式從整場縮到一張票、可提早回應的比例高時排隊服務怎麼提早告訴後面的人買不到,以及什麼條件下才值得在扣庫存前面加一道 Redis 閘門。9.0.2 交易撮合的負載路徑 走過閘道、依序日誌、撮合引擎與行情發布,示範互斥範圍沒辦法縮小、可提早回應的比例低時,負載保護的重心怎麼移到處理路徑本身與事先配置的容量,以及延遲改變請求的價值時,過載與停機後的處置為什麼與售票相反。