限價撮合引擎(price-time priority),含下單閘道、行情推播、主備高可用與確定性重放。
走低延遲 / 強一致 / 確定性路線 —— 與姊妹專案 ../seckill(.NET 高吞吐)
刻意採相反的併發哲學,對照見 設計決策 FAQ。
實作規格見 ROADMAP.md,Agent 工作守則見 CLAUDE.md。 協議 docs/protocol.md · 架構與 ack 語意 docs/architecture.md · 效能數據 docs/perf.md · 問題與決策紀錄 docs/issues.md
| Milestone | 狀態 | 完成日 | 備註 |
|---|---|---|---|
| M0 骨架 | ✅ 完成 | 2026-07-26 | 8 crates、Rust 1.96.1 釘版、workspace lints、Kafka+etcd compose |
| M1 撮合核心 | ✅ 完成 | 2026-07-26 | 四種訂單型別 + STP、proptest 3 不變量 ×10,000、criterion baseline |
| M2 WAL/重放 | ✅ 完成 | 2026-07-26 | framing+CRC32C、snapshot、replay hash 一致、kill -9 ×3 輪 |
| M3 Gateway/組裝 | ✅ 完成 | 2026-07-27 | WS 二進位協議 + JWT + 風控(etcd 熱更新)、佇列背壓、Kafka producer |
| M4 行情/落庫 | ✅ 完成 | 2026-07-27 | L2 snapshot+增量(md_seq)、冪等落庫、me_reconcile_diff==0 |
| M5 主備 HA | ✅ 完成 | 2026-07-27 | etcd 選主 + journal 串流複製、kill -9 接管 2.53–2.59s、零遺失 |
| M6 微秒級攻堅 | 🟡 2/4 量級達成 | — | 吞吐 1M/s ✅、p99 3.98–6.39µs ✅、零配置 ✅;p99.9 與 LAN p99 未達(見下) |
| M7 部署/報告 | ✅ 完成 | 2026-07-27 | RKE2 全部 Ready、k8s failover 4.04s、混沌後 me_reconcile_diff==0 |
M6 兩項量級目標未達,依 CLAUDE.md 不自行放寬、也未打
m6-donetag; 原因與所需條件見 docs/perf.md。M7 為部署與報告階段,獨立驗收。
| 項目 | 數字 |
|---|---|
kubectl apply -k deploy/k8s/ |
11 個 pod 全部 Ready |
| engine primary 永久移除 → 接管 | 4,041ms(修 SIGTERM 前是 34,602ms) |
| gateway pod kill | 新 pod 2s Running,客戶端重連 |
| Kafka 中斷 20s | engine 存活,恢復後 downstream 追上 |
| 混沌後對帳 | me_reconcile_diff{count}=0、{notional}=0,13,672 rows,0 duplicates |
| 端到端 ack p50(生產設定) | 533ms —— 其中 fsync 約 4×、主備複製約 20×(歸因見 perf.md) |
部署階段抓到、本機永遠不會發現的四個 bug(細節見 docs/issues.md):
- journal writer 的
yield_now()空轉 —— 每個 engine 常駐燒掉一整顆 CPU, 在limits.cpu:1下把吞吐壓到 431/s(修正後 2,097/s) - 容器 PID 1 忽略未安裝 handler 的 SIGTERM —— failover 慢了 30 秒
- Kafka 的 PVC 掛在 log dir 的上一層,entrypoint 每次重建該目錄 → topic 全失
- me-marketdata 不跟隨選主,會安靜地黏在熱備上(看起來健康、深度永遠不動)
┌──────────── etcd ────────────┐
│ /matching/leader/{group} │ lease TTL 5s
│ /matching/risk/{symbol} │ 風控熱更新
└───▲──────────────────▲────────┘
watch │ │ campaign
│ │
clients ──WS(binary)──▶ me-gateway ──TCP:9000──▶ me-engine (primary)
▲ JWT + 風控前置 │ │ 單寫者:
│ └── order 路由/合成 ◀──────┤ sequencer(WAL)
│ │ → core 撮合
└──────── ACCEPTED / EXECUTION / CANCELED ──────┤ → 事件分發
│
me-marketdata ◀── EventStream(L2 深度)────┤
(WS: snapshot + md_seq 增量) │
│ TCP:9001 journal 串流
me-downstream ◀── Kafka me.trades ──────────┤
(冪等落庫 → PostgreSQL + 對帳) ▼
me-engine (backup, 熱備)
單寫者模型:每個 symbol 一條撮合執行緒,journal → 撮合 → 事件分發全序化。 撮合執行緒上沒有鎖、沒有 syscall、穩態零記憶體配置。
| 開發機 | 專用機 k6 |
RKE2 節點(agent) | |
|---|---|---|---|
| CPU | AMD Ryzen 5 9600X 6C/12T(Zen 5,2024) | Intel Xeon E5-2695 v2 8 vCPU(Ivy Bridge-EP,2013) | Xeon E5-2695 v2 4 vCPU |
| 時脈 | 基頻 3.9GHz / boost 5.4GHz | 2.40GHz 固定 | 2.40GHz 固定 |
| RAM / OS | 31GB / Windows 11 Pro | 15GB / Debian 13,kernel 6.12 | 7GB / Debian 13,kernel 6.12 |
| 型態 | 實體機 | PVE VM | PVE VM |
| 干擾 | 桌面背景程序;timer 精度 15.6ms | 無其他負載 | 131 個容器任務(Longhorn/Prometheus/calico/kubelet) |
| 建置 | Rust 1.96.1 release,lto="thin", codegen-units=1 |
同左(容器交叉編譯) | 同左 |
下面最好的數字都來自開發機,而它本來就是這三台裡最快的機器 —— 兩顆 CPU
相隔約 11 年,單核性能約 2 倍(同一份 binary:開發機 1,000,000/s、k6 713,579/s)。
這不是調校的功勞。反過來說,唯一能開 isolcpus 的兩台都是老 Xeon 的 VM,
所以「快 CPU + 核心隔離」這個組合從來沒被量到 —— 詳見
docs/perf.md「這批數據沒能分離的兩個變因」。
延遲從排定到達時刻起算(constant-arrival-rate,無 coordinated omission),
quanta 打點,HdrHistogram 至 p99.99。
| 段 | p50 | p90 | p99 | p99.9 | p99.99 |
|---|---|---|---|---|---|
純撮合 sequenced→matched |
0.01µs | 0.02µs | 0.02µs | 0.02–0.03µs | 0.04µs |
ingress→matched(含跨執行緒交棒) |
0.22µs | 0.34µs | 3.98–6.39µs | 159µs | 355µs |
matched→ack(含落盤水位) |
0.21µs | 0.28µs | 43.6µs | 163µs | 646µs |
(開發機、1M orders/s、spin 模式 + RealTime 優先級;RKE2 節點的對照數據見 docs/perf.md。)
| 情境 | 結果 |
|---|---|
| in-process、fsync=off | 1,000,000 orders/s(持續 5s) |
in-process、batch(64,5) |
148,661/s —— 天花板是磁碟:2,323 fsync/s × 64,即本機 NVMe 的 0.43ms fsync |
| 端到端 LAN(loadgen 另機) | 1,000/s 時 p50 607µs / p90 1,037µs / p99 4,731µs |
| 目標 | 實測 | 判定 |
|---|---|---|
| in-process ≥ 1M orders/s | 1,000,000/s | ✅ |
| in-process p99 ≤ 10µs | 3.98–6.39µs(5 次跑 4 次) | ✅ |
| in-process p99.9 ≤ 50µs | 85–159µs | ❌ |
| LAN 端到端 p99 ≤ 1ms | 4,731µs(p50 607µs、p90 1,037µs) | ❌ |
| 確定性重放 hash 一致 | 11,211 筆 kill -9 後兩次 replay hash 相同 | ✅ |
| kill -9 接管 ≤ 3s、已 ack 零遺失 | 2.53–2.59s(4 次)、503 筆全在新 primary journal | ✅ |
| 穩態零配置 | 0 alloc / 0 dealloc / 0 realloc(100k applies) | ✅ |
兩項未達的根因是環境不是程式碼:純撮合段的 p99.9 在所有平台都 ≤0.71µs
(比 50µs 目標好 70–2,500×);超標的部分全在「撮合執行緒沒被 OS 排到」的
跨執行緒交棒段。目前沒有任何一台安靜的機器可用(開發機有桌面負載、
RKE2 節點各有 131 個容器)。達成需要:專用主機 + isolcpus/nohz_full +
只給撮合執行緒(而非整個 process)RT 優先級。詳見 docs/perf.md。
撮合的正確性取決於全序。多執行緒撮合同一個 symbol 需要鎖或 CAS 迴圈, 在爭用下尾延遲遠差於單執行緒;而單執行緒的代價(單核吞吐上限)實測是 每筆 20–90ns,遠高於任何真實市場的單 symbol 訊息率。擴展方式是 按 symbol 分片(每片一條執行緒 / 一個 StatefulSet),不是加副本。
core 不讀時鐘、不用隨機、不做 I/O,輸入只有 InboundMsg 序列 —— 因此
「同一 journal ⇒ 同一事件流」。這一條同時買到:崩潰恢復(重放到崩潰點)、
主備熱備(backup 重放同一串流)、除錯(重現生產事故)、以及 M6 換掉整個
資料結構後的等價性驗證(ladder 與 BTreeMap 逐 bit 相同,10,000 proptest cases)。
ack = min(journal fsync 落盤 seq, backup 已複製 seq)。
即**「已 ack ⇒ 紀錄同時在 primary 磁碟與 backup 上」** —— 這就是
kill -9 零遺失的數學基礎。batch(64,5) 的代價是 ack 延遲(≤5ms 窗),
不是丟失窗口(ack 在 fsync 之後才發)。完整取捨見
docs/architecture.md §2。
實測 park → spin 把 p50 從 7,262µs 打到 0.25µs(29,000×),代價是
多吃約 2 顆核。原因不美好但很真實:Windows 預設 timer 精度 15.6ms,
recv_timeout(1ms) 實際睡 ~15ms。在專用核心上值得,在共享 k8s 上不值得
—— 所以 ME_ENGINE_SPIN 預設 false。
在沒有核心隔離的環境是負收益:實測 p99 差 111×(2,757µs vs 24.8µs)。
硬綁單一邏輯核後,該核被系統執行緒/DPC 佔用時只能等;不綁時排程器立刻
把執行緒挪到空閒核。ME_ENGINE_PIN 預設 false,只有 isolcpus 環境才該開。
(還踩過一次 SMT 坑:pin 到 ids[0]/ids[1] 正好是同一物理核的兩條超執行緒。)
| matching-engine(本專案) | seckill(.NET) | |
|---|---|---|
| 目標 | 微秒級尾延遲、強一致 | 高吞吐、最終一致 |
| 併發 | 單寫者,每 symbol 一執行緒 | 無狀態水平擴展 + HPA |
| 狀態 | 記憶體 order book + WAL journal | Redis + DB |
| 擴展 | symbol 分片(垂直切) | 加副本(水平擴展) |
| 一致性 | 確定性重放、ack 後不丟 | 冪等 + 補償 |
| 熱路徑 | 禁 async/鎖/syscall/配置 | async 全鏈路 |
podman compose up -d # Kafka 3.8.1 (KRaft) + etcd 3.5.17
cargo test --workspace # 31 suites
cargo bench -p me-core # 撮合微基準
me-engine harness --rate 1000000 --duration 5 --spin # 四段延遲 harnessk8s 部署見 deploy/k8s/README.md, 節點壓測腳本見 deploy/bench/README.md。