撮合引擎设计

撮合引擎设计的完整拆解:交易撮合的价格优先与时间优先原则、订单簿的数据结构与内存布局、单线程与多线程并发模型取舍、确定性重放与崩溃恢复、LMAX Disruptor 与 Aeron 的工程实践、涨跌停与集合竞价等业务规则,以及性能测试与容量规划。

撮合引擎是交易所的核心。它的职责是把买卖双方的报单按规则配对成交,并保证公平、确定、可重放。与一般的交易系统不同,撮合引擎面对的是「不能出错」的要求:一次错误的撮合意味着真金白银的损失和可能的监管处罚。

真正难的地方是在确定性约束下的极致性能。撮合引擎必须做到:同样的输入序列,产生完全相同的输出序列,不管运行在什么硬件上、经过多少次重启。这个「确定性」要求排除了几乎所有并发优化手段——多线程撮合会因为调度顺序不同而产生不同结果。

本文按「原则 → 数据结构 → 并发模型 → 确定性 → 工程实践 → 业务规则 → 性能」的顺序展开。低延迟基础设施在 低延迟交易系统架构 中讨论,本文聚焦撮合逻辑本身。

目录

  1. 撮合引擎的职责与位置
  2. 价格优先与时间优先
  3. 订单簿的数据结构
  4. 内存布局与性能优化
  5. 单线程与多线程模型
  6. 确定性重放与恢复
  7. Disruptor 与 Aeron 实践
  8. 撮合的业务规则
  9. 性能测试与容量规划

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 的延迟最低,但恢复时间长(重放日志);定期快照能加快恢复,但快照期间有性能抖动。折中方案是在低峰期(如收盘后)做快照。

常见坑清单

  1. 多线程共享订单簿:线程调度不确定导致撮合结果不可重放。
  2. 成交价取主动方价格:应取被动方(挂单方)价格,否则对 maker 不公平。
  3. 订单表用链表:按 ID 查找 O(n),撤单性能极差。
  4. 忽略时间优先:同价位按 ID 或其他顺序撮合,违反公平原则。
  5. 不记录处理序号:日志记录的是到达顺序而非处理顺序,重放结果不一致。
  6. 先改内存再写日志:崩溃时内存状态比日志超前,无法恢复。
  7. 不做快照:恢复时需要重放全部日志,几小时的历史订单无法接受。
  8. 自成交未防范:同一账户对敲成交,构成违规。
  9. 价格用浮点:浮点比较有精度问题,必须用整数 tick。
  10. 容量按均值设计:开盘洪峰会直接压垮引擎。

小结

撮合引擎的核心是确定性与性能的平衡。确定性要求排除大部分并发手段,性能要求又不能太慢,最终的解法是「单线程分片 + 预分配内存 + 无锁队列 + WAL 持久化」这套组合。这套组合把延迟做到微秒级,同时保证了结果可重放。

判断一个撮合引擎是否合格,最快的检验是重放测试:用同一份订单日志重放两次,比对两次的成交流水。如果有任何差异,说明存在不确定性来源(多线程、时间依赖、哈希遍历顺序),必须定位并消除。

下一步建议阅读 交易风控与实时限额 ,看撮合前如何拦截异常订单;如果你是从参与者视角出发,可以回到 订单管理与执行算法 了解报单侧的工程实践。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「量化交易」更多文章

  1. 风险模型与因子归因
  2. 回测偏差与过拟合防范
  3. 市场微结构与流动性