3、快照与增量机制:全量快照 vs 增量更新、序列号对齐策略

订单簿的数据量,说实话,比很多人想象的要大得多。

一个活跃的币对,每秒可能产生几千甚至上万笔订单变动。如果每次变动都给你发一份完整的订单簿,那带宽和内存都扛不住。所以,业界的主流做法是——快照 + 增量

说白了,就是我给你一张「全家福」(全量快照),然后后续只告诉你「谁变了」(增量更新)。你拿着快照,再按顺序应用增量,就能还原出最新的订单簿。

3.1 全量快照(Snapshot)

全量快照,就是某一时刻订单簿的完整状态。它包含所有价位的买单和卖单,以及每个价位的累计数量。

我习惯把快照看作「基准点」。没有这个基准点,你后面所有的增量都是空中楼阁。

快照的核心特征:
  • 数据量大:一次快照可能包含几百甚至上千个价位
  • 频率低:通常几秒或几分钟才发一次
  • 用于初始化:新连接的客户端必须先拿快照
  • 用于纠错:当增量数据丢失或错乱时,用快照重置

举个例子,一个典型的快照数据长这样:

{
  "type": "snapshot",
  "symbol": "BTCUSDT",
  "bids": [
    [50000.00, 1.5],
    [49990.00, 2.3],
    [49980.00, 0.8]
  ],
  "asks": [
    [50010.00, 1.2],
    [50020.00, 3.1],
    [50030.00, 0.5]
  ],
  "timestamp": 1699000000000
}

这里每个数组项是 [价格, 数量]。注意,数量为 0 的价位通常会被省略,因为那意味着该价位已无挂单。

3.2 增量更新(Incremental)

增量更新,只告诉你「哪些价位发生了变化」。它比快照小得多,通常只有几个字节到几十个字节。

增量更新的格式,我见过两种主流设计:

类型 说明 示例
全量替换 每个增量包含某个价位的完整新数量 [50000.00, 1.2] 表示 50000 这个价位现在有 1.2 个币
增量累加 每个增量包含数量的变化值(正或负) [50000.00, -0.3] 表示 50000 这个价位减少了 0.3 个币

我个人更推荐「全量替换」模式。为什么?因为容错性好。万一你漏掉了一个增量,用全量替换模式,下一个增量就能把数据纠正回来。而增量累加模式,一旦漏掉一个,后面所有数据都偏了。

我的经验: 在对接某头部交易所时,他们用的就是增量累加模式。结果有一次网络抖动,我漏掉了 3 个增量,订单簿数据直接歪了 20%。后来我不得不加了一个「定期校验」机制——每收到 1000 个增量,强制请求一次快照做对比。从那以后,我再也不敢只用纯增量累加了。

3.3 序列号对齐策略

这是整个机制里最容易踩坑的地方。

你想想看,快照和增量是两条独立的数据流。快照是 T0 时刻的,增量是 T0+1、T0+2…… 的。如果你拿到快照后,直接应用增量,你怎么知道第一个增量是不是从 T0+1 开始的?

答案就是——序列号

序列号对齐的核心逻辑:
  1. 每个增量都带有一个单调递增的序列号(seq_id)
  2. 快照里也记录一个「最后序列号」(last_seq_id),表示这个快照对应到哪个增量之后的状态
  3. 客户端拿到快照后,只应用 seq_id > last_seq_id 的增量
  4. 如果发现增量序列号不连续(比如跳号了),说明有数据丢失,需要重新请求快照

用代码表示就是:

// 伪代码:序列号对齐
let snapshot = await fetchSnapshot();
let lastSeqId = snapshot.lastSeqId;

// 忽略所有 seq_id <= lastSeqId 的增量
for each incremental in stream:
    if incremental.seqId <= lastSeqId:
        continue;  // 这个增量已经被快照包含了
    if incremental.seqId != expectedSeqId:
        // 序列号不连续,数据丢失!
        log.error("序列号跳变,需要重新获取快照");
        snapshot = await fetchSnapshot();
        lastSeqId = snapshot.lastSeqId;
        expectedSeqId = lastSeqId + 1;
        continue;
    applyIncremental(incremental);
    expectedSeqId++;

这里有个细节要注意:快照本身也可能有延迟。你请求快照时,交易所生成快照需要时间,这期间可能又有新的增量产生。所以,快照里的 last_seq_id 可能比你想象的要小一些。你收到的增量里,有一部分可能已经被快照包含了,需要跳过。

我曾经踩过的坑: 有一次,我收到的快照 last_seq_id 是 1000,但我在本地已经缓存了 seq_id 从 900 到 1050 的增量。我以为直接应用 1001 到 1050 就行。结果发现,快照生成时,交易所内部的处理顺序和我收到的顺序不一致——有些 seq_id=950 的增量,在快照生成之后才被发送出来。导致我重复应用了部分数据,订单簿又歪了。

解决方案: 后来我改成「先丢弃本地所有增量,完全以快照为准,再从快照的 last_seq_id+1 开始接收」。虽然浪费了一点数据,但保证了绝对正确。

3.4 快照与增量的生命周期管理

在实际系统中,快照和增量不是无限保留的。你需要考虑:

  • 快照过期:如果客户端长时间没收到快照,应该主动重连
  • 增量缓冲区:客户端需要缓存一定数量的增量,以防网络抖动导致短暂断连
  • 定期校验:每隔一段时间(比如 5 分钟),主动请求一次快照,与本地数据对比,确保一致性

我习惯用一个简单的状态机来管理这个过程:

状态: INIT -> 请求快照
状态: SNAPSHOT_RECEIVED -> 应用快照,开始接收增量
状态: SYNCING -> 正常接收增量,序列号连续
状态: GAP_DETECTED -> 发现序列号跳变,回到 INIT 状态

这个状态机虽然简单,但能覆盖 99% 的异常情况。

3.5 一张图总结

下面这张 SVG 图,展示了快照与增量机制的核心流程:

快照与增量机制流程图 T0 T1 T2 T3 全量快照 last_seq_id=100 增量1 seq_id=101 增量2 seq_id=102 增量3 seq_id=103 客户端处理流程 收到快照 记录 last_seq_id 应用增量 异常:序列号跳变 → 丢弃本地数据 → 重新请求快照 快照 增量 异常路径

嗯,这张图把整个流程串起来了。你从左边拿到快照,记录下 last_seq_id,然后按顺序应用 seq_id 递增的增量。一旦发现序列号不连续,立刻回退到快照重新开始。

3.6 实际项目中的建议

最后,分享几个我在实战中总结出来的经验:

  • 快照不要存太久:我一般只保留最近 2 个快照,旧的直接删掉。因为快照数据量大,存多了内存扛不住。
  • 增量要设上限:增量缓冲区我通常设 10000 条。超过这个数,最旧的增量会被丢弃。但前提是,你必须确保在 10000 条增量内能拿到一次快照。
  • 序列号用 uint64:别用 int32,真的会溢出。我见过一个交易所的序列号在一天内跑了 20 亿,int32 直接炸了。
  • 测试要模拟断连:写单元测试时,一定要模拟「收到快照后断连 5 秒,再重连」的场景。这个场景最容易暴露序列号对齐的 bug。
一个小技巧: 如果你对接的交易所不支持序列号,怎么办?我遇到过这种情况。我的做法是:在客户端自己维护一个「本地序列号」。每收到一个增量,本地序列号 +1。然后每隔 100 个增量,主动请求一次快照,用快照覆盖本地数据。虽然粗暴,但有效。

好了,快照与增量机制就讲到这里。说白了,核心就是三件事:拿快照、记序列号、按序应用。把这三件事做好,订单簿的数据就不会乱。