redis-数据库一致性

2021-09-20  本文已影响0人  麦大大吃不胖

by shihang.mai

注意贯穿全文:引入缓存的目的就是提速
注意贯穿全文:引入缓存的目的就是提速
注意贯穿全文:引入缓存的目的就是提速
重要事情说3遍!!!!

1. 引入缓存导致的问题

引入redis确实能提速,但是也引入了一个数据库-缓存数据一致性的问题。这正是本文讨论的焦点

我们大可以这样做:

  1. 将数据库数据全部放到redis,并且不设置ttl,这样可以做到redis全部命中
  2. 写请求还是操作库,保持库是最新的数据
  3. 然后启动一个定时任务,定时将库最新的数据刷新到redis

但是这个方案也有明显的缺点

  1. redis利用率低,因为不经常访问的数据都在redis中了
  2. 缓存和数据库的一致性有一定延时

2. 同时更新缓存和数据库方案

2.1 缓存利用低

我们可以这么做

  1. 写请求还是操作库,保持库是最新的数据
  2. 读请求先读redis,如果redis不存在,则从数据库读取,并重建redis
    同时,写入redis中的数据,都设置ttl

这样就可以做到redis中的数据都是热数据,缓存利用率提高。

2.2 一致性延迟

在1中的方案的延时,取决于定时任务的频率。那么我们就不用定时任务了。我们可以这么做,当更新数据时,同时更新数据库和缓存即可。但问题来了,更新两者的先后问题。

  1. 先更新缓存,后更新数据库
  2. 先更新数据库,后更新缓存

其实,在没异常和没并发的情况下,两个顺序都没问题

2.3 (异常、并发)和顺序的关系

2.3.1 异常和顺序的关系

1. 先更新缓存,后更新数据库
or
2. 先更新数据库,后更新缓存

因为有异常,那么会导致第一步成功,第二部失败的情况

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

如果缓存更新成功了,但数据库更新失败

那么此时缓存中是最新值,但数据库中是旧值

虽然此时读请求可以命中缓存,拿到正确的值,但是,一旦ttl失效,然后请求从数据库中读取到旧值,重建缓存也是这个旧值。

这时Clinet会发现自己之前修改的数据又「变回去」了,对业务造成影响

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

如果数据库更新成功了,但缓存更新失败

那么此时数据库中是最新值,缓存中是旧值。

之后的读请求读到的都是旧数据,只有当ttl失效后,才能从数据库中得到正确的值。

这时Clinet会发现,自己刚刚修改了数据,但却看不到最新数据,一段时间过后,数据才变更过来,对业务也会有影响


一句话总结: 无论哪个顺序都有问题

2.3.2 并发和顺序的关系

这里先说,在并发情况下,无论哪个顺序都一样有问题,我就选个
先更新数据库,后更新缓存来说明问题吧

有线程 A 和线程 B 两个线程,需要更新同一条数据。A线程将age = 18,B线程将age= 28
1. 线程 A 更新数据库 age = 18
2. 线程 B 更新数据库 age = 28
3. 线程 B 更新缓存 age = 28
4. 线程 A 更新缓存 age = 18
结果明显了,数据库age = 28 缓存age = 18

3. 删除缓存方案

这个方案也会有顺序。

1. 先更新数据库,后删除缓存
or
2. 先删除缓存,后更新数据库

3.1 (异常、并发)和顺序的关系

3.1.1 异常和顺序的关系

1. 先更新数据库,后删除缓存
or
2. 先删除缓存,后更新数据库

因为有异常,那么会导致第一步成功,第二部失败的情况

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

略了,自己分析吧,你们行的

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

略了,自己分析吧,你们行的

3.2.1 并发和顺序的关系

这里先说,在并发情况下,这次例子是读写并发,无论哪个顺序都一样有问题。只是概率问题

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

有线程 A 和线程 B 两个线程,同时针对同一条数据,原值age = 28,A线程将age = 18,B线程读数据
1. A线程删除缓存
2. B线程读取缓存,发现没,直接读数据库age = 28
3. A线程将数据库age = 18
4. B线程重建缓存,缓存age = 28
结果明显了,数据库age = 18 缓存age = 28

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

有线程 A 和线程 B 两个线程,同时针对同一条数据,原值age = 28,A线程将age = 18,B线程读数据
1. B线程读取数据库,读到age = 28
2. A线程将数据库age = 18
3. A线程删除缓存
4. B线程重建缓存,缓存age = 28
结果明显了,数据库age = 18 缓存age = 28

还是一句话: 无论哪个顺序都有问题

那是不是搞不了?其实我们分析一下吧,说的是一致性问题注意。


既然哪种都不行,我们就选个概率低的吧,下面全部讨论
先更新数据库,后删除缓存
到此解决了并发的问题,但是我们上面提到的异常情况还没解决,接下来我们来解决异常的情况的问题

4. 解决异常问题

其实最简单的办法就是重试。但是重试有下面几点明显的问题

  1. 一般立即重试,一般会继续失败
  2. 就算你设置了休眠时间,那重试多少次合适呢?
  3. 重试还会一直占用当前线程呢

既然这样,我们换一种想法,用异步重试即可,我们可以用项目中经常用到的MQ

  1. 先写数据库
  2. 向MQ写消息
  3. 消费者消费消息,删除redis

还有另外一个方案,直接引入canal,这样

  1. 先写数据库
  2. canal监听binlog的变化,然后投递到MQ
  3. 消费者消费消息,删除redis

到现在我们使用先写数据库,再删缓存的方式去保证一致性,其中删除缓存使用MQ或者canal配合

5. mysql读写分离+主从复制延迟

就算我们使用【先写数据库,再删缓存】,在 【mysql读写分离+主从复制延迟】其实还是会导致问题的

有线程 A 和线程 B 两个线程,同时针对同一条数据,原值age = 28,A线程将age = 18,B线程读数据
1. 线程 A 更新主库 age = 18
2. 线程 A 删除缓存
3. 线程 B 查询缓存,没有命中,查询从库得到旧值age = 28
3. 从库同步主库完成age = 18
4. 线程 B 将age = 28写入缓存
结果明显了,数据库age = 18 缓存age = 28

这就引出了缓存延时双删方案,但是这个方案这个时间其实在高并发环境中很难估计,只是降低不一致的概率

  1. 先写数据库
  2. 删除缓存
  3. 发送延时消息到MQ,消费者再删除缓存一次

6. 思考

所以可以用分布式锁做强一致,但是引入缓存在文章开头已经说过,是为了性能,要强一致,必然会导致性能急剧下降。矛盾呀,矛盾呀!

上一篇 下一篇

猜你喜欢

热点阅读