- 问题
- 引入缓存提高性能
- 缓存利用率
- 缓存一致性
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 并发引发的一致性问题
- 删除缓存会保证一致性?
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
- 异步重试
- 订阅数据库变更日志,再操作缓存
- 延迟双删
- 只先删缓存
- 只后删缓存
- 普通双删
- 延时双删
- 极端情况
- 总结
面试当中总会被问题这么一个问题:如何保证 Redis 缓存和数据库一致性?
但依旧有很多的疑问:
- 到底是更新缓存还是删除缓存?
- 到底选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库?
前期业务正处在开始阶段,流量非常小,当客户端请求过来,无论是读请求还是写请求,直接操作数据库就可以,前期架构框架如下图:
但是随着业务量的增长,项目请求量越来越大,这时如果每次都从数据库中读数据,就会出现大问题了。
这个时候我们项目通常都会引入缓存来提高读性能,架构框架如下图:
如果说使用缓存中间件,必须 Redis 了,不仅性能非常高,还提供了很多友好的数据类型,可以很好地满足我们的业务需求。
我们之前的数据是只存数据库中,现在引入 Redis 后需要放到缓存中读取,那么具体该怎么存呢?,有以下几种方式:
- 数据库中所有的数据的同步到缓存中,并且不设置过期时间
- 数据写入只更新数据库,不更新缓存
- 启动定时器把数据库中的数据同步到缓存
这个实现方式如果有请求过来,所有读请求都可以直接从缓存中读取到数据,不需要再查数据库,性能非常高。
但是会存在以下2点问题:
- 不设置过期时间,不经常访问的数据还存在缓存中
- 因为是定时执行同步数据,会导致缓存和数据库的数据不一致的问题(看任务执行的频率)
这种方式适合数据量小,对数据一致性要求不高的业务场景。
缓存利用率想要缓存利用率最大化,我们很容易想到的方案是,缓存中只保留最近访问的热数据。具体要怎么做呢?如下几点:
- 写请求只写数据库
- 读请求首先读缓存,如果缓存中不存在,再从数据库中读取,并更新到缓存
- 写入缓存中的数据,都设置失效时间
这样的话缓存中不经常访问的数据,随着时间的推移,都会逐渐过期并且被淘汰掉,而缓存中保留的都是经常被访问的热数据,缓存利用率得以最大化。
当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存一起更新。
如果缓存和数据库都更新的话,就会存在以下两个问题:
- 是先更新数据库还是后更新缓存
- 是先更新缓存还是后更新数据库
我们更加上面两步来逐一分析:
先更新数据库,后更新缓存首先执行数据库更新的操作并且成功了,这时再去更新缓存并且失败了,那么此时数据库中是最新的值,而缓存中还是旧的数据值。
如果一个读请求过来,首先读取缓存中的数据,这时都是旧值,只有当缓存过期失效后,才能重新在数据库中得到新的值。
这时用户发现我明明修改了值,为什么看不到修改的值,而是过段时间数据才变更过来,对业务会有影响。
所以说无论谁先谁后,只要后面更新缓存发生异常,就会对业务造成影响。
先更新缓存,后更新数据库首先执行缓存更新的操作并且成功了,这时再去更新数据库并且失败了,那么此时缓存中是最新的值,而数据库中还是旧的数据值。
虽然此时读请求可以命中缓存,拿到正确的值,但是缓存过期失效以后就会从数据库中读取到旧值,重新同步缓存也是这个旧值。
这时用户会发现自己之前修改的数据又变回旧的值了,对业务造成影响。
除了所说的操作失败问题,那还有哪些情况下会影响数据一致性?
并发引发的一致性问题假设采用先更新数据库,再更新缓存的方案,并且两步都可以执行成功的前提下,如果存在并发,情况会是怎样的呢?
比如线程 A 和线程 B 两个线程,同时更新一条数据,会发生如下问题:
- 线程 A 更新数据库(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 更新缓存(X = 2)
- 线程 A 更新缓存(X = 1)
线程 A 虽然先于线程 B 发生,但线程 B 操作数据库和缓存的时间,却要比线程 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
同样地,采用先更新缓存,再更新数据库的方案,也会有类似问题,这里不再详述。
很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,也有可能是先查询数据库,再经过一系列计算得出的一个值,才把这个值才写到缓存中。
由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。
所以此时我们需要考虑另外一种方案:删除缓存。
删除缓存会保证一致性?删除缓存也有 2 种方式:
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
这里小伙伴们可以按照前面的思路推演一下,就可以看到依旧存在数据不一致的情况发生。
这里主要重点看并发问题:
先删除缓存,后更新数据库如果有 2 个线程 A 和 B 要并发读写数据,可能会发生如下问题:
- 线程 A 要更新 X = 2(原值 X = 1)
- 线程 A 先删除缓存
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
- 线程 A 将新值写入数据库(X = 2)
- 线程 B 将旧值写入缓存(X = 1)
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。
先更新数据库,后删除缓存依旧是 2 个线程 A 和 B 并发读写数据:
- 缓存中 X 不存在(数据库 X = 1)
- 线程 A 读取数据库,得到旧值(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(X = 1)
这种情况理论上来讲是可能发生的,但是概率很低,因为必须满足 3 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
操作写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的,这样看来,先更新数据库 + 再删除缓存的方案是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。
解决了并发问题,现在来看一下第二步执行失败导致数据不一致的问题。
无论是先操作缓存,还是先操作数据库,如果第二步执行失败,我们就发起重试,尽可能地去做补救。
异步重试就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功,或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
这里大家可能就会有一个问题:写消息队列也有可能会失败,而且,引入消息队列,又增加了更多的维护成本,这样做是否值得呢?
消息队列的特性:
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失
- 消息队列保证消息成功投递:消费者从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,同时失败的概率是很小的
- 维护成本:因为我们的项目中一般都会用到消息队列,维护成本并没有新增很多
如果不用消息队列这种方式,可以使用订阅数据库变更日志,再操作缓存。
订阅数据库变更日志,再操作缓存我们的应用在修改数据时,只需要修改数据库,不用操作缓存,而操作缓存是交给数据库的变更日志实现。
比如,MySQL中修改一条数据,MySQL 就会产生一条变更日志(Bin Log),我们可以订阅这个日志,获取到具体的操作数据,然后再根据这条日志数据,去删除对应的缓存。
订阅变更日志比较比较成熟的开源中间件,比如阿里的 canal,这种方案的优点如下:
- 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
- 自动投递到消费队列:canal 自动把数据库变更日志投递给消费的消息队列
想要保证数据库和缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。
延迟双删为什么要延迟双删呢,有什么作用呢?
看如下图就能明白:
只先删缓存先删缓存,在改数据库前,其他线程又把旧数据放到缓存里去了。
改了数据库,清理缓存前,有部分线程还是会拿到旧缓存。
普通双删第一次清空缓存后、更新数据库前:其他线程查询了数据库的值
第二次清空缓存后:其他线程更新缓存,此时又会把旧数据更新到缓存
在普通删除中,第二次清空缓存之前,多延时一会儿,等线程B更新缓存结束了,再删除缓存,这样就缓存就不存在了,其他线程查询到的为新缓存。
延时是确保 修改数据库 -> 清空缓存前,其他线程的更改缓存操作已经执行完。
这个延迟删除缓存,延迟时间到底设置要多久呢?
这个时间在分布式和高并发场景下,其实是很难评估的。
很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。
所以,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
极端情况延时双删中说到,采用延时删最后一次缓存,但这其中难免还是会大量的查询到旧缓存数据的。
这时候可以通过加锁来解决,一次性不让太多的线程都来请求,另外从图上看,我们可以尽量缩短第一次删除缓存和更新数据库的时间差,这样可以使得其他事务第一时间获取到更新数据库后的数据。
总结- 想要提高应用的性能,可以引入缓存来解决
- 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:更新数据库 + 更新缓存、更新数据库 + 删除缓存
- 更新数据库 + 更新缓存方案,在并发情况下无法保证缓存和数据一致性,且存在缓存资源浪费和机器性能浪费的情况发生
- 在更新数据库 + 删除缓存的方案中,先删除缓存,再更新数据库在并发情况下依旧有数据不一致问题,解决方案是延迟双删,但这个延迟时间很难评估,所以推荐用先更新数据库,再删除缓存的方案
- 在先更新数据库,再删除缓存方案下,为了保证两步都成功执行,需配合消息队列或订阅变更日志的方案来做,本质是通过重试的方式保证数据一致性



