撮合引擎的性能上限,一半由订单簿的数据结构决定。教科书写订单簿只有一句话:"按价格优先、时间优先排序"。落到工程上,这句话要拆成几个具体的操作,每个操作的复杂度直接决定撮合的吞吐:
- 插入:新委托进来,放到正确的价格档位的队列尾;
- 撮合查询:取对手方最优价,和进来的委托比较;
- 撤单:从队列里删掉一个指定委托,不能扫全队列;
- 快照输出:生成五档/十档行情,不能拖慢撮合主循环。
数据结构的选择就是围绕这四个操作的频率和延迟要求做的。实测和理论都指向同一个答案:价格分层的复合结构。
结构:价格树 + 档位队列 + 订单索引
买单和卖单分开存。以卖单(ask)为例,三个结构互相配合:
ask_tree: 有序映射 price → AskLevel // 红黑树/跳表,按价升序
AskLevel: { price, total_qty, queue: 双向链表 }
order_index: 哈希表 order_id → 队列节点指针 // 委托号 → 链表节点
ask_tree支持的对数操作:最优价查询 O(log P)(P 是价格档数)、新价位插入 O(log P)。挡位数远小于订单数(活跃档位通常几十到几百),这层几乎不花时间;AskLevel.queue是同价位的 FIFO 链表,队头就是等得最久的委托。插入 O(1)(尾插),撮合从队头取 O(1);order_index是撤单的关键:客户撤单带着 order_id 来,哈希表查到节点指针,双向链表摘除 O(1)。
三个操作全链路没有一个 O(N):撮合、撤单、插入全是近似常数时间,只有新价位进树是 O(log P)。这就是"价格优先时间优先"在工程上的完整翻译。
为什么撤单必须 O(1)
直觉上"从队列里删一个元素"应该很快,但在订单簿里这是被低估的热操作。量化策略的撤单率可以高到委托量的 70%(排到队尾没成交希望就撤),撤单如果要从队头扫到目标位置,单次 O(k),叠加高撤单率,撤单总开销就超过撮合本身。更隐蔽的问题是:扫队列期间订单簿处于半修改状态,要么加锁串行化整个撮合循环,要么忍受并发修改的复杂度。O(1) 撤单(哈希索引 + 双向链表摘除)把这个问题从"并发控制难题"降格成"两个指针的修改",撮合主循环因此可以保持单线程无锁——这是内存撮合设计的基石,后面"内存撮合 vs 数据库撮合"篇会展开。
档位总量与订单量的关系
AskLevel.total_qty 字段(档位总量)值得单独说。它看起来冗余——遍历队列累加也能算——但撮合时要反复回答"对手方最优档够不够吃掉我的量",每次遍历链表是 O(k)。维护一个插入时 +qty、撮合时 −qty 的累计字段,把这个查询变成 O(1)。
这引出一个订单簿设计的一般原则:派生数据主动维护,不要现算。订单簿的读多写少模式(一次撮合循环内要反复查询最优价、档总量),适合"写时维护、读时直取";反过来,如果某数据读得少(比如全簿挂单金额统计),就不必维护,现算或异步算。这个权衡和数据库索引的维护成本是同构的。
五档行情:同一份数据的第二个消费者
行情系统的十档快照(见行情篇)和撮合引擎消费的是同一份订单簿数据,但消费方式不同:撮合要求严格一致(撮合循环看到的簿就是真簿),行情要求高频读取(每 3 秒全量快照一次)。两者共享结构的坑在于:快照遍历期间撮合不能停,否则行情延迟直接加进撮合路径。
工程上常见的安排是读写分离的副本:撮合线程每完成一次簿的修改(撮合循环的粒度),把变更事件(增、删、改档总量)追加到一个无锁队列;行情线程异步消费事件,维护自己的簿副本用于快照输出。撮合主循环只多了一次无锁写(几十纳秒),行情怎么遍历都不影响撮合。代价是行情副本有毫秒级的滞后——对 3 秒一次的快照频率完全无感,但对"逐笔行情要还原每一瞬间的簿"的 Level-2 场景不够用,那需要快照 + 增量合并的算法,行情篇再展开。
实现的内存细节
订单簿是内存密集结构,百万级挂单时内存布局直接决定 cache 命中率:节点对象用内存池预分配(避免 malloc 抖动和碎片);委托结构体按热路径字段(price、qty、order_id、前后指针)紧凑排列,一个缓存行装下,撮合遍历时cache miss最少;order_index 的哈希表预分配容量(挂单量级可估),避免 rehash 期间的长尾停顿。这些细节单看都微不足道,合起来是撮合引擎"平均 1 微秒、P99 不超 3 微秒"这类指标的组成部分——微秒级的生意里,没有哪 100 纳秒是白花的。
总结成一句话:订单簿设计的第一性原理是让热路径的操作复杂度配得上它的调用频率——撮合和撤单是每微秒级都要快的,快照和审计是可以慢的,数据结构按这个优先级取舍,不会错。