Stripe 把 schema migration、shard 拆分、跨集群搬遷三件事——按工程直覺它們是不同 pipeline——壓進同一個六步 state machine。代價是承認「資料搬遷」沒有快捷路徑:每一次都要先複寫、再對賬、最後在小於兩秒的窗口裡切換 token version,2023 年靠這台機器吞下 1.5 PB 重新打包、shard 數量砍掉約四分之三、年度 uptime 維持在 99.999%。
Stripe 的 Data Movement Platform——把 schema migration、shard rebalance、跨集群搬遷收成一個 state machine
DocDB 是 Stripe 自己在 MongoDB Community 上長出來的 database-as-a-service——5 million QPS、5,000+ collection、2,000+ shard、petabyte 級財務資料、10,000+ 種 distinct query shape。Data Movement Platform(以下 DMP)是它的「搬東西的肌肉」:把所有會改變 shard 拓樸或內容形狀的操作——拆 shard、合 shard、跨集群搬、MongoDB 主版本 fork-lift——全部走同一條 state machine。本文把這台機器拆成它真正在 production 跑的六個 state、四個子系統與兩條 invariant,看為什麼「先複寫、再對賬、最後切換」是這個問題的唯一可行解。
click any component to read its responsibility · 6 components
click a component above
Coordinator · 責任邊界
持有 migration 的 state machine。它呼叫其它五個子系統、把每次轉移落盤成可 pause / resume / rollback 的 checkpoint。state 只能往前走(register → bulk → async → check → switch → deregister),任何階段失敗都退到上一個穩定 state,等人或自動 retry 接手。
不負責的事:自己不寫資料、不對賬、不路由請求。Coordinator 是 conductor 而非樂手。
chunk metadata service · 責任邊界
儲存「哪個 chunk 在哪個 shard、目前 version token 是多少」這張表。proxy 每次 routing 都先查它(或本機 cache)。整個系統的 cutover 等於這張表的一次 atomic update——把舊 chunk → 舊 shard + token N 改成新 chunk → 新 shard + token N+1。
不負責的事:不知道 chunk 裡有什麼資料、不知道 shard 在哪台機器、不參與資料流。
bulk import service · 責任邊界
從 source shard 拍 time-T snapshot,按目標 collection 最常用 index 排序後 insert 到 target——這個 sort-by-index 把 throughput 推高 10x,因為 DocDB 底層 B-tree 的寫入近鄰性決定 page split 頻率。
不負責的事:T 之後的任何 mutation。snapshot 一拍完它就交給 replication service,自己進入 idle。
replication service · 責任邊界
追 oplog(DocDB 每個 shard 上一個特殊 collection)流到 Kafka、archive 到 S3,把 T 之後的 mutation 雙向複寫(source ↔ target)。事件 tag 防止 cyclical replication。可從任一 checkpoint pause / resume,target 暫時掛掉不會 fail upstream。
不負責的事:不消耗 source shard 的 read throughput(讀的是 CDC 而非 oplog 本體)、不受 source oplog 大小限制、不決定何時 cutover。
correctness check · 責任邊界
選一個 point-in-time,從 source 與 target 各拍一份 snapshot 做完整對賬——「a deliberate design choice we made in order to avoid impacting shard throughput」。對賬報告是 cutover 的 prerequisite,差異一筆都不允許。
不負責的事:對 live traffic 做 row-level diff(會吃 source throughput);找差異 root cause(會 fail 整個 migration 等人工介入)。
proxy fleet · 責任邊界
in-house Go server 群,是 product app 到 DocDB 的唯一通路。每個 request 都帶 version token,target shard(含 Stripe 對 MongoDB 打的 custom patch)拒絕舊 token。cutover 的「使用者層無感」就靠這層——切換瞬間舊 shard 開始 reject、proxy refresh 路由表後自動 retry 到新 shard。
不負責的事:不知道資料 schema、不參與 reconciliation、不存 chunk metadata 本體(只 cache)。
互動圖表
DMP 六元件架構:Coordinator 驅動 6 步 state machine,version token 讓 cutover 不足 2 秒不中斷服務。
圖裡有六個方塊,但 production 行為只有兩條 invariant 在撐:第一條,cutover 之前 source 跟 target 任一時間點都是 byte-for-byte 等價的——這由 bulk import 的 snapshot @ T、replication 的 T 之後 oplog 全 replay、correctness check 的 point-in-time 比對共同擔保。第二條,cutover 本身落在比 primary failover 還短的窗口內——Stripe 把這個目標寫死成「a few seconds」,實測 traffic switch 跑完不到兩秒;任何把 cutover 拉到分鐘級的設計都會被砍掉,因為一旦觸碰 user-facing latency budget 就跟 zero-downtime 這個契約衝突。後面五個 H2 各自拆一個子系統的設計——為什麼把 metadata 跟 chunk data 分開、為什麼用 oplog 而非 CDC 還是反過來、為什麼對賬用 snapshot 不用 live tail、為什麼 cutover 走 version token 而非 DNS / load balancer。
chunk metadata service:把「資料在哪」跟「資料是什麼」分開
DocDB 的拓樸是三層:product 端的 logical database 含一個或多個 collection;collection 切成 small data chunk;chunk 落在 physical database(一個 replica set)上。chunk 是 DMP 唯一的搬遷單位——不是 collection、不是 document、不是 byte range——因為它同時夠小(可平行搬)也夠大(amortize 對賬與 cutover 成本)。chunk metadata service 是這張「chunk → physical database」mapping 的 source of truth。所有 proxy 在每次路由 query 前都向它(或本機 cache)詢問:「這個 chunk 現在屬於哪個 shard、version token 是多少」。整個 DMP 在 cutover 時做的「全局原子操作」其實就是更新這張表的一筆 row——配合 token version 遞增——其它子系統都圍繞「讓這次更新合法」服務。
把 metadata 跟 chunk data 分開是這個 design 的第一個關鍵抽象。如果你把 chunk-to-shard mapping 直接寫進 application schema 或塞進 MongoDB 的 sharding metadata,搬遷就會跟 MongoDB 內建 balancer 撞車——後者也想在 shard 之間搬 chunk、也持有自己的 metadata view。Stripe 顯然走另一條路:用 in-house metadata service 替代 MongoDB balancer 的角色,自己擔保 atomicity、自己選擇何時搬、自己決定要不要 cutover。代價是要寫並維護整套 metadata 服務;好處是 chunk 變成「平台層」概念,跟 product schema 解耦,DMP 才能做到「跨 collection、跨 shard、跨集群」全部用同一條 pipeline。
// chunk metadata service 的核心表(概念)
chunk_id → { shard_id, version_token, status }
c-1042 → { shard_8a3, token=17, status=stable }
c-1043 → { shard_8a3, token=17, status=migrating } // DMP 在搬
c-1044 → { shard_9c1, token=22, status=stable }
// cutover 的全局原子操作(step 5 的最後一個動作)
UPDATE chunk_route
SET shard_id = tgt_shard_id,
version_token = old_token + 1,
status = stable
WHERE chunk_id = c-1043
AND version_token = old_token // CAS-style,防止並發 cutover
注意 status 欄位的 stable / migrating 並不是 cutover 用的鎖——它是給人類運維看的 hint。真正的併發保護是 version token 遞增加上條件式 update:兩個 Coordinator 不可能同時 cutover 同一個 chunk,因為第二個 update 的 WHERE version_token = old_token 會 false。token 也是 cutover 那兩秒能成立的關鍵——下一個 H2 解。
bulk import:sort-by-index 把 throughput 推 10x
step 1(register)做完後,step 2 把 source shard 在 time T 的 chunk snapshot 灌進 target。最直覺的做法是 cursor over source、insert into target,但 Stripe 一開始就撞到 throughput 瓶頸——target shard 寫得比 source 讀得慢,bulk import 變成整個 migration 的 critical path。突破口是觀察 DocDB 底層的 B-tree 結構:insert 順序如果跟主要 index 的順序對齊,連續寫入會落在相鄰的 B-tree page,page split 頻率大幅降低、寫入近鄰性高、disk I/O pattern 從 random 變 mostly-sequential。原文:「By sorting the data based on the most common index attributes in the collections and inserting it in sorted order, we significantly enhanced the proximity of writes—boosting write throughput by 10x.」這個 10x 不是 micro-optimization,是讓 bulk import 從不可接受變成可接受的關鍵分水嶺。
「最常用的 index」這個選擇要靠 platform 自己決定——product team 通常不知道 bulk import 在乎這件事。DocDB 既然在 platform 層擁有所有 query stats(10,000+ distinct query shape 都流經 proxy),決策可以全自動:分析過去一段時間最熱的 index,按那個順序排 snapshot row。如果 collection 有複合 index、或 query 形狀差異很大,就回退到「按 _id 排」這種一定 monotonic 的次優選擇。重點是 bulk import 不應該因為「我們不知道哪個 index 最熱」而退化成 random insert。
另一個 bulk import 的隱含設計:snapshot @ T 跟 source live traffic 解耦。Stripe 沒明說怎麼拍——但邏輯上要嘛走 secondary node 的 consistent read(不影響 primary read throughput)、要嘛在 oplog 上選一個 timestamp 作為 logical snapshot。後續所有 mutation 由 step 3 的 replication service 補齊,bulk import 跟 replication 之間的交接點就是這個 T。如果 bulk import 失敗,整個 DMP 不需要回頭——只要重拍一份新 T snapshot,舊 T 直接 discard,因為還沒進入 step 5 之前 source shard 還是 single source of truth。這也是 state machine 設計「往前走」而非「補救」的具體 payoff——前四個 state 永遠可以重啟,沒有 partial commit 的問題。
replication service:oplog 跟 CDC 的兩條讀路徑
bulk import 拍完 T snapshot 後,T 之後的所有 write 必須繼續 mirror 到 target,直到 cutover 為止。對 MongoDB 來說最自然的路徑是讀 source shard 的 oplog(每個 shard 上一個特殊 collection,記錄所有 mutate operation),但 Stripe 不這樣做——他們走 CDC pipeline:oplog 事件被 stream 到 Kafka,再 archive 到 S3,replication service 從 Kafka / S3 讀,issue write 到 target。原文:「the replication service reads oplog events and issues writes to target shards. Using oplog from CDC systems avoids consuming source shard read throughput and avoids being constrained by source shard oplog size.」
// 直覺方案(NOT used)
source.oplog.tail()
.filter(t > T)
.forEach(op => target.apply(op))
// 問題 1:tail() 吃 source shard read throughput
// 問題 2:oplog 有 size 上限(MongoDB capped collection)
// 長 migration(PB 級)會掉事件
// 問題 3:source 掛掉時無法 resume
// Stripe 的方案
kafka_topic("docdb.shard-8a3.oplog")
.from_checkpoint(T)
.filter(not tagged_as_dmp_loopback) // 防 cyclical
.forEach(op => {
target.apply(op)
checkpoint.advance(op.ts)
})
// 1. Kafka / S3 是 source-shard-independent
// 2. S3 容量無上限,PB 級 migration 也撐得住
// 3. 失敗時從 checkpoint resume,零事件遺失
更微妙的設計是 bidirectional replication——不只 source 寫進 target,target 寫進 source 也會被 mirror。一直到 cutover 為止 source 是 authority,但 target 上的某些 write(譬如 correctness check 引發的 internal mutation、或測試 traffic)也需要回 source,否則 cutover 那一刻 source 與 target 會 diverge。每個 event 帶一個 tag 標記它原本從哪邊產生,replication service 看到 tag 就跳過——這是基本的 cycle-breaking 機制,但被 production 推到 PB scale 才會發現很多 corner case:cluster failover 時 tag schema 必須跟 oplog event 一起序列化、tag 不能依賴 wall-clock、回滾 source 時得知道哪些 op 是 originally-from-target。雙向是為了第 5 階段的 rollback 留路——萬一 cutover 後 target 出問題,可以把 traffic 切回 source、source 仍是 up-to-date 的,因為 cutover 後在 target 上發生的 write 一直在 mirror 回來。
第三個重點是 resume:「starting, pausing, and resuming synchronization from a checkpoint at any point in time」。checkpoint 在這裡就是 oplog timestamp 加上 Kafka offset 的組合,每筆寫入 target 成功後 atomic 推進。pause 是運維 affordance——半夜發現 target shard 接近滿了,先 pause replication、等 capacity 加上去再 resume,期間 source 仍正常服務、Kafka / S3 累積待 replay 的 event。如果 replication service 自己 crash,restart 後從上次 checkpoint 繼續,最差就是 replay 少量已 applied event(target 對 retry 要 idempotent,這由 op 的 oplog ts 唯一性擔保)。
correctness check:用 snapshot 比對換 source throughput
到 step 4 之前,target 已經有 T snapshot + 所有 T 之後的 mutation。理論上跟 source 等價,但 distributed 系統的「理論上」需要證明。Stripe 的做法是 point-in-time snapshot 比對:選一個時間點,從 source 與 target 各拍一份 chunk snapshot,逐 document compare。原文:「We conduct a comprehensive check for data completeness and correctness by comparing point-in-time snapshots—a deliberate design choice we made in order to avoid impacting shard throughput.」
「deliberate design choice」這句話值得停下來看。理論上更早發現問題的方式是 live tail:邊 replicate 邊比對,差異一發生立刻警報。但 live tail 必須在 source 上做額外讀取——每筆 mutation 都要從 source 跟 target 各 fetch 一次 row 比對——這跟「No performance impact to source shard」的 invariant 衝突。Stripe 選擇用 snapshot:對賬發生在 source 平靜時段(secondary node、或 backup snapshot),不碰 primary。代價是「對賬報告比 mutation 晚」——差異要等下次對賬才看見,最壞情況差異存在幾小時。權衡:differences 罕見、平常 zero diff,把 detection latency 換成 zero source impact 是划算的。如果這次 check 真的發現差異,整個 migration 直接 fail,不嘗試自動修——退回到 step 3 之前 idle,等工程師看完 diff 再決定是 reset 重來還是廢掉這次 migration。「fail loud」對 financial data 是唯一可接受的策略。
switch tabs to walk the state machine · 6 phases
01 · register migration
把這次 migration 的 intent 寫進 chunk metadata service,並在 target shard 上預建 index——build index 很慢,提前在這個 state 做掉,cutover 那兩秒才不會撞到 index 還沒就緒。
- inputs
- source shard、target shard、要搬的 chunk id 清單
- outputs
- metadata 條目 status=registered;target 已有對應 index
- pause/resume
- 整段操作 idempotent,重複 register 不會壞
- rollback
- 從 metadata 刪掉條目;index 留著(無 side effect)
- typical time
- 數秒到數十秒,視 index 多大
02 · bulk data import
從 source shard 拍 time-T snapshot,按 collection 最常用 index 排序後 insert 到 target。sort-by-index 把寫入近鄰性最大化、page split 最小化,throughput 較 random insert 高 10x。
- inputs
- source 的 T snapshot、target shard、index 排序鍵
- outputs
- target 含 T 時的完整 chunk 內容
- pause/resume
- resume 從上次 batch boundary 接續
- rollback
- truncate target chunk;重拍新 T 重啟
- typical time
- 分鐘到小時,視 chunk 大小
03 · async replication
追 source 的 oplog 經 Kafka / S3 流到 replication service,replay 到 target。bidirectional + tag 防 cyclical,從 checkpoint pause / resume。整段執行時間取決於 source 寫入速度跟 cutover 何時觸發。
- inputs
- T 之後的 oplog event 流
- outputs
- target 隨 source 同步推進,replication lag → 0
- pause/resume
- 從 oplog ts + Kafka offset 接續,零事件遺失
- rollback
- 停掉 replication;source 仍 authority
- typical time
- 直到 step 4/5 觸發為止
04 · correctness check
在 source / target 各拍 point-in-time snapshot 全量對賬。「a deliberate design choice」——不走 live tail 避免吃 source read throughput。有差異則整個 migration fail,人工介入。
- inputs
- source / target 的同時刻 snapshot
- outputs
- 對賬報告——pass 或 fail 加 diff 列表
- pause/resume
- 對賬本身可中斷,下次重拍重做
- rollback
- fail 退到 step 3 idle,不嘗試自動修
- typical time
- 數分鐘到數十分鐘
05 · traffic switch
Coordinator 把 source shard 的 version token 加一、所有舊 token request 立刻被 reject;等 replication 把 outstanding write 跟上、把 chunk route 更新到 target;proxy fetch 新 route 後重導流量。「The entire traffic switch protocol takes less than two seconds.」
- inputs
- step 4 對賬 pass、replication lag 接近 0
- outputs
- chunk route 指向 target、token+1、proxy refresh
- pause/resume
- 切到一半失敗:proxy 自動 retry 解掉
- rollback
- 把 route 切回 source、token 再 +1;bidirectional replication 保 source up-to-date
- typical time
- < 2 秒(critical 段)
06 · deregister + cleanup
在 metadata service 把這次 migration 標記完成、把 source shard 上的 chunk 資料刪掉。stop 雙向 replication、回收 Kafka 上對應的 oplog 流(archive 仍留在 S3)。
- inputs
- cutover 成功 + 一段觀察期
- outputs
- source chunk 刪除、空間回收、replication idle
- pause/resume
- cleanup 是 background job,可任意延後
- rollback
- 不可——已 cleanup 後要回 source 需走新的 migration
- typical time
- 數分鐘到小時,視 chunk 大小
互動圖表
六步 DMP:register、bulk、async CDC、correctness check、traffic switch、deregister。
對賬還有一個 production-critical 細節:snapshot 比對的 cost 跟 chunk 大小成正比,但 cutover window 本身只允許「不到兩秒」的中斷。所以對賬一定要在 cutover 前獨立完成;snapshot 拍完之後到真正 cutover 之間 source 仍可能有 write,這段 delta 由 step 3 的 replication 補齊、step 5 的 token bump 在 cutover 瞬間 drain。換句話說 step 4 提供的 guarantee 不是「cutover 那一刻 source == target」(這需要 step 3+5 配合),而是「截至對賬時間點為止 source 與 target 對得起來」。剩下的 delta 由 replication service 從 checkpoint 持續推進——這也是為什麼 replication 要做 resume 而不是 fire-and-forget:對賬可能隨時 rerun,replication 必須能 hold state 等待。
traffic switch:把 cutover 塞進兩秒
所有設計到這裡為 step 5 服務。Stripe 的 SLO 寫明:「the key phase of the migration process shorter than the duration of a planned database primary failover—typically lasting a few seconds」。如果 cutover 比 primary failover 慢,DMP 本身就成為 availability bottleneck,所有 product team 都不敢用。Stripe 的 traffic switch 落在不到兩秒——比 MongoDB 自己 planned failover(typically 3-5 seconds)還快。怎麼做到的?答案是 version token + custom MongoDB patch + proxy retry,三件事必須同時在位:
// step 5 的精確順序(Coordinator 跑)
1. UPDATE source_shard.version_collection
SET token = token + 1
// → 之後 source shard 任何收到 old token 的 request 全部 reject
// → 這是 Stripe 對 MongoDB 打的 custom patch 提供的能力
2. WAIT FOR replication_service.lag == 0
// → 把 step 1 之前還在飛的 write 全部 replay 到 target
// → typical lag 是 ms 級,這步通常 ~100ms
3. UPDATE chunk_metadata
SET shard = target, token = new_token
WHERE chunk_id = c-1043 AND token = old_token
// → CAS 確保只切一次
4. // proxy 在下次查 metadata 時看到新 route + 新 token
// → 帶 old token 的 in-flight request 在 source 被 reject、proxy retry
// → retry 走新 route 到 target,token 對得上
// → user 看到的是一次 transient retry,不是 outage
// 全程實測 < 2s,比 MongoDB planned failover 還短
幾個非顯而易見的設計選擇:第一,token 增量是 monotonic 的全 cluster scalar(per chunk),不是 logical timestamp。reject 條件夠簡單:「我看到的 token 是 N,你帶的 token < N,reject」。如果用 vector clock 之類 fancy scheme,custom patch 要塞進 MongoDB hot path 的程式碼會複雜十倍。第二,proxy 必須無條件 retry——這建在「product app 看到的 error 不是 fatal」的契約上,proxy 內部自動接住、user-facing API 沒有任何錯。Stripe 在文章寫得明白:「all failed reads and writes directed to the source shard succeed on retries.」第三,custom MongoDB patch 是必要邪惡——MongoDB 上游沒有這個 feature,Stripe 自己 fork 維護。對年付幾百萬美元的 enterprise software 收支表來說可以接受,對小團隊是不可承受的維護成本——也說明 DMP 這套方案不容易直接搬到其他公司。
第四,proxy fleet 是 in-house Go——這不是品味問題,是因為 proxy 必須懂 token 語意、必須能 transparent retry、必須在 millisecond 級 refresh metadata cache。MongoDB driver 內建的 retry 邏輯沒這層 awareness,會把 token reject 當成 generic error 暴露給 application。整段「使用者感覺不到」的核心 magic 就在這幾百行 Go 裡:截獲 reject、查新 route、retry、回正常 response。
click any stage to read its invariant · 4 stages, total < 2s
cutover 的四段時間軸——加總必須短於 MongoDB primary failover(typically 3-5 秒)
click any stage above
stage 1 · bump version token
Coordinator 對 source shard 上一個特殊 collection 寫一筆「token=N+1」。從這一刻起,source shard(含 Stripe 的 custom patch)對任何帶 token≤N 的 request 直接 reject。invariant:「source 不再接受新 write,但已 accepted 的 write 還在 oplog 裡跑」。
stage 2 · drain replication
等 replication service 把 stage 1 之前 source 接受的最後一批 write 全部 replay 到 target。typical replication lag 在 ms 級,這段通常 100 ms 上下。invariant:「target 跟 source 在 stage 1 token bump 那一刻 byte-for-byte 等價」。
stage 3 · route swap (CAS)
chunk metadata service 上把 chunk → shard mapping 從 source 換成 target、token 同步 +1,用 WHERE token = old_token 的 CAS 條件防止兩個 Coordinator 同時切。invariant:「全 cluster 對這個 chunk 的真相只有一個——chunk metadata service 的 row」。
stage 4 · proxy refresh + transparent retry
proxy fleet 在下次 metadata fetch(或 cache TTL 到期)看到新路由 + 新 token。期間 in-flight request 帶舊 token 撞 source 被 reject——proxy 自動 retry,重查 metadata、走新 route 到 target、user-facing API 看到的是一次 transient 重試。invariant:「product app 沒有看到任何 error」。
互動圖表
四階段 cutover:bump token、drain replication、CAS route swap、proxy refresh,合計不足 2 秒。
cutover 序列裡 stage 4 的 ~1.5s 是壓倒性占比——其餘三段加起來只有 ~200 ms。proxy refresh 的時間取決於 metadata cache TTL 跟 proxy 數量;Stripe 沒明說數字,但合理估計 cache TTL 在 1s 級、proxy 上千台,全部 refresh 完並把 retry 處理乾淨大約 1.5s。這代表 cutover 成本主要不在「協調」而在「擴散」——分散式系統的本質:寫完一個地方不算完,要等所有 reader 看到。如果未來要把 cutover 推到 sub-second,最有意義的工程是讓 proxy 對 metadata 變動做 push notification 而不是 poll;但對 Stripe 來說兩秒已經夠用,工程預算花在別的地方。
把同一台機器當 schema migration、shard rebalance、跨集群搬遷三用
本文最開頭的承諾是「DMP 把 schema migration、shard rebalance、跨集群搬遷壓進同一個 state machine」——讓我們把這三個 use case 對 DMP 各 step 的 mapping 寫清楚。Schema migration:source 跟 target 是同一個 shard,差別是 collection 結構。bulk import 在 target shard 上以新 schema 重寫資料;replication 把 T 之後的 mutation 經一個轉換 layer 重寫成新 schema;correctness check 在轉換後 form 上對;cutover 切的是 chunk 內部「用哪個 schema 讀寫」的指標。Shard rebalance:source shard 過熱,把幾個 chunk 搬到 target shard 攤平。最 straightforward 的 case,DMP 各 step 直接對應。跨集群搬遷:source 跟 target 是不同 MongoDB cluster——譬如 fork-lift 升級 MongoDB 主版本。bulk import 走跨集群 network、replication 同樣靠 Kafka / S3(這也是為什麼 oplog 走 CDC 不直接 tail 的設計回報)、correctness 走 cross-cluster snapshot。三個 use case 共用同一套 Coordinator、metadata service、proxy retry 邏輯,差別只在 bulk import 跟 replication service 的具體 worker 實作。這就是 platform 收斂的價值——以前可能要寫三套 tooling,現在一套。
2023 年的兩個成果落在這個 framing 上:
2023 年 DMP 達成
────────────────────────────────────────────────────────
• 把 thousands of underutilized databases bin-pack:
搬遷量 1.5 PB
shard 數 砍掉 ~75%("approximately three quarters")
對 product app 透明(zero downtime)
• MongoDB 主版本 fork-lift:
跳過 intermediate major + minor versions
"in one step"
避開 MongoDB 內建 upgrade path 的多階段協調成本
全年 uptime 99.999%
全年 payments processed $1 trillion
「Bin-pack」是 Stripe 的選詞——把多個負載偏低的 database 像箱子裡的物品一樣重新塞回比較少的 shard,提升整體 hardware utilization。這在 single-tenant 設計裡幾乎做不到(每個 customer 一個 shard),靠 DMP 才把它變成週期性平台維運。1.5 PB / 75% reduction 的數字對應的是「過去多年累積分散的 shard」一次性整併——這是把 DMP 當 bulk operation 用,而不是 reactive 處理 hotspot。Fork-lift 升級是另一個 platform 級成果:MongoDB 通常要求 N → N+1 → N+2 序貫升級、每階段 cluster-wide rolling restart、應用端要先確認跟新版相容;DMP 讓 Stripe 把這些濃縮成一次「拍 snapshot、replay oplog、cutover」——target cluster 直接是新版 MongoDB,不必走 in-place upgrade。
把 Stripe 在文章開頭列的四個 design requirement 跟他們對應做的設計選擇排在一起,可以看到每個選擇都是某個 invariant 的具體實作——這也回答「為什麼這個 design 是這個樣子」的問題。如果你只記得四件事,記這張表:
click a column header to sort · 4 columns × 4 rows
| invariant | 關鍵設計 | 具體機制 | 付出代價 |
|---|---|---|---|
| consistency & completeness | snapshot + oplog 兩段拼接 + 點對點對賬 | bulk import (T) → replication (T+) → correctness PIT compare | 對賬有 latency · 差異 fail loud |
| availability | cutover < primary failover | version token + custom MongoDB patch + proxy transparent retry | ~2s 上限 · 必須維護 MongoDB fork |
| granularity & adaptability | chunk 為唯一搬遷單位 | chunk metadata service · 多 source → 多 target 任意組合 | chunk size 要持續 retune |
| no source impact | read 路徑全部繞開 source | oplog → Kafka → S3 · snapshot 從 secondary 或 backup 拍 | + Kafka 維運 · 對賬比 mutation 晚 |
互動圖表
四個 invariant 與設計選擇對照表:DMP 如何用 version token、chunk metadata、Kafka CDC 分別滿足各條約束。
每一行的「付出代價」欄位才是真正有趣的——任何 distributed system 都會在 promise 跟 cost 之間 trade,DMP 也沒例外。例如「對賬有 latency」意味著如果 source 已經寫了壞資料、要等下一輪 snapshot compare 才會發現,期間 target 已經拿到複製品;如果系統是 user-facing low-latency analytics,這個 latency 可能不可接受。又如「chunk size 要持續 retune」——chunk 太小,metadata service 變成 hot spot、cutover 次數變多;chunk 太大,bulk import 拖很久、cutover 失敗 blast radius 大。Stripe 在文末提了 future direction:「currently prototyping a heat management system that proactively balances data across shards based on size and throughput, and investing in shard autoscaling that dynamically responds to changes in traffic patterns」——可以理解成把 chunk size 跟搬遷時機從人工調整推向自動化,DMP 是底層機制、heat management 是上層 policy。
還有一個對 reader 有意義的問題:DMP 這套架構能不能搬到別的公司、別的 database?答案分三層。Coordinator + state machine 的設計概念可以搬——「先複寫、再對賬、最後切換」是 zero-downtime data movement 幾乎唯一的可行 shape。version token + transparent retry 的 cutover 模式也通用,只要 application 用 proxy 接 database。但 custom MongoDB patch 是公司特定資源——Stripe 維護自己的 MongoDB fork 是負擔得起的工程預算,多數團隊不行。對沒有這個資源的團隊,retro-fitting 的路徑通常是:用 application-level dual-write 取代 token reject、用 application-level routing 取代 proxy retry、用 sample-based 對賬取代 full snapshot compare。每個替代品都比 Stripe 原版差一點,但加總起來仍可以拿到 90% 的 zero-downtime benefit。重點不是 copy 整套,是抓住「six-step state machine + 四個 invariant」的骨架。
回到題眼:Stripe 五年 99.999% uptime——換算成停機時間是每年不超過 5 分 15 秒。如果 DMP 沒被當成 first-class platform 投資,這個數字幾乎不可能達成:每次 schema migration、每次 shard rebalance、每次跨集群搬遷都是 outage 風險。傳統做法(dual-write app code、stop-the-world cutover、人工對賬)每年總計需要的 unplanned downtime 早就超過 5 分鐘。DMP 把這些操作從「每次都是 outage 風險」降級為「每次都是 background job」——cutover 的兩秒在 SLA 計算裡甚至不會被列為 downtime,因為它比 planned primary failover 還短,已經被 product app 的 retry budget 吸收。
另一個角度:DMP 把 5,000+ collection × 2,000+ shard 的維運從 O(n²) 對人的 attention 收斂到 O(1)——任何 chunk 的任何遷移都走同一套 pipeline、同一套 pause/resume/rollback、同一套對賬報告。如果每個 migration 都要手工設計,platform engineer 的時間早就被吃光,更不可能在 2023 年完成 1.5 PB 的 bin-pack。把所有資料搬遷操作收成一個 abstraction 不只是工程美學——是讓 platform team 規模化到能服務 thousands of product team 的必要條件。
最後一個不那麼明顯的觀察:DMP 是一個「敢承認 distributed systems 沒有捷徑」的設計。沒有炫技演算法、沒有新型 consensus protocol、沒有對 CAP 的奇巧繞道。每一步都是教科書 distributed systems 概念——snapshot、oplog tailing、CDC、CAS、idempotent retry——的紮實組合。創新在於把這些概念 compose 成一個 state machine、每一步都讓人類能 pause / resume / rollback / 看對賬報告。對 financial data infrastructure 來說「敢承認沒有捷徑」就是最大的工程能力——任何試圖跳過某一步的 optimization 都會在某個 PB 級的 corner case 上撞牆。
The unlock:把 schema migration、shard rebalance、跨集群搬遷收成同一條六步 state machine 之後,DocDB 的 day-to-day 維運(拆 shard、合 shard、bin-pack、MongoDB 主版本 fork-lift)從「每次都需要 outage 窗口」變成「每次都是 background job」——這是 5,000 collection × 2,000 shard × 5M QPS 規模能維持 99.999% uptime 的真正先決條件,前提是你願意維護自己的 metadata service、proxy fleet 跟 MongoDB fork。