引子
撮合引擎(Matching Engine)是交易所的心脏:从证券、期货、期权到加密货币 CEX,每一笔成交价格的形成都发生在一段不超过几十微秒的代码里。它是一个看似简单、实则对工程师心智负担极重的子系统——数据结构只有一个”订单簿(Order Book)“,算法只有一个”价格时间优先(Price-Time Priority)“,但围绕它的所有周边:确定性回放、主备一致性、订单类型组合爆炸、自成交预防、集合竞价、分片与 Gateway 协同,构成了现代交易所工程师最难啃的一块骨头。
上一篇《交易所核心系统架构》里我们把撮合引擎当作一个黑盒放在图中央。本文打开这个黑盒:订单是怎么进入的?订单簿是怎么存的?匹配是怎么发生的?为什么几乎所有主流交易所都选择”单线程、无锁、按 symbol 分片”这条路?为什么说”撮合必须是确定性的”,这一句话同时决定了数据结构选型、随机数使用、时间戳来源、甚至日志格式?
本文面向两类读者:一类是想自己写一个玩具撮合引擎、理解 Nasdaq ITCH/OUCH 协议背后机制的工程师;一类是正在交易所、券商、或加密所工作,需要对撮合引擎的性能瓶颈、升级路径、灾备切换做技术判断的架构师。我们会从数据结构讲到 LMAX Disruptor,再到 Rust/Go 的可运行示例,最后给出一份”撮合引擎代码审查清单”。
一、订单簿:撮合引擎唯一的核心数据结构
1.1 订单簿的逻辑模型
中央限价订单簿(Central Limit Order Book,CLOB)是所有主流交易所撮合引擎的底层模型。它由两侧组成:
- 买方(Bid / Buy Side):按价格从高到低排列
- 卖方(Ask / Sell Side):按价格从低到高排列
每一侧在同一价格上会有多笔订单,按到达时间先后排队,形成价格层(Price Level)内的 FIFO 队列。最优买价(Best Bid)和最优卖价(Best Ask)之间的差额叫买卖价差(Spread),它是流动性的最直接度量。
ASK (卖方, 升序)
┌─────────────────────────┐
100.05 │ O7(200) O8(150) │ 350
100.04 │ O5(100) │ 100
100.03 │ O3(500) O4(300) O6(200) │ 1000 ← Best Ask
├─────────────────────────┤
─── SPREAD 0.01 ────┤
├─────────────────────────┤
100.02 │ O1(300) O2(400) │ 700 ← Best Bid
100.01 │ O9(600) │ 600
100.00 │ O10(1000) │ 1000
└─────────────────────────┘
BID (买方, 降序)
当新订单到达并且可成交时(买单价格 ≥ 最优卖价,或卖单价格 ≤ 最优买价),撮合发生;否则订单被加入到相应价格层的队尾。
1.2 数据结构选型
撮合引擎对订单簿的核心操作只有四个:
| 操作 | 频率 | 目标复杂度 |
|---|---|---|
| 查询最优价(Best Bid / Best Ask) | 极高 | O(1) |
| 插入新订单 | 高 | O(log N) 或 O(1) |
| 撤单(按 order_id) | 高 | O(1) |
| 弹出最优价层的队头订单 | 中 | O(1) |
满足这组需求的典型实现有三种:
平衡树(红黑树 / AVL)+ 哈希索引
最常见的做法。用
std::map<Price, PriceLevel>(C++)或
BTreeMap<Price, PriceLevel>(Rust)按价格排序存储价格层,每个价格层内部是一个双向链表实现的
FIFO 队列。再维护一个
HashMap<OrderId, ListNode*> 用于 O(1)
定位到节点实现快速撤单。
- 优点:通用、易理解、对价格范围无限制
- 缺点:每次插入 O(log N)、缓存不友好、指针追逐多
跳表(Skip List)
Redis ZSET
的做法,与平衡树复杂度相同,但实现更简单、插入删除的常数因子略低,易于做
lock-free 变种。一些 Go 实现(如
goex/go-exchange 的早期版本)使用跳表。
定点价格梯(Fixed-Tick Price Ladder)
当价格精度是固定档位(tick size,如股票 0.01 元、期货 0.5
点)且波动范围有界时,可以用一个数组直接存储所有价格层,下标
= (price - min_price) / tick_size。
- 最优价查询:维护一个
best_bid_idx指针,O(1) - 插入 / 撤单:O(1)
- 代价:内存占用大(即使没有订单的价格档位也占用一个槽),价格范围必须预估
CME Globex、Nasdaq INET、以及多数追求极致低延迟的撮合引擎都采用数组价格梯。Jane Street 有一篇著名博客描述了它如何用数组(而非红黑树)把撮合延迟压到 3 微秒以内。对于加密货币撮合(价格波动大、tick size 可能变化),仍以红黑树 / 跳表为主。
1.3 价格层内部结构
价格层内部始终是 FIFO 队列(对价格时间优先规则而言)。但它不仅要支持队尾入队、队头出队,还要支持中间删除(撤单)。因此典型实现是双向链表 + HashMap 索引:
PriceLevel {
price: i64
qty: u64 // 该价格层总量(O(1) 查询用)
head: OrderNode*
tail: OrderNode*
}
OrderNode {
order_id, account_id, qty, ts_ns
prev, next
}
Index: HashMap<OrderId, OrderNode*>
这样插入、撤单、队头出队都是 O(1)。唯一的 O(log N) 操作是新建 / 删除价格层时对树的操作。
1.4 冰山订单与隐藏单的存储
冰山订单(Iceberg) 在订单簿中只暴露一部分显示量(Visible Qty / Peak),剩余为隐藏量(Hidden Qty / Reserve)。实现上,每次显示量被吃掉,引擎自动从隐藏量”补货”,但补货后的新切片会在队尾重新排队(因为时间优先要求:后来的量不能优先于同价位的其他订单)。
Iceberg Order: display=100, reserve=900
┌──────────────────────────────────┐
│ PriceLevel 100.03 queue: │
│ A(300) B(100-visible) C(200) │ ← B 是冰山,显示 100
└──────────────────────────────────┘
↓ B 被吃掉 100
┌──────────────────────────────────┐
│ A(300) C(200) B'(100-visible) │ ← 补货后 B' 重新排队尾
│ (B reserve: 800) │
└──────────────────────────────────┘
隐藏单(Hidden Order / Iceberg with display=0) 则完全不出现在公开行情中,但在成交时仍然按价格 / 时间优先参与撮合,且通常让利于显示单(见 1.5)。
1.5 L1 / L2 / L3 行情视图
订单簿对外输出的行情有三种深度:
- L1(Top of Book):只包含最优买 / 卖价及数量
- L2(MBP,Market By Price):按价格层聚合,例如买 10 档、卖 10 档
- L3(MBO,Market By Order):每一笔订单作为独立事件(Nasdaq ITCH 协议就是 L3)
L3 信息是撮合引擎内部状态的最完整对外投影,但只给做市商、专业交易者;普通用户看到的是 L2 或 L1。详见下一篇《行情分发》。
二、匹配算法:从价格时间优先到 Pro-Rata
2.1 价格时间优先(Price-Time Priority)
绝大多数现货、股票、加密交易所采用价格时间优先:
- 先比价格(更优价优先)
- 同价格比时间(先到优先)
伪代码:
function match(incoming_order):
while incoming_order.qty > 0:
best_level = opposite_book.top()
if best_level is None: break
if not crosses(incoming_order.price, best_level.price): break
maker_order = best_level.head
trade_qty = min(incoming_order.qty, maker_order.qty)
emit_trade(incoming_order, maker_order, best_level.price, trade_qty)
maker_order.qty -= trade_qty
incoming_order.qty -= trade_qty
if maker_order.qty == 0:
best_level.pop_head()
if best_level.empty(): opposite_book.remove(best_level)
if incoming_order.qty > 0 and incoming_order.type == LIMIT:
same_book.insert(incoming_order)
成交价取 maker(被动方) 的挂单价格,而非 taker(主动方)的价格——这是”价格改进(Price Improvement)“的来源,也是和”询价 / RFQ”模式的本质区别。
2.2 Pro-Rata(按比例分配)
在某些高度集中、买卖价差经常为一个 tick 的市场(如 CME 某些短期利率期货、欧洲 Bund),价格时间优先会导致”谁快谁通吃”、做市商过度拥堵。Pro-Rata 规则把到来的订单量按比例分配给同一价格层的所有挂单:
PriceLevel 100.03 (total 1000):
A(600), B(300), C(100)
Incoming SELL 500 @ 100.03:
A 分得 500 * 600/1000 = 300
B 分得 500 * 300/1000 = 150
C 分得 500 * 100/1000 = 50
Pro-Rata 鼓励挂大单,但引入了另一个问题:大单可能被故意挂出去再撤单(Spoofing 嫌疑),因此监管对 Pro-Rata 市场的 Layering/Spoofing 监控更严。
2.3 混合规则:Pro-Rata with Top Order / Allocation
为了缓解”大单垄断”,很多期货市场采用混合规则:
- Top Order Priority:价格层中时间最早的订单(“头单”)先吃一部分(如 40%),剩余 60% 按 Pro-Rata 分给所有人
- Price-Time + Pro-Rata:先按时间优先吃掉一定比例,剩余按 Pro-Rata
- Market Maker Allocation:做市商账户有单独优先层,先于普通账户成交(常见于新上市合约启动初期,激励做市商提供流动性)
CME Globex 针对不同合约有多种匹配算法:FIFO、Allocation、Pro-Rata、Threshold Pro-Rata、Configurable TPRO、LMM(Lead Market Maker)Allocation。每个合约在上市前就固定,不允许随意切换。
2.4 集合竞价(Call Auction)
连续竞价(Continuous Trading)之外,交易所在开盘、收盘(以及某些情况的临时停复牌)会进行集合竞价:接受订单但不立即撮合,在某个时间点以一个统一价格一次性成交最大量。
集合竞价定价算法通常遵循最大成交量 → 最小未匹配量 → 参考价附近三优先原则:
- 能使当次成交量最大的价格
- 如多个价格都能达到最大成交量,选择买卖剩余未成交量最小的
- 如仍有多个,选择距离前一成交价(或参考价)最近的
- 仍并列,取中间值或按交易所规则定
flowchart TD
A[订单持续进入集合竞价簿] --> B[T-1s 停止接单]
B --> C[计算每个候选价的可成交量]
C --> D{是否存在唯一最大量价格?}
D -- 是 --> E[该价即开盘价]
D -- 否 --> F[选剩余量最小的]
F --> G{仍有并列?}
G -- 是 --> H[取最接近参考价的]
G -- 否 --> E
E --> I[以开盘价统一成交 按价格时间优先分配]
I --> J[进入连续竞价]
上交所、深交所开盘集合竞价 9:15–9:25(其中 9:20–9:25 不允许撤单),收盘集合竞价 14:57–15:00。港交所、欧交所、Nasdaq 都有自己的集合竞价窗口。
2.5 自成交预防(Self-Trade Prevention, STP)
同一交易账户(或关联账户组)的买卖单在订单簿中相遇时,按法规和交易所规则不应成交(会被视为 wash trade,操纵市场嫌疑)。STP 策略常见有:
| 策略 | 处理方式 |
|---|---|
| Cancel Taker (CT) | 撤销主动方新单 |
| Cancel Maker (CM) | 撤销被动方旧单 |
| Cancel Both (CB) | 双方全撤 |
| Decrement Both (DB) | 按较小量同时减少;若一方归零则撤,另一方保留剩余 |
实现上,在匹配循环检测到
incoming.account_group == maker.account_group
时分叉走 STP 分支。注意:STP
会影响价格发现(被迫跳过一些挂单),所以通常只针对同一账号、或同一账号组(如
master-subaccount 关系),不跨账户。
Binance、Bybit 公开 API 中 STP
模式是下单参数之一:selfTradePreventionMode=EXPIRE_TAKER|EXPIRE_MAKER|EXPIRE_BOTH|NONE。Coinbase
默认 Decrement and Cancel。
三、订单类型的实现
订单类型是撮合引擎复杂度的主要来源。下表概括最常见的订单类型及其在引擎内部的实现要点:
| 类型 | 含义 | 实现要点 |
|---|---|---|
| Limit (GTC) | 限价单,Good-Till-Cancel | 撮合剩余部分挂入订单簿 |
| Market | 市价单 | 按对手方价格逐档吃,直到填满或对手方空;剩余直接拒绝(A 股)或按最差价保护拒绝(美股 NBBO 保护) |
| IOC | Immediate-or-Cancel | 可成交部分立即成交,剩余撤销 |
| FOK | Fill-or-Kill | 如不能全部立即成交则全部撤销 |
| GTD | Good-Till-Date | 带过期时间的 GTC;引擎或定时任务在到期时自动撤销 |
| Post-Only / Maker-Only | 只允许做 maker | 如果会立即成交则撤销(不当 taker) |
| Stop / Stop-Limit | 触发式 | 价格触发条件满足后转为 Market / Limit 单进入撮合 |
| Iceberg | 冰山 | 显示量 + 隐藏量,显示量吃完从隐藏量补货并重排 |
| Hidden / Dark | 隐藏单 | 不出现在公开行情;通常成交优先级低于显示单 |
| Peg | 钉盘单 | 价格跟随最优买 / 卖价自动调整 |
| MOC / LOC | Market/Limit-on-Close | 仅参与收盘集合竞价 |
3.1 Stop 单的引擎实现
Stop 单不进入订单簿,而是进入一个独立的”待触发单”结构,按触发价索引(再按 symbol 分片)。撮合引擎每次成交后,拿最新成交价去该结构里找”触发价被穿过”的 Stop 单,把它们转为 Limit/Market 注入主撮合循环。
关键约定:Stop 单的触发与进入撮合之间必须是同一个事件循环内的连续步骤,否则会被抢跑。这也是为什么几乎所有撮合引擎都坚持单线程(见第六章)。
3.2 FOK 的二阶段扫描
FOK 要求”要么全部立即成交,要么全部撤销”。实现上先预扫描订单簿对手侧,计算若以该价格吃进所有可成交单,累计量是否 ≥ 订单量。若是,正式进入匹配循环;若否,直接拒绝。预扫描不得修改簿状态。
3.3 GTD 的过期清理
GTD / GTC with expiry 需要一个过期清理线程或时间轮,到期后把订单从簿中撤出。清理本身必须通过撮合主循环的消息队列(而非直接修改簿),以保持事件顺序的确定性。
四、确定性:撮合引擎的第一性原则
4.1 为什么必须确定性
“确定性(Deterministic)”指:给定相同的初始状态和相同的输入序列,撮合引擎必须产生逐字节相同的输出(成交记录、订单簿快照、行情事件)。这是以下能力的前提:
- 主备热备与故障切换:备机重放主机的输入流即可得到相同状态
- 审计与回放:监管、风控、客户纠纷时需要回放撮合过程
- 跨机房灾备:异地备机可以从 WAL 回放建立一致副本
- 压力测试与回归:可以录制生产流量,在测试环境完全重放
任何违反确定性的代码(如
time.Now()、rand.Intn()、goroutine
调度、HashMap
遍历顺序)都必须被输入化——即把不确定来源变成输入事件的一部分。
4.2 时间戳的处理
撮合引擎使用的时间戳必须来自输入事件(Gateway
打上的 ingress 时间),而不是引擎内部
time.Now()。这样,备机重放时间戳来自同一条输入流。
# 错误做法
func onOrder(order *Order) {
order.AcceptedAt = time.Now() // 非确定性
book.Add(order)
}
# 正确做法
type Input struct {
SeqNo uint64
IngressTs int64 // Gateway 打上,随 WAL 持久化
Payload Order
}
func onInput(in *Input) {
in.Payload.AcceptedAt = in.IngressTs
book.Add(&in.Payload)
}
4.3 序列号与输入流
所有进入撮合的请求(下单、撤单、改单、定时触发、行情快照请求……)被 Gateway 层统一编号成一个单调递增的输入序列,以该序列为 WAL 写入顺序、也是撮合处理顺序。输出事件(Trade、Ack、Reject、BookUpdate)同样带连续 seq,供下游消费与回放对齐。
Input WAL:
seq=1001 ts=... NEW_ORDER order_id=A qty=100 px=10.03
seq=1002 ts=... NEW_ORDER order_id=B qty=50 px=10.03
seq=1003 ts=... CANCEL order_id=A
seq=1004 ts=... NEW_ORDER order_id=C qty=200 px=10.04 ← 触发撮合
Output WAL:
seq=2001 ref=1004 TRADE taker=C maker=B qty=50 px=10.03
seq=2002 ref=1004 ACK order_id=C remaining=150
seq=2003 ref=1004 BOOK level=10.04 +150
4.4 随机数与并发
如果业务确实需要随机(如 Pro-Rata
中有零头时的分配),从输入事件里带入种子,或用
hash(seq_no)
作为确定性随机源。严禁使用
/dev/urandom 或系统 RNG。
撮合引擎核心一律单线程(见第六章),这是解决并发不确定性最简单粗暴的办法。
五、内存结构 + WAL + 快照 + 主备
5.1 状态机模型
撮合引擎本质上是一个纯函数:
next_state, output_events = match(prev_state, input_event)
State = 订单簿 + 待触发单 + 账户持仓(部分引擎把持仓剥离出去),Input 来自上游 Gateway 的有序流,Output 是成交 + 行情事件 + ACK。这是典型的状态机复制(State Machine Replication)模型——与 Raft、Multi-Paxos 管理的状态机完全同构。
5.2 WAL(预写日志)
每条输入在进入撮合前先落盘 WAL。WAL 的几个关键设计:
- 写盘模式:
O_DSYNC/fsync每条都落盘(安全但慢),或每 N 毫秒 / M 条批量 fsync(折中) - 存储:本地 NVMe SSD(低延迟);分布式场景可用 Raft 日志(如 CockroachDB / etcd 风格),但增加 1–2 次 RTT
- 格式:通常是定长头 + 变长 payload(Protobuf / Cap’n Proto / FlatBuffers)
- 轮转与归档:按大小 / 时间切段,冷段压缩转对象存储
5.3 快照
只有 WAL 无法应对长时间运行(重放整天 WAL 太慢)。引擎定期把整个订单簿序列化成快照(Snapshot),落盘时记录”对应到哪个 WAL seq”。恢复时:加载最近快照 + 重放该 seq 之后的 WAL。
快照的关键工程点:
- 不得阻塞主循环:使用写时复制(Copy-on-Write),或在单线程主循环中 fork 出子进程(Redis RDB 思路)做真正的序列化
- 一致性边界:快照必须对应一个精确的
applied_seq,不得”撕裂” - 格式稳定:跨版本升级时兼容新旧格式(字段加法兼容,字段语义变更必须走迁移)
5.4 主备同步
主备架构有两种主流模式:
Active-Passive with Log Shipping
主机把 WAL 条目流式发送给备机,备机应用相同事件序列,得到相同状态。切换时备机接管 Gateway 流量。这是 Nasdaq、Deutsche Börse、CME 等传统交易所使用的模式。
flowchart LR
G[Gateway] -->|seq序列化| P[Primary ME]
P -->|WAL| D1[(Local Disk)]
P -->|Replicate| B[Backup ME]
B -->|Apply same seq| D2[(Local Disk)]
P --> M[Market Data]
B -.standby.-> M
Raft / Paxos 复制
写入 WAL 本身就走共识协议,至少 2/3 副本落盘才返回。一致性更强(无需额外切换协议),但延迟高一档(多一次 RTT)。dYdX v4 链上 CLOB 撮合走 Cosmos SDK + CometBFT(Tendermint),走的就是这条路:撮合逻辑本身跑在多个全节点上,通过 BFT 共识达成一致。
5.5 切换与一致性
主备切换场景下要避免的最大灾难是脑裂下的双撮合。常见手段:
- Fencing Token:主机启动时从外部协调服务(ZK / etcd)拿到单调递增 epoch,Gateway 只接受带最新 epoch 的主机
- STONITH:切换前强制旧主下电
- Gateway 层幂等:下游撮合以 (seq, epoch) 为幂等键,即使暂时双写也不会产生重复成交
六、性能工程:单线程、无锁、缓存友好
6.1 为什么是单线程
撮合引擎的计算非常轻——大部分订单的处理只是几次指针操作、一次树查找、一次链表操作——CPU 几十到几百纳秒就能完成。真正的瓶颈不是 CPU,而是:
- 锁竞争与 cache-line 争抢
- 非确定性(多线程排序)
- 复杂度带来的 bug
所以业界共识是:单 symbol 的撮合必须单线程。多 symbol 通过分片(Sharding)并行:不同 symbol 分到不同撮合线程 / 实例上。BTC/USDT、ETH/USDT 各自跑一个 worker,没有任何跨 symbol 状态。
6.2 LMAX Disruptor 模型
LMAX(伦敦一家多资产交易所)2010 年开源了 Disruptor 模式,把并发队列换成了环形缓冲区 + CAS 序号,在一台机器上做到每秒 600 万 TPS,单次订单处理延迟稳定在微秒级。核心思想:
- 环形数组预分配,无 GC、无 heap 分配
- 单生产者多消费者或多生产者单消费者,用原子序号协调
- 消费者链式编排:JournalConsumer(写 WAL)→ ReplicationConsumer(发备机)→ MatchingConsumer(撮合核心)→ OutputConsumer(下游广播),每个阶段单线程,但多个阶段流水线并行
- Mechanical Sympathy:cache-line padding 避免伪共享(false sharing),紧凑数据布局避免 cache miss
Disruptor 的流水线版本最终形态:
flowchart LR
GW[Gateway<br/>多生产者] --> RB[Ring Buffer<br/>单一事件序列]
RB --> J[Journal 线程<br/>WAL]
RB --> R[Replication 线程<br/>发备机]
RB --> ME[Matching 线程<br/>核心撮合]
J -.fence.-> ME
R -.fence.-> ME
ME --> OUT[Output 线程<br/>广播]
撮合线程等待 Journal、Replication 都越过了自己要处理的 seq 才处理(通过 fence / barrier 等待),确保”撮合输出的都是已持久化、已复制的订单”。
6.3 缓存友好布局
- 热路径数据结构尽量连续:PriceLevel
数组化、订单用
slab allocator连续分配 - 避免虚函数:订单类型用
enum + switch而非多态(编译器内联、分支预测更友好) - 结构体字段按访问频率排列:热字段放前面落在同一 cache line
- 避免 UTF-8 字符串:symbol 转为
u32id,账户转为u64id
6.4 分片策略
按 symbol(合约) 分片是唯一广泛采用的策略。优点是跨分片零通信(订单、撤单、成交都在单一 symbol 内)。缺点是热门 symbol(如 BTC/USDT)单分片压力大,这时要靠垂直扩展(更快的 CPU、RDMA、内核旁路网络)。
按用户分片 不可行,因为一笔订单涉及两个用户(taker/maker),会触发跨分片事务,撮合立即退化成分布式事务。
按价格区间分片 在学术上被讨论过,但工程上没有严肃使用:价格会漂移,区间划分难以稳定。
6.5 低延迟网络栈
撮合引擎之外的延迟优化(与本文主题相关但不展开):
- 内核旁路:DPDK、Solarflare Onload、ef_vi
- 用户态 TCP:Seastar、mTCP
- RDMA / InfiniBand
- 网卡硬件时间戳、PTP 精确时间同步
- CPU pinning、隔离核、禁用 HT、禁用 C-States、调低 IRQ coalescing
Nasdaq INET、Deutsche Börse T7、CME Globex 的”机柜到撮合”往返延迟在个位数微秒。
七、代码示例:Rust 简化撮合引擎
下面给出一个可运行的 Rust 简化撮合引擎:价格时间优先、支持 Limit/Market/Cancel、单线程。真实引擎要再加 WAL、快照、STP、订单类型、统计、行情分发,但核心循环就是这些。
7.1 数据结构
use std::collections::{BTreeMap, HashMap, VecDeque};
pub type OrderId = u64;
pub type Price = i64; // 定点价格, 乘以 tick=1e-4 得到实际价格
pub type Qty = u64;
pub type Ts = i64;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Side { Buy, Sell }
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum OrdType { Limit, Market }
#[derive(Clone, Debug)]
pub struct Order {
pub id: OrderId,
pub side: Side,
pub ord_type: OrdType,
pub price: Price, // Market 单忽略
pub qty: Qty,
pub account: u64,
pub ts: Ts,
}
#[derive(Clone, Debug)]
pub struct Trade {
pub taker: OrderId,
pub maker: OrderId,
pub price: Price,
pub qty: Qty,
pub ts: Ts,
}7.2 订单簿
pub struct Book {
// Buy 用 Reverse(Price) 让最大价在前; 这里用 BTreeMap 正序存, 取 iter().next_back() 得最大
bids: BTreeMap<Price, VecDeque<OrderId>>,
asks: BTreeMap<Price, VecDeque<OrderId>>,
orders: HashMap<OrderId, Order>, // 全量索引, 便于撤单
}
impl Book {
pub fn new() -> Self {
Self { bids: BTreeMap::new(), asks: BTreeMap::new(), orders: HashMap::new() }
}
pub fn best_bid(&self) -> Option<Price> { self.bids.keys().next_back().copied() }
pub fn best_ask(&self) -> Option<Price> { self.asks.keys().next().copied() }
fn insert(&mut self, o: Order) {
let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
side_map.entry(o.price).or_default().push_back(o.id);
self.orders.insert(o.id, o);
}
pub fn cancel(&mut self, id: OrderId) -> Option<Order> {
let o = self.orders.remove(&id)?;
let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
if let Some(q) = side_map.get_mut(&o.price) {
if let Some(pos) = q.iter().position(|&x| x == id) {
q.remove(pos);
}
if q.is_empty() { side_map.remove(&o.price); }
}
Some(o)
}
}生产级实现中,价格层应用双向链表而不是 VecDeque,以支持 O(1) 中间删除;VecDeque remove 是 O(N)。这里为了代码简短用 VecDeque。
7.3 匹配循环
impl Book {
pub fn place(&mut self, mut order: Order) -> Vec<Trade> {
let mut trades = Vec::new();
loop {
if order.qty == 0 { break; }
// 取对手方最优价
let (opp_px, opp_q_front_id) = match order.side {
Side::Buy => {
let Some((&px, q)) = self.asks.iter_mut().next() else { break; };
(px, *q.front().unwrap())
}
Side::Sell => {
let Some((&px, q)) = self.bids.iter_mut().next_back() else { break; };
(px, *q.front().unwrap())
}
};
// 是否可成交
let crosses = match (order.ord_type, order.side) {
(OrdType::Market, _) => true,
(OrdType::Limit, Side::Buy) => order.price >= opp_px,
(OrdType::Limit, Side::Sell) => order.price <= opp_px,
};
if !crosses { break; }
// 自成交预防 (简化: Cancel Taker)
let maker = self.orders.get(&opp_q_front_id).unwrap().clone();
if maker.account == order.account {
order.qty = 0; // taker 全撤
break;
}
let fill = order.qty.min(maker.qty);
trades.push(Trade {
taker: order.id,
maker: maker.id,
price: opp_px,
qty: fill,
ts: order.ts,
});
order.qty -= fill;
// 更新 maker
let maker_remaining = maker.qty - fill;
if maker_remaining == 0 {
self.cancel(maker.id);
} else {
self.orders.get_mut(&maker.id).unwrap().qty = maker_remaining;
}
}
// 剩余挂簿 (Limit 且有剩余)
if order.qty > 0 && matches!(order.ord_type, OrdType::Limit) {
self.insert(order);
}
trades
}
}7.4 单元测试
#[cfg(test)]
mod tests {
use super::*;
fn ord(id: u64, side: Side, t: OrdType, px: Price, qty: Qty, acc: u64) -> Order {
Order { id, side, ord_type: t, price: px, qty, account: acc, ts: id as i64 }
}
#[test]
fn empty_book_limit_rests() {
let mut b = Book::new();
let trades = b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
assert!(trades.is_empty());
assert_eq!(b.best_bid(), Some(10_000));
}
#[test]
fn partial_fill() {
let mut b = Book::new();
b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 60, 2));
assert_eq!(trades.len(), 1);
assert_eq!(trades[0].qty, 60);
assert_eq!(b.best_ask(), Some(10_000)); // maker 剩 40 仍挂着
}
#[test]
fn full_fill() {
let mut b = Book::new();
b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 100, 2));
assert_eq!(trades.len(), 1);
assert_eq!(trades[0].qty, 100);
assert_eq!(b.best_ask(), None);
}
#[test]
fn sweep_multiple_levels() {
let mut b = Book::new();
b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 30, 1));
b.place(ord(2, Side::Sell, OrdType::Limit, 10_001, 40, 1));
b.place(ord(3, Side::Sell, OrdType::Limit, 10_002, 50, 1));
let trades = b.place(ord(4, Side::Buy, OrdType::Limit, 10_002, 100, 2));
assert_eq!(trades.len(), 3);
assert_eq!(trades.iter().map(|t| t.qty).sum::<u64>(), 100); // 30 + 40 + 30
assert_eq!(b.best_ask(), Some(10_002)); // O3 剩 20 仍挂
}
#[test]
fn self_trade_prevents() {
let mut b = Book::new();
b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 7));
let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 50, 7));
assert!(trades.is_empty()); // 同账户, taker 被撤
assert_eq!(b.best_ask(), Some(10_000)); // maker 保留
}
#[test]
fn market_no_counterparty() {
let mut b = Book::new();
let trades = b.place(ord(1, Side::Buy, OrdType::Market, 0, 100, 1));
assert!(trades.is_empty());
// 市价单不入簿
assert_eq!(b.best_bid(), None);
}
#[test]
fn cancel() {
let mut b = Book::new();
b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
let canceled = b.cancel(1);
assert!(canceled.is_some());
assert_eq!(b.best_bid(), None);
}
}7.5 Go 版骨架
Go 的 BTree 需要引入第三方库(如
google/btree),示例骨架:
type Book struct {
bids *btree.BTreeG[*Level] // 按 -price 排序
asks *btree.BTreeG[*Level] // 按 price 排序
orders map[uint64]*Order
}
type Level struct {
price int64
queue *list.List // container/list 双向链表, 每个元素是 *Order
total uint64
}
func (b *Book) Place(o *Order) []Trade {
var trades []Trade
for o.Qty > 0 {
best := b.bestOpposite(o.Side)
if best == nil || !crosses(o, best) { break }
e := best.queue.Front()
m := e.Value.(*Order)
if m.Account == o.Account {
o.Qty = 0 // 简化的 STP
break
}
fill := min(o.Qty, m.Qty)
trades = append(trades, Trade{Taker: o.ID, Maker: m.ID, Price: best.price, Qty: fill})
o.Qty -= fill
m.Qty -= fill
if m.Qty == 0 {
best.queue.Remove(e)
delete(b.orders, m.ID)
if best.queue.Len() == 0 { b.removeLevel(best) }
}
}
if o.Qty > 0 && o.Type == Limit { b.insert(o) }
return trades
}八、订单状态机与撮合流程
8.1 订单状态机
stateDiagram-v2
[*] --> Received: Gateway 接收
Received --> Validated: 风控 / 资金冻结通过
Received --> Rejected: 校验 / 资金不足
Validated --> Accepted: 进入撮合队列
Accepted --> PartiallyFilled: 部分成交
Accepted --> Filled: 全部成交
Accepted --> Canceled: 用户撤单
Accepted --> Expired: GTD 到期
PartiallyFilled --> Filled: 继续成交填满
PartiallyFilled --> Canceled: 用户撤单剩余
PartiallyFilled --> Expired: GTD 到期
Filled --> [*]
Canceled --> [*]
Rejected --> [*]
Expired --> [*]
8.2 撮合事件流
sequenceDiagram
participant C as Client
participant G as Gateway
participant W as WAL
participant ME as Matching
participant MD as Market Data
participant R as Risk/Clearing
C->>G: NewOrder(BTC/USDT, BUY, 10, 65000)
G->>G: 签名校验 / 限流
G->>R: 冻结 10*65000 USDT
R-->>G: OK
G->>W: Append seq=N (NEW_ORDER)
W-->>G: persisted
G->>ME: dispatch by symbol
ME->>ME: match loop (单线程)
ME-->>G: Ack + Trades + BookDelta
ME->>MD: 行情事件
ME->>R: 成交 → 扣资金 / 加持仓
G-->>C: Exec Report (8=FIX...)
MD-->>C: L1/L2/L3 更新
九、开源实现参考
学习撮合引擎最好的方式是读开源代码:
- liquibook(C++,Object Computing Inc.):最早期的开源 CLOB 撮合参考实现,代码清晰,包含标准订单类型与匹配算法。 https://github.com/enmotech/liquibook
- exchange-core(Java,Maksim Zheravin):基于 LMAX Disruptor,号称单核百万 TPS 级别,是学习 Disruptor 风格撮合的最好参考。 https://github.com/exchange-core/exchange-core
- dYdX v4 clob 模块(Go / Cosmos SDK):链上 CLOB 撮合,跑在 Cosmos validator 上,用 CometBFT 做共识。工程复杂度极高,但完整展示了”可验证撮合”的挑战。 https://github.com/dydxprotocol/v4-chain
- Hummingbot / ccxt-pro:不是撮合引擎本身,但可以作为客户端接入开源撮合做压测。
- Nasdaq ITCH/OUCH 协议文档:虽然撮合实现闭源,但协议文档公开,可以从”撮合对外输出”反推其内部结构。 https://www.nasdaq.com/solutions/trading-services
- Jane Street / Optiver 技术博客:若干关于 lock-free 订单簿、数组价格梯、“为什么红黑树比不上数组”的一线分享。
十、工程坑点与选型建议
10.1 真实事件里的撮合教训
- 2010 年美股闪电崩盘(Flash Crash):一笔巨量 E-mini 卖单触发流动性抽离,几分钟内道琼斯下跌近 1000 点。事后调查揭示 CME 与 NYSE 之间的熔断机制不一致、套利算法在撮合深度瞬间消失时的行为是重要诱因。
- 2012 年 Knight Capital 事故:部署时旧代码和新代码在不同服务器共存,4 分钟内误发 397 亿美元委托,亏损 4.4 亿美元。与撮合引擎本身无关,但暴露了”撮合输入端”错单放大的可怕。
- 2013 年光大乌龙指:自营策略程序缺陷,在 2 秒内误下 234 亿元市价买单,其中 72.7 亿元成交。上交所撮合引擎正常运作、保护性机制却不足以拦住”异常但合法”的巨单——此后”异常订单过滤”成为风控前置的硬性要求。
- 2020 年东京证券交易所 arrowhead 4 宕机:共享硬盘故障加上自动切换逻辑判断错误,导致股票市场整日停摆。撮合引擎本身健康,但主备切换失败。教训是主备切换必须在平时频繁演练,至少每季度一次真实切换。
10.2 坑点清单
- 浮点价格是大坑:永远用定点整数(
price_int = round(price * 10^decimals))。用double的系统都会在某一天遇到0.1 + 0.2 != 0.3的成交价问题。 - 时间戳单位不统一:ns、µs、ms 混用是常见事故源。全链路规定 ns / int64 最省心。
- 撤单和成交的竞态:用户发起撤单、恰好同一事件循环中该单被吃掉——必须按输入顺序处理:如果撤单 seq 在成交输入 seq 之前,撤单成功;否则撤单返回 TOO_LATE。
- 市价单深度击穿:无上限的 Market 单在薄簿下会扫光对手方,造成价格跳跃。必须加保护(NBBO、价格偏离阈值、熔断)。
- Iceberg 刷新与时间优先冲突:冰山补货必须排到队尾,否则演变成”无限优先级”的作弊手段。
- Stop 单风暴:价格触发一个 Stop 转成 Market,Market 又把价格打低,再触发更多 Stop——典型的 cascade。Nasdaq / CME 的”Stop Limit with Protection”、熔断机制就是针对这种情况。
- 确定性被打破的隐蔽来源:HashMap
迭代顺序、Go map range、
rand、多线程日志、异步 GC——逐一审计。 - WAL 文件损坏:断电、文件系统问题、位翻转。每条记录加 CRC32,启动时校验;引入doublewrite或checksum per segment。
- 性能退化:某些价格层变空但结构体未清理、冰山订单堆积、过期 GTD 未清——定期”GC”或惰性清理。
- 监控盲区:撮合延迟的尾延迟(P99.9、P99.99)比均值重要得多。交易所 SLA 通常是”99.999% 订单 < X µs”,用直方图(HDR Histogram)记录。
10.3 选型建议
| 场景 | 建议 |
|---|---|
| 创业加密所、日均 < 10 万单 | Go / Rust 单进程 + Postgres WAL 模拟,优先把 STP、订单类型、对账做对,别过度优化 |
| 专业加密所、日均百万单 | Rust / C++,自建 WAL + 快照,按 symbol 分片,Disruptor 风格线程模型,P99 < 1ms 可达 |
| 传统证券 / 期货交易所 | 闭源商业引擎(Nasdaq INET、Cinnober TRADExpress、FIS MillenniumIT)+ 深度定制;自研团队 50+ 人,3 年以上周期 |
| 链上 CLOB(DeFi) | 直接基于 Cosmos SDK / dYdX v4 分叉,撮合通过 BFT 共识;延迟档次牺牲,换来可验证性与无 KYC |
| 教学 / 研究 | exchange-core、liquibook 源码 + 本文 Rust 示例 |
10.4 落地清单
十一、SVG:订单簿分层可视化
十二、若干进阶话题
12.1 基准测试的正确姿势
评估撮合引擎性能时,常见的错误做法:
- 只测均值:撮合引擎的价值在于尾延迟稳定。用 HDR Histogram 记录 P50/P99/P99.9/P99.99/Max。
- 热身不足:JIT 预热、CPU 频率调度、分支预测训练需要几十万次迭代才稳定。基准开头 30 秒结果要丢弃。
- Coordinated
Omission:固定间隔发单忽略了”发单线程被阻塞时漏测的请求”。Gil
Tene 的 HdrHistogram 有
recordValueWithExpectedInterval专治。 - Benchmark 在生产外机器:撮合微秒级,隔壁机器 docker daemon、cgroup 统计都会污染结果。
一个较严肃的测法:
cpupower frequency-set -g performance
taskset -c 3 ./me_bench # pin 到隔离核
# isolcpus=3,5,7 在内核启动参数里
# irqbalance 停掉, 把所有 IRQ 绑到非撮合核
12.2 撮合引擎与风控的协同
撮合侧不应该做余额 / 仓位校验——那是风控(Pre-Trade Risk Check)的职责。但撮合侧必须做的检查:
- 订单字段合法性(价格 / 数量 > 0,价格在 tick size 上对齐,数量在 lot size 上对齐)
- 价格漂移保护(偏离参考价超过阈值直接拒绝)
- 客户端时间戳与引擎时间差(防重放、防时钟漂移攻击)
- Kill Switch:风控下发的”全撤 + 拒收”指令作为高优先级输入进入撮合队列
12.3 撮合引擎与配对撤单(Mass Cancel)
一个账户在极端行情下想”全撤”,如果撮合侧是单线程,一次 MassCancel 触发上千个 CANCEL 事件会堵住主循环几毫秒。实现技巧:
- 把 MassCancel
展开成原子批处理:在主循环内一次遍历账户订单索引,标记为
canceled,但实际从簿里移除可以异步(打 tombstone) - 设置账户订单数上限(如单账户不超过 5000 活跃订单),否则 MassCancel 就是慢操作
- 对做市商 API 提供”快速全撤”的独立接口(bypass 下单队列,直接进入 Kill Switch 通道)
12.4 Maker-Taker 费率与撮合
虽然费率计算通常在撮合之后(Clearing 侧)进行,但 maker/taker 标签是撮合时才能确定的:吃单方是 taker,挂单方是 maker。这个标签必须在 Trade 事件里携带,供清算侧计算不同费率。
某些交易所(如 Binance VIP 做市商项目)还有”负费率”——做市商挂单成交收到 rebate。此时引擎需要把”做市商标识”随 Trade 事件一并带出。
12.5 限频与反熔断
做市商经常”秒内报撤几千次”,这会压垮撮合。应对:
- API 权重(Binance 模式):每个请求扣权重,账户权重在时间窗口内耗尽即拒
- 最低挂单寿命:某些交易所要求挂单至少停留 X 毫秒才能撤(防 quote stuffing)
- 熔断:一秒内单 symbol 成交量 / 价格变化超过阈值,暂停撮合进入冷静期
A 股有涨跌停(±10%、ST ±5%、注册板 ±20%)+ 当日 10% 异动停牌机制;美股用 LULD(Limit Up-Limit Down);加密所用价格带 / 自动去杠杆(ADL)。
12.6 撮合引擎升级:原地升级 vs 蓝绿切换
撮合引擎线上升级是高风险操作。两种主要策略:
Hot-Standby 升级: 1. 备机升级到新版本 2. 用主机 WAL 回放建立状态 3. 业务低峰窗口做主备切换 4. 观察新主机 30 分钟后升级旧主机为新备机
交易时段外停机升级:A 股等有明确盘后窗口的市场可以直接停机升级。
灰度 by symbol:先把小众 symbol 分片切到新版本,观察稳定后再切热门 symbol。但跨版本的行情格式兼容必须提前处理。
12.7 可观测性
撮合引擎的核心指标:
- 延迟:入口(Gateway 接收)→ 撮合完成 → 出口(Exec Report 回发)每段的 P99/P99.9
- 队列深度:Ring Buffer 占用率、落后多少
- 订单簿规模:总订单数、价格层数、内存占用
- WAL 延迟:Journal 确认到 Match 开始的间隔
- 主备差:备机 applied_seq 与主机 committed_seq 差额
- 成交速率:trades/s、volume/s,与日常基线对比触发告警
- 错误率:校验失败、风控拒绝、STP 触发次数
日志必须结构化(JSON / Protobuf),带
trace_id(客户端请求贯穿)、seq(撮合内部排序)。延迟数据走
HDR Histogram 周期性采样导出到 Prometheus /
VictoriaMetrics。
十三、交叉引用与小结
撮合引擎只解决一件事:在给定规则下,从输入订单序列产生确定的成交序列。它小而深:数据结构不超过三百行,但把 WAL、快照、主备、确定性、订单类型、集合竞价、STP 全部加齐后是一个三万行起步的系统。
- 上游的 Gateway、风控在《交易所核心系统架构》
- 下游的行情分发在《行情分发:MBP/MBO、快照+增量》
- 成交后的清算结算在《证券登记结算》
- 与账务侧的复式记账互动见《复式记账工程化》
- 幂等和事务保证见《幂等、事务与一致性》
- 可靠性与灾备见《金融级可靠性》
参考资料
- LMAX Disruptor, “Disruptor: High Performance Alternative to Bounded Queues for Exchanging Data Between Concurrent Threads”, https://lmax-exchange.github.io/disruptor/disruptor.html
- CME Group, “Matching Algorithms”, https://www.cmegroup.com/confluence/display/EPICSANDBOX/Matching+Algorithms
- Nasdaq, “ITCH 5.0 Specification”, https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
- SEC / CFTC, “Findings Regarding the Market Events of May 6, 2010” (Flash Crash Report), 2010
- 上海证券交易所,“交易规则”,http://www.sse.com.cn/
- dYdX, “v4 Chain Technical Architecture”, https://docs.dydx.exchange/
- Peter Lawrey, “Low Latency in Java”, blog series, https://vanilla-java.github.io/
- Martin Thompson, “Mechanical Sympathy”, blog, https://mechanical-sympathy.blogspot.com/
- Maksim Zheravin, “exchange-core” source & design notes, https://github.com/exchange-core/exchange-core
- OCI, “liquibook”, https://github.com/enmotech/liquibook
上一篇:《交易所核心系统架构:撮合、行情、做市、风控、清算》
下一篇:《行情分发:MBP/MBO、快照+增量、组播/TCP、FIX/ITCH》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【金融科技工程】交易所核心系统架构:撮合、行情、做市、风控、清算
从订单网关到撮合引擎、从 L1/L2/L3 行情到清算与结算,系统梳理证券、期货、加密货币交易所的五大核心子系统;给出低延迟工程技术栈(Disruptor、Kernel Bypass、FPGA)、订单生命周期状态机、主流交易所(NYSE Pillar、Nasdaq INET、上交所新一代、CME Globex、Binance、dYdX v4)对比、以及 Flash Crash 与 Knight Capital 的工程教训。
【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
【金融科技工程】钱的建模:金额精度、币种、会计单位、多语言金额
在代码里正确地表示"一笔钱"远比看起来难。本文系统梳理金额的数值建模(浮点、定点、Decimal、最小单位)、币种标准(ISO 4217)、本地化显示、汇率换算与数据库存储,并给出 Go、Python、Java、Rust 的工程化示例。
【金融科技工程】复式记账工程化:科目、分录、余额、对账
把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。