如何保证 Redis 缓存与数据库双写一致性?看这篇就够了

文章目录

  • 问题
  • 引入缓存提高性能
  • 缓存利用率
  • 缓存一致性
    • 先更新数据库,后更新缓存
    • 先更新缓存,后更新数据库
  • 并发引发的一致性问题
  • 删除缓存会保证一致性?
    • 先删除缓存,后更新数据库
    • 先更新数据库,后删除缓存
  • 异步重试
  • 订阅数据库变更日志,再操作缓存
  • 延迟双删
    • 只先删缓存
    • 只后删缓存
    • 普通双删
    • 延时双删
    • 极端情况
  • 总结

问题

面试当中总会被问题这么一个问题:如何保证 Redis 缓存和数据库一致性?

但依旧有很多的疑问:

  • 到底是更新缓存还是删除缓存?
  • 到底选择先更新数据库,再删除缓存,还是先删除缓存,再更新数据库?
对于上面的问题我们接下来一一讲解。

引入缓存提高性能

前期业务正处在开始阶段,流量非常小,当客户端请求过来,无论是读请求还是写请求,直接操作数据库就可以,前期架构框架如下图:

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第1张图片
但是随着业务量的增长,项目请求量越来越大,这时如果每次都从数据库中读数据,就会出现大问题了。

这个时候我们项目通常都会引入缓存来提高读性能,架构框架如下图:

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第2张图片
如果说使用缓存中间件,必须 Redis 了,不仅性能非常高,还提供了很多友好的数据类型,可以很好地满足我们的业务需求。

我们之前的数据是只存数据库中,现在引入 Redis 后需要放到缓存中读取,那么具体该怎么存呢?,有以下几种方式:

  • 数据库中所有的数据的同步到缓存中,并且不设置过期时间
  • 数据写入只更新数据库,不更新缓存
  • 启动定时器把数据库中的数据同步到缓存

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第3张图片

这个实现方式如果有请求过来,所有读请求都可以直接从缓存中读取到数据,不需要再查数据库,性能非常高。

但是会存在以下2点问题:

  • 不设置过期时间,不经常访问的数据还存在缓存中
  • 因为是定时执行同步数据,会导致缓存和数据库的数据不一致的问题(看任务执行的频率)

这种方式适合数据量小,对数据一致性要求不高的业务场景。

缓存利用率

想要缓存利用率最大化,我们很容易想到的方案是,缓存中只保留最近访问的热数据。具体要怎么做呢?如下几点:

  • 写请求只写数据库
  • 读请求首先读缓存,如果缓存中不存在,再从数据库中读取,并更新到缓存
  • 写入缓存中的数据,都设置失效时间

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第4张图片
这样的话缓存中不经常访问的数据,随着时间的推移,都会逐渐过期并且被淘汰掉,而缓存中保留的都是经常被访问的热数据,缓存利用率得以最大化。

缓存一致性

当数据发生更新时,我们不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存一起更新。

如果缓存和数据库都更新的话,就会存在以下两个问题:

  • 是先更新数据库还是后更新缓存
  • 是先更新缓存还是后更新数据库
在没有异常的情况下无论是谁先谁后,数据都可以保持一致性,但是分成两步执行,异常的情况下有可能第一步成功,第二步失败的情况发生,那这么做呢?

我们更加上面两步来逐一分析:

先更新数据库,后更新缓存

首先执行数据库更新的操作并且成功了,这时再去更新缓存并且失败了,那么此时数据库中是最新的值,而缓存中还是旧的数据值。

如果一个读请求过来,首先读取缓存中的数据,这时都是旧值,只有当缓存过期失效后,才能重新在数据库中得到新的值。

这时用户发现我明明修改了值,为什么看不到修改的值,而是过段时间数据才变更过来,对业务会有影响。

所以说无论谁先谁后,只要后面更新缓存发生异常,就会对业务造成影响。

先更新缓存,后更新数据库

首先执行缓存更新的操作并且成功了,这时再去更新数据库并且失败了,那么此时缓存中是最新的值,而数据库中还是旧的数据值。

虽然此时读请求可以命中缓存,拿到正确的值,但是缓存过期失效以后就会从数据库中读取到旧值,重新同步缓存也是这个旧值。

这时用户会发现自己之前修改的数据又变回旧的值了,对业务造成影响。

除了所说的操作失败问题,那还有哪些情况下会影响数据一致性?

并发引发的一致性问题

假设采用先更新数据库,再更新缓存的方案,并且两步都可以执行成功的前提下,如果存在并发,情况会是怎样的呢?

比如线程 A 和线程 B 两个线程,同时更新一条数据,会发生如下问题:

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第5张图片

  1. 线程 A 更新数据库(X = 1)
  2. 线程 B 更新数据库(X = 2)
  3. 线程 B 更新缓存(X = 2)
  4. 线程 A 更新缓存(X = 1)
最终 X 的值在缓存中是 1,在数据库中是 2,发生不一致。

线程 A 虽然先于线程 B 发生,但线程 B 操作数据库和缓存的时间,却要比线程 A 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。

同样地,采用先更新缓存,再更新数据库的方案,也会有类似问题,这里不再详述。

很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的,也有可能是先查询数据库,再经过一系列计算得出的一个值,才把这个值才写到缓存中。

由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能的浪费。

所以此时我们需要考虑另外一种方案:删除缓存。

删除缓存会保证一致性?

删除缓存也有 2 种方式:

  • 先删除缓存,后更新数据库
  • 先更新数据库,后删除缓存

这里小伙伴们可以按照前面的思路推演一下,就可以看到依旧存在数据不一致的情况发生。

这里主要重点看并发问题:

先删除缓存,后更新数据库

如果有 2 个线程 A 和 B 要并发读写数据,可能会发生如下问题:

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第6张图片

  1. 线程 A 要更新 X = 2(原值 X = 1)
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
  4. 线程 A 将新值写入数据库(X = 2)
  5. 线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是旧值 1,在数据库中是新值 2,发生不一致。

可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。

先更新数据库,后删除缓存

依旧是 2 个线程 A 和 B 并发读写数据:

  1. 缓存中 X 不存在(数据库 X = 1)
  2. 线程 A 读取数据库,得到旧值(X = 1)
  3. 线程 B 更新数据库(X = 2)
  4. 线程 B 删除缓存
  5. 线程 A 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是旧值 1,在数据库中是新值 2,也发生不一致。

这种情况理论上来讲是可能发生的,但是概率很低,因为必须满足 3 个条件:

  • 缓存刚好已失效
  • 读请求 + 写请求并发
  • 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)

操作写数据库一般会先加锁,所以写数据库,通常是要比读数据库的时间更长的,这样看来,先更新数据库 + 再删除缓存的方案是可以保证数据一致性的。所以,我们应该采用这种方案,来操作数据库和缓存。

解决了并发问题,现在来看一下第二步执行失败导致数据不一致的问题。

无论是先操作缓存,还是先操作数据库,如果第二步执行失败,我们就发起重试,尽可能地去做补救。

异步重试

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

这里大家可能就会有一个问题:写消息队列也有可能会失败,而且,引入消息队列,又增加了更多的维护成本,这样做是否值得呢?

消息队列的特性:

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失
  • 消息队列保证消息成功投递:消费者从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者

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

  • 写队列失败:操作缓存和写消息队列,同时失败的概率是很小的
  • 维护成本:因为我们的项目中一般都会用到消息队列,维护成本并没有新增很多
所以,引入消息队列来解决这个问题,是比较合适的。这时架构框架如下图所示:

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第7张图片

如果不用消息队列这种方式,可以使用订阅数据库变更日志,再操作缓存。

订阅数据库变更日志,再操作缓存

我们的应用在修改数据时,只需要修改数据库,不用操作缓存,而操作缓存是交给数据库的变更日志实现。

比如,MySQL中修改一条数据,MySQL 就会产生一条变更日志(Bin Log),我们可以订阅这个日志,获取到具体的操作数据,然后再根据这条日志数据,去删除对应的缓存。

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第8张图片

订阅变更日志比较比较成熟的开源中间件,比如阿里的 canal,这种方案的优点如下:

  • 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
  • 自动投递到消费队列:canal 自动把数据库变更日志投递给消费的消息队列

想要保证数据库和缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。

延迟双删

为什么要延迟双删呢,有什么作用呢?

看如下图就能明白:

只先删缓存

先删缓存,在改数据库前,其他线程又把旧数据放到缓存里去了。
如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第9张图片

只后删缓存

改了数据库,清理缓存前,有部分线程还是会拿到旧缓存。

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第10张图片

普通双删

第一次清空缓存后、更新数据库前:其他线程查询了数据库的值
第二次清空缓存后:其他线程更新缓存,此时又会把旧数据更新到缓存
如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第11张图片

延时双删

在普通删除中,第二次清空缓存之前,多延时一会儿,等线程B更新缓存结束了,再删除缓存,这样就缓存就不存在了,其他线程查询到的为新缓存。

延时是确保 修改数据库 -> 清空缓存前,其他线程的更改缓存操作已经执行完。
如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第12张图片

这个延迟删除缓存,延迟时间到底设置要多久呢?

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

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

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

极端情况

延时双删中说到,采用延时删最后一次缓存,但这其中难免还是会大量的查询到旧缓存数据的。

如何保证 Redis 缓存与数据库双写一致性?看这篇就够了_第13张图片

这时候可以通过加锁来解决,一次性不让太多的线程都来请求,另外从图上看,我们可以尽量缩短第一次删除缓存和更新数据库的时间差,这样可以使得其他事务第一时间获取到更新数据库后的数据。

总结

  • 想要提高应用的性能,可以引入缓存来解决
  • 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:更新数据库 + 更新缓存、更新数据库 + 删除缓存
  • 更新数据库 + 更新缓存方案,在并发情况下无法保证缓存和数据一致性,且存在缓存资源浪费和机器性能浪费的情况发生
  • 在更新数据库 + 删除缓存的方案中,先删除缓存,再更新数据库在并发情况下依旧有数据不一致问题,解决方案是延迟双删,但这个延迟时间很难评估,所以推荐用先更新数据库,再删除缓存的方案
  • 在先更新数据库,再删除缓存方案下,为了保证两步都成功执行,需配合消息队列或订阅变更日志的方案来做,本质是通过重试的方式保证数据一致性

你可能感兴趣的:(redis,缓存,数据库,redis)