4. 订单簿快照与增量更新:如何高效同步市场数据

做量化交易的朋友都知道,订单簿数据是高频策略的命脉。但这里有个很现实的问题——交易所的订单簿每秒都在变,你总不能每次都把整个订单簿拉下来吧?带宽扛不住,延迟也受不了。

我个人习惯把这个问题拆成两个部分来看:快照增量。说白了,快照就是给你一张完整的地图,增量就是告诉你哪里修路了、哪里盖楼了。两者配合,才能做到既省流量又实时。

4.1 为什么不能只靠全量快照?

你想想看,一个深度还不错的币对,订单簿里可能有几千甚至上万个价位。每次更新都全量推送,数据量有多大?我算过一笔账:

更新方式 单次数据量 每秒更新次数 每秒总流量
全量快照 ~200KB 10次 ~2MB
增量更新 ~2KB 100次 ~200KB

看到了吧?增量更新的流量只有全量的十分之一。而且这还只是单个币对,如果你同时跑几十个币对呢?带宽直接爆炸。

核心原则:全量快照用于初始化,增量更新用于持续同步。两者缺一不可。

4.2 增量更新的数据结构

增量更新到底长什么样?我拿一个真实的交易所协议来举例。每条增量消息通常包含这几个字段:

  • side:买盘还是卖盘(bid/ask)
  • price:价格
  • size:数量(如果为0,表示删除这个价位)
  • sequence:序列号(用于校验顺序)

举个例子,一条增量消息可能是这样的:

{
  "side": "bid",
  "price": 50000.0,
  "size": 1.5,
  "sequence": 123456
}

这条消息的意思是:在50000这个价位上,买盘的数量变成了1.5个BTC。如果之前有这个价位,就覆盖;如果没有,就新增。如果size是0,那就删掉这个价位。

小技巧:我个人习惯在本地维护一个字典结构,key是价格,value是数量。收到增量消息后,直接更新字典。这样查询速度极快,O(1)复杂度。

4.3 快照与增量的衔接

这里有个坑,我踩过不止一次。你想想看:你请求了一个全量快照,但在快照返回的过程中,交易所又产生了新的增量消息。如果你先处理快照再处理增量,那中间这段时间的增量就丢了。

正确的做法是这样的:

  1. 先订阅增量消息流,但先不处理,缓存起来
  2. 请求全量快照
  3. 收到快照后,丢弃所有序列号小于快照序列号的增量
  4. 按顺序处理剩余的增量消息

嗯,这里要注意:序列号是关键。每个增量消息都带一个递增的序列号,快照也带一个序列号。只有序列号大于快照序列号的增量,才是有效的。

避坑指南:我曾经在某个项目中忽略了序列号校验,结果订单簿数据一直对不上。排查了两天才发现,原来是增量消息的顺序乱了。从那以后,我每次都会在本地维护一个lastSequence变量,严格校验。

4.4 本地订单簿的维护

有了快照和增量,怎么在本地维护一个完整的订单簿?我一般用两个红黑树(或者跳表)来存,一个存买盘,一个存卖盘。为什么用红黑树?因为我们需要按价格排序,而且插入删除操作频繁。

伪代码大概是这样的:

class OrderBook:
    def __init__(self):
        self.bids = SortedDict()  # 买盘,价格降序
        self.asks = SortedDict()  # 卖盘,价格升序
        self.last_sequence = 0

    def apply_snapshot(self, snapshot):
        self.bids.clear()
        self.asks.clear()
        for level in snapshot['bids']:
            self.bids[level['price']] = level['size']
        for level in snapshot['asks']:
            self.asks[level['price']] = level['size']
        self.last_sequence = snapshot['sequence']

    def apply_update(self, update):
        if update['sequence'] <= self.last_sequence:
            return  # 丢弃过期消息
        side = self.bids if update['side'] == 'bid' else self.asks
        if update['size'] == 0:
            side.pop(update['price'], None)  # 删除价位
        else:
            side[update['price']] = update['size']  # 新增或覆盖
        self.last_sequence = update['sequence']

这段代码看起来简单,但实际生产环境里要考虑的东西多得多。比如网络抖动导致的消息重传、交易所偶尔推送的乱序消息、还有快照和增量之间的时间窗口问题。

4.5 核心逻辑流程图

下面这张图是我自己画的一个流程,帮你理清快照和增量的关系:

订单簿快照与增量更新流程 开始同步 步骤1:订阅增量消息流(缓存不处理) 步骤2:请求全量快照 步骤3:应用快照,记录序列号 步骤4:丢弃过期增量,按序处理有效增量

这张图把整个流程串起来了。你从「开始同步」出发,先订阅增量但不处理,然后请求快照,应用快照后再处理增量。顺序不能乱,否则数据就歪了。

4.6 实际项目中的经验

最后聊点实战经验。我在做高频交易系统的时候,遇到过几个比较头疼的问题:

  • 快照太大:有些交易所的快照有几百KB,网络不好的时候传输时间很长。我的做法是压缩传输,用gzip或者snappy,能省60%以上的带宽。
  • 增量丢失:偶尔会有几条增量消息没收到。这时候不能硬撑,得重新请求一次快照。我一般设置一个超时机制,如果连续5秒没收到增量,就触发重新同步。
  • 内存占用:订单簿的价位越多,内存占用越大。对于深度很大的币对,我建议只保留前100档价位,后面的直接忽略。反正交易量也集中在头部。

总结一下:快照负责初始化,增量负责持续更新。两者通过序列号衔接,缺一不可。本地用红黑树或跳表维护,注意处理乱序和丢失的情况。做到这几点,你的订单簿同步基本就稳了。

额外提示:如果你用的是WebSocket,记得开启心跳机制。我见过不少因为连接断开导致数据中断的情况,心跳能帮你及时发现并重连。


公众号:蓝海数据掘金营,微信deep3321