“先删缓存还是先更新数据库”这个问题在社区里出现的频率,几乎和“索引为什么失效”持平。它表面上是一个操作顺序问题,实际上是一个一致性目标问题:你能接受多长时间的脏读?谁来兜底?出问题时能不能被发现?如果不先把这三个问题回答清楚,任何方案都只是把不确定性从一个地方挪到另一个地方。
需要说明信息边界:本轮任务没有附带具体的站内帖文,所以下面不逐条引用帖子,只从通用的后端工程经验展开;文中涉及的 QPS、延迟、TTL 等阈值都是示意值,落地时需要替换成你自己的监控数据。
先给结论
在绝大多数业务系统里,缓存与数据库的正确目标不是“强一致”,而是“窗口可控、失败可观测、异常可收敛的最终一致”。 默认答案仍然是 Cache Aside(旁路缓存):写路径更新数据库后删除缓存,读路径未命中则回源并回填,同时给缓存设置带随机抖动的过期时间。
这个结论有明确的适用边界。当写入方只有一条链路、读多写少、业务能接受秒级陈旧时,它足够用;当存在多个写入方(后台运营系统、定时任务、数据同步作业)时,单靠应用层删除很容易漏删,这时才需要引入变更日志订阅。至于延迟双删,它只是在“主从延迟明显且暂时无法订阅 binlog”这一种情况下的一种折中方案,不应该被当成通用解法。
核心判断:缓存不一致通常无法靠调整两条语句的先后顺序来解决,它只能被压缩窗口、被监控发现、被异步补偿。
不一致到底是怎么产生的
Cache Aside 有两条路径。写路径是“更新数据库 → 删除缓存”,读路径是“读缓存 → 未命中 → 读数据库 → 回填缓存”。理论上只要删除动作发生在更新之后,缓存里就不会长期存在旧值。问题出在“读路径的回填”和“写路径的删除”之间没有任何互斥关系。
第一个窗口是经典的读回填覆盖。读请求在写请求提交之前从数据库读到旧值,此时写请求完成更新并删除了缓存,读请求随后把手里那份旧值写回缓存。这份脏数据会一直驻留到 TTL 到期,期间所有读请求都拿到旧值。注意,这个窗口和“先删还是后删”关系不大,只要读路径允许“读完再写回”,它就存在。
流程图
渲染中...
第二个窗口是主从延迟。写请求打到主库,删除缓存后立刻返回;紧接着的读请求未命中缓存,回源时被路由到从库,而从库还没应用这条 binlog,于是读到的仍是旧值并被回填。这个窗口在高并发下比第一个窗口更常见,因为主从延迟通常是毫秒到几十毫秒级别,正好覆盖“删除缓存后的一小段读请求”。
第三个窗口是删除动作本身失败。Redis 超时、网络抖动、连接池耗尽都会让删除静默失败。如果代码里只是 try { redis.delete(key) } catch (Exception e) { log.warn(...) },那么缓存会一直保留旧值直到过期,而日志里的这条 warn 大概率没人看。这类问题的可怕之处在于它不报错、不告警,只表现为“用户说数据不对,刷新几次又对了”。
方案与取舍
| 方案 | 一致性表现 | 主要代价 | 适用场景 |
|---|---|---|---|
| 更新库后删除缓存 | 窗口小,可能长期脏 | 实现最简单 | 默认首选 |
| 更新库后更新缓存 | 并发下可能互相覆盖 | 需要额外处理并发写 | 一般不建议 |
| 延迟双删 | 缩小窗口,不消除 | 引入睡眠或延迟队列 | 主从延迟明显且无 binlog 订阅 |
| 订阅 binlog 删除 | 最终一致,不易漏删 | 需维护同步组件 | 多写入方、数据敏感 |
| 分布式锁串行化 | 接近强一致 | 吞吐下降、锁本身可能成为故障点 | 极少量关键数据 |
“更新缓存”而不是“删除缓存”看起来更省一次回源,但它在并发写场景下会引入新的覆盖问题:两个写请求分别更新数据库的先后顺序,与它们更新缓存的先后顺序可能相反,最终缓存里留下的是先提交那次的值。删除则把“算出正确值”的责任交回给读路径,虽然多一次回源,但正确性更容易推理。
在 Spring 体系里还有一个非常具体的坑:@CacheEvict 的默认行为是在方法正常返回后执行,而如果这个方法同时被 @Transactional 包裹,缓存删除很可能发生在事务提交之前。此时另一个读请求未命中缓存、回源读到尚未提交的旧数据并回填,事务随后提交,缓存里就留下了过期值。示意代码如下,afterCommit 需要你自己用 TransactionSynchronizationManager 注册同步回调实现:
Java
@Transactional
public void updateProduct(Product p) {
productMapper.updateById(p);
afterCommit(() -> cacheDeleteRetry.submit(p.getId()));
}
binlog 订阅(Canal、Debezium 这类组件)把删除动作从业务代码里剥离出来,交给数据库变更流驱动。它的优势是“不管谁写的库,缓存都会被删”,特别适合既有 Java 服务、又有 Python 脚本、还有人工修数据的团队。代价是你多了一个需要部署、监控、处理位点回退的组件,而且它带来的是秒级延迟而非实时删除,业务必须接受这个延迟。
先把问题降级:哪些数据不该靠缓存保证正确
很多“缓存一致性”讨论,真正的问题其实是把不该缓存的东西缓存了。按对陈旧的容忍度,业务数据大致可以分三类:可以容忍分钟级陈旧的(商品描述、榜单、配置快照),可以容忍秒级的(用户资料、权限列表),以及完全不能容忍的(账户余额、库存扣减、订单状态流转)。第三类数据如果放进缓存并由缓存承担正确性,任何方案都只是在延迟故障发生。
库存扣减是典型例子。正确做法是让 Redis 承担原子扣减(Lua 脚本或 DECRBY 配合库存校验),数据库作为最终账本异步对账,而不是“先查缓存库存、再更新数据库、再删缓存”。后者的失败模式是超卖,而超卖不是一致性窗口问题,是资损问题。同理,余额类字段应该以数据库行锁或乐观锁为准,缓存只用于展示,且展示值要标注更新时间。
即使决定缓存,也要先算清楚收益。设缓存命中率为 ,总读请求为 ,那么回源压力近似为:
如果 只有 60%,回源压力仍有四成,此时花大力气做延迟双删,收益远不如把命中率提到 95%。而命中率往往被 TTL 设置、key 设计、缓存容量淘汰策略决定,这些是比一致性方案更靠前的杠杆。
缓存穿透、击穿、雪崩与一致性方案的关系
穿透是查询不存在的数据,缓存永远不命中,压力全落到数据库。常见处理是缓存空值(短 TTL)或用布隆过滤器前置拦截。它和一致性方案不冲突,但要注意空值缓存的 TTL 必须远小于真实数据的 TTL,否则数据后来被创建了,读请求还会持续拿到“不存在”。
击穿是热点 key 过期瞬间大量请求同时回源。单飞(singleflight)语义是最直接的解法:同一进程内对同一个 key 只允许一个请求回源,其余等待结果;跨进程则可以用 Redis 分布式锁,但要注意锁超时与降级,否则锁服务本身会成为新的单点。Caffeine 作为本地一级缓存加 Redis 二级缓存,也能显著削峰,代价是要接受本地缓存比 Redis 更陈旧。
雪崩是大量 key 同时过期或缓存整体不可用。前者靠 TTL 加随机抖动解决,后者靠限流和降级:缓存不可用时不应该让全部流量直接打到数据库,而应该按业务优先级放行,非核心接口直接返回兜底数据。这也是为什么一致性方案必须和限流、熔断一起设计——一个没有降级的强一致方案,在缓存故障时的表现往往比最终一致方案更糟。
实践建议
落地时可以按下面的顺序推进,不要一上来就选最复杂的方案:
- 先量化:统计读 QPS、当前命中率、数据库回源 QPS、主从延迟分位数,明确业务可接受的陈旧时间
- 默认采用更新库后删除缓存,删除动作放到事务提交之后执行
- 删除失败不吞掉,写入重试队列或消息队列,做有限次退避重试并记录失败指标
- 给所有缓存 key 设置带随机抖动的 TTL,作为一切方案失效时的最终兜底
- 监控三项指标:命中率、回源 QPS、缓存删除失败率,并为删除失败率设置告警
- 只有在出现“多写入方导致漏删”或“主从延迟导致回填旧值”的实际案例后,再引入 binlog 订阅
需要强调的是,第二项和第三项是性价比最高的动作。把删除放到事务提交之后,能消掉前面提到的 @CacheEvict 时序问题;把删除失败纳入重试和告警,能把“静默脏数据”变成“可观测事件”。这两步的改造成本通常只有几十行代码,却能覆盖大部分线上不一致投诉。
不适用的场景也要说清楚:如果业务本身要求强一致读(例如支付结果查询必须实时准确),那么正确做法不是优化缓存,而是让这类读请求直接走数据库或走带版本号的会话读,缓存只服务列表页和展示页。用缓存去模拟强一致,最终会得到一个既慢又不可靠的系统。
总结与讨论
第一,缓存不一致的根因是读回填、主从延迟、删除失败三个窗口的叠加,调整语句顺序只能影响其中一个,无法消除全部。第二,方案选择应该由一致性目标和写入方数量决定,Cache Aside 加提交后删除加 TTL 抖动是绝大多数系统的合理默认值,延迟双删和 binlog 订阅是特定条件下的补充而非升级。第三,比一致性方案更靠前的杠杆是命中率、限流降级和数据分类——不该缓存的数据,任何方案都救不回来。
留一个值得讨论的问题:在你的系统里,缓存不一致是被“用户投诉”发现的,还是被监控指标发现的?如果答案是前者,那么下一个该补的可能不是更复杂的一致性方案,而是一条能提前十分钟报警的指标。