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.3 序列号对齐策略
这是整个机制里最容易踩坑的地方。
你想想看,快照和增量是两条独立的数据流。快照是 T0 时刻的,增量是 T0+1、T0+2…… 的。如果你拿到快照后,直接应用增量,你怎么知道第一个增量是不是从 T0+1 开始的?
答案就是——序列号。
- 每个增量都带有一个单调递增的序列号(seq_id)
- 快照里也记录一个「最后序列号」(last_seq_id),表示这个快照对应到哪个增量之后的状态
- 客户端拿到快照后,只应用 seq_id > last_seq_id 的增量
- 如果发现增量序列号不连续(比如跳号了),说明有数据丢失,需要重新请求快照
用代码表示就是:
// 伪代码:序列号对齐
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+1 开始接收」。虽然浪费了一点数据,但保证了绝对正确。
3.4 快照与增量的生命周期管理
在实际系统中,快照和增量不是无限保留的。你需要考虑:
- 快照过期:如果客户端长时间没收到快照,应该主动重连
- 增量缓冲区:客户端需要缓存一定数量的增量,以防网络抖动导致短暂断连
- 定期校验:每隔一段时间(比如 5 分钟),主动请求一次快照,与本地数据对比,确保一致性
我习惯用一个简单的状态机来管理这个过程:
状态: INIT -> 请求快照
状态: SNAPSHOT_RECEIVED -> 应用快照,开始接收增量
状态: SYNCING -> 正常接收增量,序列号连续
状态: GAP_DETECTED -> 发现序列号跳变,回到 INIT 状态
这个状态机虽然简单,但能覆盖 99% 的异常情况。
3.5 一张图总结
下面这张 SVG 图,展示了快照与增量机制的核心流程:
嗯,这张图把整个流程串起来了。你从左边拿到快照,记录下 last_seq_id,然后按顺序应用 seq_id 递增的增量。一旦发现序列号不连续,立刻回退到快照重新开始。
3.6 实际项目中的建议
最后,分享几个我在实战中总结出来的经验:
- 快照不要存太久:我一般只保留最近 2 个快照,旧的直接删掉。因为快照数据量大,存多了内存扛不住。
- 增量要设上限:增量缓冲区我通常设 10000 条。超过这个数,最旧的增量会被丢弃。但前提是,你必须确保在 10000 条增量内能拿到一次快照。
- 序列号用 uint64:别用 int32,真的会溢出。我见过一个交易所的序列号在一天内跑了 20 亿,int32 直接炸了。
- 测试要模拟断连:写单元测试时,一定要模拟「收到快照后断连 5 秒,再重连」的场景。这个场景最容易暴露序列号对齐的 bug。
好了,快照与增量机制就讲到这里。说白了,核心就是三件事:拿快照、记序列号、按序应用。把这三件事做好,订单簿的数据就不会乱。