订单簿数据结构:Level2行情、快照与增量更新、内存存储模型

做量化交易的朋友都知道,订单簿是咱们吃饭的家伙。但说实话,很多人对订单簿的理解还停留在「买一卖一」的层面。这远远不够。

我个人习惯把订单簿比作一个「动态的战场」。每一笔挂单都是一个士兵,价格是他们的站位,数量是他们的火力。而Level2行情,就是让你看清这个战场全貌的望远镜。

Level2行情到底多了什么?

普通行情只给你看最优的5档或10档。Level2呢?它给你看全部的挂单明细。我记得刚接触Level2时,第一反应是「数据量真大」。没错,光一个合约的订单簿,每秒可能更新几百次。

具体来说,Level2行情包含这些核心字段:

字段 含义 我的备注
price 价格 浮点数,注意精度
size 挂单数量 可能是币数或张数
side 买卖方向 bid/ask
seq 序列号 用于增量更新
timestamp 时间戳 纳秒级最佳

你想想看,有了这些数据,你就能画出完整的订单簿曲线。斜率怎么算?流动性在哪?一目了然。

快照与增量更新:两种模式

这里有个关键问题:行情数据怎么传输?

两种方式:快照增量

快照就是给你当前订单簿的完整拷贝。简单粗暴,但数据量大。每次快照可能几十KB,高频传输带宽扛不住。

增量呢?只告诉你「变了什么」。比如「价格100.5的买单增加了200张」。数据量小,但需要你自己维护状态。

我在项目中遇到过一个问题:增量更新如果丢了一条,整个订单簿就全乱了。所以实际生产中,通常是「增量为主,快照为辅」。每隔一段时间发一次快照,用来校正本地状态。

核心原则:增量更新保证实时性,快照保证一致性。两者缺一不可。

内存存储模型:怎么存最快?

好了,数据拿到了,怎么存?

你可能会想:「用数据库呗」。不行。订单簿更新频率太高,磁盘IO根本扛不住。必须用内存。

我个人推荐两种结构:

  1. 红黑树(TreeMap):按价格排序,查找、插入、删除都是O(log n)。适合需要按价格范围查询的场景。
  2. 跳表(SkipList):类似红黑树,但实现更简单,并发性能更好。

举个例子,我用Java实现过一个简单的订单簿:

public class OrderBook {
    // 买单:价格从高到低
    private TreeMap<Double, Long> bids = new TreeMap<>(Comparator.reverseOrder());
    // 卖单:价格从低到高
    private TreeMap<Double, Long> asks = new TreeMap<>();
    
    public void applySnapshot(List<Level2Entry> entries) {
        bids.clear();
        asks.clear();
        for (Level2Entry e : entries) {
            if (e.side == Side.BID) {
                bids.put(e.price, e.size);
            } else {
                asks.put(e.price, e.size);
            }
        }
    }
    
    public void applyUpdate(Level2Update update) {
        Map<Double, Long> target = update.side == Side.BID ? bids : asks;
        if (update.size == 0) {
            target.remove(update.price);  // 撤单
        } else {
            target.put(update.price, update.size);  // 新增或修改
        }
    }
}

嗯,这里要注意:价格精度是个坑。不同交易所的价格精度不一样,有的支持小数点后8位。用Double做key会有精度问题。我建议用Long,把价格乘以10的N次方转成整数。

避坑指南:我曾经因为用Double做TreeMap的key,导致两个「相同」的价格被当成不同key,订单簿出现了重复价格档位。排查了一下午才发现。后来统一改用Long,再也没出过问题。

增量更新的应用逻辑

增量更新的核心逻辑其实就三步:

  • 收到增量消息,解析出价格、数量、方向
  • 在内存订单簿中找到对应价格档位
  • 数量为0则删除该档位,否则更新数量

说起来简单,但实际处理时有个细节:序列号校验。每条增量消息都有一个递增的序列号。如果发现序列号不连续,说明丢包了。这时候必须请求一次快照,重新构建订单簿。

我习惯的做法是:维护一个本地序列号,每次收到增量就+1。如果收到的序列号不等于本地序列号+1,立即触发快照请求。

小技巧:快照请求不要太频繁。如果网络抖动导致连续丢包,每次丢包都请求快照,反而会加重网络负担。我一般设置一个阈值:连续丢包超过3次才请求快照。

内存布局优化

做高频交易的朋友对延迟特别敏感。内存布局也能优化?当然能。

我见过一个极致的方案:用两个大数组分别存储价格和数量,价格数组保持有序。这样CPU缓存命中率更高。但代价是插入和删除需要移动元素,适合低频更新的场景。

对于大多数场景,TreeMap或SkipList已经够用了。别过度优化。

下面这张图展示了订单簿数据在内存中的流转过程:

订单簿数据流转图 交易所行情源 TCP/UDP 协议解码 JSON/Protobuf 序列号校验 seq + 1 校验 OK? 失败 请求快照 成功 内存订单簿 TreeMap/SkipList 策略/分析应用

说白了,订单簿的数据结构设计,就是在「快」和「准」之间找平衡。快照保证你永远有正确的基准,增量保证你跟上市场的节奏。而内存模型的选择,决定了你能跑多快。

我个人建议:刚开始做的时候,先用TreeMap实现一个简单版本跑通流程。等遇到性能瓶颈了,再考虑优化。别一开始就想着搞什么花哨的数据结构,先把业务逻辑跑对再说。

嗯,关于订单簿的数据结构,今天就聊这么多。记住一句话:数据结构选对了,后面的事情就顺了


专注资料整理