☰
Redis 缓存与数据库不一致
2026/10/27 2:03:35 网站建设 项目流程

Redis 缓存与数据库不一致

商品详情页显示库存 10,用户点进结算页却提示库存不足。数据库里其实已经扣到 9 了,Redis 里还留着 10。没有报错日志,也没有明显异常,只有偶尔几单数据对不上。


1. 先把缓存想成一张便签

数据库像一本账本,所有真实数据都记在里面。Redis 像贴在柜台上的便签,上面抄着账本里的热门数据,目的是让店员少翻账本。

便签的好处是快,但它有一个前提:便签只是抄本,账本才是准的。

所以修改数据时,比较稳妥的做法不是“账本改一遍、便签也改一遍”,而是“改账本,然后把便签撕掉”。下次有人来问,发现便签没了,再去账本上抄一份新的。

为什么不能两份一起改?因为这是两个独立的存储,没有一支笔能同时写在两本上。两个线程各自改一份,顺序稍微错开,就会对不上。比如:

一开始:数据库 = 10,缓存 = 10 线程 A 想把库存改成 9 线程 B 想把库存改成 8 A 先写缓存 9,B 再写缓存 8 B 先写数据库 8,A 后写数据库 9 最后:数据库 = 9,缓存 = 8

两个线程都觉得自己做对了,但最终结果错位了。问题不在于谁写错了值,而在于两份数据没有共同的“先后顺序”。

2. 先删缓存再更新数据库,问题出在哪

既然便签是抄本,很多人第一反应是:那就先撕便签,再改账本。

这个顺序听起来合理,但中间有一个很短的空档。把库存从 10 改成 9,过程可能是这样的:

初始:数据库 = 10,缓存 = 10 线程 A:删除缓存 线程 B:读缓存,发现没有 线程 B:去数据库读,读到 10(此时 A 还没改完) 线程 A:把数据库改成 9 线程 B:把刚才读到的 10 写回缓存 结果:数据库 = 9,缓存 = 10

关键在最后两步。线程 B 读数据库读得早,写缓存写得晚。它手里拿的是旧值 10,但把它放回缓存的时候,数据库已经被改成 9 了。于是便签上又出现了旧数字。

图 1:读请求在“删缓存”和“改数据库”之间读到了旧值,又把它写了回去。

这个空档通常很短,所以问题不是每次必现,而是偶尔出现。也正因为偶尔出现,排查时很容易怀疑到别的地方去。

3. 更常用的顺序:先更新数据库,再删除缓存

业界更常见的做法叫 Cache-Aside,直译过来就是“缓存放在一边”。它的规则很简单:

  • 读数据时,先看缓存;缓存没有,再查数据库,然后把结果写进缓存;
  • 写数据时,先改数据库,再让缓存失效。

注意这里说的是“让缓存失效”,也就是删除,而不是把新值写进去。删除之后,下一次读会自然地从数据库重建,不需要应用去猜缓存里应该放什么。

代码上可以写成这样:

@TransactionalpublicvoidupdateProduct(Productproduct){productMapper.update(product);applicationEventPublisher.publishEvent(newProductUpdated(product.getId()));}@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidclearCache(ProductUpdatedevent){redisTemplate.delete("product:"+event.productId());}

这里用了一个事务事件,意思就是:等数据库事务真正提交成功以后,再去删缓存。

为什么不立刻删?因为事务提交之前,数据库里的新值对其他请求还不可见。如果这时候缓存已经被删掉,别的请求来读,读到的还是旧值,并且可能把旧值重新写回缓存。等事务提交后,缓存里留下的仍然是旧值。

把删除动作放到提交之后,可以避开大部分这种交错。但它依然不是绝对安全。

极端情况下,读请求可能在数据库更新之前就读到了旧值,只是它写缓存的动作特别慢,一直到写请求删完缓存之后才完成。这样旧值还是会被写回去。这个窗口比“先删缓存”小得多,所以“先更库、后删缓存”成了更常用的默认方案。

4. 延迟双删:把窗口再缩小一点

为了处理上面那个“读得早、写得晚”的请求,可以在删除缓存之后,隔一小段时间再删一次:

更新数据库 删除缓存 等待几百毫秒 再删除一次缓存

第二次删除的作用,是清掉这期间可能被慢请求重新写回来的旧值。等待时间不能随便拍一个数字,通常要根据实际观测来定:一次读请求从查库到写缓存大概要多久,主从复制大概要多久,取一个比它们稍长的时间。

但延迟双删不是万能药。它只是把出问题的窗口压得更小,并没有从根上保证所有并发顺序都安全。第二次删除也可能失败,请求也可能比预想的更慢。所以它通常作为辅助手段,而不是唯一的保障。

5. 用 binlog 或消息做兜底

如果业务对一致性要求比较高,就需要一条“即使应用忘了删、删失败了,也能补上”的链路。

一种常见做法是订阅 MySQL 的 binlog。binlog 可以理解成数据库自己记的一本变更流水账:哪一行什么时候被改了,都会记下来。一个独立的消费者读这本流水账,发现商品表变了,就去删对应的 Redis 缓存。

应用更新数据库 ↓ MySQL 记录 binlog ↓ Canal / Debezium 读取变更 ↓ 删除 Redis 缓存 ↓ 失败就重试

这样做的好处是,删除缓存不再完全依赖业务代码。即使应用在更新数据库之后崩了,binlog 也已经记下了这次变更,消费者仍然会去处理。

如果没有 binlog 链路,也可以用本地消息表:把“更新商品”和“记录一条待删除缓存的消息”放在同一个数据库事务里。事务提交后,再由后台任务去删缓存,失败就重试。它和 binlog 的思路一样,都是把“删缓存”这件事变成一条可重试的任务。

这类方案追求的是“最终一致”:不保证每一毫秒缓存都和数据库一样,但保证过一段时间一定会追上。

6. 读写分离时,还要考虑从库延迟

如果数据库做了读写分离,写请求走主库,读请求走从库,还会多出一层延迟。

主库把数据改成 9 之后,从库需要一点时间同步。如果读请求在同步完成前打到从库,读到的仍然是 10,然后把这个 10 写回缓存,缓存又变旧了。

对库存、余额这类不能明显出错的字段,可以在写入后的短时间内强制读主库,或者给缓存设置较短的过期时间,让它在兜底失败时也能自己恢复。

7. 不同数据,不需要同一套方案

商品介绍、分类名称这类数据,旧几分钟通常没人能察觉,用简单的 Cache-Aside 加一个合理的过期时间就够了。

库存、账户余额这类数据,错一次就可能造成超卖或对账问题,需要更短的过期时间、提交后删除、binlog 兜底,必要时直接读主库。

如果是单机小业务,写入量不大,也能接受短暂旧值,那么简单的“先更库、再删缓存”就已经足够。方案越复杂,维护成本越高,先看业务能接受多长的“旧数据时间”,再决定要不要加延迟双删和 binlog。


总结

Redis 和数据库不可能像同一个事务那样同时改完,所以它们之间通常只能做到“最终一致”。比较稳妥的主线是:数据库是准的,缓存只是抄本;写的时候先改数据库,等事务提交后再删缓存;读的时候缓存没有就回源,再把结果写回缓存。延迟双删可以缩小出问题的窗口,binlog 或消息则负责在删除失败时兜底。先确定业务能接受多长时间的旧数据,再选择对应的复杂度。

参考资料

  • Redis 官方文档
  • Cache-Aside pattern

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询