Polymarket 預測市場 (Design Polymarket)
設計 Polymarket:使用者對未來事件交易 YES / NO outcome tokens 的預測市場。本質是一個 金融撮合交易所(matching exchange),外加一層「鏈下高頻撮合 + 鏈上最終結算」的特殊性。系統 寫密集且延遲敏感:下單 / 取消 p95 < 200ms、價格更新 次秒級(sub-second) 傳播、訂單管理需強一致。
招牌取捨:撮合流程 append-only、deterministic、永不回滾——撮合一旦成立就寫進 WAL 成為唯一事實來源,即使後續 鏈上結算(on-chain settlement)失敗 也只 retry / 升級 ops,絕不 rollback 已完成的 off-chain fill。回滾會破壞 ordering guarantee、讓 replica 與下游 consumer 狀態分歧。這就是用「最終一致的鏈上結算」換「鏈下撮合的確定性與可重建性」。其餘決勝點:in-memory order book + WAL(撮合在記憶體、持久化靠 WAL)、依 market_id sharding(每個市場單一 sequencer)、Kafka pub/sub 扇出把 MDS / OQS 讀路徑與撮合引擎解耦(CQRS)。
配速(35 min 預算):~5 min 釐清需求 → ~4 min 容量估算 → ~3 min API + ~4 min 資料模型 → ~7 min 高階架構 → ~9 min deep dive(訂單追蹤 OQS / 擴展 Kafka 解耦 / 容錯 failover)→ ~2 min 收尾。開場先一句話複述題目、宣告配速、把「唯一 token / 鏈上結算細節」標記為待 deep dive——宣告配速本身就是高分動作。
1. 釐清需求與範圍 (Clarify & Scope ~5 min)
開場後先框邊界,別急著畫撮合引擎。金融交易所題最容易在「一致性語意」與「鏈上 vs 鏈下分界」上翻車,必須先問清。
| 釐清問題 | 為何要問 / 影響到哪個決策 | 本題假設(依來源) |
|---|---|---|
| 市場型態?只有二元(YES/NO)嗎? | 決定 outcome token 與結算邏輯複雜度 | 全部 binary(YES/NO) |
| 使用者要做哪些訂單操作? | 驅動 API 與撮合引擎狀態機 | 建立 / 修改 / 取消未成交訂單 |
| 撮合與結算在哪裡跑?鏈上還鏈下? | 這是本題的架構分水嶺 | off-chain 撮合 + on-chain 結算(CTF / Polygon) |
| 一致性要求?哪條路徑要強一致? | 決定撮合用 in-memory + WAL 還是 DB | 訂單管理強一致;讀服務允許小而有界的延遲 |
| 規模多大?DAU / trades / 並行市場數? | 直接驅動估算與 sharding 決策 | ~20M DAU、~100M trades/day、數千並行市場 |
| 延遲要求?哪條路徑最敏感? | 決定撮合走記憶體、價格走 WebSocket | 下單/取消 p95 < 200ms;價格次秒級傳播 |
| 市場怎麼結算(誰決定 YES/NO)? | 決定要不要 oracle、payout 邏輯 | UMA oracle 寫 outcome 上鏈 → CTF 兌付 |
「鏈上結算的 atomic swap 與 token mint/burn 我先標記成 black box,等 deep dive 再展開——現在先假設『撮合成立後會有一個 Settlement Service 負責把它落鏈』,這樣我們能先把 下單→撮合→價格傳播 這條主幹串起來。」把鏈上細節推遲,保住撮合敘事的連貫性。
2. 功能性與非功能性需求 (FR / NFR 與設計北極星)
-
功能性需求 (FR)
- 所有市場皆為 binary(YES / NO)。
- 使用者 管理訂單:建立新單、修改既有單(price / size)、取消未成交單。
- 撮合成立後,鏈上 collateral(USDC)↔ outcome tokens(YES/NO) 交換;市場 resolve 後勝方兌回 USDC、敗方 token burned(價值 0)。
- 使用者查看每個市場的 即時價格與狀態(best bid / ask、last trade)。
-
非功能性需求 (NFR)
- 訂單強一致:訂單一旦接受 / 取消,後續查詢必須反映最新狀態(讀服務允許 小而有界 的傳播延遲)。
- 可擴展:~20M DAU、~100M trades/day、數千並行市場。
- 低延遲:下單 / 取消 p95 < 200ms(同區);價格更新 次秒級 傳播。
- 隱含:寫密集 + 撮合零容錯(已 ack 的 order / fill 不可遺失)。
設計北極星(NFR 直接導出後續所有選型的判準):
| NFR | 導出的設計北極星 | 後續對映選型 |
|---|---|---|
| 撮合 p95 < 200ms | 撮合不能進 DB——必須 in-memory order book | in-memory matching engine + WAL 持久化 |
| 訂單強一致 + 已 ack 不遺失 | 每筆 order/fill 先落 durable log,且至少存活 2 台 | WAL + primary/replica,≥1 ACK 才回成功 |
| ~100M trades/day + 讀很重 | 讀寫分離;重 UI 查詢不可拖累撮合延遲 | CQRS:MDS / OQS tail WAL,獨立讀路徑 |
| 數千並行市場 + 獨立擴展 | 以市場為單位切分、每市場單一 sequencer | 依 market_id sharding + Kafka 解耦 |
| 價格次秒級傳播 | server 主動推、不能靠輪詢 | WebSocket / SSE + Redis Pub/Sub fan-out |
| 鏈上慢 / 貴 / 低吞吐 | 高頻邏輯留鏈下,最終結算才上鏈 | off-chain 撮合 + on-chain settlement(batching) |
「這是一個 寫密集、延遲敏感、撮合需確定性可重建、鏈下做事 / 鏈上結算 的系統」——記住這幾個詞,後面每個選型都能從它推回去。
3. 容量估算 (Back-of-Envelope ~4 min)
估算目的是 驅動 sharding 與讀路徑解耦決策(估算 → 選型 hand-off)。回連 02-Distributed-Systems/05-Numbers-to-Know。
寫流量(撮合 QPS)
~100M trades/day
平均 QPS = 100,000,000 ÷ 86,400 s ≈ 1,157 trades / sec
撮合尖峰(熱門市場結算前、新聞事件)常為平均的 5~10x → ~6k~12k orders/sec(推導值)
→ 單一 Matching Engine 扛不住所有市場 → 必須依 market_id 分片
讀流量(價格 / 訂單查詢)
20M DAU,每人每天多次刷新價格 + 查訂單,讀遠多於寫(read-heavy 的 UI 層)
價格更新需「每筆 order event → 計算新價 → 推給所有訂閱者」的 fan-out
→ 讀路徑(MDS/OQS)流量量級遠高於撮合本身 → 必須與撮合解耦獨立擴展
狀態 / 儲存
數千並行市場,每個市場一份 in-memory order book(活躍訂單)
WAL:每筆 order/fill event append(永久或長期保留以供重建 / 重放)
Order DB(OQS materialized):歷史訂單 + 狀態,依 user_id sharding
下表 QPS 與尖峰倍數皆為 推導值(由 trades/day ÷ 86,400 與一般金融尖峰經驗推出;來源僅給 20M DAU / 100M trades/day / 數千市場 / <200ms / 次秒級等輸入)。
| 維度 | 數字 | 對後續決策的意義 |
|---|---|---|
| 平均撮合 QPS | ≈ 1,157 /sec(推導值) | 單機尚可,但尖峰 + 數千市場 → 依 market_id 分片 |
| 撮合尖峰 QPS | ≈ 6k~12k /sec(推導值) | 熱門市場結算前爆量 → 每市場單一 sequencer,分散到多 shard |
| 讀路徑(價格/查詢) | 遠高於撮合(read-heavy UI) | CQRS:MDS/OQS 獨立服務,不可拖累撮合延遲 |
| 並行市場 | 數千 | sharding 單位 = market_id,天然並行 |
| 撮合延遲預算 | p95 < 200ms | 撮合必須 in-memory,落 DB 不可能達標 |
「撮合 p95 < 200ms」直接否決「order book 放 DB」——撮合必須在記憶體;而 ~100M trades/day × 尖峰 + 數千市場 直接導出 依 market_id sharding(每市場一個 sequencer)。讀路徑流量量級又遠高於撮合 → 必須 CQRS 解耦,否則重 UI 查詢會吃掉撮合的延遲預算。
4. 用到的積木 / Building Blocks Used
架構即目錄:先給導覽圖,再展開。下表每個決策都對映一個既有 concept note(無 alias 連結);本表即「本案例練到哪些積木」的單一事實來源(single source of truth)。
| 設計決策 | 積木 | 在本題的作用(≤1 行) |
|---|---|---|
| 容量估算驅動選型 | 02-Distributed-Systems/05-Numbers-to-Know | 算出 ~1.2k 平均 / 尖峰 6k+ QPS,導出 in-memory + 分片 |
| 撮合需強一致與序列化 | 05-Database-Advanced/01-Transactions | 每市場單一 sequencer 序列化訂單,WAL 即 commit log |
| 撮合的競爭 / race 處理 | 12-Ops-and-Reliability/01-Dealing-With-Contention | 單一 writer per market 消除並發撮合的 race condition |
| 已 ack 不遺失的持久化 | 12-Ops-and-Reliability/03-Reliable-Delivery | WAL append + ≥1 replica ACK,failover 不丟單 |
| 撮合引擎複本 / failover | 05-Database-Advanced/03-Replication | primary/replica,leader 掛了 promote replica |
| leader election 防 split-brain | 09-Messaging-Coord/05-Zookeeper | 用 Raft / ZooKeeper / etcd 選主,避免雙主 |
| 依市場切分、獨立擴展 | 05-Database-Advanced/02-Sharding | market_id 為 shard key,每市場單一 WAL = authoritative |
| 讀路徑與撮合解耦(扇出) | 09-Messaging-Coord/03-Kafka | Kafka pub/sub 把 order event 扇出給 MDS / OQS,獨立擴展 |
| 事件管線 / 重放重建 | 09-Messaging-Coord/01-Data-Pipeline | WAL → Kafka → consumer materialize;可 replay 重建狀態 |
| 讀寫分離(CQRS) | 10-Design-Patterns/02-Scaling-Writes | 寫走撮合引擎、讀走 MDS/OQS materialized view |
| 訂單查詢專用讀模型 | 06-Database-Tech/04-OLTP-vs-OLAP | OQS 把 event materialize 成查詢最佳化的 Order DB |
| 即時價格推播 | 10-Design-Patterns/05-Real-time-Updates | 初始 snapshot 走 HTTP,增量走 WebSocket/SSE |
| 推播協定選擇 | 01-Networking/05-Realtime-Protocols | server→client 主動推:WebSocket / SSE |
| 價格 fan-out 通道 | 07-Caching-Storage/04-Redis | 每市場一個 Redis channel,Pub/Sub 推給訂閱的 MDS server |
| 入口路由 / 鑑權 | 11-Infrastructure/01-API-Gateway | client → API Gateway → Matching Engine |
5. API 設計 (API Design ~3 min)
由 FR 驅動,採 REST 資源風格(理據見 03-API-Design/02-REST);即時價格走「HTTP 取 baseline + WebSocket 串增量」雙軌。
| Method | Path | 用途 | 重點 |
|---|---|---|---|
GET |
/markets/{marketId}/summary |
取市場即時摘要 | 回 bestBid / bestAsk / lastTrade / timestamp,作初始 baseline |
POST |
/orders |
建立訂單(limit / market) | body 帶 side/orderType/marketId/limitPrice?/numShares → Order |
DELETE |
/orders/{orderId} |
取消訂單 | 成功後 status: CANCELED |
PUT |
/orders/{orderId} |
修改訂單(price / size) | 撮合引擎在記憶體定位該單後更新 |
GET |
/orders?status=OPEN |
查自己的訂單(OQS) | 分頁 / 篩選 / analytics 走 OQS,不打撮合引擎 |
WS |
/markets/{marketId}/stream |
訂閱增量價格 | HTTP snapshot 後 升級為 WebSocket/SSE 串 incremental updates |
POST /orders 回的 Order 帶 status(OPEN | PARTIALLY_FILLED | FILLED | CANCELED)與 filledShares——這替後面「使用者如何追蹤訂單狀態」的 deep dive(OQS materialized view)留好伏筆。重 UI 查詢(分頁 / 篩選 / 歷史)絕不打撮合引擎,全部走 OQS。
6. 資料模型與儲存選型 (Data Model & Storage Choice ~4 min)
核心不是「一張 DB 表」,而是 三種狀態載體:撮合引擎的 in-memory order book、持久化的 WAL、以及 OQS materialized 的 Order DB。
In-Memory Order Book(per market,撮合引擎內,極低延遲)
--------------------------------------------------
bids: price → [orders FIFO] (價格層級,由高到低)
asks: price → [orders FIFO] (價格層級,由低到高)
index: orderId → (price, side) ← O(1) 定位以做 cancel / modify
--------------------------------------------------
不落 DB:撮合需 < 200ms;當機靠 WAL + snapshot 重建
WAL(per market shard,append-only,唯一事實來源)
--------------------------------------------------
seq | leaderEpoch | type(NEW/CANCEL/MODIFY/FILL) | orderId | marketId | payload | ts
--------------------------------------------------
+ 定期 snapshot(in-memory book 快照 + 對應最後 WAL offset)
重啟:載入最新 snapshot → replay 該 offset 之後的 WAL → deterministic 重建
Order DB(OQS materialized view,依 user_id sharding)
--------------------------------------------------
orderId PK | userId(shard key) | marketId | side | type | limitPrice
| numShares | filledShares | status | createdAt | updatedAt
--------------------------------------------------
為查詢最佳化(分頁 / 篩選 / 歷史 / analytics),由 order event 餵養更新
On-chain(CTF / Polygon,鏈上結算與所有權)
--------------------------------------------------
conditionId → outcomeIndex(YES=0 / NO=1) ← UMA oracle resolve 後寫入
USDC collateral escrow ↔ YES/NO outcome tokens(mint / burn)
儲存選型:
| 狀態載體 | 選擇 | 理由 |
|---|---|---|
| Order Book | ✅ In-Memory | 撮合 p95 < 200ms,不可能進 DB;持久化責任外包給 WAL |
| WAL | ✅ Append-only durable log | 撮合的 source of truth,可 replay 重建;天然 deterministic、ordering 保證 |
| Order Query | ✅ Materialized Order DB | 重 UI 查詢(分頁 / join / analytics)不可拖累撮合 → 獨立讀模型(CQRS) |
| 撮合引擎直接被 UI 查 | ❌ | 撮合引擎為低延遲調校,不適合 pagination / flexible filter / big join |
| 結算 / 所有權 | ✅ On-chain(CTF/Polygon) | 需不可否認 / 不可竄改的所有權與結算紀錄 → 上鏈(最終結算) |
→ 積木:撮合的序列化與一致性見 05-Database-Advanced/01-Transactions;WAL 持久化見 12-Ops-and-Reliability/03-Reliable-Delivery;OQS 讀模型見 06-Database-Tech/04-OLTP-vs-OLAP 與 10-Design-Patterns/02-Scaling-Writes;分片見 05-Database-Advanced/02-Sharding。
「order book 放記憶體」是因為 撮合是延遲關鍵路徑;它能成立的前提是 WAL 兜底持久化 + snapshot 加速重建。沒有 WAL,記憶體一掉就丟單——這正是第 8.3 容錯 deep dive 的核心。
7. 高階架構 (High-Level Design ~7 min)
把積木串成端到端流程。先看三條核心 flow,再看 scaled 架構。
(A) 下單 / 撮合 flow(order → match)
Client
│ POST /orders { side, type, marketId, limitPrice, numShares }
▼
API Gateway ──► Matching Engine(依 market_id 路由到對應 shard)
│ 1. append order event → WAL(先持久化!)
│ 2. 更新 in-memory order book(價格層級)
│ 3. 撮合:找到 maker+taker → 產生 FILL event → append WAL → 更新 book
│ 4. 把撮合對交給 Settlement Service(鏈上結算,非同步)
▼
回傳 Order { status, filledShares } (撮合完成才回 success,且 WAL 已 ≥1 replica ACK)
(B) 鏈下執行 ↔ 鏈上結算 flow(off-chain ↔ on-chain)
撮合成立(off-chain,已寫 WAL)
│ 撮合對送 Settlement Service
▼
Settlement Service
│ 1. 確認 collateral / outcome-token reservation 已存在
│ 2. JSON-RPC → Polygon 上的 CTF Exchange contract
│ └─ atomic swap:USDC collateral ↔ YES/NO outcome tokens
│ 3. 鏈上失敗 → retry / 升級 ops
│ ★ 絕不 rollback 已完成的 off-chain fill(append-only / deterministic)
▼
taker 錢包持有 outcome tokens
│
▼(市場 resolve 時)
UMA oracle 把 outcome 寫上鏈:conditionId → outcomeIndex(YES=0/NO=1)
勝方呼叫 CTF → 兌 1 USDC;敗方 token → burned(價值 0)
(C) Scaled 架構(端到端 — CQRS 扇出)
Client
│ 下單/取消 (write) 訂閱價格/查訂單 (read)
▼ ▲
API Gateway │
│ route by market_id │
┌─────────────────────────┼──────────────────────────┐ │
▼ ▼ ▼ │
Matching Engine Matching Engine Matching Engine
(shard A: market 1..n) (shard B ...) (shard C ...)
primary + replicas 每 shard:WAL + in-mem book
│ append WAL → 更新 book → publish order events
▼
┌──────────────────────────────────────────────────────────┐
│ Kafka pub/sub (partition by market_id) │ ← 解耦撮合與讀路徑
└───────────────┬───────────────────────┬───────────────────┘
│ (consume) │ (consume)
▼ ▼
Market Data Svc (MDS) Order Query Svc (OQS)
算 best bid/ask/last materialize → Order DB
更新 price cache (依 user_id sharding)
│ │
Redis Pub/Sub (per-market channel) └──► GET /orders 查訂單
│ push incremental
▼
WebSocket / SSE ──► Client(次秒級價格)
▲
HTTP snapshot 取 baseline 後升級為 WS
關鍵 hand-off:API Gateway 依 market_id 路由 → 對應 Matching Engine shard(in-mem book + WAL,每市場單一 sequencer)→ publish 到 Kafka → MDS(價格 fan-out via Redis Pub/Sub + WebSocket)與 OQS(materialize 成可查的 Order DB)各自獨立消費。撮合的延遲關鍵路徑與重 UI 讀路徑 徹底解耦。
8. 深入探討 (Deep Dives ~9 min)
通用機制(Kafka 分區語意、replication 細節、WebSocket vs SSE、sharding 原理)只在 concept note 解釋;這裡只講「本題怎麼用、選哪個、為什麼」。任一機制超過 ~3 行就砍掉改連結。本題無 home note 的新材料(off-chain orderbook / CTF / UMA oracle)才在此 inline 教學並登記到 new_concepts_introduced。
- 為何鏈下做事:上鏈 慢、貴(gas)、不適合高頻寫入。所以高頻邏輯(下單、撮合、價格發現)留 off-chain;只有 最終結算 / 所有權 / 不可否認紀錄 才 on-chain。
- CTF(Conditional Tokens Framework):鏈上把 collateral(USDC)escrow,mint 出 YES / NO outcome tokens 交易;市場 resolve 後勝方 token 兌 1 USDC、敗方 token burned(價值 0)。鏈上的 atomic swap 由 Exchange / CTF contract 執行。
- UMA oracle:去中心化「truth machine」,把市場結果
conditionId → outcomeIndex(YES=0/NO=1)寫上鏈,觸發 payout。Polymarket 監聽 oracle event 並通知使用者。
8.1 使用者如何追蹤訂單狀態(OQS)
問題:UX 上使用者要能查「我所有 open orders」「我最近 100 筆交易」「market X 的訂單」——這些是 pagination / 彈性篩選 / analytics / big join 型查詢。
為何是瓶頸:直接打 Matching Engine 取訂單看似最省事,但 撮合引擎是為低延遲撮合調校的,這類重查詢會 吃掉撮合的延遲預算(< 200ms),等於用 UI 流量拖垮交易主幹。
| 方案 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| A. UI 直接查 Matching Engine | client 向撮合引擎要訂單 | 無需額外服務 / 即時 | 重查詢污染撮合延遲;撮合不擅分頁/join |
| B. 獨立 OQS(tail WAL) | OQS 消費 order event,materialize 成 DB | 撮合零干擾、查詢最佳化 | 多一個服務 + 小而有界的傳播延遲 |
選擇與理由:採 B(OQS)。OQS 像 MDS 一樣 tail WAL(後續改 tail Kafka),把 order event materialize 到查詢最佳化的 Order DB,client 的所有訂單查詢走 OQS。代價是 NFR 已允許的「小而有界的讀傳播延遲」——這正是強一致需求被刻意限定在「order management 本身」而非「讀服務」的原因。Order DB 依 user_id sharding,同一使用者的訂單查詢只落單一 shard。
→ 積木:06-Database-Tech/04-OLTP-vs-OLAP(撮合 OLTP / OQS 偏分析查詢)、10-Design-Patterns/02-Scaling-Writes(CQRS 讀寫分離)、05-Database-Advanced/02-Sharding(user_id shard)
強一致的範圍被刻意限定在 order management 本身:訂單一旦被撮合引擎 accept/cancel,撮合引擎內的狀態是即時權威的。OQS 是 下游 read model,NFR 明文允許「透過 OQS 這類 read service 傳播時有一個 小而有界 的延遲」。這是典型 CQRS:寫端強一致、讀端最終一致但有界。
8.2 系統如何擴展到每日大量交易(Kafka 解耦 + 分片)
問題:~100M trades/day + 尖峰 + 數千市場,且 MDS / OQS 都要能水平擴展。
為何是瓶頸:原始設計裡 MDS 與 OQS 都直接 tail Matching Engine。一旦把這三者各自加 instance 水平擴展,Matching Engine 與 consumer 的連線數呈爆炸式成長,而且 Matching Engine 還得負責 MDS/OQS 的 downtime recovery——這會直接侵蝕撮合的即時效能。
| 方案 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| A. MDS/OQS 直 tail 撮合引擎 | consumer 直連 Matching Engine WAL | 簡單、少一個元件 | 連線爆炸、撮合得管 consumer 故障 |
| B. 中間插 Kafka pub/sub | 撮合 publish event → Kafka → 多 consumer 消費 | 撮合解耦、consumer 獨立擴展/重放 | 多一層基礎設施、多一跳延遲 |
選擇與理由:採 B。Matching Engine 在 append WAL + 更新 in-mem book 後,把 order event publish 到 Kafka;一個 event 可被多 consumer(MDS / OQS)同時消費,各服務獨立擴展、各自管自己的 recovery(從 Kafka replay 而非回連撮合引擎)。Kafka 依 market_id 分區,保證同一市場內訂單的 ordering 與 consistency。而 Matching Engine 本身也 依 market_id sharding——每市場 單一 sequencer,其 shard 的 WAL 即該市場所有訂單的 authoritative event source,故能靠 snapshot + WAL replay deterministic 重建 book。
→ 積木:09-Messaging-Coord/03-Kafka(分區 / ordering / consumer group)、09-Messaging-Coord/01-Data-Pipeline(event 管線 / 重放)、05-Database-Advanced/02-Sharding(market_id 分片)
market_id 而不是 user_id?
因為 撮合的一致性邊界是「市場」:一個市場的 order book 必須由 單一 sequencer 序列化處理,才能保證撮合的 ordering 與可重建性。用 market_id 分區 → 同一市場所有事件落同一 partition、保序、由同一引擎處理。OQS 的 Order DB 才用 user_id 分片,因為訂單查詢是「以使用者為中心」——兩個 key 服務兩種不同的存取模式。
8.3 容錯:撮合引擎掛了怎麼辦(failover 不丟單)
問題:Matching Engine 是某市場 order book 的 source of truth,一旦掛掉就失去權威市場資料。目標:即使 leader 回應後立刻 crash,已 ack 的 order / fill 一筆都不能丟。
為何是瓶頸:純 in-memory book 一掉電就全沒;單靠本地 WAL,若整台機器死了也救不回。
做法(per shard 配 replica):
1. Client 下單 → primary
2. primary append log entry → 本地 durable log(WAL)
3. primary 透過 TCP 把 WAL entry 送 replicas
4. replicas 套用 WAL 到各自 in-memory state machine
5. ≥1 replica ACK → 才回 client「success」(保證事件至少存活 2 台機器)
failover:
- replica 知道最後完整套用的 WAL sequence
- 一個 replica 被 promote 成新 primary(leader election 用 Raft / ZooKeeper / etcd 防 split-brain)
- 新 primary 以新的 leader epoch 繼續 append WAL
- 其餘 replica follow 新 leader
| 取捨維度 | 同步等待的 replica 數 |
|---|---|
| replica 越多 | 一致性越強(已 ack 存活更多份),但每筆請求 延遲越高 |
| replica 越少 | 延遲越低,但容錯越弱 |
| 本題選擇 | 至少 1 replica ACK(事件存活 ≥2 台),在 < 200ms 與不丟單之間取平衡 |
選擇與理由:WAL + primary/replica + ≥1 ACK 是「不丟單」的最小代價組合;leader election 交給 共識協議 / ZooKeeper / etcd 防雙主。需要同步幾個 replica 是 latency vs consistency 的旋鈕——本題定在「≥1」以守住 200ms 延遲預算。
→ 積木:05-Database-Advanced/03-Replication(primary/replica / failover)、12-Ops-and-Reliability/03-Reliable-Delivery(ack / 不丟)、09-Messaging-Coord/05-Zookeeper(leader election / 防 split-brain)
8.4 招牌取捨:撮合 append-only、永不回滾
問題:撮合在鏈下已完成(寫進 WAL),但 鏈上結算可能失敗(gas / 合約 revert / 節點問題)。要不要把 off-chain fill 回滾以保持鏈上鏈下一致?
為何重要:這決定了整個系統的一致性語意,是本題的 core_tradeoff。
| 方案 A:鏈上失敗就 rollback off-chain fill | 方案 B:append-only,永不回滾(retry / 升級 ops) | |
|---|---|---|
| 鏈上鏈下原子性 | ✅ 強原子(要嘛都成、要嘛都退) | ❌ 鏈上最終一致(可能短暫不一致,靠 retry 收斂) |
| ordering 保證 | ❌ 回滾破壞已寫入的事件順序 | ✅ WAL 嚴格 append,順序不變 |
| 狀態可重建性 | ❌ 回滾後 replica / 下游 consumer 狀態可能 分歧 | ✅ 任何節點都能由 WAL replay deterministic 重建 |
| 複雜度 | ❌ 需分散式回滾協調,極高 | ✅ 簡單:撮合只管 append,結算非同步收斂 |
選擇與理由:選 B。撮合流程必須 deterministic 且 append-only——一旦回滾,會 破壞 ordering guarantee、增加狀態重建複雜度、導致 replica 與下游 consumer 狀態分歧。所以鏈上失敗時 只 retry 或升級 ops,絕不 rollback 已完成的 off-chain 撮合。實務上再用 settlement batching / 預先 pre-mint positions 讓撮合 不被鏈上吞吐量阻塞。用「鏈上結算只保證最終一致」換「鏈下撮合的確定性與可重建性」——這就是 core_tradeoff。
→ 積木:09-Messaging-Coord/01-Data-Pipeline(append-only event log / 重放)、12-Ops-and-Reliability/03-Reliable-Delivery(retry / 最終一致收斂)
9. 擴展與瓶頸 (Scaling & Bottlenecks ~5 min)
主動找出系統會先在哪裂開並補強(訓練 drive 而非被動回答)。
| 瓶頸 / 風險 | 補強手段 | 積木 |
|---|---|---|
| 單一撮合引擎扛不住所有市場 / 尖峰 | 依 market_id sharding,每市場單一 sequencer,分散到多 shard |
05-Database-Advanced/02-Sharding |
| MDS/OQS 直連撮合引擎 → 連線爆炸 | 插 Kafka pub/sub 解耦,consumer 獨立擴展、自管 recovery(replay) | 09-Messaging-Coord/03-Kafka、09-Messaging-Coord/01-Data-Pipeline |
| 撮合 leader crash → 失去權威市場資料 | primary/replica + ≥1 ACK + WAL;promote replica,Raft/ZK 防 split-brain | 05-Database-Advanced/03-Replication、09-Messaging-Coord/05-Zookeeper |
| 鏈上吞吐 / gas 阻塞撮合 | settlement batching / pre-mint positions;結算非同步、永不回滾撮合 | 12-Ops-and-Reliability/03-Reliable-Delivery |
| 熱門市場(新聞 / 結算前)價格 fan-out 爆量 | 每市場一個 Redis Pub/Sub channel,只有有訂閱者的 MDS server 才 SUBSCRIBE | 07-Caching-Storage/04-Redis、10-Design-Patterns/05-Real-time-Updates |
| 重 UI 查詢污染撮合延遲 | OQS materialized 讀模型,所有分頁 / 篩選 / 歷史走 OQS,不打撮合引擎 | 06-Database-Tech/04-OLTP-vs-OLAP、10-Design-Patterns/02-Scaling-Writes |
「系統會 先在『撮合引擎與讀路徑耦合』處裂開——MDS/OQS 直連撮合引擎時連線爆炸、且撮合得管 consumer 故障;所以我用 Kafka 解耦 + 依 market_id sharding。撮合本身的可用性靠 WAL + replica + ≥1 ACK 撐;價格 fan-out 靠 Redis Pub/Sub + WebSocket;鏈上慢則用 batching / pre-mint 且 永不回滾撮合。」先指出最可能裂開處再補強。
10. 取捨總表與收尾 (Trade-off Recap & Wrap-up ~2 min)
| 決策點 | 選擇 | 放棄了什麼 | 為什麼值得 |
|---|---|---|---|
| 撮合 vs 鏈上 | off-chain 撮合 + on-chain 結算 | 全鏈上的不可竄改 / 簡單 | 鏈上慢 / 貴 / 低吞吐,高頻邏輯必須留鏈下 |
| 鏈上失敗處理 | append-only,永不 rollback fill | 鏈上鏈下強原子性 | 守住 ordering / deterministic / 可重建(招牌取捨) |
| order book 存放 | in-memory + WAL 持久化 | DB 的簡單與天然持久 | 撮合 p95 < 200ms,落 DB 不可能達標 |
| 讀路徑 | CQRS:Kafka → MDS / OQS | 直連撮合的即時 / 少元件 | 重 UI 查詢不可污染撮合延遲;consumer 獨立擴展 / 重放 |
| 分片 key | 撮合 / Kafka 用 market_id;OQS 用 user_id | 單一 key 的一致 | 兩種存取模式(撮合以市場、查詢以使用者)各取所需 |
| 撮合容錯 | primary/replica + ≥1 ACK | 更高一致(多同步 replica) | 在「不丟單」與「< 200ms」之間取平衡 |
| 價格推播 | HTTP snapshot + WebSocket 增量 | 純輪詢的簡單 | 次秒級傳播只能靠 server 主動推 |
「整題的單一錨點是 撮合 append-only、永不回滾:撮合一旦寫進 WAL 就是唯一事實,鏈上結算失敗也只 retry / 升級,絕不 rollback——因為回滾會毀掉 ordering、可重建性、與下游一致。圍繞這個錨點:order book 在 記憶體 + WAL(< 200ms)、依 market_id 分片(每市場單一 sequencer)、用 Kafka 把讀路徑(MDS/OQS)與撮合解耦(CQRS),價格靠 Redis Pub/Sub + WebSocket 次秒級推送。鏈上慢就 batching / pre-mint,撮合容錯靠 replica + ≥1 ACK。」引用既有決策用連結,不重新推導。
後續追問演練 (Interviewer Follow-ups — "What if…")
撮合是 延遲關鍵路徑(p95 < 200ms),DB 的磁碟 / 網路往返無法達標。order book 放 in-memory 才能極低延遲撮合;持久化責任外包給 WAL(append-only),當機時靠 snapshot + WAL replay deterministic 重建。見 05-Database-Advanced/01-Transactions 與 12-Ops-and-Reliability/03-Reliable-Delivery。
因為撮合必須 deterministic 且 append-only。回滾會 破壞 ordering guarantee、增加狀態重建複雜度、導致 replica 與下游 consumer 狀態分歧。所以鏈上失敗只 retry / 升級 ops,撮合本身保持不變——這是本題 core_tradeoff。見 09-Messaging-Coord/01-Data-Pipeline。
一開始 直接 tail Matching Engine 的 WAL;scale-out 後改成撮合引擎把 order event publish 到 Kafka,MDS / OQS 各自作為 consumer 消費(並可從 Kafka replay 做 recovery)。Kafka 依 market_id 分區保序。見 09-Messaging-Coord/03-Kafka。
撮合層先裂。依序:(1) 細化 market_id sharding,把熱門市場拆到獨立 shard / 機器;(2) Kafka 增 partition + consumer instance 擴展 MDS/OQS;(3) 價格 fan-out 用更多 Redis Pub/Sub channel / 多層 MDS;(4) 結算層 加大 batching / pre-mint 避免鏈上吞吐阻塞。app 入口靠 API Gateway 水平擴展。見 05-Database-Advanced/02-Sharding、09-Messaging-Coord/03-Kafka。
這是 hot market(hot partition)。撮合面:該市場單一 sequencer 是一致性硬約束、不能再切 sequencer,故對 單市場 shard 做垂直擴容(更強機器)+ 把其他市場移出該 shard 騰資源。價格面:每市場一個 Redis Pub/Sub channel,用 多個 MDS server 訂閱同一 channel 分攤 fan-out,每台只服務自己的訂閱使用者。見 07-Caching-Storage/04-Redis 與 10-Design-Patterns/05-Real-time-Updates。
不會。回 success 的前提是 WAL 已被 ≥1 replica ACK,事件至少存活 2 台機器。failover 時一個 replica 被 promote 成新 primary(用 Raft / ZooKeeper / etcd 選主防 split-brain),以新 leader epoch 繼續 append。見 05-Database-Advanced/03-Replication 與 09-Messaging-Coord/05-Zookeeper。
強一致範圍被限定在 order management 本身(撮合引擎內即時權威)。OQS 是 下游 read model(CQRS),NFR 明文允許「透過 OQS 這類 read service 傳播時有一個 小而有界 的延遲」。寫端強一致、讀端最終一致但有界——這是刻意的設計取捨,不是 bug。見 10-Design-Patterns/02-Scaling-Writes。
market_id 分片,OQS 卻用 user_id?
兩種存取模式:撮合的一致性邊界是「市場」——一個 order book 需 單一 sequencer 序列化才能保序與可重建,故用 market_id。訂單查詢以「使用者」為中心(列我的 open orders / 我的歷史),故 OQS 的 Order DB 用 user_id 分片,讓同一使用者的查詢只落單一 shard。見 05-Database-Advanced/02-Sharding。
不會——撮合 不等鏈上結算:撮合成立即寫 WAL、回 success,結算 非同步 交給 Settlement Service。再用 settlement batching(一次落鏈多筆)或 pre-mint positions(事先 mint 好部位)避免撮合被鏈上吞吐阻塞。鏈上失敗只 retry / 升級,永不回滾撮合。見 12-Ops-and-Reliability/03-Reliable-Delivery。
不能靠輪詢。流程:frontend 先以 HTTP 取初始 snapshot(baseline),再 升級為 WebSocket / SSE 串增量。MDS 對每筆 order event 算新價、更新 cache,並透過 每市場一個 Redis Pub/Sub channel 把更新推給訂閱該市場的 MDS server,再由 MDS 推給其使用者。見 10-Design-Patterns/05-Real-time-Updates 與 01-Networking/05-Realtime-Protocols。
主動回想:白板重建 (Whiteboard from Scratch)
不看上文,照下面提示把整個設計重建一次。每格都該能 30 秒內口述。
常見陷阱 (Exam Traps for This Problem)
What:用 SQL/NoSQL 表存活躍訂單、撮合時查 DB。
Why:撮合 p95 < 200ms,DB 磁碟 / 網路往返根本達不到;高頻寫入也會壓垮 DB。
正確做法:order book 放 in-memory,持久化靠 WAL + snapshot,當機 replay 重建。見 05-Database-Advanced/01-Transactions、12-Ops-and-Reliability/03-Reliable-Delivery。
What:為了鏈上鏈下「強原子」,鏈上 revert 時 rollback 已成交的撮合。
Why:回滾 破壞 ordering guarantee、讓 replica / 下游 consumer 狀態分歧、爆增重建複雜度。
正確做法:撮合 append-only / deterministic,鏈上失敗只 retry / 升級 ops,永不回滾(本題 core_tradeoff)。見 09-Messaging-Coord/01-Data-Pipeline。
What:分頁 / 篩選 / 歷史 / analytics 全向撮合引擎查。
Why:撮合引擎為低延遲調校,不擅大型查詢;重 UI 流量會 吃掉撮合延遲預算。
正確做法:獨立 OQS tail event、materialize 成查詢最佳化 Order DB(依 user_id 分片),UI 走 OQS。見 06-Database-Tech/04-OLTP-vs-OLAP、10-Design-Patterns/02-Scaling-Writes。
What:每個 MDS/OQS instance 都直連 Matching Engine。
Why:連線數 爆炸式成長,且撮合引擎還得 管 consumer 的 downtime recovery,侵蝕撮合效能。
正確做法:中間插 Kafka pub/sub 解耦,consumer 獨立擴展、從 Kafka replay 自管恢復。見 09-Messaging-Coord/03-Kafka。
What:撮合引擎也按 user_id 分片。
Why:撮合的一致性邊界是 市場——同一 order book 必須由單一 sequencer 序列化,user_id 分片會把同市場訂單打散到多引擎、毀掉撮合保序。
正確做法:撮合 / Kafka 用 market_id(每市場單一 sequencer),OQS 才用 user_id。見 05-Database-Advanced/02-Sharding。
What:前端每隔幾百毫秒 poll /markets/{id}/summary。
Why:要達次秒級 + 數百萬訂閱者,輪詢會產生海量無效請求、延遲也不穩。
正確做法:HTTP 取 baseline → 升級 WebSocket/SSE 串增量,後端用 Redis Pub/Sub 每市場一 channel fan-out。見 10-Design-Patterns/05-Real-time-Updates、01-Networking/05-Realtime-Protocols。
Related Notes
- 02-Distributed-Systems/05-Numbers-to-Know
- 05-Database-Advanced/01-Transactions
- 05-Database-Advanced/02-Sharding
- 05-Database-Advanced/03-Replication
- 06-Database-Tech/04-OLTP-vs-OLAP
- 07-Caching-Storage/04-Redis
- 09-Messaging-Coord/01-Data-Pipeline
- 09-Messaging-Coord/03-Kafka
- 09-Messaging-Coord/05-Zookeeper
- 10-Design-Patterns/02-Scaling-Writes
- 10-Design-Patterns/05-Real-time-Updates
- 01-Networking/05-Realtime-Protocols
- 11-Infrastructure/01-API-Gateway
- 12-Ops-and-Reliability/01-Dealing-With-Contention
- 12-Ops-and-Reliability/03-Reliable-Delivery
- 03-API-Design/02-REST