如何保证缓存与数据库的数据一致性?
如何保证缓存与数据库的数据一致性?
- 更新后失效(Write-Through)
在这种模式下,每次将数据写入到数据库,并且在写入成功后删除缓存中的对应条目。这种方法可以确保读取缓存数据时总是最新的。
- 双写机制(Dual Writing)
每次数据有变更时,应用程序负责同时更新数据库和缓存。这需要应用逻辑来确保两个系统的更新操作要么都成功,要么都失败。
骚戴理解: 常用的就两种方式,一种是双写,一种是更新完数据库后再删除缓存,但是这里面又有很多的情况可以深入分析
首先一般不用双写策略,因为更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,解决方案是加「分布锁」,但这种方案存在「缓存资源浪费」和「机器性能浪费」的情况,这是因为每次数据发生变更,都「无脑」更新缓存,但是缓存中的数据不一定会被「马上读取」,这就会导致缓存中可能存放了很多不常访问的数据,浪费缓存资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,很有可能是先查询数据库,再经过一系列「计算」得出一个值,才把这个值才写到缓存中。由此可见,这种「更新数据库 + 更新缓存」的方案,不仅缓存利用率不高,还会造成机器性能的浪费。
那通常更多使用的是删除缓存的策略,而这种策略有两种情况,第一种是先删除缓存再更新数据库,第二种是先更新数据库再删除缓存,无论用哪一种都可能会导致数据不一致的情况,都需要根据实际情况进行处理来保证最终一致性,会导致数据一致性的原因通常有两种,如下所示
- 两步更新操作非原子性,第二步可能失败
- 高并发的时候
两步更新操作非原子性,第二步可能失败
在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值,简单来说就是没有保证这两个操作同时执行,是一个原子操作,要么都成功,要么都失败
- 我们假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
- 如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。
针对原子性问题导致的数据不一致的解决方案:消息队列+重试机制实现异步重试【补偿】
- 可以把要删除的缓存值暂存到消息队列中。当应用没有能够成功的删除缓存值时,可以从消息队列中重新读取这些值,然后再次进行删除
- 如果能够成功删除,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存一致了。否则的话,我们还需要再次进行重试。如果重试超过一定次数,还是没有成功,我们就需要向应用层发送报错信息了
分析为什么要用消息队列+重试机制实现异步重试方案
无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,我们就可以发起重试,尽可能地去做「补偿」。
那这是不是意味着,只要执行失败,我们「无脑重试」就可以了呢?
答案是否定的。现实情况往往没有想的这么简单,失败后立即重试的问题在于:
- 立即重试很大概率「还会失败」
- 「重试次数」设置多少才合理?
- 重试会一直「占用」这个线程资源,无法服务其它客户端请求
看到了么,虽然我们想通过重试的方式解决问题,但这种「同步」重试的方案依旧不严谨。那更好的方案应该怎么做?答案是:异步重试。
什么是异步重试?
其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
到这里你可能会问,写消息队列也有可能会失败啊?而且,引入消息队列,这又增加了更多的维护成本,这样做值得吗?
这个问题很好,但我们思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了。
所以,这里我们必须把重试消息或第二步操作放到另一个「服务」中,这个服务用「消息队列」最为合适。这是因为消息队列的特性,正好符合我们的需求:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的需求)
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
高并发
在更新数据库和删除缓存值的过程中,其中一个操作失败的情况,实际上,即使这两个操作第一次执行时都没有失败(即使保证了操作的原子性),当有大量并发请求时(也就是高并发的时候),应用还是有可能读到不一致的数据,同样也有两种情况
情况一:先删除缓存,再更新数据库
假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:
- 线程 B 读取到了旧值;
- 线程 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、采用「先更新数据库,再删除缓存」方案,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率