Skip to content

先拿快照再收增量,中间那几条去哪了 ​

本地缓存启动时先全量加载一次,之后订阅变更消息保持更新。跑了几天,缓存里某个商品的库存比数据库多 4 个,没有任何报错。重启一次就好了,过几天又不一样。

「一份全量快照,加上之后的增量」是让一份数据的副本跟上来源的常见做法:进程内的本地缓存跟数据库、搜索索引跟业务库、前端页面通过长连接跟后端状态、从库跟主库。它看起来只有两步,容易出错的是这两步之间的衔接,以及增量传输过程中的重复、丢失和乱序。副本一旦和来源不一致,通常不会有任何错误信号,直到有人发现数字不对。

本文在 JDK 25.0.4 上用一个单线程的模拟把几种到达顺序排出来:来源维护「商品 → 库存」,每次修改产生一条带连续序号的增量(某个商品加减多少),初始是 A=10 B=20 C=30。实验见文末。

一、取快照和订阅,谁先谁后 ​

先取快照,后订阅来源的修改123456快照:到序号 3订阅开始:从序号 5 收起序号 4 丢失先订阅,后取快照来源的修改123456缓存的增量456订阅开始快照:到序号 44 ≤ 快照序号:丢弃;5、6 应用重复投递、丢失、乱序都用同一个序号处理:不大于已应用序号的丢弃;不是下一个序号的先等,等不到就重新取快照增量是「加减多少」时重复应用会算错;是「设置为多少」时重复无害,但丢失和乱序仍然要靠序号发现
图 1 · 来源的每次修改都有一个连续的序号。先取快照再订阅,两步之间产生的修改(序号 4)既不在快照里,也没有被订阅收到,副本从此与来源不一致。先订阅并把增量缓存起来,再取快照,快照里已经包含了缓存中较早的增量;按序号丢弃这部分重叠,再应用其余的,副本才和来源一致

先取快照、再订阅。 直觉上的顺序。实测快照取到序号 3,在订阅建立之前来源又发生了一次修改(序号 4,A 减 4),订阅之后只收到序号 5:

ABC
来源62530
副本102530

序号 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 复制与延迟。缓存与数据库之间只做失效、不做增量同步的方案,取舍见缓存一致性的四种写法。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权