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