订单簿快照与增量更新:全量快照 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,因为它们在速度和压缩率之间平衡得比较好。
总结一下
全量快照和增量更新不是二选一的关系。在实际系统中,它们更像是「搭档」。快照负责兜底,增量负责提效。你想想看,如果没有快照,系统容错性会很差;如果没有增量,带宽和存储成本会很高。
嗯,这就是我对订单簿快照与增量更新的理解。希望对你有帮助。