3、增量更新机制:Snapshot与Incremental的区别、Sequence Number的作用、应用层去重

好,咱们来聊聊订单簿重建里最核心的一个话题——增量更新。

说实话,我刚入行那会儿,觉得订单簿嘛,不就是维护一个价格优先的队列?后来真正做低延迟系统才发现,怎么高效地把订单簿从零重建出来,才是真正的技术活。

3.1 Snapshot vs Incremental:两种思路的较量

先问个问题:如果你要恢复一个订单簿,你会怎么做?

最简单的办法——把当前所有挂单全拉下来。这就是全量快照(Snapshot)

另一种思路——只告诉你「刚才发生了什么变化」。这就是增量更新(Incremental)

我个人的习惯是:两者结合,缺一不可。为什么?

核心区别一览

维度 Snapshot Incremental
数据量 大(全量订单) 小(仅变化部分)
重建速度 快(一次到位) 慢(需逐条回放)
带宽消耗
容错性 强(丢包也不怕) 弱(丢一条就乱)
典型频率 每1-10秒一次 每次订单变动都发

你看,Snapshot 就像拍一张照片,Incremental 就像放一段录像。照片清晰但占空间,录像省空间但需要连续播放。

我在项目中遇到过一个问题:某交易所的增量更新频率极高,每秒几千条。如果只靠增量重建,一旦中间丢了一条,整个订单簿就全乱了。后来我们改成每5秒拉一次快照,中间用增量补,问题就解决了。

3.2 Sequence Number:增量更新的「安全带」

增量更新有个致命弱点——丢包

你想想看,如果增量消息是「第1笔:新增买单A,第2笔:撤销买单A」,结果第1笔丢了,你只收到第2笔。这时候你会去撤销一个根本不存在的订单……订单簿就崩了。

怎么解决?Sequence Number(序列号)

说白了,每条增量消息都带一个递增的序号。接收方检查序号是否连续,如果不连续,就知道丢包了,需要重新请求快照。

我的实践经验

我建议序列号用 uint64_t,别用 int。为什么?因为增量消息量巨大,int 可能溢出。我曾经见过一个系统用 uint32_t,运行了几个月后序列号回绕,导致去重逻辑全乱了……

序列号的作用其实就三个:

  • 检测丢包:当前序号 != 上次序号 + 1,说明丢了
  • 保证顺序:网络可能乱序,按序号排序后再处理
  • 去重基础:同一个序号只处理一次

嗯,这里要注意:序列号不能只靠交易所给。我习惯在应用层再维护一个本地序列号,双重校验更安全。

3.3 应用层去重:别让重复消息害了你

网络环境不是完美的。同一个增量消息可能被发送两次(比如 TCP 重传、网关重试)。如果你不处理重复,订单簿里就会出现两个相同的订单。

去重的核心逻辑很简单:

// 伪代码:应用层去重
class OrderBookReconstructor {
    uint64_t last_seq_ = 0;
    std::unordered_set<uint64_t> processed_seqs_;
    
    void OnIncremental(const IncrementalMsg& msg) {
        // 1. 检查序列号连续性
        if (msg.seq != last_seq_ + 1) {
            // 丢包了,请求快照
            RequestSnapshot();
            return;
        }
        
        // 2. 检查是否重复
        if (processed_seqs_.count(msg.seq)) {
            // 已处理过,跳过
            return;
        }
        
        // 3. 处理增量
        ApplyIncremental(msg);
        
        // 4. 更新状态
        last_seq_ = msg.seq;
        processed_seqs_.insert(msg.seq);
        
        // 5. 清理旧记录(防止内存泄漏)
        if (processed_seqs_.size() > 10000) {
            // 保留最近10000个序号
            CleanOldSeqs();
        }
    }
};

这段代码看着简单,但有几个坑:

  • 内存泄漏processed_seqs_ 会无限增长,必须定期清理
  • 性能unordered_set 的查找是 O(1),但哈希冲突多了会退化
  • 边界情况:快照后序列号重置,需要清空 processed_seqs_

我曾经踩过的坑

有一次,交易所的序列号在快照后没有重置,而是继续递增。我们的去重逻辑以为序号不连续,疯狂请求快照,导致系统雪崩。后来我加了一个判断:收到快照后,清空所有去重记录,重新开始计数

3.4 整体流程:一张图说清楚

下面这张图展示了订单簿重建的完整流程,包括快照、增量、序列号和去重的关系:

订单簿重建流程:Snapshot + Incremental 交易所数据流 Snapshot(全量快照) Incremental(增量) 序列号连续性检查 应用层去重 订单簿重建 实时订单簿 丢包 → 请求快照 重复 → 直接跳过 全量 增量 校验 去重 重建 异常处理

从这张图你可以看到:快照是基础,增量是日常,序列号是保障,去重是兜底。四者缺一不可。

3.5 实战建议:怎么选?

最后给几个我个人的建议:

  • 高频交易场景:增量为主,快照为辅。快照频率可以低一些(比如5秒一次),减少带宽消耗
  • 低延迟场景:增量消息的解析要极致优化。我习惯用 memcpy 直接解析二进制流,不用 protobuf
  • 容错优先场景:快照频率高一些(比如1秒一次),丢包后能快速恢复
  • 去重记录别用 std::set:查找是 O(log n),量大了扛不住。用 unordered_set 或者环形缓冲区

一句话总结

增量更新不是银弹,序列号不是万能药。只有把快照、增量、序列号、去重四者配合好,才能构建一个稳定高效的订单簿重建系统。


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