撮合引擎的订单簿是怎么实现的

admin 2026-09-23 05:56:07 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入解析撮合引擎订单簿的数据结构设计,从盘口界面出发逐步推导核心决策:价格用整数存储避免浮点误差、买卖两侧统一排序方向以复用代码、为订单号建索引加速撤单、价位内用链表保证位置稳定、否决按价格开大数组方案、区分剩余量与可见量、改小数量保留排队位置、汇总数量同步维护等。文章强调按使用场景选结构,提供可操作的工程实践建议。 综合评分: 88 文章分类: 实战经验,安全开发,其他


撮合引擎的订单簿是怎么实现的

原创

交易系统研究员 交易系统研究员

CppGuide

2026年9月22日 11:47 上海

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

#

本文面向没做过交易系统的读者。从「界面上那两列红绿数字到底是什么」讲起,一步步推出 订单簿该用什么数据结构——重点不是”我们用了什么”,而是为什么只能这么用,以及 每种备选方案是被什么问题否掉的。


一、先看清楚要做什么

打开任何一个交易界面,都能看到两列数字:上半部红色、下半部绿色,每行一个价格加一个数量。 这块东西叫盘口

它不是数据库里的一张表,而是撮合引擎内存里那本订单簿的一个摘要

看右边那张图里的关键一处:界面上「10.0 → 500 张」是一行,内存里对应的却是两张挂单—— #3 的 200 张和 #5 的 300 张。界面把它们加在一起显示,因为买卖双方只关心”这个价位一共 有多少量”;但撮合引擎必须把两张分开存,因为谁先成交是有区别的#3 先挂进来,就该 #3 先成交。

这条区别是订单簿全部设计的起点。

订单簿要支持哪几件事

把使用场景列清楚,数据结构就基本被定死了:

| 操作 | 频率 | 要求 | | — | — | — | | 挂单 | 高 | 放进对应价位的队尾 | | 撤单 | 极高 | 按订单号找到那张单,摘掉 | | 改小数量 | 中 | 找到那张单,改数量 | | 取最优价 | 每次撮合都要 | 买侧最高价 / 卖侧最低价 | | 从最优价位吃单 | 每次撮合都要 | 取队首、成交、成交完出队 | | 报盘口 | 高 | 按价位汇总数量 |

注意标粗的三项。撤单的频率往往比挂单还高——很多程序化策略是挂了就撤、撤了再挂, 一秒钟几十次;而取最优价和吃队首是撮合的内循环,每笔成交都要走一遍。

这三条路径慢,整个撮合就慢。剩下的设计都是围着它们转的。


二、三层结构

从外到内三层:

全部合约
  └── 一本订单簿(某个合约)
        ├── 买侧:价位表
        └── 卖侧:价位表
              └── 一个价位
                    ├── 汇总数量(这个价位一共多少量)
                    └── 挂单队列(先挂的在前)

第一层按合约分开,因为不同合约之间完全不相干——比特币的买单永远不会和以太币的卖单 成交。分开之后还有一个额外好处:不同合约可以并行撮合,互不干扰。

第二层分买卖两侧,各自维护一张按价格排序的表。

第三层是价位内的队列,先挂的排在前面。


三、第一个设计决定:价格用整数存

价格看起来天然是小数——10.5、9.05。但订单簿里一律用整数存

做法是除以这个合约的最小变动价位(业内叫 tick)。比如最小变动是 0.5,那么:

| 真实价格 | 存的整数 | | — | — | | 9.0 | 18 | | 9.5 | 19 | | 10.0 | 20 |

为什么不能用浮点数,有三个理由,一个比一个严重:

  1. 相等判断不可靠。 浮点数算出来的 10.0 可能是 9.999999999999998。订单簿要不断 回答”这个价位上有没有挂单”,用浮点数做键,这个问题就没法可靠地回答。
  2. 比较也不可靠。 撮合的核心判断是”买价是否高于等于卖价”。两个理应相等的价格因为末位 误差被判成不相等,这笔本该成交的单就不会成交——而且不会报错,只表现为”明明价格一样 却撮不上”。
  3. 整数比较更快。 这是顺带的好处,不是主要理由。

第三条常被当成主要理由,其实反了。前两条是正确性问题,第三条只是性能问题——正确性 出问题就是账目错误,性能差只是慢。

顺带说一个衍生规则:既然价格必须落在整数刻度上,那么报价没对齐最小变动价位的委托必须 直接拒绝,不能替用户四舍五入。替他取整的话,他冻结的保证金按他填的价算、成交按取整后的 价走,这两个数不一样,而界面上看不出任何异常。


四、第二个设计决定:两侧都从低到高排

买侧要找最高价,卖侧要找最低价。直觉上会让两侧排序方向相反——买侧降序、卖侧升序, 这样”最优价”永远在表头。

实际做法是两侧都从低到高排,区别只在取哪一端:

| | 最优价 | 取哪里 | | — | — | — | | 卖侧 | 最低价 | 表头 | | 买侧 | 最高价 | 表尾 |

为什么宁可取两个不同的端点,也要让排序方向一致?

因为排序方向一致,两侧就能共用同一套代码。挂单、撤单、汇总数量、遍历价位——这些操作在 两侧的逻辑完全相同,只有”取最优”这一处需要区分方向,而这一处封装成一个函数就完了:

// 按方向取对应侧的价位表——两侧类型完全相同,调用方不必知道排序方向
std::map<int64_t, PriceLevel>&&nbsp;sideMap(DirectionType d)
{
&nbsp; &nbsp;&nbsp;return&nbsp;d == DirectionType::kBuy ? m_buy : m_sell;
}

// 最优价:买侧取最大键,卖侧取最小键
int64_t&nbsp;bestPrice(DirectionType side)&nbsp;const
{
&nbsp; &nbsp;&nbsp;return&nbsp;(side == DirectionType::kBuy) ? m_buy.rbegin()->first
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;: m_sell.begin()->first;
}

如果两侧排序方向相反,它们的类型就不同了,上面那个 sideMap 写不出来——每个操作都得写 两份,买侧一份卖侧一份。两份代码就会分叉,而分叉的表现是”买单撤得掉、卖单撤不掉”这类 只影响一侧的怪问题。

取表头和取表尾都是常数时间,所以这个选择不花任何性能代价


五、第三个设计决定:给订单号建一张索引

撤单要按订单号找到那张单。没有索引的话只能从头翻:

翻一遍的代价随挂单数线性增长。簿上有一万张单时,平均要比较五千次——而撤单是每秒发生 几十次的操作。

所以额外维护一张表:订单号 → 它在簿上的位置。撤单时查一次表直接拿到位置,摘掉即可, 与簿上有多少张单无关。

// 位置:撤改时定位到具体挂单。记录"哪一侧、哪个价位、队列里的哪个节点"
struct&nbsp;Loc
{
&nbsp; &nbsp; DirectionType side;
&nbsp; &nbsp;&nbsp;int64_t&nbsp;price_ticks;
&nbsp; &nbsp;&nbsp;std::list<BookOrder>::iterator it; &nbsp;&nbsp;// 直接指向队列节点
};

std::unordered_map<int64_t, Loc> m_index; &nbsp;&nbsp;// 订单号 → 位置

六、第四个设计决定:价位内用链表,不用数组

上面那张索引里存的是「位置」。这就引出一个必须先回答的问题:这个位置会不会失效?

用数组的话,删掉中间一个元素,后面所有元素的下标都会变。那索引里记的位置就全都指错了, 每次删除都得把整张索引重算一遍——等于没有索引。

链表不同:删掉一个节点,其余节点的位置保持不变。所以索引可以长期持有这些位置,一直 指得准。

链表的代价是”按序号访问要一个一个走”。但撮合从来不按序号取单——它只取队首那一张。 所以这个代价在这里不成立。

这是一个典型的”按使用方式选结构”的例子:脱离场景比较链表和数组没有意义,关键是这个场景 里只需要队首访问,而且需要位置稳定


七、被否掉的方案:按价格直接开大数组

还有一种更快的做法:既然价格已经是整数了,干脆按价格直接做数组下标。取任意价位都是真正的 一步到位。

它被否掉,不是因为慢,而是两个工程上的代价:

一、空占内存,而且常驻不释放。 数组必须按整个可交易价格区间预先分配。价格区间越宽、 最小变动价位越细,空格子越多。实盘上真正有挂单的通常只有几十个价位,其余全是空的。

二、价格区间调整时整张表要重建。 运营调宽价格带是常规操作,而重建整张表意味着这段时间 撮合要停下来。

所以实际选的是只为”真的有挂单的价位”建条目,按价格排好序。取价位要多走几步(在几十个 价位里做二分查找,大约五六次比较),但省下的内存和避免的重建,远比这几次比较值钱。

这里想强调的是取舍本身:扁平数组在纸面上更优,被否掉靠的不是复杂度分析,而是”价格带 多宽””实盘有多少活跃价位””运营会不会调价格带”这些具体事实。没有这些事实,就只能选出 纸面上好看的方案。


八、一张挂单为什么有两个数量

到这里结构定完了。但有一个容易被忽略的细节:同一张挂单,撮合看到的数量和盘口看到的 数量不是同一个数。

  • 剩余量 = 委托量 − 已成交量。这是撮合能吃的量
  • 可见量。这是盘口该报的量

大多数时候两者相等。让它们不相等的是两类功能:

隐藏单。 用户挂 500 张,但只想让别人看到 100 张(挂太厚会影响别人的报价)。这时 可见量是 100,剩余量仍是 500。

这里有个必须注意的细节:可见量要以剩余量封顶。已经成交 300 张之后剩余只有 200, 可见量就得取 min(100, 200) = 100;剩余降到 60 时,可见量必须跟着降到 60。不封顶的话, 盘口会报出一个成交不到的数量——照这个盘口报价的人只会成交更少,或者被迫到更差的 价位去补,而盘口上看不出任何异常。

定向成交单。 有的挂单指定只与自己账户成交。这类单的可见量强制为 0——外部账户 取到它时,它会被强制撤销而不是成交。把它算进公开盘口,等于对外报出一个根本吃不到的量。

两个数必须分开存,合并成一个就会坏掉:

  • 只保留可见量 → 隐藏的那部分永远成交不了,这张单等于废了
  • 只保留剩余量 → 隐藏单失效,用户要求只露 100 张,结果 500 张全暴露

九、把数量改小,为什么不用重新排队

最后一个设计决定,理由完全是业务上的。

同一个价位先挂的先成交。排队位置是用户等出来的

现在用户想把 500 张改成 300 张。如果实现成”撤掉再挂回去”,这张单就掉到了队尾——它明明 比后面那张先挂,现在却要等后面那张成交完才轮到自己。

而它并没有多占用任何成交机会:数量变少了,对别人只有好处。凭什么惩罚它?

所以减量单独实现成”就地改小”,保留位置:

bool&nbsp;shrinkVolume(int64_t&nbsp;id,&nbsp;int64_t&nbsp;new_volume)
{
&nbsp; &nbsp;&nbsp;// ...按订单号查到位置...

&nbsp; &nbsp;&nbsp;// 新量不得大于原委托量:那是加量,必须重新排队,不能走这条保序的路径
&nbsp; &nbsp;&nbsp;// 新量也不得小于等于已成交量:那样剩余量为零或为负,无法表达成一个合法的挂单
&nbsp; &nbsp;&nbsp;if&nbsp;(new_volume > o.order_volume || new_volume <= o.traded_volume)
&nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;false;
&nbsp; &nbsp; }
&nbsp; &nbsp; o.order_volume = new_volume;
&nbsp; &nbsp;&nbsp;// ...同步该价位的汇总数量...
}

加量则必须重新排队——那是实实在在多占了成交机会,如果还能保留位置,所有人都会先挂 一张小单占住位置,再慢慢加量。这个口子一开,先来先得的规则就没有意义了。

改价同理:换到另一个价位,等于进了另一条队,只能从队尾开始。


十、一个容易写错的地方:汇总数量

每个价位上都缓存了两个汇总数量——这个价位的剩余量总和、可见量总和。盘口要报的就是它们, 每次都遍历整个队列去加太慢。

汇总数量是派生量:它可以由队列里的挂单算出来,缓存它只是为了快。而所有派生量都有同一 个风险:跟真实内容对不上

所以写入侧被收紧成四个方法——挂单、撤单、改小数量、队首成交——每个方法在改动队列时必须 同步改汇总数量。对外只给只读访问:

// 刻意只给 const 版本:写侧一律走 insert/cancel/fillFront/shrinkVolume,
// 它们会同步维护索引与汇总数量;放出可变引用等于允许绕过这些方法往队列里塞单,
// 那正是本模块最不该出现的错误。
const&nbsp;std::map<int64_t, PriceLevel>&&nbsp;levels(DirectionType side)&nbsp;const
{
&nbsp; &nbsp;&nbsp;return&nbsp;sideMap(side);
}

这类错误的表现是:簿上的单是对的,盘口报的数是错的。撮合照常工作,账也对得上,只有 盘口深度不对——排查起来会先怀疑行情推送,一路查下去才发现是订单簿里的一个加法漏了。

顺带一个细节:改小数量时,剩余量的汇总按差值调整没问题,但可见量的汇总必须单独重算。 因为设了显示数量的单,可见量是”显示数量与剩余量取小”,减量后剩余量可能降到显示数量以下, 两者的变化幅度并不相同。套用剩余量的差值,盘口深度就会算错。


十一、撮合怎么用这本簿

结构讲完了,看一眼撮合是怎么消费它的——双层循环,与订单簿的两层结构严丝合缝

while&nbsp;(还有剩余量 && 对手侧非空)
{
&nbsp; &nbsp;&nbsp;const&nbsp;int64_t&nbsp;best = book.bestPrice(对手侧); &nbsp;&nbsp;// 外层:逐档

&nbsp; &nbsp;&nbsp;if&nbsp;(是限价单 && 价格不够优)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 不再成交,去挂单

&nbsp; &nbsp;&nbsp;while&nbsp;(还有剩余量 && 该价位还有量) &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 内层:逐单 FIFO
&nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp; BookOrder& maker = book.frontOrder(对手侧); &nbsp;// 只取队首
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// ...算成交量、判自成交防护...
&nbsp; &nbsp; &nbsp; &nbsp; book.fillFront(对手侧, qty); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 成交,成交完自动出队
&nbsp; &nbsp; }
}

三个细节值得注意:

  • 外层只用 bestPrice,内层只用 frontOrder 撮合从不按序号访问挂单——这正是第六节 里”链表的按序号访问慢在这里不成立”的依据。
  • 价位吃空后自动删档,所以下一轮 bestPrice 自然取到下一档,不需要显式推进游标。
  • 限价单在价格不够优时直接跳出,剩余量转为挂单。这一步决定了这张单是 taker 还是 maker。

复杂度小结

| 操作 | 复杂度 | 说明 | | — | — | — | | 取最优价 / 最优档 | O(1) | 取价位表的一端 | | 挂单 | O(log L) | L = 当前有挂单的价位数 | | 撤单 / 改数量 | O(1) 定位 + O(log L) 可能删档 | 靠订单号索引 | | 从队首成交 | O(1) | 成交完出队,档空删档 | | 报某价位的量 | O(1) | 读缓存的汇总数量 |

L 是有挂单的价位数,不是整个价格区间。实盘上通常只有几十,所以 O(log L) 实际就是 五六次比较。


回头看这些决定

| 决定 | 真正的理由 | | — | — | | 价格用整数 | 浮点的相等与比较不可靠,会让该成交的单不成交且不报错 | | 两侧同向排序 | 共用一套代码,避免两份代码分叉;取两端都是常数时间,不花代价 | | 建订单号索引 | 撤单频率极高,线性查找会随挂单数变慢 | | 价位内用链表 | 索引里存的位置必须在别人增删时保持有效 | | 不用扁平数组 | 空占内存且常驻;价格带调整要重建整表 | | 两套数量分开 | 合并会让隐藏单失效,或让隐藏部分永远成交不了 | | 减量保留位置 | 排队位置是等出来的,减量没多占成交机会 | | 汇总数量只给只读访问 | 派生量一旦与真实内容分叉,表现是”账对、盘口错”,极难排查 |

八条里只有一条(两侧同向排序)主要是为了代码整洁,其余七条都是不这么做就会出错—— 要么算错账,要么让某个功能失效,要么留下一类极难排查的故障。

这是交易系统里数据结构选择的常态:很少是”哪个更快”,多半是”哪个不会错”。

如果你对交易系统的开发与设计感兴趣,可以阅读小方在知识星球的专栏:

使用 C++20 从零构建一个完整的低延迟交易系统

如果你想求职交易系统相关的岗位,可以阅读小方在知识星球的专栏:

交易系统开发岗位求职与面试指南

如果你对交易系统开发感兴趣或者想这方面的工作,可以看看交易系统开发训练营,这个周六开始小方将带你从零开发一套完整的交易系统,包括上文中提到的全部技术:

AI实战训练营:用AI从零开发一套高可用交易系统 即将开营


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:CppGuide 交易系统研究员 交易系统研究员《撮合引擎的订单簿是怎么实现的》

评论:0   参与:  0