Redis 缓存数据库一致性手撕面答

引言

自Redis入门篇过后,已经好久没更Redis了,接下来应该从实战篇,原理篇,面试篇几个层次来展开,本篇主要是实战篇中的数据库缓存一致性问题,以问题形式展开,应对面试场景作答【melo称其为"手撕面答"】,尽量简短,某些部分可能不会进行详细介绍。

🎨本篇脑图速览

Redis 缓存数据库一致性手撕面答

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

🎐懒加载

一种懒加载的思想,因为每次更改数据之后,不一定立马就有人来用。 若更新的次数远大于读取的次数,此时会频繁更新缓存,但一直没人使用,若缓存更新的成本很高的话,此时会非常浪费性能资源。

🎈并发更新的情况

ABBA 【A的操作过程中,穿插了B的完整过程】

  1. A更新数据库为1
  2. B更新数据库为2
  3. B更新缓存为2
  4. A更新缓存为1

最后导致数据库最终是2,但缓存是1,也就是B的缓存更新丢失了

Redis 缓存数据库一致性手撕面答

🎯为什么要先更新数据库,再删除缓存

若先删除缓存,再更新数据库的话:

  1. A线程删除缓存
  2. B线程查询数据,缓存中没有了,会去计算数据库,设置缓存【此时计算出来的缓存是旧数据,是数据库更新之前的数据】
  3. A线程再更新数据库

接下来的查询,一直走的是缓存,也就是旧数据,这样就出现了数据库和缓存中数据不一致的情况

若先更新数据库,再删除缓存的话

  1. A线程更新数据库
  2. B线程查询数据,此时还未删除缓存,缓存中还有,得到的就是旧的缓存数据【此时就出现了数据库和缓存中数据不一致的情况】
  3. A线程再删除缓存

A读B写【先更后删可能会有数据不一致的情况,但很少见】

  1. A读取,发现缓存刚好过期了
  2. A查询出数据库的旧值,在设置缓存之前
  3. B更新数据库,并删除了缓存
  4. A用旧值,设置了缓存【缓存中是旧的数据,数据库是新的】

但由于缓存的写入要快于MySQL的写入,一般不可能在2跟4之间,穿插一个3操作【3还操作了数据库】

  • 如果顺序是【2,4,3】的话,A设置了缓存之后也没关系,因为后续B还会再次删除缓存
  • 之后的查询,发现缓存过期会去数据库中查询得到最新的数据

🎯🎈如何解决A读B写时先更后删的极端情况?

延时双删

  1. A读取,发现缓存失效了,此时去查询数据库【查出旧值】
  2. B更新数据库
  3. B设置新缓存
  4. A设置缓存为旧值

到这一步,如果之后的查询,查到的都会是旧的缓存,所以我们可以

  1. 延时500毫秒

延时500毫秒是为了让B能够更新完数据库,我们再次查询数据库才能获得最新的值,来设置缓存

  1. 删除缓存

🎯先删除缓存,后更新数据库如果也出现类似上边的问题怎么办?

假设一个场景:

  • 请求A进行写操作,删除缓存
  • 请求B查询发现缓存不存在
  • 请求B去数据库查询得到旧值
  • 请求B将旧值写入缓存
  • 请求A将新值写入数据库

这时候缓存中的数据就是脏数据,如果缓存没有设置过期时间的话,就导致这个数据在下一次修改之前返回给用户的都是脏数据。

Redis 缓存数据库一致性手撕面答

🎯从后者操作可能失败的角度来看,选择哪种策略更好一点?

先更新后删除缓存的话,后者失败,情况是这样的:

  1. A更新数据库,然后缓存没有删除成功
  2. B查询,直接走了缓存【旧数据】

这就出现了缓存跟数据库不一致的情况

先删除缓存后更新数据库的话,后者失败,情况是这样的:

  1. A删除缓存,然后没能更新数据库
  2. B查询,发现缓存没有,则去查询数据库,设置到缓存里边

此时并不会出现缓存跟数据库不一致的情况,因为A还没来得及更新,这样数据库跟缓存中,一直都是旧的数据,但至少不会出现数据不一致的问题

  • 所以如果从这个角度来看的话,先删除缓存,后更新数据库才是更优解。

🎯🎈如何解决后者失败的情况呢?

使用消息队列重试

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

Redis 缓存数据库一致性手撕面答

但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起。

有没有必要更新成功后就投递到mq呢?而不是等到redis删除失败了再投递到mq

如果考虑Java项目进程在更新数据库之后就宕机了的话,那无论哪种都没法避免缓存删除的失败 如果硬抠的话,更新成功后,到删除redis还有一段间隙,这个间隙先投递到mq,会更有保障一点,但相应的编码规则也更麻烦了点

  1. 更新数据库成功,投递到mq表示要删除 某个key
  2. 业务继续执行,删除redis:
  1. 删除成功的话,需要删除掉mq里边的消息,防止重复消费
  2. 删除失败的话,就需要消费者拉取mq,再次删除缓存,实现重试机制,当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。后续的处理可能就先丢到死信队列里边了。

可以发现编码确实麻烦了些,因为要考虑重复消费的问题。那么如何解决呢?Java崩了,咱下游还有 mysql 和 mq 没崩呢!也就因此引出了下文的订阅binlog日志的方法

订阅binlog日志

原理:更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

具体流程:

  1. canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议;
  2. mysql master收到dump请求,开始推送binary log给slave(也就是canal);
  3. canal解析binary log对象(原始为byte流);
  4. canal将解析后的对象,根据业务场景,分发到比如 MySQL 、RocketMQ 或者 ES 中。

Redis 缓存数据库一致性手撕面答

总结

Redis 缓存数据库一致性手撕面答

发表评论

相关文章