缓存一致性:Cache Aside、延迟双删与 binlog 同步怎样选

从保存成功却读到旧值出发,用小流程图逐步解释 Cache Aside、延迟双删、可靠重试、binlog/CDC 同步,以及怎样用版本门槛拒绝旧值回填。

本文使用documd-visualshumanizer

运营把活动开始时间从十点改成十一点,后台提示保存成功。用户刷新详情,却仍然看见十点。研发检查数据库,数据已经更新;检查 Redis,发现旧值还在。再删一次缓存,页面暂时恢复,过一会儿旧值又出现了。

这类问题不能只靠一句“先更新数据库,再删除缓存”解释。写操作有先后,读操作也会查询数据库并回填;读写之间还有网络等待、线程暂停、消息重试和数据库复制。两行正确排序的代码,可能被另一个请求插入第三步。

本文的问题是:数据库作为权威来源时,怎样让缓存跟随数据变化,失败后又怎样恢复?先用活动详情看清读写操作,再比较延迟删除、可靠任务和监听数据库日志三条处理路径。最后再加入版本门槛,处理删除成功以后仍然出现的旧值回填。

这些方案可以组合:应用先提交数据库并删除缓存,日志订阅补上其他写入来源和失败遗漏,回填时再检查版本。选型要看每一步补了什么保证,以及业务是否愿意维护这一步的额外状态。

案例中的版本、时间和缓存规则是教学假设。基础模式与协议边界使用公开文档核对,交错反例由仓库脚本复现;流程图和脚本都不代表某家公司的生产实现。已有的缓存高并发文章覆盖穿透、击穿和雪崩,这篇集中分析数据更新后的陈旧与恢复,不重复讨论所有缓存故障。

一、先说清楚“一致”要保证哪一次读取

假设数据库里活动 123 的记录是 version=41,开始时间为十点,缓存保存同一版本。运营提交修改后,数据库变成 version=42,开始时间为十一点。这里的版本由权威写入流程维护,表示同一活动的变化顺序;它不是请求时间戳,也不与其他活动的版本比较。

至少有三种不同要求。普通访客可以短暂看见旧说明,但要在约定的新鲜度目标内恢复;修改者保存后再打开后台,要看见自己刚提交的结果;真正执行报名资格或交易规则时,必须检查相应的权威状态。展示副本与业务判定的错误成本不同,不宜用同一个缓存接口承担全部责任。

“最终会正确”还需要条件:写入停止以后,失效事件能够被处理,旧加载请求不会无限回填,回源能够读到新数据,缓存条目不会永久续期。缺少这些条件,最终一致只是期望。明确最大陈旧时间,则还要约束事件延迟、回源快照年龄和加载时长,不能直接把 Redis TTL 当成提交后的倒计时。

read-your-writes 即“读己之写”,指修改者的后续读取能看到自己已经确认的写入;单调读关注同一读者不要从新版本退回旧版本。它们都不自动等同于所有用户的读请求具有线性一致性。一个已经在修改前开始的查询,可能在修改后返回旧快照;要讨论是否违规,需要先确定读取的起点和契约。

我会先给活动说明选择普通展示缓存,给运营确认页选择提交结果或有版本要求的权威读取,给资格判断保留事务内校验。这使缓存负责加速可重复查询,而不是独自决定用户能不能报名。相关一致性术语可结合一致性模型理解。

二、把 Cache Aside 的读取和写入分开看

Cache Aside 通常译成旁路缓存。数据库和缓存都在应用旁边,由应用决定什么时候查哪一份数据,什么时候删除缓存;Redis 不会因为 MySQL 被修改就自动跟着变化。常见读路径是先查缓存,未命中时查询数据库,再保存查询结果;写路径是提交数据库后删除对应缓存。这是 Microsoft 的 Cache-Aside 模式描述的基本路径,该模式本身不保证两份数据时刻一致。

先约定三个动作。“更新缓存”是把新数据直接写进去,例如 SET activity:123 新值;“删除缓存”是让已有副本失效,例如 DEL activity:123;“回填”是一个查询请求从数据库拿到数据后,再把查询结果放进缓存。回填的结果是否最新,取决于查询发生时能看见的数据库状态。“刷新缓存”容易同时指这三个动作,讨论顺序时最好说出具体命令。

Cache Aside 读取流程:命中直接返回,未命中查询数据库并回填

图中的分支发生在缓存查询以后。假设 Redis 保存了十点、v41,查询直接命中就可以返回;如果 key 不存在,应用查询数据库,这一步叫回源。数据库返回十点、v41,应用将它与 TTL 一起写进 Redis,这一步才叫回填。缓存 miss 是“没有这份副本”,不表示数据库中没有活动;数据库确实查不到记录时,是否短暂缓存空值属于另一项策略。

未命中会新增一次数据库查询和一次回填,这个回填也是写操作。数据库返回 v41 到缓存接受 v41 之间存在时间间隔,后面的竞态正是利用这个间隔。返回本次查询结果与成功保存缓存也应分开:回填失败不必让一次已经成功的普通查询失败,但需要限制后续重复回源的压力。

写路径的顺序则是:提交活动变更,删除对应缓存,再等待下一次查询加载新值。这里的“提交”要落到数据库事务已经成功提交之后。在事务内部刚执行完 UPDATE 就删除,其他读请求可能仍看不见未提交结果,随后把旧值回填;事务回滚时,提前更新缓存还可能暴露根本没有成立的业务变化。

缓存 key 也要对应实际查询。例如活动详情包含活动、规则和店铺资料,改变一条规则可能影响多个 key。只删除 activity:123 而遗漏页面使用的组合 key,并没有完成失效。租户、权限范围、语言与数据模型版本也可能属于 key 身份,不能为了合并命中率让不同授权范围共享一份结果。

这篇先固定为一个活动、一个详情 key、一个可见的数据版本,便于检查顺序。扩展到组合查询时,需要额外维护依赖关系,或者选择更容易失效的 key 粒度。普通缓存条目丢失以后应能重建;后面出现的版本水位则承担了拒绝旧数据的职责,丢失后的处理会更严格。

三、四种写入顺序为什么各有问题

先把数据库写入、缓存更新和缓存删除当成独立操作。它们之间没有天然的跨系统事务,某一步成功后,下一步仍可能超时或失败。即使不发生任何网络故障,并发执行也能造成陈旧。

写入顺序 可以构造的反例 需要额外解决什么
更新缓存,再提交数据库 缓存已显示十一点,数据库事务却失败 未提交值的可见性与失败恢复
删除缓存,再提交数据库 并发读在提交前读到十点并回填 旧回源与写入的交错
提交数据库,再更新缓存 两个写请求按不同顺序更新缓存 缓存更新的版本顺序与完整性
提交数据库,再删除缓存 删除失败,或旧读在删除后回填 可靠失效和过时回填约束

先更新缓存的问题很直接:应用把十一点、v42 放进 Redis,另一个用户立刻读到十一点,但接下来的数据库事务失败,权威记录仍然是十点、v41。用户已经观察到了从未提交的变化,事后把缓存改回十点也无法撤回这次观察。修改请求还可能在两步之间崩溃,连恢复缓存的代码都没执行。

并发会让“改回去”更危险。A 的数据库提交失败,准备恢复 v41;B 随后成功提交了 v42,并已经更新缓存。A 不检查版本就把旧值写回,等于用失败请求覆盖了成功请求。若缓存被专门设计成权威写入入口,并通过自己的日志和持久化协议承担责任,那是另一种系统模型,不应和普通数据库旁路缓存混用。

先删除缓存的反例不需要很慢的读请求。缓存一删,正常读就会回源;只要数据库尚未提交,新读就拿到 v41,回填之后再提交 v42,缓存便持续保留旧值。

先删除缓存的时序:读请求在数据库提交前回填 v41

这张图按编号从上到下读。第 4 步发生时数据库仍是 v41,所以读请求得到旧值完全符合当时的数据库事实;问题是第 5 步提交以后,没有动作让已经回填的副本跟随变化。若写请求失败,情况还不同:旧值仍是合法数据,删除只造成一次额外加载。

直接更新缓存可以减少一次后续 miss,但会让写端承担缓存对象的构造、版本推进与并发顺序。假设 A 提交 v42 后暂停,B 提交 v43 并更新缓存,A 恢复后执行普通 SET,缓存又退回 v42。

两个写请求的时序:数据库按 v42、v43 提交,缓存却按 v43、v42 写入

数据库的提交顺序与 Redis 接收请求的顺序是两件事。把整个业务对象序列化成 JSON 并不能解决乱序;对缓存执行版本比较和条件写入,才是在增加新的顺序约束。多个字段独立更新时,还要避免使用过时的整对象快照覆盖其他字段的新值。

删除缓存通常更容易重试,因为重复删除不会恢复一个旧版本。不过,它会增加下一次加载,还可能让热点缓存频繁失效。选择失效策略是用重建成本换取更简单的写端职责,并没有消除所有一致性问题。

四、先写库再删除,为什么还会出现旧值

这次先假设缓存为空。读请求 R 查询数据库得到 v41,在回填之前暂停。写请求 W 提交 v42,成功删除缓存;R 随后恢复,将之前拿到的 v41 放进缓存。删除本身没有失败,两个请求也都成功结束。

旧值回填的时序:读请求拿到 v41 后暂停,在 v42 提交和删除成功后回填

危险的是图中第 5 步。DEL 只删除执行时存在的值,不会撤销另一个进程已经拿到的旧查询结果,也不阻止未来的 SET。数据库本地事务只能组织数据库里的操作,无法自动约束这次缓存回填。

这种现象常称为 stale set。Scaling Memcache at Facebook的 lease 机制会为 miss 提供与 key 绑定的回填许可,删除使旧许可失效;回填时再验证许可。这里可以借鉴的是“失效要影响旧 loader 的写入资格”,并非直接把任意分布式锁当作同样的协议。

所谓“先写库再删概率很低”,需要实际数据支撑。短查询、低更新率可能很少遇到交错;长尾查询、GC 暂停、慢网络或频繁修改会扩大窗口。只看平均数据库延迟不足以估计风险,更不能从一张时序图推导线上发生概率。

读副本和事务快照也会产生旧结果。缓存删除以后回源到尚未追上的副本,可以重新加载 v41;回源请求若沿用一个早已建立的事务快照,即使查主库也未必看到 v42。MySQL 的一致性非锁定读文档说明了隔离级别与快照可见性的差异。要保证读到某个已提交版本,应同时约束读取路由和快照语义。

因此要分开定位:缓存删除有没有成功,回填依据是哪份数据库视图,旧查询何时开始,回填何时发生。只增加删除次数而不检查回源,可能反复删除、反复加载同一个旧版本。

五、TTL 和延迟双删分别缩小哪个窗口

TTL 是 Time To Live,即条目的存活时间。例如回填时给 key 设置 60 秒 TTL,Redis 到期后不再把这份值当作有效缓存。它不需要数据库更新端每次都成功通知缓存,因此适合做兜底,但起算点是条目的设置或续期时间。

假设 v42 在 t=20 提交,旧加载请求到 t=200 才把 v41 回填,TTL 为 60,条目在 t=260 才过期。距离提交已经 240 个时间单位,明显不是“写完最多旧 60”。这里的时间单位只服务于模型计算,不是性能测试。从提交推导陈旧上限,还要限制旧结果最多晚多久写入、回源数据最多旧多久,以及是否持续续期。缓存每次过期都回源到同一个落后副本,再设置新 TTL,陈旧就可能被反复延长。

第二次删除到底想清掉什么

延迟双删是在两次删除之间留出时间,希望清理掉这段时间里出现的旧回填。常见讨论存在两种顺序,不能只说“双删”而不说操作位置:

变体 实际操作顺序 第二次删除的目标
更新前先删 删除缓存 → 提交数据库 → 延迟后再删 清理第一次删除后、提交前读到旧值的回填
提交后先删 提交数据库 → 删除缓存 → 延迟后再删 清理提交前已经取到旧值、第一次删除后才写入的回填

以第一种为例,初始数据库和缓存都是 v41。W 删除缓存,R 恰好 miss,查数据库拿到 v41;W 提交 v42,R 随后回填 v41。第二次删除如果发生在这次回填之后,就清掉了旧副本。新的读请求再查询一个足够新的数据库视图,才会回填 v42。第二次删除本身没有生成新值,更没有把数据库“回填回去”。

延迟双删的两种交错:第二次删除可以清除已回填的旧值,但无法阻止更晚的旧回填

图里两条路径的区别只有旧回填何时完成。上面是回填先完成,第二次删除随后清理;下面让 R 暂停更久,两次删除都已经结束才恢复,v41 又进入缓存。给删除任务加上可靠重试,也无法让过去的一次删除阻止未来的普通 SET。

延迟长度和延迟任务都需要设计

不能直接用平均查询耗时选择延迟。需要测量数据库返回到回填完成的长尾,还要看线程调度、GC、网络和副本延迟。延迟覆盖某个分位数,意味着接受超过这个分位数的残余风险;系统若允许无期限暂停,就没有一个固定等待时间可以覆盖全部旧请求。增大延迟还会延后清理,并增加待办任务数量。

第二次删除不能只挂在请求线程的 sleep 后面。实例退出时线程丢失,删除任务也随之丢失;请求如果等完再返回,还会增加接口延迟。可以在可靠记录中保存 key、对应业务版本和到期时间,由调度器投递给处理进程。处理进程执行删除,成功才完成任务,失败则退避重试。延迟队列负责“何时执行”,可靠记录负责“重启后还记得要执行”,两种职责都要存在。

“数据库提交,再把任务发到队列”仍有双写缺口:提交成功后、投递前退出,队列里什么都没有。下一节的本地事件表可以把业务更新与待办记录一起提交。更新前第一次删除失败时,也要明确继续保存还是中止;数据库提交成功以后,后续删除失败则进入恢复流程,不能假装数据库没有变化。

我会把 TTL 用作展示缓存的兜底,把延迟删除用于可接受残余风险的场景。业务如果要求确定拒绝某次旧回填,就需要限制回填资格;如果要求修改者马上看见新值,则还要设计相应读路径。

六、删除失败以后,恢复工作必须留得下来

数据库提交 v42 后 Redis 不可用,删除失败。若失效任务只在进程内存里,再遇到实例重启,系统就忘了这次更新。把删除动作补进消息队列之前还存在一个缺口:数据库成功提交,消息尚未发送,进程退出。

Outbox 通常指本地事务消息表。可以把它理解成数据库里的待办清单:保存“活动 123 变成 v42,相关缓存需要失效”这条工作,与保存活动本身使用同一个事务。成功提交时两条记录都在,事务回滚时两条都不成立。后续扫描器或日志订阅器从清单里恢复工作;AWS 的 Transactional Outbox 指南解释了这种双写缺口及重复消息处理。

这里的 Worker 是独立处理任务的进程,不是必须与接口请求同生共死的一条线程。它可能轮询未完成的 Outbox 行直接删除缓存,也可能由投递器先把行转成 MQ 消息,消费者再删除。增加 MQ 时需要分别确认投递成功和下游处理成功,不能把“消息已送达队列”当成“缓存已失效”。

可靠失效流程:事务写入变更与事件,Worker 删除成功后确认,失败或超时保留重试

图中两个菱形分别判断数据库是否提交、缓存删除是否确认。数据库事务已经提交以后,缓存删除失败不会把业务写入变成“没有发生”;响应契约应说明数据已保存,展示副本仍在同步。客户端重试修改时,仍需要业务幂等或版本校验,避免把一次保存重做成另一项业务动作。

下面是一个事务形状示例,省略具体 SQL 参数绑定。event_id 是这次逻辑修改的身份,aggregate_id 是被修改的活动,version 是该活动修改后的版本。示例中的预期版本检查负责拒绝过时修改;业务重试还需要稳定的请求身份和结果查询,不能在每次网络重试时无条件新增一笔修改:

BEGIN;
UPDATE activity
SET starts_at = :new_time, version = version + 1
WHERE id = :id AND version = :expected_version;
-- 应用检查影响行数为 1;否则回滚,不继续写事件。
INSERT INTO cache_outbox(event_id, aggregate_id, version, event_type)
VALUES (:event_id, :id, :expected_version + 1, 'ActivityChanged');
COMMIT;

假设客户端不知道这次 COMMIT 是否成功,又带着预期 v41 重试。若第一次已经提交,数据库现在是 v42,第二次的 UPDATE 影响行数为零;应用应查询原请求的处理结果或返回版本冲突,不能继续插入事件。若第一次没提交,重试可以重新执行。生产表还需要合适的唯一约束、领取租约、重试时间和索引,SQL 片段只展示更新与事件的事务边界。

Worker 只有在确认删除成功后才能标记处理完成。若删除已执行但响应丢失,可以继续删除同一个 key;若删除之后尚未确认就崩溃,重复处理可能删除一个后来重建的新值,通常造成额外 miss,但不会直接写回旧值。吞吐、重试频率与回源保护仍要控制,不能让事件风暴变成数据库风暴。

反过来,先把事件标成已处理,再执行 DEL,两步之间崩溃就会留下永久遗漏。任务领取也要可恢复:Worker 可以临时领取一条记录,但不能领取以后就从数据库中永久移除。领取者失联后,其他 Worker 应能重新接管;重复执行是预期情况,不能靠“这条任务应该只执行一次”维持正确性。

只删除 key 的消费者,对重复事件通常比较容易处理;按事件 payload 更新缓存的消费者则要按实体版本拒绝倒序写。事件身份用于去重,实体版本用于判顺序,两者不能互换。最新事件被删除、过期或无限留在死信队列里,也会破坏恢复条件,所以要记录最老待处理年龄、最后确认版本与人工接管结果。

Outbox 的作用是保留恢复证据,不会消除第四节的 stale set。一个已经读到 v41 的 loader,仍可在消费者删除之后回填。把事件可靠性与回填正确性分别处理,才能知道增加了哪个保证。

七、监听 binlog:怎样发现变化并同步缓存

现在换一个问题:运营接口已经遵守提交后删除,但管理脚本直接修改了活动表,根本没走这个接口。Redis 中的十点仍然存在,接口里的重试也无从知道该删除什么。需要在数据库侧观察变化,覆盖接入范围内的其他写入来源。

binlog、CDC 与订阅组件分别是什么

binlog 是 MySQL 的 binary log,即二进制日志,用于记录数据变化和相关事件,服务复制与恢复。它与 InnoDB 的 redo log 不是同一个东西。本文只讨论事务表的有效变更:缓存同步应尊重事务边界,不把一条还没确认提交的更新当作已成立的业务事实。MySQL binlog 文档说明,InnoDB 的事务变更会暂存,并在提交过程中写入 binlog;“数据库提交 → 订阅变化”的业务流程图没有展开数据库内部的提交协议。

CDC 是 Change Data Capture,即变更数据捕获。它描述“把数据源的变化取出来”这项能力。基于日志的 CDC 不用反复扫描整张活动表,而是跟踪日志位置,解析新增、修改、删除,再交给下游。它也可以用于搜索索引、数据仓库和审计,缓存同步只是其中一种用途。

Canal 和 Debezium 是可用于这条链路的组件名称。Canal 的项目说明描述了通过 MySQL 复制交互协议订阅并解析 binlog 的过程;Debezium MySQL Connector则把行级变化转为事件送往 Kafka。组件发现变化以后,仍然需要应用编写消费者,将变化映射成自己的缓存动作。安装订阅器不会自动知道某张表对应哪些业务 key。

从活动更新走到一次 DEL

继续使用活动 123。假设事务提交了十一点、v42,缓存同步采用“订阅变化后删除缓存”。这个例子分两段执行:数据库日志由订阅器解析并投递到可靠事件流,缓存消费者再从事件流中领取任务。MQ 是消息队列,承担缓存暂时不可用时保留事件和后续重放的职责;也可以使用其他可恢复事件存储,不要求每个实现都必须加入 Kafka。

binlog 失效流程:订阅已提交变化,消费者删除缓存,成功后确认位点,失败则保留重试

沿图看这笔修改:数据库接受了 v42,订阅器得到活动行的变化;消费者识别活动 ID,把它映射成 activity:123;执行 DEL 并收到成功响应以后,才把这条事件确认为处理完成。下一次普通读 miss,再查询数据库,加载新数据。如果订阅器仍在积压,或者消费者正在重试,Redis 中的 v41 可能继续被命中,这就是异步同步的陈旧窗口。

以下 JSON 是消费者使用的教学归一化事件,不是 Canal 或 Debezium 的原始报文。转换层负责把产品字段映射成统一结构:

{
  "eventId": "activity-change-42",
  "entityId": 123,
  "entityVersion": 42,
  "operation": "UPDATE",
  "before": { "startsAt": "10:00", "version": 41 },
  "after": { "startsAt": "11:00", "version": 42 },
  "sourcePosition": "checkpoint-for-this-change"
}

entityId 决定变更涉及哪个活动;before 和 after 帮助判断哪些字段发生变化;eventId 识别同一条事件的重复投递;entityVersion 判断同一活动的新旧顺序;sourcePosition 指出订阅或消费到了哪里。日志文件位置、GTID、MQ offset 与业务版本各自属于不同序列,不能直接拿 offset 当作活动版本比较,也不能跨不同分区随意比较大小。原始事件身份的生成和位点格式取决于所用组件。

如果详情还组合了店铺信息,店铺规则变化就可能影响多个活动 key。消费者可以维护店铺到活动的依赖索引,按有限批次失效;或者让组合 key 包含依赖版本,在查询时验证。只知道“哪一行变了”,还不知道“哪些查询结果因此过时”。广播删除全部缓存虽然简单,却可能让回源超过容量。

消费事件后删除,还是直接更新

监听日志提供了变化来源,消费者还要选择动作:

消费方式 事件到达后做什么 好处 新增问题
删除缓存 根据主键和依赖关系执行 DEL 无需在写端构造完整缓存对象,重复删除容易处理 下一次读需要回源,旧读仍可能在删除后回填
更新缓存 从事件构造新副本,再做条件写入 可以减少失效后的 miss 需要完整对象、版本顺序、删除事件与过时回填治理

对于只展示活动行的简单对象,完整的 after 可以作为候选新值。对于含店铺、规则和权限信息的详情,只修改活动行不足以构造整个响应;消费者可能需要重新查库。此时要检查重新查询的来源和快照,如果把落后副本查询结果重新写进缓存,监听到了最新事件也无济于事。事件中的数据还要限制字段范围,不能顺手把敏感列广播到所有下游。

直接更新也会乱序。v42、v43 的事件按顺序进入消息系统,不代表两位并发消费者的 Redis 请求一定按顺序完成;重试还可能让 v42 在 v43 之后再次到达。普通 SET 会把缓存从 v43 改回 v42。按活动 ID 有序处理能减少交错,但出现重放、切换、并发回填时仍要明确接受规则。下一节的版本门槛可以供事件刷新和查询回填共同使用,前提是两条路径使用同一套可比较版本。

删除活动也需要单独处理。仅执行 DEL 后,一个早先查到活动仍存在的请求可能把它“复活”。若要求阻止这种回填,需要保留删除版本或删除标记,使旧结果被拒绝。数据库的行已经不存在时,从哪里获得可比较的删除版本,也必须预先设计;普通硬删除事件不一定自带一个递增的业务版本。重新创建同一 ID 还需要延续顺序或使用新的代际。

重启以后,凭什么知道还欠哪些工作

图中“确认位点”是记录已经安全处理到哪里。至少有两个边界:订阅器从数据库读取并可靠投递的位置,消费者已经完成缓存动作的位置。订阅器读到了 v42,不表示缓存消费者也处理完 v42。消息消费完成的确认不能越过尚未处理的事件;并发处理时,要按各分区的连续完成位置推进,或者留下可恢复的独立任务。

如果消费者先确认,再删除,确认后崩溃会跳过这次修改。如果先删除,再确认,删除后崩溃可能重新消费;对 DEL 来说,多删一次通常只产生额外 miss。超时则意味着结果未知:Redis 可能已经执行删除,但响应丢了。保持事件未确认,再重试,比无证据地宣布完成更容易恢复。

订阅器停机期间,binlog 仍然在轮转。如果保存的位置已经被清理,仅恢复进程无法找回变化。Debezium 的快照与恢复说明描述了快照与日志位置的衔接。工程上应监控“距离日志保留边界还有多久”,在发生缺口时阻止无声跳过,并执行受控快照或缓存重建。重建还必须接上后续变化:不能扫描完一遍旧快照就覆盖同期更新,宣布恢复完成。

Outbox 与 CDC 可以组合。应用先在事务里写活动和事件表,CDC 再订阅事件表,把事件送往下游。Debezium Outbox Event Router提供了这种消息映射。直接订阅活动表能观察接入范围内的外部修改;订阅 Outbox 能表达更清楚的业务语义,但没写 Outbox 的外部修改不会凭空生成业务事件。选择哪张表,决定了覆盖范围和语义粒度。

至此可以解释监听 binlog 的收益:应用不必在每个修改入口都手工发消息,且有日志位置支持恢复。但它仍有异步延迟、日志保留、过滤范围、依赖映射和消费者正确性的成本。它没有解决第四节的旧 loader 在最后一次 DEL 之后执行 SET 的问题。

八、拒绝旧回填:版本栅栏怎样工作

loader 指缓存未命中后负责查库、回填的加载请求。R 拿到 v41 后暂停,W 已经提交 v42,日志消费者也完成了删除;R 再恢复,就成为一个“工作还没结束、依据已经过时”的 loader。让它再删一次只能补救一部分时间窗口,可以改为在它尝试写缓存时检查资格。

版本栅栏就是基于版本的写入门槛。可以把它理解成 key 旁边的一张牌子:“这个活动至少已经推进到 v42,低于 42 的结果不能再放进来。”它是一类协议思路,Redis 没有一个叫“版本栅栏”的开关。应用需要维护元数据,并让所有相关写缓存路径遵守检查。

为什么门槛必须独立于缓存值

只在缓存对象里加 version 还不够。删除以后对象不存在,旧 loader 比较不到更高版本,依然会把 v41 填进去。需要单独保存 floor,即最低可接受版本。可以用两个 key 表达:一个保存 floor=42,另一个保存活动详情,详情为空时 floor 仍然存在。数据和门槛的生命周期不同。

在这个教学协议中,数据库的业务版本是顺序依据,floor 是缓存服务已经接收到的最低版本要求,缓存值版本是目前保存的副本版本。假设 floor=42、当前值=v43,候选 v42 满足 floor,但还会让现有缓存倒退,因此同样需要拒绝。候选同时不能低于 floor 和当前值版本。

失效事件先处理门槛和旧副本。这两个动作在一个不可交错的操作中完成:

带版本的失效流程:推进最低版本,判断当前缓存是否更旧,只清理低版本副本

收到 v42 事件时执行 floor = max(floor, 42)。若当前值是 v41,清掉它;若值已经是 v43,保留。稍后重复收到 v42,或者收到更早的 v41,floor 不应下降,更高版本的值也不该被删掉。这个条件失效比无条件 DEL 更复杂,但能减少旧事件对新副本的反复清理。

回填随后在同一个缓存原子边界内读取 floor 和现有值版本,判断候选是否满足条件,再写入。图的前提是 floor 已经可靠存在:

版本回填流程:读取独立水位和当前版本,不满足条件则拒绝旧值

不能先在客户端 GET floor,判断通过,再发送普通 SET。假设 R 读到 floor=41,W 此时推进到 42 并清理旧值,R 仍按先前比较结果 SET v41,门槛就被绕过。检查和写入必须连在一起,中间不能插入其他写入。失效操作推进 floor 与清旧值也要有同样的原子边界。

以下函数使用数字表示版本,省略业务对象。仓库实验把每个函数调用视为不可交错的原子操作,用它检查协议规则;复制到多线程业务代码里不会自动获得这个保证:

function invalidate(state, eventVersion) {
  state.floor = Math.max(state.floor, eventVersion);
  if (state.value !== null && state.value < state.floor) state.value = null;
}

function fill(state, version) {
  if (state.floor === null) return 'unknown-floor';
  if (version < state.floor ||
      (state.value !== null && version < state.value)) return 'stale';
  state.value = version;
  return 'stored';
}

这里 invalidate 只用于门槛已初始化的状态;fill 额外演示门槛缺失时停止回填。初始化和恢复是另一条协议,不能把 Math.max(null, eventVersion) 当作它的实现。以下状态变化可以逐步验证:

操作 floor 缓存值版本 结果
初始状态 41 41 数据与缓存一致
收到 v42 失效 42 空 推进门槛,清掉 v41
旧 R 回填 v41 42 空 拒绝旧候选
新请求回填 v42 42 42 接受
再处理 v43 并回填 43 43 跟随下一次变化
迟到的 v42 事件 43 43 不降低门槛,不清新值
迟到的 v42 候选 43 43 不覆盖 v43

Redis 的服务端脚本可以组合检查与写入,Lua 文档说明了执行期间的原子性。脚本仍只约束 Redis,不能同时提交 MySQL;Redis 对回滚机制的说明也提醒我们,运行错误不等于自动回滚已执行命令。实现时应先校验类型和版本编码,再修改状态,并考虑多 key 在集群中是否位于同一 hash slot。版本若超过 JavaScript 可精确表示的整数范围,不能用示例的 Number 直接比较。

门槛什么时候存在,丢失以后怎么办

图里 floor=42 已经生效,v41 才会被拒绝。若数据库已经提交 v42,但事件还没到,floor 仍是 41,v41 暂时可以被接受。事件到达后再推进门槛并清理。这套规则不能消除异步传播窗口,也不能据此宣称数据库提交后的每次读取都线性一致。

值因 TTL 到期可以消失,floor 却不能在旧 loader 仍可回填时被当作零。缓存淘汰或重启让 floor 丢失,旧请求拿着 v41 来写,系统若只按“现在没有值”接受它,旧副本又出现了。门槛数据已经参与正确性,不能再按普通可随时丢弃的缓存对象对待。

一种恢复策略是门槛未知时禁止回填,查询请求走受控的权威来源;后台协调恢复 floor 与有效的新加载。恢复需要接上期间的更新和代际,防止恢复前启动的旧请求跨过重建边界。只查询一次数据库当前版本,单独 SET 一个 floor,再开放所有旧任务,并没有自动解决并发问题。

另一种策略是给 loader 一个可验证的有效期或代际许可。元数据必须可靠覆盖所有仍有效的许可;许可到期后的结果,即使进程恢复,也不能通过回填检查。有效期由接收写入的一方验证,不能只依靠 loader 自己“按时结束”。无法维护这些状态时,选择允许短暂陈旧的普通展示缓存,通常比上线半套版本协议更容易解释和维护。

论文中的 lease 是另一种回填资格:缓存服务在 miss 时发给 loader 许可,失效使旧许可作废,回填时再检查。它不要求每个方案都使用相同的业务版本。普通分布式锁也不同:只锁重建可以合并查询,但不参与锁的业务写端仍然可以更新数据库、删除缓存。要协调读写,需要相关路径共同遵守协议,租约过期以后还要拒绝旧持有者。

最后,R 的 v41 被拒绝进入 Redis,不代表 R 没有拿到 v41。它已经把旧查询结果保存在本地,仍可能直接返回给调用方。要求至少看到 v42 的读取,需要检查返回版本,不满足就重新查询符合要求的来源、等待或明确超时;“禁止污染缓存”和“这一次响应必须新鲜”是两个验收条件。

九、修改者确认页与多级缓存要单独设计

运营保存后立刻进入详情,通常期待看到自己写入的十一点。低成本做法是直接展示已经确认提交的响应;需要重新查询时,可以携带服务端确认的最低版本,暂时绕过普通缓存并读取能够满足该版本要求的数据库视图。数据库不可用时应说明不能确认,不能为了成功返回而退回旧值。

所谓“写完后五秒都读主库”只是时间启发式。复制可能超过五秒,主库查询也可能沿用旧事务快照。版本条件更容易表达要求,但服务端仍需决定等待、重试和超时;客户端携带的版本不应授予任何访问权限,也要防止无限高版本制造等待。

从 MySQL 到 Redis,再到本地 Caffeine,副本数量增加了。Redis 的 key 删除成功,不代表每个实例的本地副本消失。若实例在通知期间离线,恢复后仍可能命中原来的 L1。多级缓存应逐层定义 TTL、失效、重连处理和返回版本,而不是只观察 L2 命中率。

Redis 的键空间通知文档说明底层 Pub/Sub 在断线期间会丢失事件。通知可以作为快速路径,不能单独承担可重放的失效保证。可以结合可靠事件流、本地代际切换和重连清理;对于少量敏感字段,放弃本地缓存往往更容易维护明确契约。

一个组合页面也可能混合不同版本:活动时间是新值,优惠说明还是旧值。要求业务快照一致时,需要在查询聚合与发布模型中维护对应关系,或者让关键判定重新读取权威规则。单 key 的版本门槛不能自动解决跨实体的一致快照。

十、把方案放到不同的保证上比较

选型时,我会把“保留失效工作”“拒绝旧回填”“保证特定读者读到新值”拆开检查。它们可能一起使用,但不能互相代替。短 TTL 适合低成本兜底,可靠事件适合覆盖失败,版本或 lease 适合限制旧 loader,权威读适合明确的新鲜度要求。

方案 主要解决什么 仍需付出的代价或边界
提交后删除 + TTL 普通展示缓存的失效与过期 删除可能失败,旧值仍可回填;回源要受控
延迟双删 清掉延迟窗口内的旧回填 更晚回填可逃过,任务必须可靠
Outbox 将业务更新与失效待办一起提交 修改入口需写事件表,下游需可靠投递和重试
日志订阅 / CDC 从接入范围内的数据库变化恢复同步工作 有传播延迟,需维护日志保留、位点与依赖映射
版本栅栏 / lease 拒绝已失效资格下的回填 元数据和有效期必须可靠,不能只比当前缓存值
最低版本读取 / 绕过缓存 指定读者的写后读要求 权威来源容量、事务视图与不可用处理
协调读写的缓存服务 明确组织顺序与可见性 要验证整套协议,不能只贴 write-through 名称

Read Through 指应用调用缓存服务,未命中时由缓存服务负责回源;与 Cache Aside 的区别是这段逻辑由谁执行。Write Through 指写请求通过缓存服务同步推进后端存储,成功响应应符合该服务承诺的保存与可见性规则。Write Behind 则先接受写入,随后异步写回后端,必须有可靠记录、重试和明确的数据丢失边界。

所以“先写缓存,再提交数据库”可能是在描述一套写回服务,也可能只是普通应用里的两次独立调用。前者要检查完整协议,后者仍有第三节的失败窗口。把代码命名为 write-through 不能让它自动原子化。如果缓存成为权威入口,本文“丢失以后直接查数据库重建”的前提也需要重新审查。

对于活动介绍,我会先使用提交后删除、可靠失效和 TTL,并验证实际陈旧目标。对后台确认页补充写后读路径。只有观测到旧回填造成明显问题,或者业务明确要求阻止它时,再引入版本资格与元数据恢复。资格、库存承诺和资金结果,则按各自的权威状态机处理,不把普通缓存值当作最终判定依据。

十一、用确定性交错验证,而不是靠压测碰运气

仓库附带 scripts/experiments/cache-consistency-interleavings.mjs,用确定顺序驱动数据库版本、缓存值和 reader 的本地快照。每个请求内部顺序保持不变,枚举请求之间允许的交错。运行方式:

node scripts/experiments/cache-consistency-interleavings.mjs

模型分别检查先删后提交、先提交后删、两位写者更新缓存的顺序;还固定构造双删之后回填、floor 丢失和 TTL 从晚到回填重新起算的反例。版本模型检查旧候选被拒绝、乱序事件不能降低 floor,以及旧事件不会删除更高版本值。补充的恢复模型验证先确认再删除造成遗漏、先删除再确认允许重放,以及未知 floor 的拒绝路径。

在本文模型中,两步写流程与三步读流程各有 10 种合法交错。先删后提交以初始缓存 v41 开始,有 2 种结束后缓存陈旧;先提交后删以冷缓存开始,有 1 种。两位写者固定 A 的数据库提交早于 B,有 3 种交错,其中 1 种缓存退回旧版本。这些是有限模型的枚举计数,不是线上概率,也不能用 2/10 与 1/10 宣称某方案降低了多少事故率;初始状态本身就不同。

验证输出保留了模型限制:没有真实 MySQL、Redis、事务隔离、网络、副本切换或多机持久化。测试通过证明给定模型和表述一致,不能证明生产协议完整。这里故意让“坏方案产生旧值”的断言通过,以保存反例;真正实现防护后,还要增加“不再出现该反例”的断言。

集成测试可以在查询得到旧值之后设置屏障,让更新事务和删除先完成,再释放回填。另一次测试让数据库提交后 Redis 删除超时,然后重启处理进程,确认失效记录仍在;还要测试删除成功但确认丢失、重复与乱序事件、落后副本回源、L1 断线和 floor 丢失。屏障比随机 sleep 更容易稳定复现。

线上观测应包含提交版本、回填版本、加载起止时间、回源位置、事件处理水位及 key 模型版本。陈旧数据年龄与事件最老积压年龄需要单独统计。抽样对照权威数据时,也要记录比较时间,避免把比较期间正常发生的新写误报成缓存错误。命中率很高与数据足够新,是两种独立结果。

十二、沿一次修改检查最终能否收尾

运营提交十一点与预期 v41,数据库原子生成 v42 和失效事件。后台拿到确认版本,展示新结果。Redis 暂时不可用,直接删除失败,事件仍留在 Outbox;普通展示请求是否允许短暂返回旧说明,由既定的新鲜度契约决定。

Redis 恢复后,Worker 处理 v42 事件。若只采用删除协议,删除完成后还需要依赖受控的加载、TTL 与后续恢复条件收敛;若采用版本协议,则在同一缓存原子操作中推进 floor 并清理旧值。先前拿到 v41 的 loader 恢复,回填被拒绝,新的有效加载再保存 v42。重复 v42 事件不降低水位,较旧事件也不能把缓存改回去。

确认收尾时检查数据库版本、事件处理状态、缓存返回版本和修改者读路径。若页面仍显示十点,继续检查 L1、组合 key、回源副本与客户端展示,而不是只看 Redis 已经正确就结束。线上遇到的新副本,应加入失效覆盖和验证范围。

如果无法维护版本元数据的恢复协议,可以继续选择较简单的展示缓存,但要保留它允许陈旧的事实;如果字段根本不能接受陈旧,就减少副本或走权威判定。工程取舍最终落到一次读操作应当得到什么,以及系统在失败以后能否兑现这份承诺。

参考与继续阅读