4、增量更新机制:理解Event-driven架构、Snapshot + Update模式、Sequence Number校验

做订单簿重建,最怕什么?

我最怕的是数据对不上。你想想看,行情数据哗哗地流进来,如果每次都是全量快照,那带宽和延迟都扛不住。但只用增量更新吧,又怕漏掉一条,整个订单簿就歪了。

这就像拼乐高。你手里有一张完整的图纸(快照),然后不断收到“加一块红色2x4砖”、“拆掉蓝色1x2砖”这样的指令(更新)。只要漏掉一条指令,最后拼出来的东西就跟图纸不一样了。

今天我们就来聊聊,怎么用Event-driven架构、Snapshot + Update模式、还有Sequence Number校验,把这套机制做得既快又稳。

4.1 Event-driven架构:让数据自己跑起来

传统的做法是轮询。你每隔几毫秒去问一下:“有新数据吗?” 这效率太低了。大部分时候你都在空转。

Event-driven就不一样了。数据来了,它主动通知你。你只需要等着就行。

核心思想: 系统对“事件”做出响应,而不是主动去“拉取”数据。

在订单簿场景里,事件就是一条行情消息。可能是新增订单、撤单、或者成交。每个事件都携带着改变订单簿状态所需的信息。

我习惯把事件处理流程拆成三块:

  • 事件源:交易所的行情推送接口。比如WebSocket或者FIX协议。
  • 事件总线:一个轻量级的消息队列。我用过Redis的Pub/Sub,也用过ZeroMQ。关键是要快,延迟要低。
  • 事件处理器:真正干活的地方。收到事件后,更新本地的订单簿数据结构。

这样做的好处很明显。各个模块解耦了。事件源只管发,处理器只管收。中间加个总线,还能做流量削峰。我曾经在实盘里遇到行情瞬间爆发,每秒几千条消息,全靠事件总线扛住了压力。

4.2 Snapshot + Update模式:又快又稳的秘密

全量快照太笨重,纯增量又不可靠。怎么办?

折中一下。用快照做基准,用增量做持续更新。

具体来说:

  1. 定期获取全量快照:比如每10秒或者每1分钟拉一次。快照里是当前时刻订单簿的完整状态。
  2. 在快照之间,只处理增量更新:每次收到一条增量的订单消息,就修改本地订单簿。
  3. 用快照做校验和修复:如果发现本地订单簿跟新快照对不上,就丢弃本地数据,用新快照重建。

这里有个关键点:快照和增量之间不能有缝隙。你拿到快照之后,必须确保快照之后的所有增量都处理了。否则就会丢数据。

我的经验: 快照频率不能太密,否则带宽扛不住。也不能太疏,否则增量累积太多,一旦出错,回退的成本很高。我个人习惯在流动性高的合约上设10秒一次快照,流动性差的设30秒。

下面这张图展示了Snapshot + Update模式的完整流程:

Snapshot + Update 模式流程图 交易所行情源 全量快照 增量更新 事件总线 事件处理器 本地订单簿 Sequence校验模块 快照存储 触发重建 请求新快照

4.3 Sequence Number校验:别让数据溜走

有了快照和增量,还不够。你还需要一个东西来保证顺序和完整性。

这就是Sequence Number(序列号)。

每条行情消息,交易所都会给它一个递增的序号。比如第1001条、第1002条。你本地也维护一个变量,记录你最后处理的那条消息的序号。

校验逻辑很简单:

  • 如果新消息的序号 = 本地序号 + 1,说明一切正常,处理它。
  • 如果新消息的序号 > 本地序号 + 1,说明中间有消息丢了。这时候不能继续处理,必须请求新的全量快照来重建。
  • 如果新消息的序号 <= 本地序号,说明是重复消息,直接丢弃。

注意: 千万不要在序号不连续的时候强行处理。我曾经在早期版本里跳过丢失的消息继续跑,结果订单簿价格差了整整两个tick,导致一笔套利交易直接亏损。从那以后,我对序号校验格外严格。

代码实现其实不复杂。我贴一段核心逻辑:

class OrderBookUpdater:
    def __init__(self):
        self.last_seq = 0
        self.order_book = {}
        self.need_snapshot = False
    
    def process_update(self, msg):
        seq = msg['sequence']
        
        # 重复消息,直接忽略
        if seq <= self.last_seq:
            return
        
        # 序号不连续,标记需要重建
        if seq != self.last_seq + 1:
            self.need_snapshot = True
            print(f"序列号断裂: 期望 {self.last_seq + 1}, 收到 {seq}")
            return
        
        # 正常处理增量
        self._apply_update(msg)
        self.last_seq = seq
    
    def apply_snapshot(self, snapshot):
        self.order_book = snapshot['data']
        self.last_seq = snapshot['sequence']
        self.need_snapshot = False

这段代码看着简单,但实际生产环境里,你还要考虑并发问题。比如多个线程同时处理消息,序号校验就可能乱掉。我一般用单线程的事件循环来处理,省心。

4.4 综合起来:一个完整的增量更新流程

把上面三块拼在一起,流程就清晰了:

  1. 系统启动时,先请求一次全量快照,拿到基准状态和最新的序列号。
  2. 之后持续监听增量更新事件。每来一条,先校验序列号。
  3. 如果序号连续,就更新本地订单簿。
  4. 如果序号断裂,立即标记“需要重建”,并请求新的快照。
  5. 收到新快照后,用快照覆盖本地数据,重置序列号,继续处理增量。

这里有个小技巧。我建议在本地维护一个“待处理队列”。当序号断裂时,后续的增量消息不要直接丢弃,而是先缓存起来。等快照重建完成后,再根据新快照的序列号,判断哪些缓存的消息可以继续处理。这样可以减少数据丢失。

核心原则: 宁可慢一点,也要保证数据准确。订单簿错了,交易策略再牛也是白搭。

嗯,增量更新机制就聊到这里。Event-driven架构让你高效,Snapshot + Update模式让你兼顾速度和可靠性,Sequence Number校验则是最后的保险。这三者缺一不可。

你在实际开发中,可以根据交易所的API特性,调整快照频率和缓存策略。但核心逻辑,我建议不要轻易改动。毕竟,我踩过的坑,不想你再踩一遍。


公众号:蓝海资料掘金营,微信deep3321