当前位置: 首页 > article >正文

如何保证缓存与数据库的数据一致性?

如何保证缓存与数据库的数据一致性?

  1. 更新后失效(Write-Through)

在这种模式下,每次将数据写入到数据库,并且在写入成功后删除缓存中的对应条目。这种方法可以确保读取缓存数据时总是最新的。

  1. 双写机制(Dual Writing)

每次数据有变更时,应用程序负责同时更新数据库和缓存。这需要应用逻辑来确保两个系统的更新操作要么都成功,要么都失败。

骚戴理解: 常用的就两种方式,一种是双写,一种是更新完数据库后再删除缓存,但是这里面又有很多的情况可以深入分析

首先一般不用双写策略,因为更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况,这是因为每次数据发生变更,都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列「计算」得出一个值,才把这个值才写到缓存中。由此可见,这种「更新数据库 + 更新缓存」的方案,不仅缓存利用率不高,还会造成机器性能的浪费。

那通常更多使用的是删除缓存的策略,而这种策略有两种情况,第一种是先删除缓存再更新数据库,第二种是先更新数据库再删除缓存,无论用哪一种都可能会导致数据不一致的情况,都需要根据实际情况进行处理来保证最终一致性,会导致数据一致性的原因通常有两种,如下所示

  • 两步更新操作非原子性,第二步可能失败
  • 高并发的时候

两步更新操作非原子性,第二步可能失败

在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值,简单来说就是没有保证这两个操作同时执行,是一个原子操作,要么都成功,要么都失败

  • 我们假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
  • 如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。

针对原子性问题导致的数据不一致的解决方案:消息队列+重试机制实现异步重试【补偿】

  • 可以把要删除的缓存值暂存到消息队列中。当应用没有能够成功的删除缓存值时,可以从消息队列中重新读取这些值,然后再次进行删除
  • 如果能够成功删除,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存一致了。否则的话,我们还需要再次进行重试。如果重试超过一定次数,还是没有成功,我们就需要向应用层发送报错信息了

分析为什么要用消息队列+重试机制实现异步重试方案

无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,我们就可以发起重试,尽可能地去做「补偿」。

那这是不是意味着,只要执行失败,我们「无脑重试」就可以了呢?

答案是否定的。现实情况往往没有想的这么简单,失败后立即重试的问题在于:

  • 立即重试很大概率「还会失败」
  • 「重试次数」设置多少才合理?
  • 重试会一直「占用」这个线程资源,无法服务其它客户端请求

看到了么,虽然我们想通过重试的方式解决问题,但这种「同步」重试的方案依旧不严谨。那更好的方案应该怎么做?答案是:异步重试。

什么是异步重试?

其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。

到这里你可能会问,写消息队列也有可能会失败啊?而且,引入消息队列,这又增加了更多的维护成本,这样做值得吗?

这个问题很好,但我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了。

所以,这里我们必须把重试消息或第二步操作放到另一个「服务」中,这个服务用「消息队列」最为合适。这是因为消息队列的特性,正好符合我们的需求:

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
  • 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的需求)

至于写队列失败和消息队列的维护成本问题:

  • 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
  • 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多

高并发

在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败(即使保证了操作的原子性),当有大量并发请求时(也就是高并发的时候),应用还是有可能读到不一致的数据,同样也有两种情况

情况一:先删除缓存,再更新数据库

假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:

  1. 线程 B 读取到了旧值;
  2. 线程 B 是在缓存缺失的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。
情况二:先更新数据库值,再删除缓存值
  • 如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。
  • 不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

可以看到在高并发的情况下还是会有数据一致性问题,解决方案是延迟双删,所谓的延迟双删就是指删除两次缓存,例如针对第一种情况:先删除缓存,再更新数据库,在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作(可以看到首先一开始删除了一次缓存,然后更新完数据库后又删除了一次缓存)之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。所以,线程 A sleep 的时间,就需要大于线
程 B 读取数据再写入缓存的时间。这个时间怎么确定呢?建议你在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。这样一来,其它线程读取数据时,会发现缓存缺失,所以会从数据库中读取最新值。因为这个方案会在第一次删除缓存值后,延迟一段时间再次进行删除,所以我们也把它叫做“延迟双删”。

redis.delKey(X) 
db.update(X) 
Thread.sleep(N) 
redis.delKey(X)

骚戴理解:需要注意的是使用延迟双删并不能保证强一致性,只能保证最终一致性,例如线程A删除了缓存,还未更新数据库时,线程B直接读取数据库中的旧值,并且写入缓存,虽然使用延迟双删后A线程会把B线程写入的缓存给删掉,后面其他线程就是直接从数据库中拿,但是B线程还是拿到的一个旧值,所以还是会有极少数线程拿到的是旧值,无法保证缓存和数据库的强一致性,只能保证最终一致性

但问题来了,这个「延迟删除」缓存,延迟时间到底设置要多久呢?

  • 延迟时间要大于线程 B 读取数据库 + 写入缓存的时间

这个时间在分布式和高并发场景下,其实是很难评估的。

很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。

所以你看,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。

所以实际使用中,我还是建议你采用「先更新数据库,再删除缓存」的方案,降低出问题的概率。

在大部分业务场景下,我们会把redis作为只读缓存使用。针对只读缓存来说,我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存。建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:

  • 先删除缓存值在更新数据库,有可能导致请求因为缓存缺失而访问数据库,给数据库带来压力
  • 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延时双删中的等待时间就不好设置

不过,当使用先更新数据库再删除缓存时,也有个地方需要注意,如果业务层要求必须读取一致的数据,那么,我们就需要在更新数据库时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

可以做到强一致吗?

看到这里你可能会想,这些方案还是不够完美,我就想让缓存和数据库「强一致」,到底能不能做到呢?

其实很难。要想做到强一致,最常见的方案是 2PC、3PC、Paxos、Raft 这类一致性协议,但它们的性能往往比较差,而且这些方案也比较复杂,还要考虑各种容错问题。相反,这时我们换个角度思考一下,我们引入缓存的目的是什么?

没错,性能。一旦我们决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。而且,就拿我们前面讲到的方案来说,当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到「中间状态」的数据。

所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有「任何请求」进来。

虽然我们可以通过加「分布锁」的方式来实现,但我们也要付出相应的代价,甚至很可能会超过引入缓存带来的性能提升。

所以,既然决定使用缓存,就必须容忍「一致性」问题,我们只能尽可能地去降低问题出现的概率。

同时我们也要知道,缓存都是有「失效时间」的,就算在这期间存在短期不一致,我们依旧有失效时间来兜底,这样也能达到最终一致。

总结

1、想要提高应用的性能,可以引入「缓存」来解决

2、引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」

3、更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况

4、采用「先删除缓存,再更新数据库」方案,在「并发」场景下依旧有不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估

5、采用「先更新数据库,再删除缓存」方案,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据最终一致

6、采用「先更新数据库,再删除缓存」方案,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率


http://www.kler.cn/a/523395.html

相关文章:

  • 独立成分分析 (ICA):用于信号分离或降维
  • Hive:日志,hql运行方式,Array,行列转换
  • 算法基础学习——二分查找(附带Java模板)
  • c++多态
  • Fullcalendar @fullcalendar/react 样式错乱丢失问题和导致页面卡顿崩溃问题
  • JJJ:linux时间子系统相关术语
  • 《多线程基础之条件变量》
  • @RestControllerAdvice 的作用
  • 【信息系统项目管理师-选择真题】2010下半年综合知识答案和详解
  • C#面试常考随笔5:简单讲述下反射
  • 腾讯云开发提供免费GPU服务
  • 大数运算:整数、小数的加减乘除与取余乘方(c++实现)
  • 我们需要有哪些知识体系,知识体系里面要有什么哪些内容?
  • 面试被问的一些问题汇总(持续更新)
  • Python帝王學集成-母稿
  • 【开源免费】基于Vue和SpringBoot的在线文档管理系统(附论文)
  • AIGC常见基础概念
  • DeepSeek R1学习
  • 27.日常算法
  • 【Leetcode 热题 100】152. 乘积最大子数组
  • 2025春晚临时直播源接口
  • Jellyfin的快速全文搜索代理JellySearch
  • iperf 测 TCP 和 UDP 网络吞吐量
  • 2025年数学建模美赛 A题分析(2)楼梯使用频率数学模型
  • t113 procd-init文件系统增加自己的程序文件
  • 7-Zip Mark-of-the-Web绕过漏洞复现(CVE-2025-0411)