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图,是我自己设计的订单簿核心架构。你看一眼就能明白整个数据流向。
4.7 实战中的性能优化
最后分享几个我在实战中总结的优化点:
- 内存池:订单对象频繁创建销毁,用内存池避免碎片。我见过一个系统,运行8小时后内存碎片率达到30%。
- 无锁队列:订单流输入和撮合引擎之间,用无锁队列解耦。这样输入线程不会被撮合线程阻塞。
- 预分配价格档位:如果知道价格精度(比如0.01),可以预分配一个固定大小的数组。这样查询就是O(1)。
- 批量处理:不要来一个订单就更新一次订单簿。攒一批,批量更新,批量推送。IO次数能减少90%。
一个小技巧:
如果你用C++开发,可以考虑用absl::flat_hash_map替代std::unordered_map。前者在内存布局上更紧凑,缓存命中率更高。我在一个高频项目里替换后,整体吞吐量提升了15%。
订单簿数据结构,说白了就是「怎么存、怎么查、怎么改」的问题。选对数据结构,你的引擎就能跑得又快又稳。选错了,嗯,你可能就要加班到深夜了。
公众号:蓝海资料掘金营,微信deep3321