实现

撮合引擎实现逻辑

详细说明撮合引擎的事件循环、双向优先队列与跳表的实现方式,以及撤单、杠杆和平滑恢复。

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. 双向优先队列实现

维护两个方向的堆(或有序集合)加价格映射,兼顾最快的最佳价获取与任意撤单。

操作示例:

  1. 卖盘有 42000[O1], 42100[O2]。买单 42050[Q] 进来。
  2. bidsHeap 取 42050,对手盘 asksHeap 顶是 42000,先成交 O1。
  3. O1 清空后,42000 价位标记为空洞;下一轮堆顶变 42100。
  4. 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. 跳表实现

跳表按价格排序,天然支持插入/删除/取最小或最大价位,避免堆空洞;适合高撤单场景。

操作示例:

  1. 卖盘层有 40000, 41000, 42000。
  2. 撤单清空 41000 后,从顶部开始寻找 41000,调整 forward 指针跳过该节点。
  3. bestAsk 立即从 40000 直连 42000,取价不受空洞影响。
节点

price + FIFO 队列 + 多级 forward 指针;层数按随机策略生成。

插入

自顶向下寻找插入点,O(log n);新建节点放入相邻指针。

取最佳

买盘取 tail(最大价),卖盘取 head(最小价) O(1)。

删除

撤单后若价位为空,直接调整 forward 指针 O(log n),不会残留空价位。

优势:删除/取价更干净;缺点:实现复杂度略高,需要良好内存管理。

跳表可视化

在下方插入、搜索、删除价格节点,观察跳表的多级索引结构:

跳表 (Skip List) 可视化 -- 订单簿价格层级索引
提示: 点击节点可删除 (删除节点)。搜索时会逐步高亮路径。
L3
L2
L1
L0
HEAD
HEAD
HEAD
HEAD
NIL
NIL
NIL
NIL
35000
35000
35000
35000
38000
38000
40000
40000
40000
41000
42000
42000
42000
42000
43000
43000
44000
45000
45000
45000
节点数: 8最大层级: L335000 (L3)38000 (L1)40000 (L2)41000 (L0)42000 (L3)43000 (L1)44000 (L0)45000 (L2)
跳表 vs 红黑树 vs 数组
操作跳表 (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)
跳表初始化完成,包含 8 个价格节点

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,后置风控触发强平,系统生成强平卖单。

风控检查模拟器

配置下单参数,观察前置/后置风控检查如何逐项验证:

风控检查流水线 — 配置参数后点击提交

前置风控 (Pre-trade)
账户余额检查Balance Check
价格偏离检查Price Deviation
频率限制Rate Limit
持仓限额Position Limit
撮合中风控 (In-trade)
自成交检测Self-trade Prevention
最小成交量Min Trade Size
后置风控 (Post-trade)
保证金充足率Margin Adequacy
强平触发Liquidation Trigger
风险度报警Risk Alert

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 缓存命中率。

批量 I/O

批量拉取 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 的订单,内存簿恢复到崩溃前状态。