先拿快照再收增量,中间那几条去哪了
本地缓存启动时先全量加载一次,之后订阅变更消息保持更新。跑了几天,缓存里某个商品的库存比数据库多 4 个,没有任何报错。重启一次就好了,过几天又不一样。
「一份全量快照,加上之后的增量」是让一份数据的副本跟上来源的常见做法:进程内的本地缓存跟数据库、搜索索引跟业务库、前端页面通过长连接跟后端状态、从库跟主库。它看起来只有两步,容易出错的是这两步之间的衔接,以及增量传输过程中的重复、丢失和乱序。副本一旦和来源不一致,通常不会有任何错误信号,直到有人发现数字不对。
本文在 JDK 25.0.4 上用一个单线程的模拟把几种到达顺序排出来:来源维护「商品 → 库存」,每次修改产生一条带连续序号的增量(某个商品加减多少),初始是 A=10 B=20 C=30。实验见文末。
一、取快照和订阅,谁先谁后
先取快照、再订阅。 直觉上的顺序。实测快照取到序号 3,在订阅建立之前来源又发生了一次修改(序号 4,A 减 4),订阅之后只收到序号 5:
| A | B | C | |
|---|---|---|---|
| 来源 | 6 | 25 | 30 |
| 副本 | 10 | 25 | 30 |
序号 4 发生在两步之间,快照里没有它,订阅也没收到它。这个窗口的宽度就是「取完快照到订阅生效」的时间,全量加载越慢,窗口越宽。
先订阅、再取快照。 先建立订阅,把收到的增量缓存起来不应用,然后取快照。这样不会漏,但快照可能已经包含了缓存里较早的几条。实测快照取到序号 4,缓存里有序号 4 和 5:
| 处理方式 | 副本的 A | 与来源一致 |
|---|---|---|
| 缓存的增量全部应用 | 2(序号 4 被算了两次) | 否 |
| 丢弃序号不大于快照序号的增量 | 6 | 是 |
所以需要三样东西:增量带序号;快照带上「它反映到哪个序号为止」;副本在应用缓存的增量时按序号过滤。来源给不出快照对应的序号时,这个方案做不对,只能退而求其次:让增量是「设置为某个值」而不是「加减多少」,重叠部分重复应用也不会错。
二、重复、丢失、乱序:都靠同一个序号
副本记住已经应用到的序号 lastSeq,每来一条增量:
- 序号不大于
lastSeq:已经应用过(或快照已包含),丢弃; - 序号等于
lastSeq + 1:应用,lastSeq加一; - 序号更大:中间缺了,不能应用。
实测序号 4 被重复投递一次、序号 5 丢失、随后收到序号 6:
| 副本的做法 | 结果 |
|---|---|
| 不检查序号,来一条应用一条 | {A=2, B=20, C=29},来源是 {A=6, B=25, C=29};没有报错 |
| 检查序号 | 忽略重复 1 条;收到序号 6 时发现缺序号 5;重新取快照后与来源一致 |
发现缺口之后有两种恢复方式:向来源要缺的那几条(来源得保留一段增量历史),或者重新取一次快照。后者简单,前者省流量,可以先试前者、失败了再退到后者。无论哪种,关键是发现:不检查序号的副本,丢了一条之后会一直错下去。
乱序是缺口的一种轻微形式:缺的那条稍后就到。实测序号 5、6 先到、4 后到,副本最多暂存 2 条等待:没有触发重新同步,最终与来源一致。暂存要有上限(条数或时间),超过就当作丢失处理,否则一条永远不来的增量会让副本无限期停住。
这些规则与消息队列的投递语义是配套的:至少一次投递带来重复,靠序号去重;分区内有序、分区间无序,所以序号要按分区分别维护。见 Kafka 不丢、不重与 Exactly Once。
序号本身还要满足「小的先可见」。用数据库的自增 ID 当序号时这一点不成立,见未读数显示 3,点进去一条新消息也没有。
三、副本不能只留「用得着的部分」
副本往往只需要数据的一部分,比如「库存最多的前 2 个商品」。如果为了省内存,在副本里只保存这 2 个:
实测初始时库存最多的是 B 和 C,副本只保存它们。随后 C 售罄。副本里只剩 {B=20};来源里有库存的是 {A=10, B=20},前 2 个应该是 A 和 B。A 在裁剪的时候被丢掉了,后面的增量里也不会再提到它(它没有变化),副本没有办法知道它的存在。
增量描述的是「相对于完整状态的变化」,应用它的那份状态必须是完整的。只想要一部分,就在完整副本之上再算一个视图,每次变化后重新取前 N 个。存不下完整状态时,要由来源提供按范围过滤过的快照和增量,而不是在副本这边自己裁。
四、重启之后从哪里继续
副本重启时,要么重新走一遍「订阅、取快照」,要么从上次停下的地方继续。后者需要把状态和 lastSeq 持久化,而且两者要一起写入:
- 先存状态、后存序号,中间崩溃:重启后序号是旧的,已经应用过的增量会再应用一次;
- 先存序号、后存状态,中间崩溃:重启后会跳过实际上没有应用的增量。
放在同一个事务或同一个文件里原子地替换。做不到的话,让增量的应用是幂等的,并采用「先存状态、后存序号」,宁可重复不可跳过。内存状态怎样安全地保存,见自动保存显示成功了,最后一次修改却没存上。
五、怎么知道副本是对的
前面的机制都依赖实现没有漏洞。再加一道独立的检查:定期把副本与来源做比较。数据量小的直接比全量;大的比较行数加校验和,或者按范围分段比较。发现不一致就告警并重新同步,同时记录下来,它说明前面某个环节有问题。
需要观测的量:副本的 lastSeq 与来源最新序号的差(延迟)、重复丢弃的条数、缺口次数与重新同步次数、上一次核对的时间与结果。缺口次数突然升高,通常是传输通道或者来源的发布逻辑出了问题。
数据库复制是同一个模型的成熟实现,位点、延迟与切换时的一致性见 MySQL 复制与延迟。缓存与数据库之间只做失效、不做增量同步的方案,取舍见缓存一致性的四种写法。
配套实验
- codesphere-labs/system-design/snapshot-and-delta-sync:JDK 25.0.4 上单线程模拟的五种情形:取快照与订阅的先后、按序号丢弃重叠、重复与丢失、乱序、裁剪过的副本(验证记录)
参考资料
- MySQL 8.4 Reference Manual:Replication with Global Transaction Identifiers(副本按事务标识跳过已应用的事务)
- Apache Kafka 文档:Message Delivery Semantics
- Debezium 文档:MySQL connector snapshots(先记录位点、再读取快照、再从位点开始读增量)