3、数据模型设计:订单簿数据结构设计
订单簿,说白了就是交易所的「挂单账本」。
买家和卖家都在上面报价,谁出价高谁先成交。我做了这么多年量化,见过太多团队在订单簿上栽跟头。数据结构没选好,后面全盘皆输。
价格/数量/订单数:三个核心字段
一个订单簿条目,最少需要三个字段:
- 价格:挂单的价格,浮点数或定点数
- 数量:该价格上的总挂单量
- 订单数:该价格上有多少个独立订单
为什么需要订单数?我刚开始做的时候也忽略了。后来发现,光看数量不够——如果某个价格上只有1笔大单,和100笔小单,市场深度是完全不同的。大单可能是机构在挂,小单是散户在挂,撤单概率天差地别。
核心要点:订单数能帮你判断「虚假深度」。我曾经在币安上看到某个价格挂了几百个BTC,但订单数只有1笔,明显是量化基金在挂单,随时可能撤掉。
数据结构设计上,我推荐用跳表(Skip List)或红黑树来存储。为什么?因为订单簿需要按价格排序,而且需要频繁插入和删除。数组太慢,链表查找太慢,平衡树刚刚好。
// 伪代码示例:订单簿条目
struct OrderBookLevel {
double price; // 价格
double quantity; // 数量
int orderCount; // 订单数
int64_t timestamp; // 时间戳
int64_t seqNum; // 序列号
};
// 买盘和卖盘各维护一棵树
map<double, OrderBookLevel, greater<double>> bids; // 买盘,价格降序
map<double, OrderBookLevel> asks; // 卖盘,价格升序
增量更新 vs 全量快照
这是个老生常谈的问题,但很多人还是搞不清楚。
全量快照:把整个订单簿发给你。简单粗暴,但数据量大。比如币安的深度快照,一次可能几十KB,高频场景下带宽扛不住。
增量更新:只告诉你「谁变了」。比如「价格100.5的买单数量从10变成15」,或者「价格101.2的卖单被删除了」。数据量小,但需要你自己维护状态。
我个人的习惯是:增量更新为主,全量快照为辅。每收到1000条增量,或者每过30秒,拉一次全量快照做校验。为什么?因为增量更新可能会丢包,一旦状态错乱,后面全废了。
避坑指南:我曾经在某个小交易所对接时,只用了增量更新。结果网络抖动丢了一条消息,整个订单簿价格偏移了0.5个点,导致策略连续亏损。从那以后,我强制要求每N条增量后必须做一次全量校验。
增量更新的消息格式一般长这样:
// 增量更新消息
struct OrderBookDelta {
enum Action { ADD, UPDATE, DELETE };
Action action;
double price;
double quantity;
int orderCount;
int64_t seqNum;
};
收到增量后,你需要在本地维护的订单簿上做对应操作:
- ADD:插入新价格
- UPDATE:更新已有价格的数量和订单数
- DELETE:移除该价格
时间戳与序列号管理
嗯,这里要注意。时间戳和序列号是两回事。
时间戳:事件发生的时间。交易所服务器的时间,不是你的本地时间。我见过有人用本地时间戳,结果网络延迟导致时间错乱,订单簿回放时完全对不上。
序列号:事件的全局递增编号。用来判断消息顺序和检测丢包。
为什么需要序列号?因为网络传输是无序的。你可能会先收到第100条增量,再收到第50条增量。如果没有序列号,你根本不知道顺序对不对。
重要:序列号必须是严格递增的,不能有跳跃。如果发现序列号不连续,说明丢包了,需要立即拉取全量快照修复。
我一般这样管理:
// 序列号管理
class SeqManager {
int64_t lastSeq = 0;
int64_t gapCount = 0;
bool process(int64_t seq) {
if (seq <= lastSeq) {
// 重复消息,忽略
return false;
}
if (seq != lastSeq + 1) {
// 丢包了!
gapCount++;
requestSnapshot(); // 请求全量快照
return false;
}
lastSeq = seq;
return true;
}
};
时间戳的精度也很关键。大部分交易所给的是毫秒级,但有些高频场景需要微秒级。我个人建议统一用纳秒级时间戳存储,方便做回放和回测。
内存模型优化
订单簿是高频交易中最核心的数据结构,内存访问模式几乎决定了性能。
我总结了几条优化原则:
- 避免动态内存分配:每次插入删除都new/delete,GC会哭的。用对象池或者预分配内存。
- 缓存友好:订单簿条目尽量紧凑,不要有虚函数,不要有指针链。能放在栈上就别放堆上。
- 读写分离:行情线程只写,策略线程只读。用无锁队列或者双缓冲。
- 局部性原理:价格相近的订单在内存中也应该相邻。跳表和红黑树在这方面天然有优势。
实战经验:我曾在C++中把订单簿从std::map换成自定义的跳表实现,延迟从微秒级降到了纳秒级。核心改动就是去掉了红黑树的平衡操作,改用概率平衡。
内存布局上,我推荐这样设计:
// 紧凑的内存布局
struct alignas(64) OrderBookLevel {
double price; // 8字节
double quantity; // 8字节
int64_t seqNum; // 8字节
int32_t orderCount;// 4字节
int32_t padding; // 4字节,对齐到64字节缓存行
};
为什么要对齐到64字节?因为CPU缓存行通常是64字节。一个缓存行刚好放一个订单簿条目,避免伪共享(False Sharing)。
另外,我建议把买盘和卖盘分开存储。不要放在同一个容器里,否则遍历买盘时会污染卖盘的缓存。
小技巧:如果你用Java或Go,注意对象头开销。Java一个对象可能占40字节以上,而实际数据只有24字节。可以考虑用数组+偏移量的方式手动管理内存。
最后,画一张图来总结订单簿数据模型的核心逻辑:
这张图展示了整个数据流的走向:行情源 -> 增量/快照 -> 序列号和时间戳管理 -> 数据结构存储 -> 内存优化 -> 最终输出。
每个环节都可能成为瓶颈。我见过太多团队只关注算法,忽略了数据模型和内存布局,结果延迟高得离谱。
最后提醒:订单簿设计没有银弹。不同的交易所、不同的策略、不同的语言,最优解都不一样。但核心原则是相通的:数据紧凑、访问局部性好、增量为主快照为辅、序列号不能丢。
嗯,今天就聊到这里。数据模型这块,多花时间打磨,后面会省很多事。