撮合引擎是交易所的核心。它的职责是把买卖双方的报单按规则配对成交,并保证公平、确定、可重放。与一般的交易系统不同,撮合引擎面对的是「不能出错」的要求:一次错误的撮合意味着真金白银的损失和可能的监管处罚。
真正难的地方是在确定性约束下的极致性能。撮合引擎必须做到:同样的输入序列,产生完全相同的输出序列,不管运行在什么硬件上、经过多少次重启。这个「确定性」要求排除了几乎所有并发优化手段——多线程撮合会因为调度顺序不同而产生不同结果。
本文按「原则 → 数据结构 → 并发模型 → 确定性 → 工程实践 → 业务规则 → 性能」的顺序展开。低延迟基础设施在 低延迟交易系统架构 中讨论,本文聚焦撮合逻辑本身。
目录
- 撮合引擎的职责与位置
- 价格优先与时间优先
- 订单簿的数据结构
- 内存布局与性能优化
- 单线程与多线程模型
- 确定性重放与恢复
- Disruptor 与 Aeron 实践
- 撮合的业务规则
- 性能测试与容量规划
1. 撮合引擎的职责与位置
撮合引擎位于交易链路的核心,它的输入输出都很明确:
输入:NewOrder / CancelOrder / ModifyOrder(来自参与者)
输出:OrderAccepted / OrderRejected / Trade / OrderCancelled(回报)
状态:订单簿(Order Book)+ 订单表(Order Table)
| 职责 | 说明 |
|---|---|
| 订单校验 | 价格、数量、账户状态合法性 |
| 订单匹配 | 按价格时间优先撮合 |
| 成交生成 | 生成成交记录,更新订单簿 |
| 状态维护 | 订单生命周期、盘口快照 |
| 行情发布 | 输出订单簿变化(增量行情) |
撮合引擎不做的事:资金校验(属于前置风控)、账户管理(属于会员系统)、清算交割(属于清算系统)。职责边界的清晰是保证确定性的前提——撮合引擎的输入只有订单流。
2. 价格优先与时间优先
撮合的基本规则是价格优先、时间优先:
1. 买单出价高的优先成交
2. 卖单要价低的优先成交
3. 同价格的,先报单的优先成交(时间优先)
连续竞价的撮合循环:
def match(book, incoming):
trades = []
if incoming.side == 'BUY':
while incoming.qty > 0 and book.asks: # 买单与卖盘撮合,从最低卖价起
best_ask_price = min(book.asks)
if incoming.price < best_ask_price:
break # 无法成交,挂入买盘
level = book.asks[best_ask_price]
while level and incoming.qty > 0:
maker = level[0] # FIFO,最早的单先成交
qty = min(incoming.qty, maker.qty)
trades.append(Trade(price=best_ask_price, qty=qty,
buyer=incoming.id, seller=maker.id))
incoming.qty -= qty
maker.qty -= qty
if maker.qty == 0:
level.popleft()
if not level:
del book.asks[best_ask_price]
if incoming.qty > 0: # 未成交部分挂入订单簿
book.add(incoming)
return trades
价格是「被动方价格」:买单主动成交时,成交价取卖方的挂单价(更优的价格),这是行业惯例,对被动方(maker)有利。
3. 订单簿的数据结构
订单簿需要支持四种操作,每种都要 O(1) 或 O(log n):
| 操作 | 复杂度要求 | 常用结构 |
|---|---|---|
| 查最优价 | O(1) | 有序结构 + 首元素指针 |
| 插入订单 | O(log n) | 价格树 + 队列 |
| 删除订单 | O(1) | 订单表 + 迭代器 |
| 按价格聚合 | O(k) | 价格档位数组 |
价格档位数组是最快的实现方式:把价格离散成整数 tick,用数组下标直接寻址。
// 价格档位数组:价格范围 [min_price, max_price],每 tick 一个槽位
template <size_t MAX_TICKS>
class PriceLevelArray {
struct Level {
int64_t total_qty{0};
Order* head{nullptr}; // FIFO 队列头
Order* tail{nullptr};
};
std::array<Level, MAX_TICKS> levels_;
int32_t best_bid_idx_{-1};
int32_t best_ask_idx_{-1};
public:
// 价格 → 数组下标,O(1) 寻址
static size_t to_index(int64_t price_ticks) { return price_ticks; }
void add_order(Order* o) {
auto& lv = levels_[to_index(o->price_ticks)];
if (lv.tail) { lv.tail->next = o; o->prev = lv.tail; }
else { lv.head = o; o->prev = nullptr; }
lv.tail = o;
o->next = nullptr;
lv.total_qty += o->qty;
}
};
数组寻址的代价是价格范围有限。A 股价格在 0.01~10000 元之间,按 0.01 元 tick 离散需要 100 万个槽位,每个槽位几十字节,约 50 MB——可以接受。期货价格范围更窄,更容易。
对于价格范围无限的市场(如加密货币),需要用跳表或红黑树维护价格档位,插入和查询变成 O(log n)。这是数据结构选择上最核心的权衡:数组快但要求价格有界,树灵活但慢常数倍。
4. 内存布局与性能优化
订单对象的布局决定了缓存效率。订单池 + 索引是最常用的模式:
struct Order {
uint64_t order_id;
uint64_t account_id;
int64_t price_ticks;
int64_t qty;
int64_t filled;
Order* next; // FIFO 链表
Order* prev;
uint8_t side;
uint8_t type;
// ... 共 64 字节,正好一个缓存行
};
static_assert(sizeof(Order) == 64, "Order must fit in one cache line");
订单 ID 到订单对象的映射用开放寻址哈希表,避免链表的指针追逐:
class OrderIndex {
static constexpr size_t CAP = 1 << 20; // 100 万订单
std::array<Order*, CAP> slots_{};
static size_t hash(uint64_t id) { return (id * 0x9E3779B97F4A7C15ULL) >> 44; }
public:
Order* find(uint64_t id) {
size_t i = hash(id);
while (slots_[i] && slots_[i]->order_id != id) i = (i + 1) & (CAP - 1);
return slots_[i];
}
void insert(Order* o) {
size_t i = hash(o->order_id);
while (slots_[i]) i = (i + 1) & (CAP - 1);
slots_[i] = o;
}
};
订单池预分配所有 Order 对象,acquire/release 只是链表操作,零分配。这种设计下,一次撮合的核心操作(查档位、改数量、更新索引)都在几十纳秒内完成。
5. 单线程与多线程模型
撮合引擎的默认答案是单线程。原因有三:
1. 确定性:单线程天然保证同样的输入产生同样的输出
2. 无锁:不需要任何同步原语,没有竞争开销
3. 简单:状态机简单,容易验证正确性
单线程如何支撑高吞吐?答案是按标的分片:不同标的的订单簿互不影响,可以并行处理。
┌──────────────┐
订单流 ──►│ 路由(按 symbol 哈希)│
└──────┬───────┘
┌─────────┼─────────┐
▼ ▼ ▼
[线程 0] [线程 1] [线程 2]
600519 000001 300750
订单簿 订单簿 订单簿
| 模型 | 吞吐 | 确定性 | 复杂度 |
|---|---|---|---|
| 单线程单簿 | 低 | 高 | 低 |
| 分片多线程 | 高 | 每分片内高 | 中 |
| 多线程共享簿 | 最高 | 低 | 极高 |
| LMAX 风格 | 高 | 高 | 中 |
多线程共享同一个订单簿几乎不可行:两笔同时到达的订单谁先撮合会影响成交结果,而线程调度是不确定的。极少数系统用「逻辑时钟排序 + 重放」来解决,但复杂度极高。
6. 确定性重放与恢复
确定性是撮合引擎的生命线。它带来两个关键能力:
1. 重放验证:用同一份订单日志重放,结果必须完全一致
2. 快速恢复:崩溃后重放日志即可恢复订单簿状态
重放的前提是输入序列完全有序。订单日志必须记录「引擎实际处理订单的顺序」,而不是「订单到达的顺序」。
struct JournalEntry {
uint64_t seq; // 单调递增序号
uint64_t timestamp_ns; // 处理时刻
uint8_t type; // NEW / CANCEL / MODIFY
Order order; // 订单内容
};
class Journal {
int fd_; // O_APPEND | O_DSYNC
public:
void append(const JournalEntry& e) {
// 先写日志再改内存(write-ahead log)
::write(fd_, &e, sizeof(e));
}
};
关键点是 WAL(Write-Ahead Log):先落日志再更新内存状态。崩溃后从快照 + 日志重放恢复。
恢复流程:
1. 加载最近的内存快照(如每 100 万条订单做一次快照)
2. 从快照对应的 seq 开始重放日志
3. 重放到最后一条,状态恢复完成
快照 + 增量日志的模式在分布式系统里很常见,与 Raft 实现 里的日志压缩思路一致。日志写入必须幂等且可校验,每条记录都要带唯一序号。
7. Disruptor 与 Aeron 实践
LMAX Disruptor 是撮合引擎最经典的工程参考。它的核心思想是用一个环形缓冲区 + 序号栅栏(sequence barrier)替代队列:
// Disruptor 风格:预分配事件对象,只更新内容不创建新对象
public final class OrderEvent {
long orderId;
long price;
long qty;
byte side;
}
// 生产者:申请序号 → 写入 → 发布
long seq = ringBuffer.next();
OrderEvent e = ringBuffer.get(seq);
e.orderId = id; e.price = px; e.qty = qty; e.side = side;
ringBuffer.publish(seq);
// 消费者:等待序号可用 → 处理
long nextSeq = sequenceBarrier.waitFor(cursor + 1);
for (long i = cursor + 1; i <= nextSeq; i++) {
process(ringBuffer.get(i));
}
Disruptor 的关键优化:
- 预分配事件对象:环形缓冲区里的事件对象在启动时创建,运行时只更新字段,零 GC。
- 序号栅栏:消费者用序号(一个 long)判断可用性,而不是检查队列是否为空,避免了内存屏障开销。
- 批量处理:一次处理多个事件,摊薄开销。
Aeron 则解决了网络与 IPC 的确定性问题,它提供了可靠的 UDP 单播/组播和 IPC 通道,延迟在微秒级,且支持流控与重传。撮合引擎与网关之间用它通信,比 TCP 更可控。
8. 撮合的业务规则
真实交易所的撮合不只是「价格时间优先」,还有大量业务规则:
| 规则 | 说明 |
|---|---|
| 涨跌停 | 报价必须在 [跌停价, 涨停价] 内 |
| 最小变动价位 | 价格必须是 tick 的整数倍 |
| 最小成交量 | 如 A 股 100 股一手 |
| 集合竞价 | 开盘/收盘用最大成交量原则 |
| 熔断 | 价格波动超阈值时暂停交易 |
| 自成交防范 | 同一账户的买卖单不能互相成交 |
集合竞价的撮合逻辑与连续竞价完全不同:它要找出使成交量最大的那个价格。
def call_auction_price(orders):
candidates = sorted({o.price for o in orders}) # 候选价格 = 所有订单价格
best_price, best_volume = None, -1
for p in candidates:
buy_vol = sum(o.qty for o in orders if o.side == 'BUY' and o.price >= p)
sell_vol = sum(o.qty for o in orders if o.side == 'SELL' and o.price <= p)
volume = min(buy_vol, sell_vol)
if volume > best_volume:
best_volume, best_price = volume, p
return best_price, best_volume
自成交防范(STP) 是很多市场的硬要求:同一个账户的买单和卖单不能撮合成交,否则构成「洗售」。实现上需要在撮合循环里跳过同账户的对手单。
9. 性能测试与容量规划
撮合引擎的性能指标有三个维度:
| 指标 | 定义 | 目标 |
|---|---|---|
| 延迟 | 单笔订单处理耗时 | P99 < 10 μs |
| 吞吐 | 每秒处理订单数 | 10 万~100 万 TPS |
| 确定性 | 相同输入输出一致 | 100% |
压测要覆盖三种负载形态:
1. 稳态负载:持续均匀的订单流
2. 突发负载:开盘瞬间的订单洪峰(可达稳态的 100 倍)
3. 极端场景:涨停板时的巨量挂单与撤单
容量规划的经验法则:按峰值的 3 倍设计。A 股开盘前 1 分钟和收盘前 3 分钟的订单量是日均的几十倍,如果按均值设计容量,这两个时段必然过载。
def capacity_plan(daily_orders, peak_ratio=50, safety=3, trading_seconds=14400):
avg_tps = daily_orders / trading_seconds
peak_tps = avg_tps * peak_ratio
return {
'avg_tps': avg_tps,
'peak_tps': peak_tps,
'design_tps': peak_tps * safety,
}
print(capacity_plan(5e7)) # 日均 5000 万单
权衡取舍
| 维度 | 价格档位数组 | 跳表/红黑树 | 哈希 + 排序 |
|---|---|---|---|
| 查最优价 | O(1) | O(log n) | O(n) |
| 插入 | O(1) | O(log n) | O(1) |
| 价格范围 | 受限 | 无限 | 无限 |
| 缓存友好 | 极好 | 差 | 中 |
并发模型上的取舍是吞吐与确定性的对抗。单线程分片能达到几十万 TPS 且完全确定,多线程共享簿理论上吞吐更高但确定性无法保证。绝大多数交易所选择前者,因为确定性是监管要求,吞吐可以通过加机器解决。
内存与持久化的取舍:全内存 + WAL 的延迟最低,但恢复时间长(重放日志);定期快照能加快恢复,但快照期间有性能抖动。折中方案是在低峰期(如收盘后)做快照。
常见坑清单
- 多线程共享订单簿:线程调度不确定导致撮合结果不可重放。
- 成交价取主动方价格:应取被动方(挂单方)价格,否则对 maker 不公平。
- 订单表用链表:按 ID 查找 O(n),撤单性能极差。
- 忽略时间优先:同价位按 ID 或其他顺序撮合,违反公平原则。
- 不记录处理序号:日志记录的是到达顺序而非处理顺序,重放结果不一致。
- 先改内存再写日志:崩溃时内存状态比日志超前,无法恢复。
- 不做快照:恢复时需要重放全部日志,几小时的历史订单无法接受。
- 自成交未防范:同一账户对敲成交,构成违规。
- 价格用浮点:浮点比较有精度问题,必须用整数 tick。
- 容量按均值设计:开盘洪峰会直接压垮引擎。
小结
撮合引擎的核心是确定性与性能的平衡。确定性要求排除大部分并发手段,性能要求又不能太慢,最终的解法是「单线程分片 + 预分配内存 + 无锁队列 + WAL 持久化」这套组合。这套组合把延迟做到微秒级,同时保证了结果可重放。
判断一个撮合引擎是否合格,最快的检验是重放测试:用同一份订单日志重放两次,比对两次的成交流水。如果有任何差异,说明存在不确定性来源(多线程、时间依赖、哈希遍历顺序),必须定位并消除。
下一步建议阅读 交易风控与实时限额 ,看撮合前如何拦截异常订单;如果你是从参与者视角出发,可以回到 订单管理与执行算法 了解报单侧的工程实践。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。