撮合引擎实现逻辑
详细说明撮合引擎的事件循环、双向优先队列与跳表的实现方式,以及撤单、杠杆和平滑恢复。
1. 核心事件循环
每个交易对绑定单线程循环,拉取 Kafka 分区消息,按类型进入匹配或撤单流程,结果写 trade.done / order.update,保证绝对顺序与幂等。
2. 数据结构选型
买盘需快速拿到最高价,卖盘拿到最低价;支持插入、删除、取 min/max。
同价位内 FIFO;需要 O(1) 取队首和删除任意 orderId。
① 双向优先队列(堆/有序映射结合);② 跳表(排序 + 快速定位/删除)。
两种实现都可做到取最优价 O(1) 或 O(log n),插入/删除 O(log n)。
| 操作 | 堆 + HashMap | 跳表 | 红黑树 |
|---|---|---|---|
| 取最优价 | O(1) | O(1) | O(log n) |
| 插入订单 | O(log n) | O(log n) | O(log n) |
| 撤单删除 | O(n) 最坏 | O(log n) | O(log n) |
| 空洞问题 | 有 (lazy pop) | 无 | 无 |
| 实现复杂度 | 简单 | 中等 | 较高 |
| 推荐场景 | 低撤单率 | 高撤单率 ✓ | 通用 |
示例: 当有卖盘 42000×1.0, 42100×2.0, 买盘 41950×1.5 时,新买单 42050×1.2 会依次吃 42000(全吃),剩余 0.2 以 42050 挂入买盘。
3. 双向优先队列实现
维护两个方向的堆(或有序集合)加价格映射,兼顾最快的最佳价获取与任意撤单。
操作示例:
- 卖盘有 42000[O1], 42100[O2]。买单 42050[Q] 进来。
- bidsHeap 取 42050,对手盘 asksHeap 顶是 42000,先成交 O1。
- O1 清空后,42000 价位标记为空洞;下一轮堆顶变 42100。
- Q 剩余 0.2 且买价 42050 < 42100,停止匹配并将剩余入 bids priceMap。
核心结构
price → FIFO 队列(链表);bidsHeap=最大堆存价格,asksHeap=最小堆。
插入
写 priceMap,如果新价位则 push 到堆;订单入对应队列尾。
取最佳
peek 堆顶获取 bestPrice,返回队首订单;空价位时堆顶 lazy pop 重试。
删除
撤单时从队列中删除节点(队列维护 orderId → node 索引);若价位为空,标记堆顶失效,匹配时跳过。
class PriceLevelQueue {
head: Node; tail: Node;
index: Map<string, Node>;
}
class BookSide {
heap: Heap<number>; // max for bids, min for asks
priceMap: Map<number, PriceLevelQueue>;
best(): [number, Order]? {
while (heap not empty) {
const p = heap.peek();
const q = priceMap.get(p);
if (!q || !q.head) { heap.pop(); continue; }
return [p, q.head.order];
}
return null;
}
}优势:实现简单、堆顶取价 O(1);缺点:大量撤单会产生堆空洞(lazy pop)。
4. 跳表实现
跳表按价格排序,天然支持插入/删除/取最小或最大价位,避免堆空洞;适合高撤单场景。
操作示例:
- 卖盘层有 40000, 41000, 42000。
- 撤单清空 41000 后,从顶部开始寻找 41000,调整 forward 指针跳过该节点。
- bestAsk 立即从 40000 直连 42000,取价不受空洞影响。
price + FIFO 队列 + 多级 forward 指针;层数按随机策略生成。
自顶向下寻找插入点,O(log n);新建节点放入相邻指针。
买盘取 tail(最大价),卖盘取 head(最小价) O(1)。
撤单后若价位为空,直接调整 forward 指针 O(log n),不会残留空价位。
优势:删除/取价更干净;缺点:实现复杂度略高,需要良好内存管理。
跳表可视化
在下方插入、搜索、删除价格节点,观察跳表的多级索引结构:
| 操作 | 跳表 (Skip List) | 红黑树 (Red-Black Tree) | 有序数组 (Sorted Array) |
|---|---|---|---|
| 搜索 | O(log n) 平均 | O(log n) 最坏 | O(log n) 二分 |
| 插入 | O(log n) 平均 | O(log n) + 旋转 | O(n) 移位 |
| 删除 | O(log n) 平均 | O(log n) + 旋转 | O(n) 移位 |
| 范围查询 | O(log n + k) | O(log n + k) 中序 | O(log n + k) |
| 实现复杂度 | 简单 | 复杂 (旋转/着色) | 简单 |
| 并发友好 | 优秀 (无锁实现) | 差 (全局锁) | 差 |
| 空间复杂度 | O(n) 期望 | O(n) | O(n) |
5. 撮合算法伪代码
function match(order):
book = order.side == BUY ? books[symbol].asks : books[symbol].bids
while order.qty > 0:
best = book.best()
if !best or !cross(order, best.price): break
fillQty = min(order.qty, best.qty)
tradePrice = best.price
emitTrade(order, best, fillQty, tradePrice)
order.qty -= fillQty; best.qty -= fillQty
if best.qty == 0: book.remove(best)
if order.qty > 0 and canRest(order): addToOwnSide(order)
else if order.qty > 0: cancelRemaining(order)
撮合示例:
- 盘口:卖 42000×1, 42010×2;买 41990×1。新买单 42010×2(GTC)进入。
- 循环 1:bestAsk=42000,fillQty=1,生成 trade#1,剩余 1。
- 循环 2:bestAsk=42010,fillQty=1,生成 trade#2,剩余 0,订单完成,不再挂单。
- cross 判定包含价格条件(买价 ≥ 卖价)和时间限制(IOC/FOK/Post-Only)。
6. 撤单 / 过期 / 部分成交
- 撤单: 收到 cancel.req 直接在对应 priceLevel 队列删除节点;若价位为空,depq 跳过/skiplist 直接删除价位。
- 过期: 支持 GTT/IOC/FOK 等超时或即时策略;过期订单生成 order.update=EXPIRED 并释放冻结。
- 部分成交: 订单状态持有 cumQty / avgPrice;每次 trade.done 带剩余量,便于下游幂等。
撤单示例
用户下了 BTC 限价买单 42000×3.0,已成交 1.2(cumQty=1.2, leavesQty=1.8)。
- 发送 cancel.req,撮合引擎在 42000 价位找到该订单。
- 从 FIFO 队列中删除该节点,42000 价位还有其他订单时不删价位。
- 发出 order.update: status=CANCELED, cumQty=1.2, leavesQty=1.8。
- 结算服务解冻 1.8×42000 = 75,600 USDT 回可用余额。
7. 杠杆与风控钩子
下单前调用 Risk 计算占用保证金,逐仓/全仓分别评估;超限拒绝。
reduceOnly 保证只减仓;撮合时若平仓量超过持仓则截断到可平数量。
系统生成特殊订单(最高优先),可按市价或限价保护价执行。
不在撮合内计算,但撮合事件提供持仓变更,为资金费率结算提供基线。
杠杆风控示例
场景:用户账户 10,000 USDT,开 10x 做多 BTC@42,000。
- 名义价值 = 42,000 × 1 BTC = 42,000 USDT。
- 初始保证金 = 42,000 / 10 = 4,200 USDT,余额够,通过前置风控。
- 维持保证金率 = 0.5%,维持保证金 = 42,000 × 0.5% = 210 USDT。
- 强平价格 ≈ 42,000 × (1 - 1/10 + 0.005) = 38,010 USDT。
- 若 BTC 跌到 38,010,后置风控触发强平,系统生成强平卖单。
风控检查模拟器
配置下单参数,观察前置/后置风控检查如何逐项验证:
8. 手续费与费用计算
成交时立刻判定 Maker(挂单)/Taker(吃单),将 feeRole 写入 trade.done,避免下游二次推断。
按用户等级 / VIP / 做市计划存储 makerRate、takerRate、折扣(平台币抵扣、券);撮合读取缓存快照。
默认按成交计价币扣(如 BTC/USDT 对以 USDT 扣费),若启用平台币抵扣且余额充足则改用平台币。
包含资金费率(按周期结算,不随单成交)、强平附加费(系统单时按配置收取)。
现货费率公式: fee = 成交额 × (makerRate 或 takerRate)。例:成交额 10,000 USDT,takerRate=0.0006,则手续费 6 USDT;若用平台币打 25% 折扣,则为 4.5 USDT。
杠杆利息: 逐仓借币计息 = 借币本金 × 年化利率/24/60 × 持有分钟;在结算服务周期性扣除并写资金流水,与撮合成交解耦。
资金费率(合约): 每周期按持仓名义价值 × 资金费率收/付,非单次成交费用,但撮合产生的持仓变更为资金费率基线。
强平费用: 触发强平时按成交额 × liquidationFeeRate 收取,写入 trade.done 的 fee 字段,优先用于覆盖清算成本。
示例
- 现货吃单:用户以 20,000 USDT 成交(Taker),费率 0.08% 得 16 USDT;平台币抵扣 25% 后实付 12 USDT,trade.done 记录 fee=12, feeAsset=BNB。
- 现货挂单:用户 Maker 成交 5,000 USDT,费率 0.02% 得 1 USDT;若无平台币余额,则以报价币(USDT)扣费。
- 杠杆利息:借入 1 BTC,年化 8%,持有 3 小时,利息约 0.000027 BTC;由结算服务扣除并写资金流水。
- 合约资金费率:名义价值 50,000 USDT,资金费率 0.01%,多头支付 5 USDT 给空头;由资金费率周期任务结算。
- 强平费:强平单以 10,000 USDT 成交,liquidationFeeRate=0.5%,收取 50 USDT,用于覆盖清算成本。
9. 持久化与恢复
- 内存状态: 订单簿为权威;每 N 秒写 Redis 快照(价位 + 队列),同时持久化最后消费 offset。
- 恢复: 重启后读取快照,设置 Kafka 起始 offset,重放 order.new / cancel 直至最新,保证状态一致。
- 监控: 观测延迟(消息消费滞后)、盘口层数、撤单比例;堆实现需关注空洞比例,跳表实现关注碎片/内存。
恢复耗时估算
假设快照间隔 5 秒,峰值 TPS=10,000 笔/秒,崩溃恢复最坏需重放 50,000 条消息。以单线程 200K ops/s 计算,恢复时间小于 0.25 秒。
10. 高性能与容错
每个交易对独立 Kafka 分区 + 独立单线程撮合进程;内存状态不共享,从设计上消除锁。
按 symbol 分片映射到固定进程;增加交易对即增加分区/进程,避免同盘跨线程锁竞争。
预分配订单对象池、复用队列节点,减少 GC;使用结构化内存布局提升 CPU 缓存命中率。
批量拉取 Kafka 消息;批量写 trade.done 与 order.update,降低网络抖动。
避免锁竞争示例: BTCUSDT 的所有订单/撤单只落在 Partition 0 → Engine#0,撮合时只访问本机内存簿;其他交易对不会争抢锁。
Kafka 设计与恢复
- 主题:order.new.{symbol}(同一 symbol 单分区),trade.done.{symbol}(可多分区但单生产者顺序写),cancel.req 单分区。
- offset 管理:撮合进程消费后异步提交 offset;每次快照记录最新 offset,以便崩溃后继续。
- 崩溃恢复:加载快照 → 从快照记录的 offset 开始消费 → 重放 order.new/cancel 到最新;trade.done 幂等,重复生成也被下游幂等忽略。
- 反压与回滚:消费端监控滞后;滞后过大可暂停生产或快速水平扩容分片(新增 symbol → partition → engine)。
恢复示例: Engine#1 崩溃,快照 offset=1050;重启后从 1050 继续消费,重放 1051-1060 的订单,内存簿恢复到崩溃前状态。