聊一聊缓存和数据库不一致性问题的产生及主流解决方案以及扩展的思考2:https://developer.aliyun.com/article/1394670
四、缓存与数据库的一致性问题
先谈问题,再谈理论,最后说如何去实现。
需要一点点耐心阅读,为了减轻大家的理解和记忆负担,图文并茂。
咱就说,别慌!!!
一切设计都是基于业务的,所以不同的场景会产出不同的最佳实践
暂无共有的最佳实践,下面的讨论也是如此。
希望大家友善踊跃交流,谢谢
数据库和缓存的数据不一致问题,大都是产生在更新数据时。
在更新的时候,操作缓存和数据库无疑就是以下四种可能之一:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
一个一个分析,为什么会产生数据不一致问题?
4.1、先更新缓存,再更新数据库
操作流程大致如下:问题出现在第四个操作上
如果我成功更新了缓存,但是在执行更新数据库的那一步,服务器突然宕机了,那么此时,我的缓存中是最新的数据,而数据库中是旧的数据。
脏数据就因此诞生了,并且如果我缓存的信息(是单独某张表的),而且这张表也在其他表的关联查询中,那么其他表关联查询出来的数据也是脏数据,结果就是直接会产生一系列的问题。
4.2、先更新数据库,再更新缓存
先更新数据库,再更新缓存,其实还是存在类似的问题。
只有等到缓存过期之后,才能访问到正确的信息。那么在缓存没过期的时间段内,所看到的都是脏数据。
从上面两张图中,大家也能看出,无论咋样,只要执行第二步时失败了,就必然会产生脏数据。
思考:如果如果如果两步都能执行成功?能保证数据一致性吗?
其实也不能,因为还有Java常考的并发。
4.3、并发情况下的思考
如果上面的两小节,两步操作都能成功,在并发情况下是怎么样的呢?
换成是先更新数据库,再更新缓存,也是一样的。
在这里可以看到当执行时序被改变,那么就必然会产生脏数据。
看到这里,也许学过 Java 锁知识的小伙伴可能会说,咱们可以加锁啊,这样就不会产生这样的问题啦~
在这里确实可以加锁,以保证用户的请求顺序,来达到数据一致性。
虽然加锁确实可以通过牺牲一些性能来保证一定数据一致性,但我还是不推荐更新缓存的方式。
原因如下:
- 首先加入缓存的主要作用是提高系统性能。
- 其次更新缓存的代价并不低。
- 复杂场景下:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
- 可能一些场景是需要这样的。
- 缓存利用率问题。一个频繁更新的缓存,它是否会被频繁的访问呢?
- 一个缓存在很短的时间内,更新10次,20次或者更多,但是实际访问次数只有1、2次,这其实也是一种浪费。
- 如果采用删除缓存就不会这样,删除了缓存,那么就只会等到有人要使用缓存的时候,才会重新查询数据,放入缓存中。这其实也是懒加载的思想,等到要使用了,再加载。
当然业务场景确实有这样的场景,这么使用也未免不可, 一切都要实事求是,而并非空谈。
接着我们再思考思考:难道先删除缓存,再更新数据库,或者是先更新数据库,再删除缓存就没有问题了吗?
4.4、先删除缓存,再更新数据库
这种方式在没有高并发的情况下,是可能保持数据一致性的。
如果只有第一步执行成功,而第二步失败,那么只有缓存中的数据被删除了,但是数据库没有更新,那么在下一次进行查询的时候,查不到缓存,只能重新查询数据库,构建缓存,这样其实也是相对做到了数据一致性。
但如果是处于读写并发的情况下,还是会出现数据不一致的情况:
执行完成后,明显可以看出,1号用户所构建的缓存,并不是最新的数据,还是存在问题的~
4.5、先更新数据库,再删除缓存
如果更新数据库成功了,而删除缓存失败了,那么数据库中就会是新数据,而缓存中是旧数据,数据就出现了不一致情况。
和之前一样,如果两段代码都执行成功,在并发情况下会是什么样呢?
还是会造成数据的不一致性。
但是此处达成这个数据不一致性的条件明显会比起其他的方式更为困难 :
- 时刻1:读请求的时候,缓存正好过期
- 时刻2:读请求在写请求更新数据库之前查询数据库,
- 时刻3:写请求,在更新数据库之后,要在读请求成功写入缓存前,先执行删除缓存操作。
这通常是很难做到的,因为在真正的并发开发中,更新数据库是需要加锁的,不然没一点安全性~
一定程度上来讲,这种方式还是解决了一定程度上的数据不一致性问题的。
4.6、小小总结
1、无论选择下列那种方式
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
如果是在多服务或是并发情况下,其实都有可能产生数据不一致性。
不过在这四种选择中,平常都会优先考虑后两种方式。并且市面上对于这后两种选择,也已经有一些解决方案。
在谈解决方案之前,我们先看看需要解决的问题:
- 我们要如何保证这两段代码一起执行成功?
- 【先删除缓存,再更新数据库】在读写并发时,会产生一个缓存旧数据,而数据库是新数据的问题,这该如何解决呢?
- 加锁可以解决并发情况下出现的不一致问题吗?
关于第三点讲解,在下一篇关于本地锁到Redis分布式锁中讲解。
4.7、关于数据一致性的补充
简单说,只要使用缓存,那么必然就会产生缓存和数据库数据不一致的问题。
在这首先我们要明确一个问题,就是我们的系统是否一定要做到“缓存+数据库”完全一致性?是否能够接受偶尔的数据不一致性问题?能够接受最长时间的数据不一致性?
强一致性
如果缓存和数据库要达到数据的完全一致,那么就只能读写都加锁,变成串行化执行,系统吞吐量也就大大降低了,一般不是必须达到强一致性,不采用这样的方式。
并且实在过于要求强一致性,会采用限流+降级,直接走MySQL,而不是特意加一层 Redis 来处理。
弱一致性(最终一致性)
一般而言,大都数项目中,都只是要求最终一致性,而非强一致性。
最终一致性是能忍受一定时间内的数据不一致性的,只要求最后的数据是一致的即可。
例如缓存一般是设有失效时间的,失效之后数据也会保证一致性,或者是下次修改时,没有并发,也会让数据回到一致性等等。
五、数据一致性解决方案
所谓的解决方案,其实大都也就是解决之前我们提出来的几个问题~
5.1、如何保证这两段代码一起执行成功
要想第二段代码成功执行,那么重试是必不可少的啦。
重试的思想,在学习Java的道路会遇到很多次的哈,
1)引子
像如果学习过Java中锁相关知识的朋友,应该会记得自旋锁和互斥锁~
自旋锁
:一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,它不用将线程阻塞起来(NON-BLOCKING);
互斥锁
:把自己阻塞起来,等待重新调度请求。
自旋锁的思想其实也就是一个while(true)
一直重试罢了。
还有使用过openfegin
的朋友会知道,它在发送请求时,也包含有一个重试机制,很多高可用的场景,都会加上重试~
2)重试
但是重试存在的问题,也有很多,需要重试几次呢?重试的间隔时间是多少呢?重试再失败该如何补偿呢?在重试的过程中,如果程序宕机,重试也就丢失啦
看到这些你有没有头大,有的话,就对了,认真思考每一个点,你都会发现很多其他的知识,这往往比老老实实的学习更有效。
我们如果仍然像锁机制
或者是openfeign
的机制一样,采取同步重试的方式的话,是解决不了问题的,如同步重试是可能会失败的,如果一直失败,则会一直占用线程资源,导致其他用户的请求无法正常被执行。
应该很容易想到,同步的对立面就是异步,异步重试,交由别人来做这件事情,自己不用去管这件事情即可。
谈到异步,并且是第三方来做的,最快想到的无疑就是消息队列啦~
3)消息队列-异步
如果学习过消息队列的朋友,应该很快就能get到,或者自己思考到这一点;
如果没有学习过的话,我觉得学习消息队列还是非常有必要的一件事情。
我们可以把第二步操作交由消息队列去做,达到一个异步重试的效果。并且引入消息队列来实现,代价并非想象中的那么大。
当然大家也会说,如果发送消息也失败呢?
有这种可能,但真的不算高,另外消息队列自身是很好的支持高可用的。
- 首先消息队列在高并发的场景下,可以毋庸置疑的说是一个非常重要的组件啦,所以引入消息队列以及维护消息队列,其实都不能算是额外的负担。
- 其次消息队列具有持久化,即使项目重启也不会丢失。
- 最后消息队列自身可以实现可靠性
- 保证消息成功发送,发送到交换机;
- 保证消息成功从交换机发送至队列;
- 消费者端接收到消息,采用手动
ACK
确认机制,成功消费后才会删除消息,消费失败则重新投递~
图:
(说明:消息队列的内部可靠机制就没有再详细画了)
4)Canal 订阅日志实现
消息队列虽然已经比较简单,但是仍然要手动的进行代码的编写,以及写一个消费者来进行监听,可以说还是比较麻烦,每个地方都还要引入消息队列,发送一个消息~,有没有办法省去这一步呢?有的勒,偷懒的人大有人在勒
现有的解决方案中,可以使用 alibaba 的开源组件 Canal
,订阅数据库变更日志,当数据库发生变更时,我们可以拿到具体操作的数据,然后再去根据具体的数据,去删除对应的缓存。
当然Canal
也是要配合消息队列一起来使用的,因为其Canal
本身是没有数据处理能力的。
相应的流程图大致变成下列这样:
优点:
- 算的上彻底解耦了,应用程序代码无需再管消息队列方面发送失败问题,全交由
Canal
来发送。
缺点:
- 引入了
Canal
中间件,需要一定的维护成本,需要实现高可用的话,也需考虑集群等,架构也会进一步变得复杂。
具体的代码实现,还是需要各位朋友去进行一番搜索啦。
在本文中,我更多的是针对Redis方面的学习,关于这部分的内容以及实现,我也只是通过八股文和一些文章观摩,并没有进行深入的研究,说来实在惭愧,还请各位见谅。
5.2、延时双删策略
问题:【先删除缓存,再更新数据库】在读写并发时,会产生缓存是旧数据,而数据库是新数据的问题,这该如何解决呢?
(图片说明:上图为产生数据不一致性的情况)
延时双删流程图
解决这样的问题,其实最好的方式就是在执行完更新数据库的操作后,先休眠一会儿,再进行一次缓存的删除,以确保数据一致性,这也就是市面上给出的主流解决方案--延时双删。
相信大家在诸多面试八股文中,也常常会看到这个吧~
但是更加深入的思考“延时”两字,这个延时到底延时多久合适呢?有什么评判依据吗?
首先延迟删除的时间需要大于 1号用户执行流程的总时间
即:【1号用户从数据库读取数据+写入缓存】时间
但是要说具体是多长,这无法给出一个准确答复,只能经过不断的压测和实验,预估一个大概的时间,尽可能的去降低发生数据不一致的概率罢了。
补充
:并发问题的解决,最常用的方式无疑就是加锁,那到底是加什么锁呢?在分布式系统中,对于并发,加的无疑就是分布式锁。
写到这里已经感觉有点长了,分布式锁的演进,打算另外开一篇文章。
总结
能够看到这里的话,那咱们就一起再看一遍文章大纲吧,看看你是否已经理解啦~
后记
本文参考于水滴与银弹(很强的一个大佬,同名公众号)大佬文章《缓存和数据库一致性问题,看这篇就够了》 大家感兴趣可以去关注关注大佬的公众号。
虽然写完也有再次阅读,但不可避免会出现疏忽或遗漏,如在阅读中发现任何问题,请及时联系我(留言、私信、微信群、微信nzc_wyh都可,备注掘金),一定会在第一时间进行修正,非常非常感谢各位,能够读到此处。
其实写下这篇文章的我,也并不知道这篇文章有没有让你有所收获,究竟是满载而归,还是竹篮打水一场空,我也不知。
但无论是哪一种,我都希望这里能留下属于你的痕迹(留言或者私信啦~)
写的不好的话,我下次就再改改啦~
写的还行的话,就请给予一些正向反馈吧,也让我收获收获属于工作日的快乐吧~
希望有吧,有的话记得给我来个赞~