MySQL数据库和Redis缓存一致性

我正在参加「掘金·启航计划」
缓存一致性是后端开发同学在实际开发中经常会遇到的问题,特别是系统并发量上去了之后,问题会更加凸显出来。近几年工作中也遇到过一些实际问题,不过一直没有总结相关问题,今天做一次梳理和总结。

常见缓存策略

读取

读缓存.png

读取的策略大多数情况下是比较一致的:先从缓存读取,如果未读取到,就从DB读取,写入缓存,再返回

更新

常见的4种更新策略:

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

4种策略在读写并发和双写并发下的表现

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

读写并发场景

读取线程A在更新线程B更新数据库前,未命中缓存,从数据库读取old值并写入缓存
读取线程C在更新线程B更新缓存前,读取命中缓存,返回old值,产生读取旧数据
这些线程结束后,数据库为new,缓存为old,产生不一致
先更新数据库,再更新缓存-读写并发.png

双写并发场景

线程A更新数据库和更新缓存都在线程B更新之后数据库之后,更新缓存之前,最终数据库为newA,缓存为newB,产生不一致。
先更新数据库,再更新缓存-双写并发.png

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

读写并发场景

与上面问题类似,当线程A出现读取未命中时,从数据库查询到旧数据,如果更新缓存发生在线程B之后,就会出现最终状态数据库为new,缓存为old的不一致。 先更新缓存,再更新数据库-读写并发.png

双写并发场景

线程B更新数据库和更新缓存都在线程A更新之后数据库之后,更新缓存之前,最终数据库为newA,缓存为newB,产生不一致。
先更新缓存,再更新数据库-双写并发.png

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

读写并发场景

与上面问题类似,当线程A出现读取未命中时,从数据库查询到旧数据,如果更新缓存发生在线程B之后,就会出现最终状态数据库为new,缓存为old的不一致。 先删除缓存,再更新数据库-读写并发.png

双写并发场景

最终状态数据库为newB,缓存为空,算一致。 先删除缓存,再更新数据库-双写并发.png

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

读写并发场景

与上面问题类似,当线程A出现读取未命中时,从数据库查询到旧数据,如果更新缓存发生在线程B之后,就会出现最终状态数据库为new,缓存为old的不一致。 先更新数据库,再删除缓存-读写并发.png

双写并发场景

最终状态数据库为newB,缓存为空,算一致。 先更新数据库,再删除缓存-双写并发.png

分析

上面4种策略,在读写并发和双写并发场景下,都存在读取到旧值,数据库缓存不一致的情况,看来并没有银弹,采取某种策略能直接彻底解决这个问题。

删除 or 更新

针对更新的场景,我们对缓存值的处理有删除和更新,哪种更好呢?
删除

优点:双写场景下,不会出现不一致
缺点:读取时才更新,类似懒加载,降低了命中

更新

优点:增加读取命中
缺点:会出现不一致

比较下来,如果存在较多并发更新场景,这里的不一致可能是更加不可接受的点,所以我倾向于使用删除,当然,如果业务场景很明确不太可能出现并发更新的话,使用更新缓存也挺好的。

延迟双删

上面4种策略,在读写并发场景下,有一个共性的问题,就是读取线程未命中缓存时,由于读取策略会带来一次写缓存,如果从DB读取到的是old值,就会出现数据库与缓存不一致,那是否有办法解决呢?
这里介绍一种延迟双删的策略,即在"先更新数据库,再删除缓存"策略的基础上,最后延迟一段时间n,再执行一次缓存删除,n的时长略大于一次线程A从DB查询再写入缓存的时间,比如1s,通过这种方式,既是发生了读写并发导致的不一致,也只在时间段n以内,最终还是会达到一致性。

延迟双删.png

第1次删除在更新DB前 OR 第1次删除在更新DB后

网上也有很多介绍延迟双删的文章,第1次删除是在更新DB前。
我是这么理解的:
如果说后续请求在(删除+更新)这个组合之后,那么无论第一次删除是在更新前还是更新后,后续的读取请求都是读取到新数据,这个应该不在讨论范围。
如果说后续请求是在(删除+更新)一个操作之后,第二个操作之前,那么先删除情况,读取不到缓存,会走到DB,由于更新未完成,读取到旧数据,且会把这个旧数据写回到缓存,等延迟的第2次删除才会被清理掉;先更新的情况,由于读取请求在第2个操作删除缓存之前,所以此时读取到缓存中的旧数据,但是不会再会写缓存,等第2个操作删除(这里不是指延迟后的第2次删除,而是更新+删除的第2个操作删除,就是延迟双删的第1次删除)后,后续的请求都会读取到新数据。
比较下来,无论(更新+删除)哪个操作在前,如果产生了并发读,都会读取到旧数据,不过好像先更新更好一些,避免了这种情况下第2次删除前的缓存旧数据(当然,这种策略还是需要双删的,因为如果并发读请求在更新之前,但是写缓存在第1次删除之后,还是需要第2次延迟的删除来保证最终一致性,这里说的仅仅是避免了这种情况下,在第一次删除后第二次删除前读取到旧数据)。

总结

没有银弹策略,场景不同选择就会不同,例如存在双写并发,删除会好于更新,如果不存在,更新会好于删除。要解决读写并发带来的不一致,延迟双删是一个不错的思路,但是也会有代价,为了实现延迟,又会引入一个别的组件,这个可能与“如非必要,勿增实体”的原则有点违背,引入越多的组件带来越高的复杂度,也带来更多的bug的可能。
回到缓存最初的目标,为了抗住更大的列量,不让多数请求都走到DB,我们区分出了那些业务上不会经常变更(写少)但是读取非常频繁的数据,增加了1层缓存,所以在我看来,对于大多数刚起步的业务系统,还是简单处理为主,采取"先更新数据库,再删除缓存"的策略,而不会引入延迟双删,第2次删除是要靠缓存本身的有效时间来保证,虽然大多数时候,这个有效时间比较长。