缓存删两次就稳了吗:把一次旧读回填拆成可回放的时序

举报
yd_225778114 发表于 2026/10/21 10:12:19 2026/10/21
【摘要】 用六个确定性场景回放缓存失效与在途旧读,比较双删长尾和版本水位的边界;记录16项测试、真实浏览器回看及JSON导出验收,不把教学模型当成生产一致性保证。

讨论数据库和缓存的一致性时,很容易把答案浓缩成一句“先写库,再删缓存”。这个顺序能解释常见的正常路径,却没有自动覆盖所有并发读。更容易被忽略的是:数据库快照可能很早就被读出来,真正写回缓存却发生在很晚的时候。为了把这个区别讲清楚,我做了一个离线的缓存并发时序实验台。它没有连接数据库或Redis,只使用合成版本和确定性步骤,让同一个问题可以逐步回看并导出状态。

1. 先明确我们观察的是什么

实验台有四份状态:事实来源DB、缓存、在途读快照和最低接受版本。DB初始记录为v7/旧方案,写提交后变成v8/新方案。在途快照单独保存,后续提交不会修改已经读出的那份副本。把它和DB分开,是整个实验最重要的建模选择。

这里的“读”表示取得稍后回填所用的快照,不表示此前给客户端返回的响应一定新鲜。“缓存未命中”也只是缓存不存在,不等于已经回源读到新数据。界面上的对齐状态只比较当前合成记录,不代表真实系统获得线性一致或强一致保证。

微软的Cache-Aside模式文档说明了按需装载以及更新存储后失效缓存的通常路径,同时明确提醒该模式不保证存储和缓存始终一致。本文后面的长尾反例来自自己的确定性模型推演,不是对某个真实Redis实例的测试结论。

2. 正常路径为什么看起来没有问题

第二个场景从DB与缓存均为v7开始,依次执行提交DB、删除缓存、读新快照、回填。终态是DB v8、缓存v8。逐步回看却能发现:刚完成数据库提交、尚未删除缓存的时刻,缓存仍为v7。

因此,“最后对齐”和“过程中所有读都读到新值”是两种不同的要求。如果业务只允许最终收敛,就应该明确可接受的陈旧窗口与恢复方式;如果业务要求写成功后立即读到最新值,还需要另外设计读取路径及其故障语义。只看最终截图会漏掉中间的旧值窗口。

3. 在途读如何穿过一次删除

第三个场景从缓存缺失开始,顺序是:读线程取得DB v7快照;写线程提交v8;写线程删除缓存;读线程把之前保存的v7回填。终态是DB v8、缓存v7。

这不是说先写库再删缓存没有价值,而是说明一次失效不能撤销更早已经开始、随后才完成的读。代码中我让pending保存独立记录,fill只使用pending,而不在回填时偷偷重新读取DB。这样才能保留问题本身;如果回填函数直接读取当前DB,反例会被模型的错误实现掩盖。

4. 双删要看第二次删除落在哪儿

第四个场景把第二次删除放在旧快照回填之后,缓存因此被清空。第五个场景保持同样的读快照与提交,却让旧回填发生在两次删除之后,最终缓存仍然是v7。

两个场景的差别只在事件顺序:

能清除本次旧值:读取v7 → 提交v8 → 删除 → 回填v7 → 再删除
长尾反例:      读取v7 → 提交v8 → 删除 → 再删除 → 回填v7

这两条轨迹不估算哪种情况更常见,也不提供应该等待多少毫秒的经验参数。固定延迟能覆盖多少读尾延迟,需要真实业务的测量与故障测试;模型没有这些数据,就不能把两次删除当成无条件保证。TTL可以限制部分旧值持续时间,但不会把已经返回的旧响应变新。

5. 为何仅给缓存值加版本还不够

如果缓存已被删除,回填方就可能找不到可比较的当前缓存版本。实验台因此增加一份独立水位:写方在模型内保留最低接受版本v8并失效缓存,读方把比较与回填当成一个不可被其他缓存事件穿插的模型步骤。候选v7低于水位,所以被拒绝,缓存继续缺失。

实现的判断核心很短:

const minimum = Math.max(state.floor, state.cache?.version ?? 0);
if (state.pending.version < minimum) {
  // 保留缓存现状,并记录拒绝旧回填
} else {
  // 以独立副本写入候选记录
}

这里不是可以直接复制部署的Redis实现。Redis官方文档说明Lua脚本在Redis服务内原子执行,但这不覆盖数据库事务。水位是否会被淘汰、故障后能否恢复、主从延迟如何处理,以及更新失败后谁负责补偿,都没有被这段判断解决。即使拒绝了旧回填,也不能撤销在拒绝之前已经返回给客户端的旧结果。

6. 验证要覆盖中间步骤和真实页面

我先为六个场景写了计算测试,再实现纯状态转换。每次回放都从该场景的初始条件重新计算前缀,不共享上次场景的pending、水位或日志。测试检查完整终态,也检查第二步的状态、重置隔离、输入不被修改,以及缓存中已有更高版本时不能被旧候选覆盖。

真实浏览器验收还发现一个计算测试没覆盖的入口错误:内联界面脚本多了一个右括号,页面框架能显示,却没有场景和数值。新增脚本解析测试先复现失败,再修复;最终16项Node测试全部通过。六个场景也在真实页面逐一回放,重置和滑块回看符合预期。实际下载的JSON包含modelOnly、当前步骤、完整状态与限制说明,下载文件中的水位v8和缓存缺失与页面一致。

另做了一次独立只读审查,没有发现需修改的问题;390像素宽度下按钮和状态卡可以正常显示。没有进行真实DB/Redis并发实验,没有验证实际打印,也没有测吞吐和延迟。这些边界随源码一起记录,避免把教学演示当成生产方案证明。

7. 如何使用这份实验台

源码只有index.html、engine.js、test.cjs与README.md四个文件。下载后直接打开index.html即可使用;运行node test.cjs可以复核计算行为。没有安装依赖、第三方脚本、账号、上传或后台任务。适合用来讨论一次具体竞态,选择相应场景后导出当前前缀,作为设计评审的共同上下文。

源码:https://gitcode.com/hh11211sa/cache-race-lab

参考资料:

- Cache-Aside模式:https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside - Redis脚本执行与原子性:https://redis.io/docs/latest/develop/programmability/eval-intro/

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。