vatt'ghern jaskier's ballads

星期二凌晨兩點,值班工程師的 pager 響了——兩千個 shard 裡的一個出了問題。過去這代表 15 分鐘人工排序、最長 3 小時逐步觸發與監看;Stripe 後來做的事,是把「下一步該做什麼」這個問題,直接變成一張圖上的最短路徑搜尋。

把兩千個資料庫節點的修復,變成一道最短路徑問題

期二凌晨兩點的這聲 pager,背後接的是一個不小的機群:Stripe 用來扛支付流量的 MongoDB,一共兩千個 shard,2025 年合計處理了 1.9 兆美元的金流,橫跨 40 多種不同的佈局,從成本優化到高可用性各有取捨。這種規模下,任何一個 shard 掉進不健康的狀態都不是小事——沒有自動化的話,值班工程師得先花 15 分鐘小心排出操作順序,再花最長 3 小時一項一項觸發、盯著結果。Stripe 這篇文章講的不是又一次「我們寫了個自動化腳本」的故事,而是把整個修復問題的骨架換掉:從一堆針對個別故障寫死的判斷邏輯,換成一張狀態圖與一個真正的最短路徑演算法。

把兩種做法擺在同一條時間軸上,落差看得很清楚。

人工修復 診斷+排序 15 分鐘 逐一觸發並監看每個操作 最長 3 小時 自動化 模擬 毫秒級 Temporal 執行,中斷可從原地恢復 視操作而定,可能仍需數小時

自動化省下的是人工診斷、排序、緊盯每一步的時間,不是讓 node rebuild 這類操作本身瞬間完成——兩段圖示的比例尺不同,重點是標出哪一段被壓縮掉了。

但「自動化」這三個字,在 V1 的年代其實只做到一半。確實有系統會嘗試修復,可是修復的判斷邏輯是逐案累積出來的:新故障組合出現,就再加一條規則、再排一次優先權,長期下來規則彼此打架的機率只會越疊越高。

V1 為什麼撐不住

Stripe 原本的自動修復系統(文章稱為 V1)是一堆各自獨立的規則 plugin:每個 plugin 負責偵測並修復一種特定的誤設定,彼此之間再用優先權排出執行順序。問題是,優先權跟前提條件是兩件事,沒人保證它們互相相容。文章舉的例子很直接:「fix votes」跟「fix priorities」這兩個 plugin 的優先權都比「node down」高,但它們的前提是『沒有節點下線』,偏偏此刻正好有節點下線。三個 plugin 排在那裡,誰都動不了:該先處理的 node down 因為優先權低被排在後面,優先權高的兩個又因為前提不成立跑不起來。

這種失效模式不是 Stripe 獨有的巧合。任何用「優先權排序+前提條件」組合出來的規則系統都有同樣的風險:優先權只回答「誰先跑」,前提條件回答「能不能跑」,兩者分屬不同的作者、不同的時間點寫下,沒有人天生會去檢查兩者是否互相打架。規則越多,這種組合爆炸的機率就越高,這也是為什麼後來的解法不是「再加一條規則去處理這個特例」,而是把整個判斷邏輯換成一個不會有「前提」跟「優先權」分裂問題的模型。

還有另一種失效模式跟優先權無關:workflow 被取消。人工介入、部署更新、逾時,都可能讓一個正在跑的修復 workflow 被取消,而 V1 沒有為「取消到一半」設計任何收尾邏輯,shard 就這樣卡在一個系統從沒設想過的中間狀態。把這些問題疊加起來,六個月的觀察窗裡,control plane 因為誤設定觸發了 124 次 pager,另外 32 次是含有複合狀況的單節點故障;每次誤觸發卡住 index build 之類的關鍵維運操作,平均要等一小時才能繼續。拆開來看,124 次裡的誤設定代表系統判斷錯了,本來不需要人介入;32 次的複合單節點故障,才是真正需要人力排查的案例。兩種加起來,就是值班輪班裡最容易累積疲勞的那一塊。

V1:規則 plugin 各自為政 fix votes 優先權:高 fix priorities 優先權:高 node down 優先權:低 現況:一個節點下線 正是這兩個 plugin 的前提 前提不成立,跑不了 該先跑,卻排在後面 三個 plugin,沒有一個真的動得了 workflow 被取消 部署/逾時/人工介入 shard 卡在沒設想過的中間狀態 V1 沒有為「取消到一半」設計收尾邏輯

佈局越多,維運成本越貴

V1 真正撐不住規模的地方,是佈局耦合。Stripe 的機群橫跨 40 多種佈局,而每個規則 plugin 裡都寫死了針對特定佈局的安全檢查——文章的說法是「layout coupling was everywhere」。這代表每新增一種新佈局,得把所有 plugin、所有 workflow 的邏輯重新稽核一遍,確認沒有哪條規則因為換了拓樸而失效,這個過程大約要花一週。佈局種類線性成長,人工稽核的工作量就跟著線性成長,而且每個新佈局都要重新算一次帳,不會因為稽核過類似的佈局而變快。

拖曳滑桿看佈局數量如何影響兩種架構的維運成本 · 1 至 45 種佈局

41
佈局種類數 N 41 週 V1 人工稽核 0 行程式碼 新架構

N = 41 時:V1 大約要花 41 週人工稽核所有 plugin;新架構不用改任何程式碼。

把「每種佈局約一週」線性外推到 N 種,是本圖的簡化演算法,不是 Stripe 公佈的加總數字;新架構「零程式碼」則是文章裡實際 onboard 一種新佈局時驗證過的結果,不是外推。

把修復問題重新定義成一張圖

新架構的做法,是把「這個 shard 該怎麼修」重新定義成圖搜尋問題。每一種可能的 shard 設定——哪些節點在投票、優先權怎麼分配、有沒有節點被隱藏、oplog 開多大——都是圖上的一個節點;每一個可以執行的維運操作,調整投票或優先權、調整 oplog 大小、重建節點,都是一條邊,把系統從一個狀態帶往另一個狀態。有些狀態直接被劃為不合法:文章列出的例子包括投票數是偶數、健康的投票節點不到半數(這會讓寫入可用性有風險),或者一個節點的設定本身就矛盾,比如 priority=1 卻 vote=0。合理的解讀是,這裡的問題在於兩個欄位的語意衝突:priority 通常代表一個節點在選主時的權重,數字越高越容易被選為 primary;vote=0 則代表這個節點沒有投票權,不該參與選主。一個沒有投票權的節點卻被設定成有選主權重,這種組合本身就是設定寫錯的訊號,不需要等到真的選主出錯,圖搜尋在建圖的當下就能把這種狀態直接標成不合法,連路徑都不用算。

判斷一個狀態有多糟,靠的是一組可以疊加的規則,各自針對一類問題——節點下線、投票不match、優先權不match、oplog 大小、隱藏狀態、可用區不平衡——分別打分,加總起來就是這個狀態的 misconfiguration 分數。文章沒有進一步展開每條規則怎麼打分,隱藏狀態、可用區不平衡這兩項也沒有多做解釋;照 MongoDB replica set 本身的常見定義,hidden 通常代表節點被設成不可被讀取,可用區不平衡則是副本集中在同一個可用區,一次機房事故就可能波及過多節點——這是這套機制常見的一般定義,不是文章本身的說明。文章給的一個真實範例路徑是「Current State → [Rebuild backup] → [Fix vote+priority] → [Rebuild data node] → Healthy」,同一個 shard 可能得先重建備份、修正投票與優先權,最後才重建資料節點,四個操作串成一條路徑;跟下面 widget 示範用的簡化模型比起來,真實案例往往更長、涉及更多種操作交錯。

下面這個 widget 用一張簡化的示意圖,把三種真實故障場景放在同一張圖上:選一個起點,再選 BFS 或 Dijkstra,看演算法實際選出哪一條修復路徑。

切換故障場景與演算法 · 3 種場景 × 2 種演算法

3 3 101 101 19 3 3 單節點下線 兩節點+錯票 ephemeral 失聯 投票已修正 人工待辦 oplog 已調整 健康狀態 goal

兩節點+錯票 → 投票已修正 → oplog 已調整 → 健康狀態

步數 3 · 總成本 25

Dijkstra 依真實操作時間選路徑,這條 3 步路徑的總成本是 25,比直接一步到位的 node rebuild(成本 101)省得多。

邊上的數字是操作成本:3 = vote/priority 修正、19 = oplog resize、101 = node rebuild,數字直接取自 Stripe 程式碼裡的常數。這是依真實成本重建的簡化示意圖,節點與邊的具體拓樸是本文示範用的簡化模型,不是 Stripe 內部實際的狀態圖。

BFS 先上,撞到牆

最早的版本用 BFS 在這張圖上找路:從目前的狀態出發,找到通往完全健康狀態、步驟最少的一條路徑。BFS 的優點是簡單、保證找到最少步驟的解,前提是「一條通往完全健康的路徑」真的存在。文章直接點出這個版本的限制:「BFS finds the optimal path to a fully healthy state, but it fails entirely if no complete path exists」,找不到完整路徑,演算法就直接宣告失敗,沒有退路。

後來換成 Dijkstra,關鍵不只是「能不能找到路」,還有「步驟少不等於實際修得快」。每條邊的權重不是固定的 1,而是這條公式:edgeWeight(state, operation) = misconfiguration(state) × estimatedTime(operation)。operation 的耗時直接寫在程式碼裡,是三個常數:OPTIME_NODE_UPDATE_MONGO_CONFIG = 3、OPTIME_SHARD_UPDATE_OPLOG = 19、OPTIME_NODE_REBUILD = 101,換算下來,重建一個節點比單純改一次設定慢了大約 33 倍。這三個數字本身沒有標注實際單位,文章沒有進一步說明是秒、分鐘還是其他相對刻度,但比例才是重點:只要 node rebuild 比改設定慢 33 倍這件事成立,用「步驟少」決定路徑就有系統性風險。

用乘法而不是相加組合這兩個數字,是為了讓演算法在該省的地方省、該花的地方才花。文章的說法是「Using the product of these two for edge weight means the algorithm minimizes time spent in misconfigured states, prioritizing quick fixes that reduce exposure before committing to longer operations」,先把便宜能做的修正做掉,讓 shard 儘快離開嚴重誤設定的狀態,再決定要不要花大成本做 node rebuild 這種操作。

用 Dijkstra 而不是暴力列舉所有路徑,可以合理推想還有一個現實考量:機群裡的狀態組合不算少,暴力窮舉的成本可能隨節點數爆炸;Dijkstra 在邊權重非負的前提下能找到全域最優解,不需要真的走過每一條路徑——這也是為什麼從 BFS 換到 Dijkstra 不需要換掉整個圖搜尋的骨架,只是把邊上原本固定的「1」,換成一個算出來的數字。

相對耗時(程式碼常數,數字越大越慢) vote/priority 修正 3 OPTIME_NODE_UPDATE_MONGO_CONFIG oplog resize 19 OPTIME_SHARD_UPDATE_OPLOG node rebuild 101 OPTIME_NODE_REBUILD node rebuild 比 vote/priority 修正慢了大約 33 倍。

這正是上面那個 widget 裡場景 B(兩節點下線+投票設定錯誤)想呈現的落差:直接一步 node rebuild 能修好一切,成本是 101;先修投票、再調 oplog、最後補一個小操作,走三步,成本卻只要 25。BFS 只看步數,會挑中前者;Dijkstra 看真實時間成本,會挑中後者。步驟少不等於修得快,這句話換成三個具體數字,就是「101」跟「25」的差距。

找不到完全健康的路徑時,Dijkstra 版本不會像 BFS 一樣直接放棄。文章的說法是「When no complete path exists, the algorithm returns the path to the least misconfigured state」,找不到通往完全健康的路,就退而求其次,回傳目前可達、misconfiguration 分數最低的狀態,把剩下修不了的部分留給人工處理。上面 widget 裡選第三種場景(ephemeral 節點失聯+缺票)就是這種情況:自動化沒辦法把失聯的 ephemeral 節點修好,但至少能先把票數修正,把問題縮小到人工只需要處理那一小塊,而不是整個 shard 都等人來看。

模擬即所執行

圖跟權重算對還不夠,修復邏輯終究要真的執行在生產環境的 shard 上,而且像 node rebuild 這種操作可能要跑好幾個小時,中途遇到部署、當機、逾時都得扛得住。Stripe 用 Temporal 承接這段:文章形容它是「a durable execution framework that guarantees a workflow interrupted by a deploy, crash, or timeout can resume exactly where it left off」,中斷之後能從原地接著跑,不用整套重來,這對可能耗時數小時的 node rebuild 來說是必要條件。

真正把「規劃」跟「執行」黏在一起的,是一個叫 CommonContext 的介面。文章的說法是「The interface wraps Temporal's core primitives so that remediation logic can call them without knowing whether it's running inside a real workflow or a local simulation」,同一套修復邏輯,可以在不知情的狀況下,跑在真正的 Temporal workflow 上,也可以跑在一個純記憶體的模擬器上。這帶來的好處,文章講得很直接:「what you simulate is what you execute. There's no separate planning codebase that could diverge from the execution codebase」,模擬出來的修復計畫可以高信心地直接拿去執行,因為根本沒有兩套邏輯,不存在規劃碼跟執行碼慢慢長歪這種問題。模擬本身也快得多,把原本 I/O bound、以分鐘計的操作壓縮成「milliseconds of CPU-bound computation」,讓演算法可以在很短時間內把大量候選修復路徑都算一遍,再挑出最划算的那條。

這句話背後的工程意義,是很多自動化系統踩過的坑:規劃用的是一套簡化過的模型,執行用的是完整的正式邏輯,兩邊各自演進,久了就會出現「模擬說沒問題,真的執行卻爆炸」的落差。CommonContext 把這條分界線直接拿掉,規劃模型跟執行邏輯是同一份程式碼,差別只在背後接的是記憶體裡的假狀態,還是真正的 MongoDB API。

三個數字

上線之後,三個數字撐起了這次重構的成績單。pager 量降低 30%,換算下來一年少約 200 次呼叫;shard 卡在不健康狀態的時間,一年少了 12 天;佈局種類的擴充成本從「每種約一週人工稽核」變成零。文章提到的一個實例是,onboard 一種新的 planned-failover 佈局時,自動修復「worked with zero code changes」,不是理論上可以,是真的驗證過。三成 pager 少了,不代表值班變輕鬆三成——文章沒有拆解這 30% 的組成,但合理猜測少掉的多半是那種誤觸發、查半天發現沒事的假警報;12 天的機群健康時間,文章也沒說明計算口徑,猜想是兩千個 shard 分攤下來的總和。

30% pager 量下降

一年少約 200 次呼叫。

12 天 找回的機群健康時間

shard 卡在不健康狀態的時間,一年少了 12 天。

0 行 新增佈局要改的程式碼

40 多種佈局,onboard 新佈局時已驗證零程式碼改動。

接下來還要做的事

團隊接下來想把同一套框架用在修復以外的地方。第一個方向是佈局遷移:與其寫死一套遷移腳本,不如把目標佈局設成 goal state,讓演算法自己找出安全的遷移順序,文章的說法是「Instead, we can set the goal state and let the algorithm discover a safe migration sequence the same way it discovers a recovery sequence」,跟修復用的是同一套邏輯,只是終點從健康狀態換成目標佈局。第二個方向是 blue-green 遷移,這需要演算法本身能感知拓樸:「The pathfinder must be topology-aware, understanding which color is active and what 'healthy' means at each phase of the migration」,因為遷移過程中,哪個顏色是現役、什麼叫健康會隨階段改變。第三個方向是把多個維運操作合併執行,文章舉的例子是「Right now, we execute one operation at a time on a single node across the entire shard, but when a node is already offline for a version upgrade, we could batch an oplog resize or index build into the same window」,既然一個節點已經因為版本升級離線,不如把 oplog resize 或 index build 一起做掉,省下重複下線同一個節點的成本。

這三個方向有一個共同點:目標從「健康狀態」換成別的東西,目標佈局、遷移中的某個階段、批次維護窗口,但「把目標寫成 goal state、讓演算法自己找路徑」這件事本身不用重寫。這也是圖搜尋這個框架比逐案寫規則更耐用的地方——規則系統每多一種新情境就要多寫一條規則,圖搜尋只要新增節點跟邊,goal state 換了,找路徑的邏輯還是同一套。

圖跟權重公式解決了「怎麼修」,CommonContext 跟 Temporal 解決了「修的計畫敢不敢真的拿去執行」。下面把文章裡幾個關鍵詞收在一起,方便回頭查。

invalid state(不合法狀態)

違反安全不變量的狀態,直接被排除在圖搜尋範圍之外。文章列的例子包括投票數是偶數、健康投票節點不到半數,或者一個節點的設定本身矛盾,像 priority=1 卻 vote=0。

misconfiguration 分數

由一組可疊加的規則算出來,每條規則對應一類具體問題:節點下線、投票不match、優先權不match、oplog 大小、隱藏狀態、可用區不平衡。分數越高,狀態越糟,也是 Dijkstra 邊權重公式裡的其中一項。

CommonContext

包住 Temporal 核心原語的介面,讓修復邏輯在不知情的狀況下,既可以跑在真正的 workflow 裡,也可以跑在純記憶體模擬器上,是「模擬即所執行」成立的關鍵。

Temporal durable execution

保證一個被部署、當機或逾時打斷的 workflow,能從中斷的地方繼續跑,不用整套重來,這對可能耗時數小時的 node rebuild 是必要條件。

這個框架能搬去哪裡:把「狀態」攤開成圖上的節點、把「操作」攤開成帶權重的邊、用同一套邏輯同時餵模擬與執行,這不是 MongoDB shard 專屬的技巧。任何一個異質、多拓樸、需要自動修復的分散式系統,都可以照著這個骨架重新設計,不用再為每一種故障組合手寫一條規則。