加密货币交易所五:交易引擎
拿一份六档的订单簿算一遍:市价买 15 张成交在什么均价、限价单剩下的部分怎么变成买一、价差为什么会被吃宽。撮合规则、订单簿的两层结构、崩溃之后怎么把它重建出来。
先看一份订单簿,六个档位,按屏幕上的排法卖单在上、买单在下:
| 档位 | 价格(USDT) | 数量 |
|---|---|---|
| 卖三 | 10003 | 7 |
| 卖二 | 10002 | 12 |
| 卖一 | 10001 | 8 |
| 买一 | 10000 | 5 |
| 买二 | 9999 | 10 |
| 买三 | 9998 | 15 |
现在你打一笔市价买单,15 张。成交均价是多少?
不是 10001。卖一只有 8 张,吃完之后还差 7 张,只能往上够卖二:
吃卖一 10001 × 8 = 80008吃卖二 10002 × 7 = 70014 ───────合计 150022 ÷ 15 = 10001.4667成交均价 10001.47,比你看到的卖一贵了 0.47。这 0.47 就是滑点,它不是手续费,也没有任何人收走 —— 它是「盘口那一档不够你吃」这件事的价格。单子越大,往上够的档位越多,均价离盘口越远。
成交完这一笔,订单簿变成卖一 10002 剩 5 张、买一还是 10000 的 5 张。价差从 1 变成了 2。 一笔市价单不只是成交,它还把盘口拉宽了,下一个人的滑点因此更大。
限价单要么吃掉对手,要么变成对手
换一笔:限价买单,价格 10001,数量 20。
限价买 10001 的意思是「10001 及以下我都要」。卖一正好是 10001,8 张全吃掉。剩下的 12 张呢?不能再往上吃 —— 卖二 10002 超出了你给的上限。这 12 张以 10001 的价格挂进买盘,成为新的买一。
订单簿现在是这样:卖一 10002 的 5 张(前一笔留下的),买一 10001 的 12 张。价差回到 1,但买一从 10000 被抬到了 10001。
同一笔订单,前 8 张和后 12 张的身份完全不同:
- 前 8 张主动吃掉了簿上已有的挂单,这部分是 taker(吃单方)。
- 后 12 张躺在簿上等着被别人吃,这部分是 maker(挂单方)。
交易所对两者收不同的费率,maker 常常更便宜甚至是负的(返佣),因为 maker 提供了流动性,taker 消耗流动性。同一张单子被拆成两种身份分别计费,是很多人对账时对不上的原因。
限价单保价格不保成交,市价单正好反过来。 如果上面那笔限价买单价格填的是 9999,一张都不会成交,20 张全部挂进买二后面排队。
价格优先、时间优先决定谁先成交
排队规则只有两条,按顺序应用:
- 价格优先。 买单价格越高越靠前,卖单价格越低越靠前。出价 10001 的买单一定排在 10000 前面。
- 时间优先。 同一个价格上,先到的先成交。
第二条是交易所对用户的硬承诺,也是撮合必须串行的根本原因 —— 如果两个线程能同时改一个价位的队列,「谁先到」就没有确定答案了。
这两条决定了订单簿的形状是两层:外层按价格有序,内层是同价位的 FIFO 队列。撮合时永远从最优价格档的队首取单。
外层用什么结构,各家不一样,取决于价格的分布。有序映射(红黑树、跳表这一类)适合价格跨度大、档位稀疏的品种;如果这个品种的最小变动价位固定、价格波动范围有限,直接开一个按价格索引的数组、再用位图记住哪些档位非空,比树快得多。这是实现选择,不是行业标准,别人的代码里用了什么不构成你该用什么的理由。
内层的 FIFO 队列反而没什么可选的 —— 时间优先要求它就是个队列。
Pro-rata:把优先权从「谁先来」换成「谁下得大」
价格优先、时间优先不是唯一的规则。另一种叫 Pro-rata:找到能成交的最优价格之后,按每个挂单的数量占该档位挂单总量的比例分配成交量。
分配给订单 A 的成交量 = (订单 A 的数量 ÷ 该价格档位的挂单总量) × 本次可成交总量拿上面卖一那 8 张举例。假设它其实是三笔挂单:4 张、3 张、1 张。来了一笔 4 张的买单:
- 时间优先规则下:最先挂的那笔 4 张全部成交,后两笔一张不动。
- Pro-rata 规则下:三笔各拿到 4×(4/8)=2、4×(3/8)=1.5、4×(1/8)=0.5 张。
差别一眼可见。Pro-rata 让大单更容易拿到成交,代价是碎单变多 —— 上面那 1.5 张和 0.5 张都是部分成交,每一笔都要单独记账、单独推送、单独结算。实现复杂,可能导致频繁的小额部分成交,这两条是同一件事的两面。
Pro-rata 主要出现在传统衍生品市场。CME Globex 的撮合算法列表里就有 Pro-Rata 和 Threshold Pro-Rata 这几种,按品种指定;加密永续合约这边极少见。这一节放在这里是为了说明一件事:「价格优先、时间优先」是选择,不是物理定律。
止损止盈单根本不在订单簿里
订单类型看起来有一长串,但它们分成截然不同的两类:
| 类型 | 在订单簿里吗 | 触发之后 |
|---|---|---|
| 限价单 | 在,占一个档位 | —— |
| 市价单 | 不在,瞬间吃完就没了 | —— |
| 止损单 | 不在 | 触发价被触及,转成市价单或限价单送进撮合 |
| 止盈单 | 不在 | 同上,方向相反 |
| 跟踪止损单 | 不在 | 触发价跟着行情走,触及后同上 |
| 冰山订单 | 在,但只露一小截 | 露出的部分成交后自动补量 |
止损、止盈、跟踪止损统称条件单。它们躺在撮合引擎之外的一张触发表里,由一个单独的服务盯着价格。价格没到,订单簿里看不到它们,市场深度里也不体现 —— 这就是为什么一次快速下跌会「凭空」冒出大量卖单:它们一直都在,只是刚才不在簿上。
盯的是哪个价格是个要紧的细节。用最新成交价当触发条件,一次插针就能把一批止损扫掉;用标记价格则挡得住这种玩法。交易所通常把这个选择交给用户,而默认值决定了大多数人的实际处境。第一篇讲过标记价格为什么和最新成交价不是一回事。
冰山订单是唯一在簿上但不完整的那个。它把大单切碎、只露一小截,藏住的是意图 —— 别人看不出这个档位后面还压着多少量。付出的是成交速度:露出的部分成交之后补上来的新量,在同价位的时间优先里通常要重新排到队尾。
一张订单只在这几个状态之间跳
Filled、Canceled、Rejected 是终态,进去就不再出来。要紧的是状态转换本身必须是原子的:一笔订单不能出现「已经从 Active 出去了但还没进 PartiallyFilled」的中间态,因为在那一瞬间它既不在簿上也不在成交记录里,撤单请求打过来无处可去。
每次状态变更都要落一条持久记录,这不是为了审计,是为了故障恢复 —— 下一节的重放就是靠它。同时状态变更要幂等:网络重试、消息队列重投、恢复时重放,同一条变更会被执行不止一次,第二次必须什么也不做。
订单簿活在内存里,靠日志和快照兜底
订单簿是纯内存结构,进程一挂全没了。撑住这件事的是两样东西。
写前日志(Write-Ahead Logging):所有更改先记录日志,然后应用到内存中的订单簿。 顺序不能反 —— 先改内存再写日志的话,两步之间崩掉,这笔变更就永远丢了,而它可能已经作为成交推送给用户了。
快照(Checkpoint): 定期把整个订单簿的完整状态存下来。没有快照的话,恢复要从第一天的日志开始重放。
恢复就是这两样拼起来:加载最近一次快照,然后重放快照之后的所有日志,内存里的订单簿就回到了崩溃前那一刻。这套东西的正确性完全依赖「日志里记的是导致状态变化的输入,而不是变化的结果」—— 记输入才能重放,记结果就只是一份不完整的备份。
按交易对分片是唯一能扩的方向
将订单簿按照不同的交易对分割到多个服务器上,每个服务器负责处理特定的交易对。BTCUSDT 一个分片,ETHUSDT 另一个,接入层按交易对把请求路由过去。
好处有两条,一条是横向扩容,另一条常被忽略:故障被隔离住了。某个小币种的撮合进程崩了,BTCUSDT 照常成交。这在单进程撮合所有交易对的设计里做不到。
难点全部转移到了跨分片和一致性上:
- 跨分片的操作没有原子性。 全仓保证金是账户级的,而账户的仓位散落在多个分片上。「这个账户的风险到底怎么样」这个问题,任何单个分片都答不了。
- 热点分片切不动。 流量在交易对之间极度不均,BTCUSDT 可能超过后面几十个的总和。它撞上单机上限的时候,分片这条路已经走到头了,只能回去优化那一个进程。
第二篇讲过这条界线:整套架构里能靠加机器解决的部分都在撮合引擎之外。这里是同一件事的另一个角度 —— 分片扩的是交易对的数量,不是单个交易对的吞吐。
这套设计的代价
顺序和吞吐是同一枚硬币的两面。 时间优先要求串行,串行意味着单核,单核意味着这条路径上的每一微秒都得省。省的办法是把一切非必要的东西移出去:风控校验在进撮合之前做完,通知和落库在成交之后异步做,撮合本身只剩「取单、比价、成交、改簿」。这套拆分带来的复杂度,全部是为了保住那一条串行路径。
内存态和持久态永远有偏差。 撮合在内存里完成,日志和快照在后面追。任何一个直接读数据库判断「这单成交了没有」的服务,都会在某个时刻读到旧答案。
低延迟的收益不归交易所。 专线、托管、FPGA 这些手段压的是做市商到交易所那一段,收益归做市商。交易所这边能做的是别让自己成为抖动源:撮合到行情推送之间的延迟稳定,比它的绝对值小更重要 —— 一个稳定 5 毫秒的系统,做市商能报出比一个 1 到 20 毫秒之间跳的系统更窄的价差。
链上的永续合约走了完全不同的一条路。 Perpetual Protocol 那样的设计里根本没有订单簿,价格由一条恒定乘积曲线算出来,也就不存在价格优先、时间优先,更不存在 maker 和 taker 的区分。这个对照能说明一件事:本文这一整套机制,都是「要维护一份所有人共享的、顺序敏感的挂单队列」这个前提推出来的。前提换掉,结论全部作废。
这篇是 加密货币交易所的第 5 篇。前一篇是 加密货币交易所 四:用户接入系统,后一篇是 加密货币交易所六:风险管理系统。