redis:怎么保证缓存和数据库数据的一致性?

2022-09-06 14:08:23

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

一般来讲,有四种方案

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

第一种和第二种方案,没有人使用的,因为第一种方案存在问题是:并发更新数据库场景下,会将脏数据刷到缓存。

第二种方案存在的问题是:如果先更新缓存成功,但是数据库更新失败,则肯定会造成数据不一致。

先更新数据库,后删除缓存(推荐)

这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,其中读策略的步骤是

  • 从缓存中读取数据
  • 如果缓存命中,则直接返回数据
  • 如果缓存不命中,则从数据库中查询数据
  • 查询到数据后,将数据写入到缓存中,并返回给用户

其中写策略的步骤是

  • 更新数据库中的记录
  • 删除缓存记录

在这里插入图片描述

为什么是删除缓存,而不是更新缓存

  • 很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。
    • 比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行计算,才能计算出缓存最新的值。
  • 另外更新缓存的代价有时候很高的。
    • 是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份?也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会频繁被访问到
    • 举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。
  • 其实删除缓存,而不是更新缓存,就是一个 lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。
    • 像 mybatis,hibernate,都有懒加载思想。
    • 查询一个部门,部门带了一个员工的 list,没有必要说每次查询部门,都里面的 1000 个员工的数据也同时查出来啊。80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了。先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。

当然,先更新数据库,然后更新缓存也有其缺陷

问题一:假设某个用户数据在缓存中不存在,请求A读取数据时从数据库查询到年龄为20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存和数据库数据不一致。

不过这种问题出现的几率不高,因为缓存的写入通常远远快于数据库的写入,所以在实际中很难出现请求B已经更新了数据库并且清空了缓存,请求A才更新完缓存的情况。而一旦请求A早于请求B情况缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载缓存,所以不会出现这种不一致的情况

在这里插入图片描述

问题二:先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。

在这里插入图片描述

解决思路一:先删除缓存,然后更新数据库 (不能这样,具体参见下面)

如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。

解决思路二:利用消息队列进行删除的补偿

在这里插入图片描述

  • 请求 A 先对数据库进行更新操作
  • 在对 Redis 进行删除操作的时候发现报错,删除失败
  • 此时将Redis 的 key 作为消息体发送到消息队列中
  • 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作

但这个方案有一个缺点就是会对业务代码造成到来的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道mysql数据库更新操作后再binlg日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。
在这里插入图片描述

Cache Aside 策略存在的最大的问题是当写入数据比较频繁时,缓存中的数据会被频繁的清理,这样会对缓存的命中率有一些影响。如果你的业务会缓存命中率有严格的要求,那么可以考虑两种解决方案:

  • 一种做法是更新数据时也更新缓存,只是在更新缓存前加一个分布式锁,因为这样在同一个时间只允许一个线程更新缓存,就不会产生并发问题了。当然这样做对于写入的性能会有一些影响
  • 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受的

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

问题:数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了…

举个例子:假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为21,这就造成了缓存和数据库的不一致。

在这里插入图片描述

只有在对一个数据在并发的进行读写的时候,才可能会出现这种问题。其实如果说你的并发量很低的话,特别是读并发很低,每天访问量就 1 万次,那么很少的情况下,会出现刚才描述的那种不一致的场景。但是问题是,如果每天的是上亿的流量,每秒并发读是几万,每秒只要有数据更新的请求,就可能会出现上述的数据库+缓存不一致的情况。

解决方法一:延迟双删

最简单的解决方法是延迟双删

  • 先淘汰缓存
  • 再写数据库
  • 休眠一秒,再次淘汰缓存,这样做,可以将1s内所造成的缓存1脏数据,再次删除。确保读请求结束。写请求可以删除读请求造成的缓存脏数据。自行评估自己的项目的读数据业务逻辑的耗时,写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。

如果使用的是 Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。

在这里插入图片描述
此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)

  • 请求 A 更新操作,删除了 Redis
  • 请求主库进行更新操作,主库与从库进行同步数据的操作
  • 请 B 查询操作,发现 Redis 中没有数据
  • 去从库中拿去数据
  • 此时同步数据还未完成,拿到的数据是旧数据

此时的解决方法就是如果是对redis进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询

在这里插入图片描述

解决方法二:更新与读取操作进行异步串行化

怎么做:

(1)异步串行化

  • 在系统内部维护n个内存队列,更新数据的时候,根据数据的唯一标识,将该操作路由之后,发送到其中一个jvm内部的内存队列中(对同一数据的请求发送到同一个队列)。读取数据的时候,如果发现数据不在缓存中,并且此时队列里有更新库存的操作,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也将发送到同一个jvm内部的内存队列中。然后每个队列对应一个工作线程,每个工作线程串行地拿到对应的操作,然后一条一条的执行。
  • 这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,如果此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库的操作之后,然后同步等待缓存更新完成,再读库。

(2)读操作去重

  • 多个读库更新缓存的请求串在同一个队列中是没意义的,因此可以做过滤,如果发现队列中已经有了该数据的更新缓存的请求了,那么就不用再放进去了,直接等待前面的更新操作请求完成即可,待那个队列对应的工作线程完成了上一个操作(数据库的修改)之后,才会去执行下一个操作(读库更新缓存),此时会从数据库中读取最新的值,然后写入缓存中。
  • 如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。(返回旧值不是又导致缓存和数据库不一致了么?那至少可以减少这个情况发生,因为等待超时也不是每次都是,几率很小吧。这里我想的是,如果超时了就直接读旧值,这时候仅仅是读库后返回而不放缓存)
  • 作者:OceanStar的学习笔记
  • 原文链接:https://blog.csdn.net/zhizhengguan/article/details/120616508
    更新时间:2022-09-06 14:08:23