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

admin 2026-10-16 11:35:00 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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

原创

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

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>& 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) 实际就是 五六次比较。


回头看这些决定

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

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

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

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

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

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

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

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

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


撮合引擎的订单簿是怎么实现的" 网络安全文章

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

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

别再把 APIKey塞进Agent:企业AI密钥管理的正确打开方式"

本文探讨企业AI应用中的安全挑战与解决方案,指出传统安全体系难以应对AI系统复杂性。核心问题包括API密钥散落、多模型管理困难、跨机构数据协作困境及治理难题。文章提出密钥门限密码学拆分、构建模型网关统一管理、隐私计算工程化等解决思路,并给出五条建议:安全控制与模型解耦、Agent不持密钥、建立模型控制平面、隐私计算可用化、密码学验证审计。强调从可信计算走向可验证计算是企业安全架构的重要分界线。
评论:0   参与:  0