撮合引擎订单簿:一些设计上的取舍
这篇文章是想交代:在强顺序、单线程撮合的前提下,订单簿和外围运行时是怎么被摆在一起的 设计与思考。
想先解决的是什么问题
一旦接受「全局指令有序、订单簿状态只能在这条序上演进」,瓶颈往往就不在「能不能撮合」,而在:每个命令在单线程里要走多少内存、多少分支。锁竞争可以靠「改簿只在消费者线程」消掉;剩下的是缓存、分配和预测失败。
所以整体思路很老派,也很有效:LMAX Disruptor 式环形缓冲 + 单消费者顺序处理。命令从环里出来,依次经过风控预处理、撮合、风控后处理,再交给结果出口。订单簿按 SymbolId 分簿,在消费者线程内不需要对簿加锁——这不是优化技巧,而是并发模型的选择:用专用线程和顺序性换确定性与尾延迟。
flowchart LR
RiskPre[RiskEngine.pre_process]
Router[MatchingEngineRouter]
Book[OrderBook per symbol]
RiskPost[RiskEngine.post_process]
RiskPre --> Router --> Book
Book --> RiskPost
路由层对每个交易对持有一个 OrderBook 实例(运行时 Box<dyn OrderBook>)。虚表调用有一点固定成本,换来的是:同一套流水线可以按部署切换簿实现,而不把「要极致限价」和「要全套订单类型」绑死在一种数据结构上。
簿每次有意义变更后会记一版 last_seq(与 Snowflake 发号配合),方便行情或下游对齐「看到的是哪一版盘口」——这是设计意图上的版本戳,而不是教程里必须展开的细节。
订单簿之前:运行时愿意付出什么代价
环形缓冲的容量(如默认 ring_buffer_size)和单生产者配置,都是在说同一件事:先保证写入顺序和消费顺序可预期,再谈微优化。
等待策略上,配置里虽然枚举了忙等、yield、阻塞等,实际接 disruptor 4.x 时,非 BusySpin 会打日志并落在带 spin hint 的忙等族实现上。这里想分享的是设计上的诚实:忙等不是道德上的「最优」,而是专用核上拿 CPU 换尾延迟稳定的一种选择;若期待「配了 yield 就真的让出」,要以当前依赖行为为准。
CPU 亲和在消费者第一次处理事件时用 Once 绑到指定核,目的是减少乱飘带来的缓存失效——属于「能省则省」的工程习惯,而不是订单簿本身的算法问题。
I/O 与改状态分离:HTTP、Kafka 走异步;真正碰订单簿的路径留在 disruptor 线程里同步跑完。这样热路径里不会出现「撮合到一半去 await」的模型断裂。流水线里还有一层按 client_id 的幂等去重,既是正确性也是少做无效簿操作——设计上是把能挡在簿外的请求先挡掉。
为什么要有四条订单簿,而不是一个「终极结构」
如果只有一个实现,要么为了功能把热路径写肿,要么为了延迟把产品需求砍光。多实现在这里是显式的产品—性能谱系,而不是历史包袱。
抽象上大家都实现同一套 OrderBook:下单、撤单、改单、缩单、L2、序列化恢复等。配置项 orderbook_type 决定在 add_symbol 时实例化哪一种。四种角色的分工,可以这样理解:
Naive 是「能读懂的参考系」:BTreeMap 分价,SmallVec 堆一档里的单。逻辑直,适合对照和回归,不背负「极限场景」的包袱。
Direct 是「限价主战场」:Slab 做订单池,档内用整数索引的双向链表,刻意避开 Rc/RefCell 一类热路径税;再用 AHashMap 做订单号索引,并维护最优买卖侧的订单引用,让撮合尽快切入对手最优档。支持的订单类型相对克制(如 GTC、IOC、FOK_BUDGET),但保留了 move_order 等与风控联动的路径——这是在延迟和功能之间划的一条线。
DirectOptimized 是「在 Direct 哲学上再推一步」:热字段 SOA 化、冷字段拆开、大容量预分配池 + free list,撮合可走 SIMD 批量路径。代价也写死在设计里:类型面更窄(例如仅 GTC/IOC)、部分运维级操作直接标成不支持——用内存和功能子集换更可控的热路径。
Advanced 承担「交易所语义全集」:Post-Only、冰山、GTD、止损池、与成交价相关的触发等。数据形态仍偏 BTreeMap + 档位内 SmallVec,但订单结构和分支都更丰富。这里追求的是语义完整,而不是在同 workload 下赢 Direct 的每一个纳秒。
两套「快」:结构设计与数值批处理
Direct 系里,实现上习惯把优化拆成两类,而不是混成一句「高性能」。
一类是结构与分配:订单活在紧凑池子里,用索引链表串起来;档位上聚合量;最优价有捷径。核心是减少「每笔撮合不必要的指针追逐和堆分配」。
另一类是 DirectOptimized 里的数据布局与 SIMD:把撮合循环里反复读的字段放进并行向量,批量做价格比较和数量累加。它不是「所有单都更快」的魔法——浅簿、轻量指令时,准备批量的成本可能吃不掉收益;设计预期是深度和批量触碰足够大时再说话。
flowchart TB
subgraph hot [HotPath_DirectOptimized]
SOA[SOA_Vecs price size filled next prev]
Pool[Preallocated OrderPool free_list]
SIMD[simd_batch_match_prepare]
end
subgraph index [Indexes]
BT[BTreeMap price buckets]
AH[AHashMap order_id to idx]
end
SOA --> SIMD
Pool --> SOA
BT --> SOA
AH --> SOA
预分配十万级槽位是典型的用内存换分配延迟:容量规划变成运维问题;池耗尽时当前逻辑不会默默扩容——这是刻意简单,也是使用时要心里有数的地方。
正确性没有「高性能版」和「低配版」
价格优先、时间优先体现在各实现的队列语义里;自成交在撮合循环里被显式跳过。Direct 的 GTC 在重复 order_id 已存在时会再走撮合并可能对剩余量发 reject——这是行为选择,接上游幂等时要对齐预期,而不是当 bug 修掉。
再快的簿也挂在风控前后之间:不变量是流水线共同保证的,订单簿只是其中一环。
配置、恢复与「怎么验证设计」
engine.orderbook_type 在 TOML 里切换实现;解析失败时应用层会回退到保守默认。示例配置往往偏向 advanced,和「默认部署要功能全」的假设一致。
从快照恢复时,具体簿类型由序列化的 OrderBookState 变体决定,但路由结构体里有一个写死的 orderbook_type 字段——若未来有代码路径只读内存字段、不读持久化状态,可能和直觉不一致。这是实现细节里值得记在心里的坑,而不是写给别人背的条文。
至于「设计有没有带来延迟收益」,不在这里编 TP99。更诚实的方式是:固定 workload,切换 orderbook_type,用本机的 cargo bench 或场景压测自己量——设计分享替代不了你自己的数据。
收束一下
如果要用一句话概括这里的哲学:低延迟来自线程模型(顺序消费、可选绑核、对等待策略诚实)乘以数据结构(Slab、索引链表、SOA、预分配)乘以数值批处理(在合适 workload 下)再乘以对功能范围的自觉裁剪。把几种 OrderBook 并列,是让部署在延迟、内存和产品语义之间做可验证的选择,而不是假装存在一个万能结构。