10年 Java程序员,硬核人生!勇往直前,永不退缩!

欢迎围观我的git:https://github.com/R1310328554/spring_security_learn 寻找志同道合的有志于研究技术的朋友,关注本人微信公众号: 觉醒的码农,或Q群 165874185

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

如何保证缓存与数据库的双写一致性? 这是个难题,本文在阅览大量专家博客之后吐血总结10种方案!

 

令 tu为更新线程,tr读线程; tx 为任意线程,就是说可能是tu,也可能是tr。数据库记为D,缓存记为C。从而:
新增数据库记录记为aD, 新增数据库记录记为aC,
更新数据库记录 记为uD,更新缓存记录记为uC。
删除数据库记录记为dD, 删除缓存记录记为dC,
读取缓存记录记为rC, 读取缓存记录记为rC。
 
先列出来更新双写的时候,所有可选的方案:
1 更新数据库,不处理缓存
2 更新数据库 -> 更新缓存
3 更新数据库 -> 删除缓存
4 更新缓存 -> 更新数据库
5 删除缓存 -> 更新数据库
6 更新缓存 -> 更新数据库 -> 更新缓存
7 删除缓存 -> 更新数据库 -> 更新缓存
8 更新缓存 -> 更新数据库 -> 删除缓存
9 删除缓存 -> 更新数据库 -> 删除缓存
10 删除缓存 -> 更新数据库 --> 延迟比如10ms -> 删除缓存
... 可能还有其他情况,不过,太复杂了就不列出了
 
注意:

在使用的方案中,更新数据库 是必须要进行,因为数据库才算真正的持久化保障。

对我们读取、更新、删除缓存的时候,我们是指对同一份即 同一个key 的cache进行操作。 下面的多个操作都是一个方法内完成的多个操作。 因为涉及多个系统,无法保证事务强一致性。只能折中。

更新方法可以考虑加锁,但是对于多实例部署的情况,jvm锁失效!

读线程一般都是: 读缓存 -> 存在缓存则直接返回缓存内容 -> 不存在则查询数据库,查到则更新缓存 -> 查不到则返回空或null

上面10种方案讨论的都是 更新操作的步骤。

-> 表示非常短的时间, --> 表示比较长、但不确定的时间 

 
下面依次详细分析:
 

1 只做数据库更新,不主动删除redis,等它自己过期。

先总结: 非常简单,但缓存过期前会不一致。
分析:tu更新数据库以后,不管redis。等redis中的数据自己过期。tx查询数据库时,顺便把新值写入redis。 就是说t1 只做数据库操作,忽略缓存的存在。
 

2 更新数据库 -> 更新缓存;即 uD -> uC

先总结: 简单,基本没问题,而且一致性比较强(实时性比较高),缺点是可能导致没必要的频繁更新缓存。而且高并发情况下多线程问题可能引起不一致。但是只要缓存过期了,就会恢复一致。虽然可能不一致,但是这种可能性还是比较低的,比不删除缓存,让缓存自动过期好多了!其实我们使用 @CachePut 即可达到此目的。
分析:如果更新线程uD成功,但中间过程有其他读线程操作呢?显然读取的是旧缓存值。这种情况也不好处理,但几乎没有什么影响, 最多也是中间过程的一瞬间 读取到了旧值而已, 当uC之后,后面的读线程就会一致
如果是中间过程有其他写操作呢? 假设方法没有加锁,考虑一种情况: tu1 uD -> tu2 uD -> tu2 uC -> tu1 uC,那么就是tu1 的缓存,tu2的数据库记录,同上不好处理,只能容忍或者加锁。当然,如果写很少,那么写也不会有什么并发,这种情况很少可能发生! 加锁, 仅限于一个jvm,跨了就还是不行。
 

3 更新数据库 -> 删除缓存 ;即 uD -> dC

先总结: 简单,基本没问题。是最推荐的做法,也就是所谓的 cache aside。其实我们使用 @CacheEvict 即可达到此目的。
分析:不考虑各种崩溃问题;
如果tu1 uD成功,然后其他线程t 进行读操作,可能是读到了旧缓存值,返回,虽然不一致,但是没关系;
如果tu1 全部执行完了,其他线程 进行读操作,肯定是读不到值,那么从数据库中查询,然后更新缓存。数据库、缓存都是最新的,而且是一致的,没任何问题。
如果是中间过程有其他写操作呢? 同样假设方法没有加锁,考虑一种情况: tu1 uD -> tu2 uD -> tu2 dC -> tu1 dC,那么tu1 dC 其实是空操作,因为缓存已经被tu1 dC 删除了。但不要紧,这种情况也没有任何问题。
 

4 更新缓存 -> 更新数据库;即 uC -> uD

先总结: 简单,基本没问题,但是同样的,可能导致缓存更新过于频繁。

分析:如果uC成功,但中间过程有其他读操作呢?显然读取的是新缓存值。这种情况,因为数据库还未更新,有瞬间的不一致,但影响不大。
如果是中间过程有其他写操作呢? 如果方法上有整体加锁,那基本上还是可以保证不会乱序。 否则就可能出问题。如果 tu1 uC -> tu2 uC -> tu1 uD -> tu2 uD 那么也不会有问题 ; 但考虑一种情况,比如tu1 执行了uC, 然后tu2进来了,tu2执行完了整个方法。tu1 执行uD, 即 tu1 uC -> tu2 uC -> tu2 uD -> tu1 uD ,那么就存在了 数据库和缓存不一致,即tu2 的缓存,tu1的数据库记录。 这种不一致怎么办呢? 那就没办法,如果业务容忍度比较高,那只能等缓存过期,否则就加锁吧
 

5 删除缓存 -> 更新数据库;即 dC -> uD

先总结: 读写操作并发的时候,可能导致缓存被重新赋值为旧数据。
分析:不考虑各种崩溃问题;
如果tu1 dc成功,然后其他线程tr 进行读操作,读不到值,那么从数据库中查询,读到的旧值(因为此时uD还没完成),然后tu1 uD,然后tr再用旧值更新缓存。从而导致缓存是旧值,数据库是新值,导致了不一致.. 即tu dC -> tr uD -> tu uD、tr uC 不限顺序; 这种情况比较麻烦,在读多写少的高并发场景, 还是很可能发生的
 
如果是中间过程有其他写操作呢?
 

6 更新缓存 -> 更新数据库 -> 更新缓存;即 uC -> uD -> uC2

先总结: 这种做法能解决uC -> uD方案的问题吗?两次更新缓存,虽然减少了不一致的过滤,但仍然存在不一致的情况,代价比较高,意义不大。
分析:考虑到即 tu1 uC -> tu2 uC -> tu2 uD -> tu1 uD 存在不一致的问题,那如果再次更新缓存呢?有以下几种 交叉写线程执行 的情况:
如 tu1 uC -> tu2 uC -> tu2 uD -> tu2 uC2 -> tu1 uD -> tu1 uC2 一致!
如 tu1 uC -> tu2 uC -> tu2 uD -> tu1 uD -> tu2 uC2 -> tu1 uC2 一致!
如 tu1 uC -> tu2 uC -> tu2 uD -> tu1 uD -> tu1 uC2 -> tu2 uC2 不一致!
如 tu1 uC -> tu2 uC -> tu1 uD -> tu2 uD -> tu2 uC2 -> tu1 uC2 一致!
如 tu1 uC -> tu2 uC -> tu1 uD -> tu2 uD -> tu1 uC2 -> tu2 uC2 不一致!
 
如 tu1 uC -> tu1 uD -> tu2 uC -> tu2 uD -> tu2 uC2 -> tu1 uC2 一致!
如 tu1 uC -> tu1 uD -> tu2 uC -> tu2 uD -> tu1 uC2 -> tu2 uC2 不一致!
如 tu1 uC -> tu1 uD -> tu2 uC -> tu1 uC2 -> tu2 uD -> tu2 uC2 不一致!
如 tu1 uC -> tu1 uD -> tu2 uC -> tu2 uD -> tu1 uC2 -> tu2 uC2 一致!
 
可见,仍然存在不一致的情况。
 

7 更新缓存 -> 更新数据库 -> 删除缓存;即 uC -> uD -> dC

先总结: 这种做法能解决uC -> uD或uD -> dC方案的问题吗?两次更新缓存,虽然减少了不一致的可能,但在同写并发的时候仍然存在不一致的情况,此处不再赘述。代价比较高,不如直接 uD -> dC 方案,意义不大。其实我认为这个方案比双删 方案还要好一点,但它也有可能存在更新缓存过于频繁的问题。
 

8 删除缓存 -> 更新数据库 -> 更新缓存;即 dC -> uD -> uC

先总结: 这种做法代价比较高,而且同dC -> uD,在同时读写的时候仍然存在不一致的情况。几乎毫无意义。所以这种方案直接舍弃

9 删除缓存 -> 更新数据库 -> 删除缓存;即 dC -> uC -> dC2

先总结: dC -> uD在同时读写的时候可能出现不一致,这种方法能否补救呢?有效,但不完全是!
分析:
如 tu dC -> tr uD -> tu uD、tr uC 不限顺序 -> tu dC ; 基本上没问题,一致!
如 tu dC -> tr uD -> tu uD -> tu dC -> tr uC 不一致!
 
就是说还是存在不一致,但是几率小了一些!
 

10 删除缓存 -> 更新数据库 -> 延时比如10ms -> 删除缓存;即 dC -> uC --> 延时 -> dC2, 即所谓的 延时双删

先总结: 还是存在不一致,但是几率比上者 更加小了一些! 因为此种情况读的两个操作不大可能相隔比写+延时 还要久!
分析:
如 tu dC -> tr uD -> tu uD -> 延迟比如10ms -> tu dC -> tr uC 不一致!
 
 
 

考虑极端情况

  • 一种极端情况是:读线程tr 查询的时候,缓存已经失效,同时有写线程进来更新方法。比如对于tu uD -> tu uC :tr 从数据库读取旧值,然后刚好另外的写线程tu进来,tu 更新数据库、 更新缓存, 然后 tr 更新缓存。 这就导致了 不一致,即 tr rC, C过期失效 ---> tr rD -> tu uD -> tu uC -> tr uC。—— 可能性很小, 但是, 读线程执行的读+更新缓存, 会比写线程执行两个更新操作耗时更久吗?因为cpu调度不可控,这种极端情况也不是不可能, 其实极端情况对于任何情况,也适用: tr rC, C过期失效 ---> tr rD -> tu 无论执行什么 -> tr uC。这个情况,真不好搞,但是也没办法。考虑读线程 从发现缓存失效过期,到 tr rD 再 tr uC的时间一般很短,所以这种可能性是非常小的,是极端情况。
    • 如果非要搞一个方法解决, 那么可以这样:读线程发现缓存失效过期了,然后 rD, 准备 uC,uC之前发现缓存已经存在,那么放弃uC! 或者给缓存加个版本号,也可以解决此问题,但是更加麻烦了!
  • 考虑操作失败的情况:如果数据库更新失败,更新/删除缓存失败,那么自然就导致不一致的情况。 不同之处是更新缓存失败 影响较小,因为缓存可以设置过期,只要过期时间足够短就好了。但问题是,为什么操作失败? 缓存、数据库系统崩溃?
如果我们系统足够鲁棒的话,我们可以进行重试,也可以直接忽略之。比如,如果是数据库崩溃了或不可用,那么也可以考虑只使用缓存进行读,而不允许写,这样就避开了数据库。如果缓存操作失败,只要系统会自动识别这种错误,然后不使用缓存,所有请求直接走数据库, 其实还是可以保证系统基本可用。
 

考虑删除的情况

上面只是更新,如果考虑删除数据库记录的情况, 怎么做比较好呢?:
删除缓存 -> 删除数据库,类似上面的分析,可能在读线程并发的时候,缓存被旧值覆盖,
删除数据库 -> 删除缓存,显然这个方案,更好,简单无缺陷!我们使用 @CacheEvict 即可!
 
如果更新、删除混合到一起呢? 其实这种情况不太可能在一个方法中发生, 不再分析。
 

最后的总结

综上所述, 延时双删 这种方案最优,但是也是最复杂的。上面虽有10种方案,但是其中任何一种方法都无法保证强一致性,这个是由于CAP理论导致的吧。
 
一般情况下,如果业务要求也没那么高要求,也没有要求比较高的实时一致,我们使用spring 的缓存注解也就足够了。特殊业务,可能要求比较高的实时一致,任何才需要考虑缓存与数据库的双写一致性, 但是就导致了比较高的复杂度!
 
 
纸上得来终觉浅,绝知此事要躬行!看了那么多相似博客,仍然无法记得住,不能真正理解,不如自己亲自分析一遍!自己单独分析可以发现可能很一样的思路( 可能是老旧的错误的不成熟的思路),然后发现自己思路的错误,然后 对比, 然后纠正,才能印象深刻。
 
参考: 
https://my.oschina.net/anxiaole/blog/4922351 写得真是好, 但同时也感觉真是复杂,真的把人看都看哭了!
posted on 2021-03-14 12:17  CanntBelieve  阅读(842)  评论(0编辑  收藏  举报