vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

支付系統最怕的不是某個 service 掛掉,而是它掛掉的方式會牽連到別人。American Express 的解法不是把每個元件都做到不會壞,而是先把整個平台切成一格一格的失敗域——讓壞掉的那一格,沒有能力把自己的故障傳出邊界。

AmEx 的 cell-based 架構——把支付故障關進失敗域

American Express 把核心支付平台改寫成 cell-based 架構。一個 cell 是「能自己處理 payment 的可獨立部署單位,自帶 microservices、databases 與其他元件」,同時也是一個 single failure domain——按文章的說法,「if one cell experiences issues, it doesn't cascade the failure beyond the cell boundary」。這篇拆的不是某個演算法,而是一套邊界紀律:資料怎麼分類擺放、跨 cell 的流量為什麼只能走一條路、in-flight 交易壞掉時要 restart 而不是 resume。每一個決定背後都對著同一個約束——這個平台「processes live payment transactions that require high availability, low latency, and predictable performance」。

讀這套設計最好的方式,是先把它要對抗的故障模型擺清楚。一個傳統的共享平台裡,所有交易跑在同一組 service 與同一份資料上,任何一個元件退化都會沿著共享路徑往外傳:一個慢掉的 database 拖慢所有讀它的 service,一個塞住的 queue 讓上游全部排隊。故障在這種拓樸裡是會傳染的,因為元件彼此之間有太多隱形的共用點。cell-based 架構的賭注是把這些共用點全部切斷——每個 cell 自帶自己的 service 與資料,彼此之間「do not have the ability to communicate at all」。能傳染的前提是有路可傳,把路拿掉,故障就只能留在原地。文章把這個性質講得很乾脆——「with each cell being independent, if one cell experiences issues, it doesn't impact the others」。獨立不是一句口號,它是一條可以驗證的性質:要嘛 cell 之間有共享的東西、故障能傳,要嘛沒有、傳不過去。整套設計後面所有的細節,都是在反覆確保「沒有共享的東西」這件事成立。

先看這套架構真正想買到的東西:把故障的影響範圍框住。下面這個模擬讓你直接觀察 blast radius。交易像粒子一樣不斷流進一排 cell,按一下任一格注入故障——在 cell 模型裡,壞掉那格的交易會被 router 改送到健康的 cell,紅色只染一格;切到 monolith 模型,同一個故障會沿著共享元件擴散到全部。文章對這件事的描述很直接——文章說「the primary benefit of cell-based architecture is reducing the blast radius of failures」。

健康 cell 5 / 5
點任一 cell 注入故障。cell 模型下 router 把該格交易改送健康 cell,紅色止於一格;monolith 模型下故障沿共享元件擴散到全部。粒子=live transaction。

點任一 cell 注入故障

cell 模型注入故障只染壞掉那格、交易改送健康 cell;monolith 則沿共享元件擴散到全部。這是切成 failure domain 的回報。

把交易留在 cell 內、不讓故障外溢,聽起來像把問題藏進更小的盒子。真正的難處在後面:交易需要資料,而資料天生想集中。一筆支付交易要驗證、要算風險、要查幣別與商戶分類,這些資料如果只有一份權威副本放在中央,那每個 cell 處理交易時都得回去讀它——這條讀取路徑就是一個跨所有 cell 的共享依賴,中央那份資料一退化,全部 cell 一起卡住,blast radius 又回來了。所以資料怎麼擺,決定了 cell 是不是真的獨立。下面四個小節順著一筆交易撞到的順序走——資料怎麼到 cell 手上、cell 之間憑什麼互不通訊、流量怎麼搬、交易在半路壞掉時怎麼辦。每一段都是同一個邊界紀律的不同面向。

資料分三類,各走各的路

cell 要能自己處理交易,就得在本地拿到所有需要的資料。但不是所有資料都長一樣。American Express 把資料按變動頻率切成三類,三類各有一條路徑——這是整套架構最關鍵的設計判斷,因為它決定了哪些資料可以複製、哪些必須靠路由送對地方。變動頻率之所以是切分的軸,是因為它直接對應到「複製能不能保證一致」這個問題:變得越慢的資料,複製越安全;變得越快的資料,複製越追不上。把資料按這條軸攤開,每一類自然落到它唯一合理的策略上。

點任一類資料看它走哪條路徑 · 3 類資料

三類資料、三條路徑——複製、路由、與離開 hot path 的 async 同步

三類資料、三條路徑——complexity 都被推到 hot path 之外 static / semi-static · replicate 幣別、MCC——事先預灌每個 cell,交易處理時不再讀中央 system of record 每 cell 一份 dynamic(每筆會變)· deterministic route 複製來不及——把交易送到資料已經在的 cell,而不是把資料追著交易跑 送對 cell failover · async message replication 跨 cell 同步但放在 transaction path 之外——no in-flight transaction waits for replication 離開 hot path

點任一類資料

static / semi-static · 複製到每個 cell

「For static or semi-static data like currency rates and merchant category codes, we replicate that data to each cell.」幣別與 MCC 變動極慢,每個 cell 各留一份完整副本沒有一致性難題。

關鍵在它取代了什麼——「rather than relying on a fall-through read to a centralized system of record during transaction processing, we pre-populate this data in each cell ahead of time」。交易路徑上少一次跨域讀取,就少一個可以拖垮全平台的共享依賴。

dynamic · deterministic routing

每筆交易都會變的資料沒法靠複製——「data replication may not be fast enough to ensure that every cell has the right data at the right time」。文章在這裡用的是 may,是個誠實的 hedge,不是斷言複製一定來不及。

解法是反過來——不把資料追著交易跑,而是「route transactions to the cell where the right data is already available」。router「understands just enough of the payment specifications to make routing decisions based on the transaction data」,所以它不是 dumb 的 load balancer。

failover · async 且離開 hot path

failover 資料仍要跨 cell 同步,但刻意 async——「that replication happens asynchronously outside the transaction path, so it doesn't impact latency or availability」。

代價是 cell 之間會有短暫不一致;補法是路由——「no in-flight transaction waits for replication to complete; if the latest state is required, the Global Transaction Router sends the transaction to the cell where that data is already authoritative or available」。要最新狀態就把交易送去資料權威的那個 cell,而不是等複製追上。

三條路徑的共通點是把 complexity 推到 transaction path 之外。複製在背景進行、failover 同步是 async、需要最新狀態時靠路由補位——交易本身在 hot path 上看到的永遠是「資料已經在本地」。這一點值得對著工程師的直覺多講一句:常見的反射動作是「資料不夠新就回去讀權威來源」,也就是 fall-through read。文章刻意不這麼做——「rather than relying on a fall-through read to a centralized system of record during transaction processing, we pre-populate this data in each cell ahead of time」。fall-through read 在順風時看不出問題,它的代價只在中央那份資料退化時才現形:那一刻所有 cell 的交易同時卡在同一次讀取上。把這條讀取從 hot path 移走,等於提前把一個會同時打中所有 cell 的單點拆掉。

這也回答了一個自然的疑問:既然每個 cell 都要備齊資料,靜態資料不就被複製了 N 份嗎?是的——合理的推測是,複製把一份資料變成 N 份,多佔空間、也多一套同步機制要維護;換來的是每個 cell 在 hot path 上自給自足,不必為了讀一個幣率而跨出邊界。對 static 與 semi-static 資料這筆帳很好算——它們變得夠慢,N 份副本之間的不一致窗口幾乎可以忽略。真正不能這樣處理的是 dynamic 資料,所以它走的是路由而非複製:與其把每筆交易都會變的資料即時推到所有 cell,不如「route transactions to the cell where the right data is already available」,把交易送到資料天生就在的那一格。複製是把資料搬向交易,路由是把交易搬向資料——當資料變得太快、搬不動,就反過來搬交易。

Global Transaction Router:唯一的跨 cell 通道

cell 要互不影響,最徹底的做法是讓它們根本沒有能力互相影響。American Express 的紀律近乎極端——cell 之間「do not have the ability to communicate at all」,只有 Global Transaction Router 能跨 cell。所有跨域流量都收斂到這一個元件:「all cross-cell traffic is funneled through the Global Transaction Router, which preserves strict cell boundaries」。這個 router 不是事後補上的流量分配器,它是整套邊界紀律的承重牆——文章把它的職責寫得很白:「the Global Transaction Router is responsible for managing connectivity and routing transactions to the appropriate cell」。連線管理與路由決策都收在它身上,cell 自己不需要、也不被允許知道其他 cell 的存在。

這條規則沒有例外。交易要進 cell 必須經 router;一個 cell 處理不了、需要轉到別的 cell,也必須回到 router——「transactions must enter a cell through the Global Transaction Router; if a cell cannot process a transaction and that transaction needs to be rerouted to another cell, it must also go through the Global Transaction Router」。沒有 cell 對 cell 的直連捷徑。為什麼要這麼硬?因為任何一條直連都是一條潛在的強依賴:cell A 直接呼叫 cell B,A 的健康就綁上了 B 的健康,blast radius 又開始外溢。把通訊全部集中到 router,就是用一個明確的瓶頸換掉無數條隱形的耦合。這對任何做過微服務的人都不陌生——service mesh 裡最難治的就是服務之間長出來的東西調用網,A 呼 B、B 呼 C、C 又回呼 A,故障沿著調用圖傳播。Amex 的做法是直接禁止這張圖存在:跨 cell 只有一跳,而且那一跳永遠是 router。

值得留意的是這個 router 不是 dumb 的 load balancer。要做 deterministic routing、要在 reroute 時把交易送到資料已經權威的那個 cell,router 必須看得懂交易內容——文章說它「understands just enough of the payment specifications to make routing decisions based on the transaction data」。just enough 這個措辭是有重量的:router 懂的剛好夠做路由決策,不多也不少。懂太少就退化成隨機分流,dynamic 資料的「送到資料已經在的 cell」這個保證就破了;懂太多,router 自己就變成一個塞滿支付邏輯的胖元件,反而成了新的單點。把 router 的知識量壓在「剛好夠路由」這條線上,是這個設計的一個微妙平衡。

cell 之間沒有直連——唯一的跨 cell 路徑是 router Global Transaction Router cell 1 cell 2 cell 3 cell 4 cell↔cell 直連:不存在 虛線=被禁止的捷徑(會長出強依賴) 代價:偶爾要重複實作服務——但換來 cell 獨立性與更少 cross-cell network hop
實線是被允許的路徑(一律經 router),虛線是被禁止的 cell 對 cell 直連。把通訊集中換掉的,是無數條會讓 blast radius 外溢的隱形耦合。

這條紀律有它自己的帳要付。文章直言:「this enforcement occasionally results in duplicated services where shared implementations might otherwise seem simpler」——禁止直連有時逼得同一個服務在多個 cell 各做一份,明明共用一份看起來更簡單。但他們認為值得:「it preserves cell independence and improves latency by reducing cross-cell network hops」。重複實作換來的不只是獨立性,還有延遲——少一次跨域 network hop,本身就是一筆 latency。對支付這種對 predictable performance 有硬需求的系統,這筆帳算得過來。這裡有個容易被忽略的取捨方向:多數架構討論把「共用一份實作」當成預設的好,DRY 是反射;但在 cell 模型裡,共用一份實作意味著一個跨 cell 的共享元件,而那正是要消滅的東西。寧可重複,也不要共享——這是把 blast radius 放在 DRY 之上的判斷。

按百分比搬流量:維護與故障切換不必全有全無

router 既然是所有流量的咽喉,它就能做一件 monolith 做不到的事——按百分比搬移流量。文章列了三個用途:「gradually drain a cell for maintenance, validate a recovering cell under partial load, or respond more safely during incidents」。下面這個滑桿就是這個能力:把某個 cell 的流量佔比從 100% 一路拉到 0%,看 router 怎麼把缺口補到其他 cell 上。

拖滑桿把 cell 3 的流量佔比從 100% 漸進降到 0% · drain / 切換

100%
router 把 cell 3 騰出的流量重分配到 cell 1、2、4 cell 1 25% cell 2 25% cell 3 25% cell 4 25% 每根長條=該 cell 實際承接的交易比例

cell 3 承接 100% 的常態份額——四個 cell 均分,沒有任何 drain。這是穩態。

滑桿是 cell 3 的流量佔比。100% 是穩態均分;往下拖就是 drain——維護時拉到 0%、驗證 recovery 時停在低百分比、incident 時平滑搬離。其他 cell 自動接走缺口。

百分比搬移之所以重要,是因為它把「上線/下線」從一個離散的開關變成連續的旋鈕。一個 recovery 中的 cell 不必賭上 100% 流量證明自己健康——先給它 5%,觀察,再慢慢加。維護一個 cell 不必先把它打成 0 再祈禱沒人受影響——慢慢 drain,搬空了再動手。incident 當下不必在「整個 cell 留著」跟「整個 cell 砍掉」之間二選一。這些都是 router 作為單一控制點才換得到的操作彈性。把這件事放回前面那條邊界紀律看會更清楚:正因為所有流量都過 router、cell 之間沒有直連,router 才有資格、也有能力對流量做連續的調節。如果 cell 彼此直連,流量的分配散落在無數條連線上,根本沒有一個地方可以一次把某個 cell 的佔比調成 5%。集中通訊付出的瓶頸成本,在這裡換回了一個別處拿不到的控制面。

半路壞掉:restart 而非 resume,加上 point of no return

最難的情況是交易處理到一半、它所在的 cell 出事了。直覺會想「把處理到一半的狀態接力到另一個 cell 繼續跑」——American Express 明確不這麼做:「we do not attempt to resume partially processed transactions across cells. Instead, we restart transaction processing in another cell with the original transaction data」。不接力半成品,而是拿原始交易資料,在另一個 cell 從頭跑一遍。這個選擇背後是一個很務實的判斷:resume 要求你把「處理到一半的狀態」完整、正確地搬到另一個 cell,而那份中間狀態恰恰是最難跨邊界搬的東西——它散落在記憶體、在連線、在剛寫一半的紀錄裡。restart 把問題簡化成只搬一樣東西:原始交易資料,那是個乾淨、完整、本來就在的輸入。用一個冪等的重跑,換掉一套脆弱的狀態搬遷。

restart 比 resume 乾淨,但它帶來一個顯而易見的風險:同一筆交易跑了兩次,會不會扣兩次款?這裡就需要兩個機制接手。第一個是 idempotency——「each transaction carries a unique transaction identifier that remains consistent across retries and reroutes」,這個識別碼跨 retry 與 reroute 都不變,「downstream systems use these identifiers to detect and suppress duplicate requests」,下游靠它偵測並壓掉重複。下面這個小工具就是在演這件事:同一筆交易被 restart 幾次,下游看到的是同一個 id,於是只認第一次、其餘壓掉;換成幾筆不同交易,id 各不相同,就全部放行。

拉滑桿增加 restart 次數,切換「同一筆」與「不同筆」看下游放行幾次 · idempotency

4 次
下游用 transaction identifier 偵測並壓掉重複 restart / retry downstream

同一筆交易 restart 4 次,下游看到 4 個相同 id——只認第一次,其餘 3 次被壓掉,最終扣款 1 次。

左側是同一筆交易被 restart / retry 多次,右側是下游。同一個 unique transaction identifier 出現第二次起就被當成 duplicate 壓掉,扣款只發生一次;切到「各自不同」就會看到 id 全不同、全部放行。idempotency 壓的是重複,不是流量。

第二個機制是時間軸上的一條線:point of no return。restart 只在交易還沒「逸出」核心系統時才安全——「this restart is only safe while the transaction is still within the core payments ecosystem. Once a transaction has been sent to an external system (e.g., card issuer), we consider that a point of no return」。一旦送到外部系統(例如發卡行),就過了不歸點,之後不准 reroute。卡片授權的設計訣竅就藏在這裡:他們刻意把這條線推到流程末端——「card authorizations are structured so that the point of no return is toward the end of processing」。兩個機制分工很乾淨:idempotency 處理「重複已經發生了怎麼辦」,point of no return 處理「什麼時候根本不該再重」。前者是下游的防線,後者是上游的閘門,兩道一起才讓 restart 既安全又不會無限放大重複。

point of no return 刻意設在授權流程末端 可 reroute / restart 區——交易仍在 core payments ecosystem 內 接收 驗證 風險評估 準備送出 point of no return 不可 reroute 區 送發卡行 線越往右,可安全 restart 的範圍越大——絕大多數失敗都落在綠色區 過線後一旦送出外部系統,idempotency 識別碼負責壓掉任何重試造成的重複
授權流程把不歸點推到末端:前面接收、驗證、風險評估都還在核心系統內,可以安全 restart——只有最後送往發卡行那一步過線。線越靠右,能 reroute 的範圍越大。

把 point of no return 推到末端是一個值得記住的設計手法。流程裡越早的步驟越可逆——接收、驗證、風險評估都還在自家核心系統內,壞了就在別的 cell 從頭來。真正不可逆的只有「把授權請求交給發卡行」這一步,因為那一刻之後狀態的權威落在外部、你再也收不回來。把這唯一不可逆的動作擠到最後,等於讓「可安全 restart 的窗口」覆蓋掉絕大多數的處理時間。reroute 能救的失敗自然就多。這裡有個一般化的教訓,跨出支付也成立:任何分散式流程裡,真正昂貴的是那些把狀態交給你管不到的外部世界的步驟——一旦交出去,你的重試語意就從「再做一次」變成「可能做了兩次」。能不能安全重試,取決於不可逆的那一步排在多後面。把它往後排,就是把可重試的安全區往前撐大。

同一套「不要 block 交易」的紀律也延伸到周邊系統。logging 與 configuration 都被刻意設計成不能拖垮交易。日誌走 async logger 配 buffer truncation——「if the buffer is full, we drop logs instead of blocking transaction processing」,buffer 滿了寧可丟 log 也不擋交易。這是個刻意的取捨:在交易處理與可觀測性之間,他們選交易。多數系統的預設恰恰相反——logger 同步寫、寫不動就讓呼叫端等,於是一個慢掉的日誌後端能反過來掐住業務邏輯。把 logger 改成 async 加 truncation,等於宣告 log 是盡力而為的,掉幾筆 log 不該換來掉一筆交易。設定服務則保留 last known good——「we maintain an in-memory configuration that is updated asynchronously, so if the configuration service becomes unavailable, we can continue running with the last known configuration until it becomes available again」,config service 掛了就沿用記憶體裡最後一份設定撐到它恢復。這同樣是把一個外部依賴從 hot path 上拆下來:config service 在順風時更新記憶體裡那份設定,逆風時記憶體那份就是權威,交易完全不必知道 config service 還在不在。兩者是同一個判斷:可觀測性與可設定性都重要,但都不該擋在交易的關鍵路徑上。

這套架構不是免費的。文章自己把帳攤開:「the trade-off is that cell-based architecture often increases management overhead and architectural complexity, as it requires careful design to ensure that cells are truly independent and that data is appropriately localized」。要確保 cell 真的獨立、資料擺對位置,需要持續而謹慎的設計——加上前面提過的服務重複實作、靜態資料的多份複製。換句話說,你是用更高的營運與設計成本,買一個更小的 blast radius。值得把這句話對著自己的系統念一遍再決定:如果你的系統一個元件掛掉只是某個非關鍵功能短暫不可用,那 cell 化的營運成本多半不划算,加重試與降級就夠了。但如果你處理的是 live payment、是一掛就全國刷不了卡那種等級的 availability 底線,blast radius 的每一次收斂都直接對應到真實的金額與信任。對絕大多數系統這筆帳不划算;對一個每天處理 live payment、對 availability 有硬底線的平台,它划算。

What this enables:把平台切成一格格自給自足的失敗域、所有跨域流量收斂到單一 router、in-flight 失敗用 restart 加 idempotency 而非 resume——這套組合讓「某個元件壞掉」不再等於「平台受影響」,而是被框進一個你早就劃好邊界的 cell 裡。