在日常开发中,只要系统并发量稍微上来一点,我们都会不自觉地想到引入 Redis 来做缓存。这本来是件好事,读写速度直接起飞,数据库的压力也降下来了。
但是,引入缓存就像是一把双刃剑,它带来了一个让无数开发者头疼、也是面试官最爱问的终极问题:数据库和缓存的数据不一致怎么办?
今天,咱们就抛开那些晦涩难懂的学术名词,用大白话把这个“老生常谈”的问题扒得干干净净。
第一步:更新缓存,还是删除缓存?
当数据库里的数据发生变化时,我们要怎么处理 Redis 里的数据?拍脑袋一想,无非就两种操作:
更新缓存:数据库改了,我顺手把 Redis 里的数据也改了。
删除缓存:数据库改了,我直接把 Redis 里的这条数据删掉。等下次有人来查的时候,发现缓存没有,再去查数据库,然后把新数据放进 Redis。
结论先行:在绝大多数业务场景下,请直接选择“删除缓存”(这也是经典的 Cache Aside Pattern 旁路缓存模式的做法)。
为什么不推荐“更新缓存”?
性能浪费:有些缓存数据不是直接从数据库原封不动拿出来的,而是经过了一系列复杂的计算。如果这条数据一天被修改了 100 次,但没人来查,你不仅白白计算了 100 次,还跟数据库交互了 100 次。
并发安全问题:假设线程 A 和 B 同时在更新同一条数据。A 先更新数据库,B 后更新数据库;但在网络抖动的情况下,B 可能先更新了缓存,A 后更新了缓存。这时候,数据库里是 B 的新数据,缓存里却是 A 的老数据,直接脏数据了。
所以,“删就完事儿了”,简单粗暴且安全。等谁真正需要用到这条数据时,让他自己去查数据库并把结果写回缓存(也就是延迟加载的思想)。
第二步:先动数据库,还是先动缓存?
既然确定了策略是“删除缓存”,那随之而来的就是顺序问题。
方案一:先删除缓存,再更新数据库
这个方案听起来很合理,先把旧缓存干掉,再慢慢去更新数据库。但如果是高并发场景,极容易踩坑:
线程 A 请求更新数据,先把缓存删了。
还没等线程 A 去更新数据库,线程 B 跑来查询数据。
线程 B 发现缓存没了,就去查数据库,此时查到的是老数据。
线程 B 把老数据又塞回了 Redis。
线程 A 终于把数据库更新完了。
结果:数据库是最新的,缓存里永远是老数据。凉凉。
方案二:先更新数据库,再删除缓存
这是目前业界公认的最佳实践。我们来看看这种顺序下的执行过程:
线程 A 更新了数据库。
线程 A 删除了缓存。
线程 B 来查询,发现缓存没有,去查数据库(此时拿到的是新数据),写入缓存。
完美!那这个方案有没有破绽呢?有,但概率极小。
只有在读写并发极其凑巧的瞬间:缓存刚好过期失效 -> 线程 A 查库拿到老数据 -> 线程 B 更新数据库 -> 线程 B 删除缓存 -> 线程 A 把老数据写回缓存。
这种情况发生的条件是:写数据库的操作比读数据库的操作还要快。但在实际中,写库(加锁、写日志等)通常比读库慢得多,所以这种极端情况极少发生。如果是为了应对这种极小概率事件,可以考虑给缓存加上过期时间(兜底方案)。
第三步:万一第二步失败了怎么办?
“先更新数据库,再删除缓存”看似完美,但不要忘了,现在的系统都是分布式的,网络随时会抖动。
如果数据库更新成功了,但是删除缓存失败了怎么办?缓存里依然是老数据,不一致的问题又出现了。
为了解决这个“失败”的问题,我们通常有以下两种工业级解法:
解法 1:消息队列重试机制(简单有效)
既然删缓存失败了,那我就多试几次直到成功为止。但如果用死循环去重试,会阻塞当前的业务线程。所以,我们可以引入消息队列(比如 RabbitMQ、Kafka、RocketMQ)。
更新数据库。
尝试删除缓存。
如果删除失败,把这个“删除 Redis 某某 key”的任务扔进消息队列。
自己写个消费者,监听这个队列,不断尝试删除缓存,直到成功。
优点:实现简单,能保证最终一致性。
缺点:业务代码里揉进去了消息队列的逻辑,对代码有侵入性。
解法 2:订阅 MySQL Binlog(优雅解耦)
如果你不想在业务代码里写一堆删除缓存、发消息的逻辑,可以使用阿里的开源组件Canal。
Canal 的原理是把自己伪装成 MySQL 的从库(Slave)。当 MySQL 的数据发生变化时,会生成 Binlog(二进制日志)。Canal 监听到 Binlog 的变化后,再去触发删除 Redis 缓存的操作。
业务代码只管更新数据库,别的啥也不用管。
Canal 监听 MySQL Binlog 发现某张表的数据变了。
Canal 把变动信息丢给消息队列,或者直接自己起一段逻辑去删 Redis 里的 key。
如果删失败了,依然利用消息队列的机制进行重试。
优点:对业务代码零侵入,解耦得非常干净。
缺点:系统架构变复杂了,需要额外维护 Canal 组件。
补充彩蛋:面试最爱问的“延迟双删”
虽然刚才我们说了“先更新数据库,再删除缓存”是主流,但在一些历史遗留系统或者特殊场景下,你可能会听到“延迟双删”这个词。这其实是为了解决**“先删缓存,再更新数据库”**带来的并发问题而打的补丁。
流程大概是这样的:
先删除缓存。
更新数据库。
休眠一小会儿(比如 500ms)。
再删除一次缓存。
为什么要休眠?是为了等那些正在查老数据的读请求执行完,然后再补一刀,把他们可能写回 Redis 的老数据彻底删掉。
虽然这也是个办法,但在实际开发中,评估“休眠多长时间”纯粹是门玄学,且降低了吞吐量。所以现在的系统,除非万不得已,很少用这个方案了。知道这个概念,用来对付面试官就行。
最后总结一下
保证缓存和数据库一致性,记住以下三条核心准则:
兜底策略永远不能忘:给所有的 Redis 缓存都加上过期时间(TTL)。就算发生了一致性问题,等过期时间一到,缓存自动失效,数据自然就强行一致了。
日常业务首推:先更新数据库,再删除缓存。这种方案能应对 99% 的场景。
高要求场景兜底:如果遇到删除缓存失败的情况,使用消息队列重试或订阅 Binlog的方式,保证缓存最终一定会被删除。
说到底,分布式系统里没有绝对的“实时强一致性”,我们做的所有努力,都是在追求**“最终一致性”**。理解了这一点,你在处理缓存问题时就会游刃有余了。