文章总结: 本文深入解析撮合引擎订单簿的数据结构设计,从盘口界面出发逐步推导核心决策:价格用整数存储避免浮点误差、买卖两侧统一排序方向以复用代码、为订单号建索引加速撤单、价位内用链表保证位置稳定、否决按价格开大数组方案、区分剩余量与可见量、改小数量保留排队位置、汇总数量同步维护等。文章强调按使用场景选结构,提供可操作的工程实践建议。
综合评分: 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 |
为什么不能用浮点数,有三个理由,一个比一个严重:
- 相等判断不可靠。 浮点数算出来的
10.0可能是9.999999999999998。订单簿要不断 回答”这个价位上有没有挂单”,用浮点数做键,这个问题就没法可靠地回答。 - 比较也不可靠。 撮合的核心判断是”买价是否高于等于卖价”。两个理应相等的价格因为末位 误差被判成不相等,这笔本该成交的单就不会成交——而且不会报错,只表现为”明明价格一样 却撮不上”。
- 整数比较更快。 这是顺带的好处,不是主要理由。
第三条常被当成主要理由,其实反了。前两条是正确性问题,第三条只是性能问题——正确性 出问题就是账目错误,性能差只是慢。
顺带说一个衍生规则:既然价格必须落在整数刻度上,那么报价没对齐最小变动价位的委托必须 直接拒绝,不能替用户四舍五入。替他取整的话,他冻结的保证金按他填的价算、成交按取整后的 价走,这两个数不一样,而界面上看不出任何异常。
四、第二个设计决定:两侧都从低到高排
买侧要找最高价,卖侧要找最低价。直觉上会让两侧排序方向相反——买侧降序、卖侧升序, 这样”最优价”永远在表头。
实际做法是两侧都从低到高排,区别只在取哪一端:
| | 最优价 | 取哪里 |
| — | — | — |
| 卖侧 | 最低价 | 表头 |
| 买侧 | 最高价 | 表尾 |
为什么宁可取两个不同的端点,也要让排序方向一致?
因为排序方向一致,两侧就能共用同一套代码。挂单、撤单、汇总数量、遍历价位——这些操作在 两侧的逻辑完全相同,只有”取最优”这一处需要区分方向,而这一处封装成一个函数就完了:
// 按方向取对应侧的价位表——两侧类型完全相同,调用方不必知道排序方向
std::map<int64_t, PriceLevel>& sideMap(DirectionType d)
{
return d == DirectionType::kBuy ? m_buy : m_sell;
}
// 最优价:买侧取最大键,卖侧取最小键
int64_t bestPrice(DirectionType side) const
{
return (side == DirectionType::kBuy) ? m_buy.rbegin()->first
: m_sell.begin()->first;
}
如果两侧排序方向相反,它们的类型就不同了,上面那个 sideMap 写不出来——每个操作都得写 两份,买侧一份卖侧一份。两份代码就会分叉,而分叉的表现是”买单撤得掉、卖单撤不掉”这类 只影响一侧的怪问题。
取表头和取表尾都是常数时间,所以这个选择不花任何性能代价。
五、第三个设计决定:给订单号建一张索引
撤单要按订单号找到那张单。没有索引的话只能从头翻:
翻一遍的代价随挂单数线性增长。簿上有一万张单时,平均要比较五千次——而撤单是每秒发生 几十次的操作。
所以额外维护一张表:订单号 → 它在簿上的位置。撤单时查一次表直接拿到位置,摘掉即可, 与簿上有多少张单无关。
// 位置:撤改时定位到具体挂单。记录"哪一侧、哪个价位、队列里的哪个节点"
struct Loc
{
DirectionType side;
int64_t price_ticks;
std::list<BookOrder>::iterator it; // 直接指向队列节点
};
std::unordered_map<int64_t, Loc> m_index; // 订单号 → 位置
六、第四个设计决定:价位内用链表,不用数组
上面那张索引里存的是「位置」。这就引出一个必须先回答的问题:这个位置会不会失效?
用数组的话,删掉中间一个元素,后面所有元素的下标都会变。那索引里记的位置就全都指错了, 每次删除都得把整张索引重算一遍——等于没有索引。
链表不同:删掉一个节点,其余节点的位置保持不变。所以索引可以长期持有这些位置,一直 指得准。
链表的代价是”按序号访问要一个一个走”。但撮合从来不按序号取单——它只取队首那一张。 所以这个代价在这里不成立。
这是一个典型的”按使用方式选结构”的例子:脱离场景比较链表和数组没有意义,关键是这个场景 里只需要队首访问,而且需要位置稳定。
七、被否掉的方案:按价格直接开大数组
还有一种更快的做法:既然价格已经是整数了,干脆按价格直接做数组下标。取任意价位都是真正的 一步到位。
它被否掉,不是因为慢,而是两个工程上的代价:
一、空占内存,而且常驻不释放。 数组必须按整个可交易价格区间预先分配。价格区间越宽、 最小变动价位越细,空格子越多。实盘上真正有挂单的通常只有几十个价位,其余全是空的。
二、价格区间调整时整张表要重建。 运营调宽价格带是常规操作,而重建整张表意味着这段时间 撮合要停下来。
所以实际选的是只为”真的有挂单的价位”建条目,按价格排好序。取价位要多走几步(在几十个 价位里做二分查找,大约五六次比较),但省下的内存和避免的重建,远比这几次比较值钱。
这里想强调的是取舍本身:扁平数组在纸面上更优,被否掉靠的不是复杂度分析,而是”价格带 多宽””实盘有多少活跃价位””运营会不会调价格带”这些具体事实。没有这些事实,就只能选出 纸面上好看的方案。
八、一张挂单为什么有两个数量
到这里结构定完了。但有一个容易被忽略的细节:同一张挂单,撮合看到的数量和盘口看到的 数量不是同一个数。
- 剩余量 = 委托量 − 已成交量。这是撮合能吃的量。
- 可见量。这是盘口该报的量。
大多数时候两者相等。让它们不相等的是两类功能:
隐藏单。 用户挂 500 张,但只想让别人看到 100 张(挂太厚会影响别人的报价)。这时 可见量是 100,剩余量仍是 500。
这里有个必须注意的细节:可见量要以剩余量封顶。已经成交 300 张之后剩余只有 200, 可见量就得取 min(100, 200) = 100;剩余降到 60 时,可见量必须跟着降到 60。不封顶的话, 盘口会报出一个成交不到的数量——照这个盘口报价的人只会成交更少,或者被迫到更差的 价位去补,而盘口上看不出任何异常。
定向成交单。 有的挂单指定只与自己账户成交。这类单的可见量强制为 0——外部账户 取到它时,它会被强制撤销而不是成交。把它算进公开盘口,等于对外报出一个根本吃不到的量。
两个数必须分开存,合并成一个就会坏掉:
- 只保留可见量 → 隐藏的那部分永远成交不了,这张单等于废了
- 只保留剩余量 → 隐藏单失效,用户要求只露 100 张,结果 500 张全暴露
九、把数量改小,为什么不用重新排队
最后一个设计决定,理由完全是业务上的。
同一个价位先挂的先成交。排队位置是用户等出来的。
现在用户想把 500 张改成 300 张。如果实现成”撤掉再挂回去”,这张单就掉到了队尾——它明明 比后面那张先挂,现在却要等后面那张成交完才轮到自己。
而它并没有多占用任何成交机会:数量变少了,对别人只有好处。凭什么惩罚它?
所以减量单独实现成”就地改小”,保留位置:
bool shrinkVolume(int64_t id, int64_t new_volume)
{
// ...按订单号查到位置...
// 新量不得大于原委托量:那是加量,必须重新排队,不能走这条保序的路径
// 新量也不得小于等于已成交量:那样剩余量为零或为负,无法表达成一个合法的挂单
if (new_volume > o.order_volume || new_volume <= o.traded_volume)
{
return false;
}
o.order_volume = new_volume;
// ...同步该价位的汇总数量...
}
加量则必须重新排队——那是实实在在多占了成交机会,如果还能保留位置,所有人都会先挂 一张小单占住位置,再慢慢加量。这个口子一开,先来先得的规则就没有意义了。
改价同理:换到另一个价位,等于进了另一条队,只能从队尾开始。
十、一个容易写错的地方:汇总数量
每个价位上都缓存了两个汇总数量——这个价位的剩余量总和、可见量总和。盘口要报的就是它们, 每次都遍历整个队列去加太慢。
汇总数量是派生量:它可以由队列里的挂单算出来,缓存它只是为了快。而所有派生量都有同一 个风险:跟真实内容对不上。
所以写入侧被收紧成四个方法——挂单、撤单、改小数量、队首成交——每个方法在改动队列时必须 同步改汇总数量。对外只给只读访问:
// 刻意只给 const 版本:写侧一律走 insert/cancel/fillFront/shrinkVolume,
// 它们会同步维护索引与汇总数量;放出可变引用等于允许绕过这些方法往队列里塞单,
// 那正是本模块最不该出现的错误。
const std::map<int64_t, PriceLevel>& levels(DirectionType side) const
{
return sideMap(side);
}
这类错误的表现是:簿上的单是对的,盘口报的数是错的。撮合照常工作,账也对得上,只有 盘口深度不对——排查起来会先怀疑行情推送,一路查下去才发现是订单簿里的一个加法漏了。
顺带一个细节:改小数量时,剩余量的汇总按差值调整没问题,但可见量的汇总必须单独重算。 因为设了显示数量的单,可见量是”显示数量与剩余量取小”,减量后剩余量可能降到显示数量以下, 两者的变化幅度并不相同。套用剩余量的差值,盘口深度就会算错。
十一、撮合怎么用这本簿
结构讲完了,看一眼撮合是怎么消费它的——双层循环,与订单簿的两层结构严丝合缝:
while (还有剩余量 && 对手侧非空)
{
const int64_t best = book.bestPrice(对手侧); // 外层:逐档
if (是限价单 && 价格不够优)
break; // 不再成交,去挂单
while (还有剩余量 && 该价位还有量) // 内层:逐单 FIFO
{
BookOrder& maker = book.frontOrder(对手侧); // 只取队首
// ...算成交量、判自成交防护...
book.fillFront(对手侧, qty); // 成交,成交完自动出队
}
}
三个细节值得注意:
- 外层只用
bestPrice,内层只用frontOrder。 撮合从不按序号访问挂单——这正是第六节 里”链表的按序号访问慢在这里不成立”的依据。 - 价位吃空后自动删档,所以下一轮
bestPrice自然取到下一档,不需要显式推进游标。 - 限价单在价格不够优时直接跳出,剩余量转为挂单。这一步决定了这张单是 taker 还是 maker。
复杂度小结
| 操作 | 复杂度 | 说明 |
| — | — | — |
| 取最优价 / 最优档 | O(1) | 取价位表的一端 |
| 挂单 | O(log L) | L = 当前有挂单的价位数 |
| 撤单 / 改数量 | O(1) 定位 + O(log L) 可能删档 | 靠订单号索引 |
| 从队首成交 | O(1) | 成交完出队,档空删档 |
| 报某价位的量 | O(1) | 读缓存的汇总数量 |
L 是有挂单的价位数,不是整个价格区间。实盘上通常只有几十,所以 O(log L) 实际就是 五六次比较。
回头看这些决定
| 决定 | 真正的理由 |
| — | — |
| 价格用整数 | 浮点的相等与比较不可靠,会让该成交的单不成交且不报错 |
| 两侧同向排序 | 共用一套代码,避免两份代码分叉;取两端都是常数时间,不花代价 |
| 建订单号索引 | 撤单频率极高,线性查找会随挂单数变慢 |
| 价位内用链表 | 索引里存的位置必须在别人增删时保持有效 |
| 不用扁平数组 | 空占内存且常驻;价格带调整要重建整表 |
| 两套数量分开 | 合并会让隐藏单失效,或让隐藏部分永远成交不了 |
| 减量保留位置 | 排队位置是等出来的,减量没多占成交机会 |
| 汇总数量只给只读访问 | 派生量一旦与真实内容分叉,表现是”账对、盘口错”,极难排查 |
八条里只有一条(两侧同向排序)主要是为了代码整洁,其余七条都是不这么做就会出错—— 要么算错账,要么让某个功能失效,要么留下一类极难排查的故障。
这是交易系统里数据结构选择的常态:很少是”哪个更快”,多半是”哪个不会错”。
如果你对交易系统的开发与设计感兴趣,可以阅读小方在知识星球的专栏:
如果你想求职交易系统相关的岗位,可以阅读小方在知识星球的专栏:
如果你对交易系统开发感兴趣或者想这方面的工作,可以看看交易系统开发训练营,这个周六开始小方将带你从零开发一套完整的交易系统,包括上文中提到的全部技术:
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:CppGuide 交易系统研究员
交易系统研究员《撮合引擎的订单簿是怎么实现的》