现货 matching engine 是一台被硬约束包围的小状态机:price-time priority、部分成交,以及突发负载下不抬头的延迟长尾。本文讲 order book、撮合循环、日志和回放框架是怎么搭起来的,以及语言在哪里就帮不上忙了。
现货交易所的 matching engine 职责很窄。它接收一条有序的指令流——下单、撤单、改单——按 price-time priority 应用到 order book 上,并输出一条有序的事件流:成交、账本更新、拒单、确认。
它的全部难点,来自叠加在这项职责上的三条约束:同一输入每次回放的结果必须完全一致;部分成交订单的剩余量必须保住它在队列中的位置;突发到来时延迟长尾不得抬头。
账本由两侧组成,每侧是按价格排序的价位集合。价位不是一个数字——它是该价格上按到达顺序排列的挂单队列。撮合频繁触碰最优价位、极少触碰深处价位,所以结构是按这种访问模式挑的,而不是按优雅程度挑的。
侵入式队列与索引句柄带来的结果是:挂单在其存活期间从不在内存中移动。它的队列位置是其链接的属性,而不是它恰好坐在哪里的属性,这正是后面部分成交能做得廉价的原因。
到来的主动单从最优价起,向内遍历对手方。在每个价位上,它从队首开始遍历 FIFO 队列。当该价位的价格对来单不再可接受,或来单数量归零时,它就停下。
部分成交的挂单保留自己的位置。它的剩余量带着原有的到达序号留在队首,因为成交只改变数量,不改变别的。部分成交的主动单如果是普通限价单,则成为自己价位队尾的挂单,并获得新的到达序号——它是现在到的,不是更早。
订单类型的语义,是在这个循环的边界上做出的决定,而不是在循环内部。Immediate-or-cancel 丢弃剩余量而不把它挂出。Fill-or-kill 先做一次空跑,要么整单执行,要么拒单。Post-only 在订单到达即会交叉时拒单。把这些放在循环之外,意味着循环仍是唯一改变账本状态的地方。
自成交防范、最小数量,以及 tick 与手数校验,同样属于循环之前的事。抵达撮合的订单已被证明格式良好,因此循环里没有会拖慢它、也没有会引发分歧的错误分支。
平均延迟很少是问题。问题在于突发期间最差的那次观测值,而那正是引擎最要紧、也最可能撞上 stop-the-world 停顿的时刻。在带 garbage collector 的托管运行时下,这个停顿由收集器而不是由你来安排,并且它恰好落在制造了垃圾的那波突发中间。
手动分配只是同一问题的缩小版。通用分配器可能遍历空闲链表、加锁,或者向内核要更多内存,而做这件事的那次调用,正是出现在长尾里的那次调用。两种情况的解法是一样的:热路径上根本不做分配。
当同一输入序列在另一台机器上、在一年之后仍逐字节产生同一输出序列时,引擎才是确定性的。撮合路径内任何读取墙上时钟、线程调度或哈希遍历顺序的东西,都会破坏这个性质。
因此时间戳是一种输入,而不是引擎自己去读的东西。定序器在接受指令时为它打上时间戳,撮合循环把这个戳当作数据。若确实需要随机性,它来自一个带种子的生成器,而种子是日志的一部分。
恢复不是撮合跑通之后再拴上去的功能。引擎按序号顺序,把已接受的指令写入只追加的日志,而内存中的账本不过是折叠这份日志的结果。崩溃后的重建,就是把它重放一遍。
引擎负责写日志,但持久性取决于存储路径,以及在确认发出之前有多少台机器已经拿到这条记录。那是复制与硬件层面的决定,恢复时间真正的胜负也在那里。
确定性正是引擎可测试的前提。因为同一输入给出同一输出,录下的一段会话就是一个回归测试,发现过一次的故障可以被精确复现,而不用去追。
参考模型的价值比看上去大。依同一份规格写出的两个实现,恰恰会在规格含糊之处产生分歧,而撮合规则的边界处满是含糊:交叉的限价、归零的剩余量、与成交竞速的撤单。
Rust 消除的是一类问题,而不是它本身让循环跑得更快。这里没有 garbage collector,所以不会有停顿在你背后被安排。所有权让单写者纪律成为编译器强制的事,而不是代码评审必须留意的事。slab 句柄和侵入式链接在没有生命周期的语言里很容易出错,在这里则是可检查的。调试构建下整数溢出会触发 panic,能抓住一类会悄悄弄坏账本的缺陷。
诚实的边界在于:决定长尾延迟的绝大部分因素并不是语言:
Rust 也是有代价的。在设计还在变动的头几周,借用检查器会拖慢进度;交易所专有协议的生态比老牌语言更薄;围绕无锁结构的 unsafe 块,需要与别处等价代码同样严格的评审纪律。选择它,是关于延迟长尾、以及单写者内核中内存安全的决定,不是关于开发者舒适度的决定。
amBrain 自 2019 年起在亚美尼亚埃里温做交易基础设施,热路径用 Rust 编写——行情数据在 5 ms 以内送达,交易前风控检查在 1 ms 以内完成。如果你正在设计 matching engine,想一起过一遍账本结构、日志格式或回放框架,这样的交流值得在写下热路径第一行代码之前进行。
我们的工程团队专注于FinTech方向的解决方案。聊聊怎么把你的项目做出来。