4、订单簿数据结构:L2/L3订单簿、价格-数量映射、时间优先与价格优先

做市商报价引擎的核心,说白了就是一本「账本」。

这本账本记录着市场上所有未成交的买卖意愿。谁想买、谁想卖、出什么价、要多少量,全得记清楚。我做了这么多年量化系统,见过太多因为订单簿设计不合理导致的惨案——内存爆了、撮合慢了、甚至价格错乱。嗯,今天咱们就把这个核心数据结构彻底讲透。

4.1 订单簿的本质:一个双向队列

订单簿其实不复杂。它分两边:

  • 买单(Bid):买方出价,从高到低排列
  • 卖单(Ask):卖方要价,从低到高排列

中间那个价差,就是做市商吃饭的地方。我个人习惯把订单簿想象成两个「价格-数量」的映射表,一边是买,一边是卖。

核心公式:

最佳买价(Best Bid)= max(所有买单价格)

最佳卖价(Best Ask)= min(所有卖单价格)

价差(Spread)= Best Ask - Best Bid

4.2 L2订单簿 vs L3订单簿

这里有个常见的概念混淆。我面试过不少候选人,能把L2和L3说清楚的没几个。

4.2.1 L2订单簿:聚合视角

L2只告诉你「在某个价格上,总共有多少量」。它不关心这些量是谁的、什么时候挂的。说白了,就是价格-数量的聚合映射。

举个例子:

// L2订单簿结构(伪代码)
struct Level2OrderBook {
    map<Price, Volume> bids;  // 买单:价格 -> 总量
    map<Price, Volume> asks;  // 卖单:价格 -> 总量
};

我在项目中遇到过,有些交易所只提供L2数据。这时候做市商只能看到「价格堆叠」,看不到订单的细节。够用吗?大部分策略其实够用。但如果你想做更精细的订单流分析,L2就不够了。

4.2.2 L3订单簿:全量视角

L3记录的是每一个独立的订单。每个订单都有自己的ID、价格、数量、时间戳、方向。这才是真正的「全量数据」。

// L3订单簿结构(伪代码)
struct Order {
    string orderId;
    Price price;
    Volume volume;
    Timestamp time;
    Side side;  // BUY or SELL
};

struct Level3OrderBook {
    map<Price, list<Order>> bids;  // 每个价格下挂一个订单列表
    map<Price, list<Order>> asks;
};

你想想看,L3的数据量比L2大一个数量级。一个活跃的合约,每秒可能有几千个订单变动。如果全量存储L3,内存消耗非常恐怖。我曾经帮一个团队优化过,他们用L3存了全天的数据,结果服务器直接OOM了。

我的建议:

做市商引擎内部,建议用L3结构管理订单,但对外暴露L2快照。这样既能精细控制,又能减少网络传输量。

4.3 价格-数量映射:用什么数据结构?

这是性能的关键。订单簿的核心操作就几个:插入、删除、修改、查询最佳价格。不同的数据结构,性能天差地别。

数据结构 插入 删除 查询最佳价 内存占用
红黑树(std::map) O(log n) O(log n) O(1) 中等
跳表(Skip List) O(log n) O(log n) O(1) 较高
哈希表 + 有序链表 O(1) + O(n) O(1) + O(n) O(1)
数组(固定价格档位) O(1) O(1) O(1) 极高

我个人习惯用红黑树。为什么?因为它的性能稳定,没有哈希表的rehash抖动,也没有跳表的内存开销。在C++里,std::map就是红黑树,直接用就好。

但有一个坑:如果你用std::map存L3订单,每个价格下挂一个list,那list里的订单顺序怎么维护?这就引出了下一个话题——时间优先。

4.4 价格优先与时间优先

这是撮合引擎的黄金法则。几乎所有交易所都遵循这个规则:

  • 价格优先:买单出价高的优先成交,卖单要价低的优先成交
  • 时间优先:相同价格下,先挂单的优先成交

说白了,就是「价高者得,先到先得」。这个规则保证了公平性。

4.4.1 如何实现时间优先?

在L3订单簿里,每个价格下挂一个订单队列。这个队列必须是有序的——按时间戳从小到大排列。新订单插到队尾,成交从队头开始取。

// 时间优先队列实现
struct TimePriorityQueue {
    list<Order> orders;  // 按时间排序的订单列表
    
    void addOrder(Order order) {
        orders.push_back(order);  // 新订单插到队尾
    }
    
    Order getBestOrder() {
        return orders.front();  // 最早挂单在队头
    }
    
    void cancelOrder(string orderId) {
        // 需要遍历查找,O(n)操作
        // 优化:可以用哈希表记录每个订单在list中的位置
    }
};

这里有个性能问题:取消订单需要遍历整个队列。如果某个价格档位上有几千个订单,取消操作就会很慢。我曾经遇到过这种情况,一个高频交易者的撤单请求把引擎拖垮了。

避坑指南:

我曾经在实现L3订单簿时,直接用std::list存订单队列。结果撤单操作变成了O(n),在高频场景下完全扛不住。后来改成了「双向链表 + 哈希表索引」,撤单变成了O(1)。

具体做法:每个订单在链表中的节点指针存到哈希表里,撤单时直接通过哈希表找到节点,O(1)删除。

4.5 订单簿的增量更新

做市商引擎每秒要处理成千上万次订单变动。如果每次变动都全量推送,带宽和CPU都扛不住。所以必须用增量更新。

增量更新的消息类型一般就三种:

  • 新增(Add):新订单进入订单簿
  • 删除(Delete):订单被成交或撤销
  • 修改(Modify):订单数量变化(价格一般不允许改)

我个人习惯用「快照 + 增量流」的模式。每10秒或每1000笔变动发一次全量快照,中间只发增量。这样即使客户端漏掉几条增量,也能通过快照恢复。

4.6 一张图看懂订单簿架构

下面这张SVG图,是我自己设计的订单簿核心架构。你看一眼就能明白整个数据流向。

订单簿核心架构图 订单流输入 新增/删除/修改 L3订单簿核心 红黑树 + 时间优先队列 价格-数量映射 L2聚合快照 价格档位聚合 撮合引擎 价格优先 → 时间优先 成交结果输出 成交明细 / 订单状态变更 增量更新反馈 核心数据结构:红黑树(价格索引)+ 双向链表(时间队列)+ 哈希表(订单ID索引) 输入 核心处理 输出 撮合 结果

4.7 实战中的性能优化

最后分享几个我在实战中总结的优化点:

  1. 内存池:订单对象频繁创建销毁,用内存池避免碎片。我见过一个系统,运行8小时后内存碎片率达到30%。
  2. 无锁队列:订单流输入和撮合引擎之间,用无锁队列解耦。这样输入线程不会被撮合线程阻塞。
  3. 预分配价格档位:如果知道价格精度(比如0.01),可以预分配一个固定大小的数组。这样查询就是O(1)。
  4. 批量处理:不要来一个订单就更新一次订单簿。攒一批,批量更新,批量推送。IO次数能减少90%。

一个小技巧:

如果你用C++开发,可以考虑用absl::flat_hash_map替代std::unordered_map。前者在内存布局上更紧凑,缓存命中率更高。我在一个高频项目里替换后,整体吞吐量提升了15%。

订单簿数据结构,说白了就是「怎么存、怎么查、怎么改」的问题。选对数据结构,你的引擎就能跑得又快又稳。选错了,嗯,你可能就要加班到深夜了。


公众号:蓝海资料掘金营,微信deep3321