订单簿快照与增量更新:全量快照 vs 增量更新

做高频交易的朋友都知道,订单簿是市场的「心跳」。但怎么高效地记录这个心跳,是个挺讲究的事。

我刚开始做行情数据压缩那会儿,踩过一个坑——直接把每毫秒的订单簿全量存下来。结果呢?一天的数据量就几十个GB,存储成本直接爆炸。后来才明白,全量快照和增量更新各有各的用武之地,关键看你怎么搭配。

什么是全量快照?

全量快照,说白了就是「拍一张完整的照片」。它记录的是某个时间点上,订单簿里所有的买单和卖单。

举个例子,某个时刻的订单簿快照可能是这样的:

{
  "timestamp": 1625000000000,
  "bids": [
    [100.50, 1000],
    [100.49, 2000],
    [100.48, 1500]
  ],
  "asks": [
    [100.51, 800],
    [100.52, 1200],
    [100.53, 3000]
  ]
}

这个结构很直观。价格、数量,一目了然。但问题也在这里——每次都要把整个订单簿传一遍。

核心特点:
  • 数据完整,拿到就能直接用
  • 容错性好,不怕丢包
  • 但数据量大,传输和存储成本高

什么是增量更新?

增量更新只记录「变化的部分」。比如有人下了一笔新单,或者撤了一笔旧单,我们只记录这个变动。

增量更新的格式通常长这样:

{
  "timestamp": 1625000000001,
  "changes": [
    {"side": "bid", "price": 100.50, "size": 500, "type": "modify"},
    {"side": "ask", "price": 100.52, "size": 0, "type": "delete"}
  ]
}

你看,只记录了「100.50这个价位的买单数量变了」和「100.52这个价位的卖单被撤了」。其他没变的部分,一个字都不提。

核心特点:
  • 数据量小,传输效率高
  • 适合高频场景
  • 但依赖前一个状态,丢包就出问题

两种方案的对比

维度 全量快照 增量更新
数据大小 大(O(N),N为订单数) 小(O(M),M为变化数)
恢复成本 低,直接使用 高,需重放所有增量
容错性 强,丢包可重传 弱,丢包导致状态不一致
延迟敏感度 低,适合低频 高,适合高频
带宽占用

嗯,这张表其实已经说得很清楚了。但我还是想聊聊实际项目中的体会。

我个人的实践经验

我在做国内某交易所的行情系统时,一开始只用了增量更新。为什么?因为觉得省带宽啊。结果有一次网络抖动,丢了一个增量包,整个订单簿的状态就全乱了。后来花了整整一个下午去排查,才发现是某个中间节点的缓存出了问题。

那次之后,我学乖了。现在的做法是:

  • 定期发送全量快照:比如每10秒或每100毫秒发一次
  • 中间用增量更新:两次快照之间,只传变化
  • 客户端做校验:收到快照后,和本地状态对比,不一致就请求重传

这个方案,说白了就是「快照兜底,增量提效」。你想想看,如果每毫秒都传全量,带宽扛不住;但如果只传增量,又怕丢包。两者结合,才是最优解。

我的建议:

如果你在做行情数据压缩,建议采用「快照+增量」的混合模式。快照频率可以根据市场波动率动态调整——波动大的时候,快照频率高一些;波动小的时候,可以拉长间隔。

适用场景分析

什么时候该用全量快照?

  • 数据归档:历史数据回放时,全量快照更方便
  • 系统初始化:新节点加入时,需要全量同步
  • 容错恢复:检测到状态不一致时,用快照重置

什么时候该用增量更新?

  • 实时行情推送:毫秒级变化,带宽敏感
  • 高频交易策略:需要低延迟获取最新状态
  • 数据压缩存储:减少磁盘占用

一个简单的实现思路

如果你自己写一个订单簿重构模块,核心逻辑大概是这样的:

class OrderBook:
    def __init__(self):
        self.bids = {}  # 价格 -> 数量
        self.asks = {}
        self.last_snapshot_time = 0
    
    def apply_snapshot(self, snapshot):
        """应用全量快照"""
        self.bids = {p: s for p, s in snapshot['bids']}
        self.asks = {p: s for p, s in snapshot['asks']}
        self.last_snapshot_time = snapshot['timestamp']
    
    def apply_incremental(self, update):
        """应用增量更新"""
        for change in update['changes']:
            if change['size'] == 0:
                # 删除
                if change['side'] == 'bid':
                    self.bids.pop(change['price'], None)
                else:
                    self.asks.pop(change['price'], None)
            else:
                # 新增或修改
                if change['side'] == 'bid':
                    self.bids[change['price']] = change['size']
                else:
                    self.asks[change['price']] = change['size']

这段代码很简单,但实际生产环境要考虑的东西多得多。比如并发访问、内存管理、序列化格式选择等等。

注意:

我曾经遇到过一个问题——增量更新的顺序和实际发生顺序不一致。原因是网络传输中出现了乱序。解决方案是在增量更新里加一个序列号,接收端按序列号排序后再应用。这个坑,希望你别踩。

数据压缩视角

从数据压缩的角度看,增量更新天然就有更高的压缩率。为什么?因为变化的数据量小,而且变化模式往往有规律可循。

我做过一个实验:

  • 全量快照:原始数据约 2KB/次,压缩后约 800B
  • 增量更新:原始数据约 200B/次,压缩后约 80B

你看,增量更新本身数据量就小,压缩后更是只有全量的十分之一。但要注意,增量更新的压缩算法选择也有讲究。我个人习惯用 LZ4 或 Zstandard,因为它们在速度和压缩率之间平衡得比较好。

总结一下

全量快照和增量更新不是二选一的关系。在实际系统中,它们更像是「搭档」。快照负责兜底,增量负责提效。你想想看,如果没有快照,系统容错性会很差;如果没有增量,带宽和存储成本会很高。

嗯,这就是我对订单簿快照与增量更新的理解。希望对你有帮助。

订单簿快照与增量更新混合架构 行情数据源 快照生成器 每10秒生成一次 增量生成器 每毫秒生成一次 混合器 快照 + 增量 按序输出 输出 核心思路:快照兜底 + 增量提效 快照用于容错恢复和初始化,增量用于日常高频更新 两者结合,兼顾带宽效率和系统可靠性

专注资料整理