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;
    }
};

时间戳的精度也很关键。大部分交易所给的是毫秒级,但有些高频场景需要微秒级。我个人建议统一用纳秒级时间戳存储,方便做回放和回测。

内存模型优化

订单簿是高频交易中最核心的数据结构,内存访问模式几乎决定了性能。

我总结了几条优化原则:

  1. 避免动态内存分配:每次插入删除都new/delete,GC会哭的。用对象池或者预分配内存。
  2. 缓存友好:订单簿条目尽量紧凑,不要有虚函数,不要有指针链。能放在栈上就别放堆上。
  3. 读写分离:行情线程只写,策略线程只读。用无锁队列或者双缓冲。
  4. 局部性原理:价格相近的订单在内存中也应该相邻。跳表和红黑树在这方面天然有优势。

实战经验:我曾在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字节。可以考虑用数组+偏移量的方式手动管理内存。

最后,画一张图来总结订单簿数据模型的核心逻辑:

订单簿数据模型核心逻辑 交易所行情源 增量更新流 全量快照 序列号管理 时间戳管理 内存模型优化 缓存对齐 · 对象池 · 读写分离 订单簿数据结构 价格 / 数量 / 订单数 · 跳表 / 红黑树 最终输出:实时订单簿

这张图展示了整个数据流的走向:行情源 -> 增量/快照 -> 序列号和时间戳管理 -> 数据结构存储 -> 内存优化 -> 最终输出。

每个环节都可能成为瓶颈。我见过太多团队只关注算法,忽略了数据模型和内存布局,结果延迟高得离谱。

最后提醒:订单簿设计没有银弹。不同的交易所、不同的策略、不同的语言,最优解都不一样。但核心原则是相通的:数据紧凑、访问局部性好、增量为主快照为辅、序列号不能丢。

嗯,今天就聊到这里。数据模型这块,多花时间打磨,后面会省很多事。


蓝海数据掘金营,专注资料整理