先说结论

常见 Cache Aside 模式是读未命中时查库并回填,写请求先提交数据库再删除缓存。它降低了把旧值长期写回缓存的概率,但仍是最终一致方案,需要删除失败重试、TTL 兜底和必要的版本校验。

严格强一致通常意味着把缓存排除在关键读路径,或引入串行化协调,成本显著更高。

竞态分析

若先删缓存再写库,并发读可能在数据库更新前回填旧值。先写库再删缓存也存在删除失败问题,可通过事务消息、Outbox 或订阅 Binlog 异步失效,并保证消费幂等。

Cache Aside 的完整读写路径

读:缓存命中 -> 返回
        缓存未命中 -> 查数据库 -> 写缓存(带 TTL/版本) -> 返回

写:校验与本地事务更新数据库 -> 提交成功 -> 删除/失效缓存
        删除失败 -> 可靠事件重试 -> 对账修复

数据库是事实源,缓存是可丢失的派生副本。把这个层级明确下来,很多争论会更容易判断:缓存更新失败不能回滚已提交数据库;缓存数据错了,应该能通过失效和回填最终恢复。

两种典型竞态时间线

先删后写:请求 A 删除缓存,请求 B 缓存未命中并读取旧数据库值回填,然后 A 提交新值,缓存长期是旧数据直到 TTL。先写后删:A 提交新值后删缓存失败,旧缓存继续存在。后者的窗口通常更可控,因为删除操作可可靠重试,且 TTL 仍是最后兜底。

“延迟双删”是在第一次删后写库,再延迟删一次,试图覆盖旧读回填。它只能降低概率,延迟时间无法准确覆盖线程调度、数据库提交、GC 和网络波动;不能作为高一致性系统的唯一保证。

版本化回填防止旧值覆盖

缓存值可包含业务版本:

{"version": 42, "payload": {"status": "PAID"}}

回填前比较数据库版本或使用 Lua 脚本仅在新版本更高时覆盖。这样即使一个慢读请求晚于新写完成,也不会把版本 41 覆盖版本 42。版本必须来自单调、可信的事实源,不能随意使用多机时钟。

删除失败如何可靠恢复

在数据库本地事务内同时写业务表和 outbox 事件;提交后由投递器删除缓存或发布变更。事件带唯一 ID、对象 key 和版本,消费者幂等处理;失败持续重试并进入死信或人工处理队列。也可以订阅 Binlog/CDC 统一失效,但需要处理消费延迟、重复、顺序和补偿。

对高价值数据设置定期对账:从数据库抽取版本与缓存抽样比较,发现漏删或错版本时重新失效。这是最后的收敛机制,而不是把所有请求都变成强校验。

不同数据不同一致性

商品浏览量可以允许分钟级最终一致;库存、余额、权限和支付状态通常不能只依赖缓存。对强一致读,直接读数据库、使用主库/事务快照或建立专门的协调协议;不要用“加 Redis 锁”掩盖业务一致性要求。

热点更新的策略

热点 Key 每次写后删缓存会导致频繁回源,可用写穿、异步批量刷新、短逻辑过期或按版本增量更新,但都要明确写入顺序和失败恢复。性能优化不能牺牲该数据类别的正确性边界。

工程实践

缓存值可携带数据版本,回填时拒绝旧版本覆盖新版本。热点 Key 重建要限制并发;对账任务、删除重试队列和不一致监控共同构成可恢复机制。

容易踩坑的地方

延迟双删中的固定等待时间无法覆盖所有数据库提交和请求耗时,只是概率性补偿。分布式锁也很难覆盖所有绕过缓存的写入口,且会增加延迟和可用性风险。

常见问题

追问:为什么通常删除缓存而不是更新缓存?

删除让下一次真实读取按统一逻辑重建,避免复杂值计算、部分更新顺序和无效更新;代价是下一次读会回源,应对热点做重建保护。

追问:缓存和数据库能做到绝对强一致吗?

可以通过同步协调和把缓存纳入严格协议接近强一致,但延迟、可用性和复杂度显著上升。多数业务选择明确窗口的最终一致,并对关键读绕过缓存。

追问:删除缓存使用 DEL 还是设置短 TTL?

删除可让下次读立即回填,短 TTL 仍可能在窗口内返回旧值。选择取决于业务容忍度;无论哪种,都要有失败重试和版本保护。

追问:读缓存命中后还需要校验数据库版本吗?

对普通最终一致读不需要,否则缓存价值会消失。只在高价值、风险敏感或发生疑似不一致的特定路径做校验。