您的位置:首页 > 数据库 > Redis

【Redis笔记】一起学习Redis | 聊聊缓存,数据库的双写数据不一致问题

2019-08-01 14:41 585 查看
版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons

一起学习Redis | 聊聊缓存,数据库的数据不一致问题

如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!博客目录 | 先点这里

  • 前提概要 通常的缓存架构流程
  • 双写数据不一致问题?
  • 没有绝对的真理
  • 了解缓存架构的写操作
      前提提醒
    • 先更新数据库,再更新缓存
    • 先更新缓存,再更新数据库
    • 先更新数据库,再删除缓存
    • 先删除缓存,再更新数据库
  • 纠结的抉择?
      前提提醒
    • 删除缓存还是更新缓存?
    • 先删除缓存,还是先更新数据库?
  • 最后的总结
      如何选择的总结

    前提概要

    通常的缓存架构流程

    • 读操作

      读取数据的时候,先读缓存,缓存没有的话,再读数据库获取数据,更新到缓存,同时返回数据
    • 写操作

      (1) 先更新缓存,后更新数据库
      (2) 先更新数据库,后更新缓存
      (3) 先删除缓存,后更新数据库
      (4) 先更新数据库,后更新缓存

    双写数据不一致问题?

    缓存是什么,就不解释了吧。那什么是双写呢?

    • 意思就是当数据库中的数据要被更新的时候,数据库需要执行一次写操作,缓存同样也要执行一次写操作,组合起来就是双写啦!

    那什么是双写数据不一致呢?

    • 就是当缓存和数据库都要执行写操作的时候,因为各种原因,很容易导致缓存和数据库中的数据出现不一致现象

    简而言之,我们本篇文章主要讨论的就是缓存架构中的

    写操作
    ,以及其可能会导致的数据不一致问题。

    没有绝对的真理

    首先在如何解决双写数据不一致的问题上,是绝对完美的正确答案的,所以在看本文的时候,请不要抱着绝对正确的态度去理解。怎么才算是缓存架构双写的最佳实践?不不不,这本身就是有争议的。同时抛开场景,抛开具体的业务谈最佳实践,本身就是一件扯淡的事情,所以这里只是描述一下存在的问题,以及通常情况下,大家是如何解决的!并没有最佳实践之说。


    了解缓存架构的写操作

    前提提醒

    我们知道缓存架构中的写操作,只有四种情况

    • (1) 先更新缓存,后更新数据库
    • (2) 先更新数据库,后更新缓存
    • (3) 先删除缓存,后更新数据库
    • (4) 先更新数据库,后更新缓存

    而这四种情况都会导致一定的数据不一致问题。那什么情况下会让这四种情况分别出现数据不一致问题呢?
    一般我们从两个方向来分析:

    • 双写的第二次写操作失败的情况
    • 高并发场景下的双写线程安全问题

    为什么不讨论双写的第一次写失败呢? 因为第一次写失败了,在代码上就可以判断,不继续第二次的写操作就行了,这是一个简单的问题。但是第二次写失败了后,因为第一次写已经成功了,所以就要考虑回滚或是其他的补偿方式了,这个场景比较复杂。

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

    第二次写失败:

    • 如果先更新数据库成功后,再更新缓存失败,会导致缓存是旧数据,这就是出现了数据不一致

    并发激烈的时候:

    • 线程A成功更新了数据库的值为100, 准备更新缓存
    • 线程B紧跟线程A更新了数据库的值为200,也准备更新缓存
    • 按道理线程A更新缓存的动作应该比线程B快,但因为网络波动的问题,线程B的更新请求更快到达Redis,于是缓存值被线程B更新为200
    • 在线程B更新了缓存之后,线程A的更新请求也到达了Redis, 于是缓存值被更新为100
    • 最后导致数据库值为200,而缓存值为100的数据不一致情况
    • 写多读少
      的场景容易产生这种问题

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

    第二次写失败:

    • 如果先更新缓存成功,再更新数据库时失败了,就会导致缓存中的数据是脏数据,是一份不应该出现的数据,这也就出现了数据不一致问题

    并发激烈的时候:

    • 线程A更新缓存值为100,准备更新数据库
    • 线程B更新缓存值为200,准备更新数据库
    • 因为网络的问题,线程B的请求比线程A的更快到达数据库
    • 所以线程B先将数据库的值更新为200
    • 然后线程A又将数据库的值更新为100
    • 最后导致缓存值为200,而数据库值却为100的数据不一致情况
    • 写多读少
      的场景容易产生这种问题

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

    第二次写失败:

    • 如果删除缓存成功,更新数据库失败,没有关系,最多下次访问,缓存就重新写上了,
      没有数据不一致问题
      ,但这样的结果只在并发弱场景下才有意义。

    并发激烈的时候

    • 线程A要将数据库的值100更新为200,所以线程A执行了删除缓存操作,准备更新数据库
    • 线程B在线程A删除缓存操作之后,更新数据库之前,对该同一数据进行了
      读访问
    • 因为线程B发现缓存数据为空(线程A删除了),所以要去数据库load数据,并重新将值100刷到缓存中。所以此时缓存的数据又被线程B更新为旧数据
    • 在线程B读操作并更新了缓存之后,线程A此时更新了数据库的值为新值200
    • 于是就是出现了缓存为100,数据库为200的数据不一致情况
    • 读多写少
      的场景容易发生数据不一致

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

    第二次写失败:

    • 如果先更新了数据库,再删除缓存时失败了,会导致缓存中的数据是旧数据

    并发激烈的时候

    • 数据库和缓存中的值都为100,而恰好此时缓存过期了,或是被其他线程更新数据后删除
    • 线程A此时对该数据进行
      读访问
      ,因为缓存为空, 7ff7 所以去数据库查询,得到值为100, 并准备刷入缓存
    • 线程B紧跟着,做了一个更新数据的操作,将数据库的值更新为200,准备删除缓存
    • 线程B删除缓存,不过缓存目前本来就不存在,所以emm
    • 线程A将旧值100更新到缓存中
    • 这就导致缓存的值为100,而数据库的值为200,出现了数据不一致情况
    • 读多写少
      的场景容易发生数据不一致

    纠结的抉择?

    前提提醒

    上一节,我们了解了缓存架构中四种方式的双写策略。也知道了四种双写策略都会有一定的问题。大致就是第二次写操作失败的情况和高并发导致的线程不安全情况。其实第二次写操作失败还是比较好处理的,只需要做一下补偿或重试即可。而高并发下的线程不安全就比较复杂了。总不能为每一个数据的双写操作都加一个锁吧,这样就会大幅度降低服务的性能,还是有一些不妥。

    既然四种都有一些问题,但是问题总有大有小,那么我们平时到底怎么从四种双写策略中进行选择呢?哈哈哈。我们先不管双写的顺序如何,我们第一个问题考虑的是,写的类型。对缓存的写操作是

    “淘汰缓存,还是更新缓存?”

    删除缓存还是更新缓存?

    我们知道四种情况中,其实除了双写顺序的区别,另一个最大的区别就是对缓存写操作的类型,既是 "删除缓存,还是更新缓存?"

    • 如果业务是一个写多读少的场景,并且你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算才能要得到写入的缓存结果。此时如果采用
      "更新缓存"
      的策略就会导致,数据压根还没被任何人读到,缓存就因为频繁的数据库写操作,而被替换掉。所以在这种情况下,采用更新缓存的策略,就是一种浪费性能的表现。所以在这些场景下,更推荐使用
      "删除缓存"
      的策略
    • 如果你的业务是读多写少,而且更新缓存很快的话,那么你还是可以考虑一下采用
      "更新缓存"
      的策略,唯一的好处就是,可以提前将缓存预热,避免下次访问的时候再生成。但是这里一开始也说了一个前提,就是更新缓存本身就很快,所以这一点预热好像也不是非常紧要!

    所以综上分析,在一遍无痛无痒,读多写少的业务情况下,可以考虑采用

    "更新缓存"
    的策略,但是却没有什么太多的好处。所以反而是
    "删除缓存"
    的策略更为通用,好处更大。

    所以笔者这里更为推荐将

    "删除缓存"
    作为对缓存的写操作!!

    先删除缓存,还是先更新数据库?

    前面,我们在删除缓存和更新缓存之前,做出了一定的分析,并从中更为推荐采用

    "删除缓存"
    。 所以我们这里就抛开
    “更新缓存”
    的方案,专注删除缓存和更新数据库的顺序操作。

    • (1)
      先删除缓存,再更新数据库
    • (2)
      先更新数据库,再删除缓存

    其实吧,以上这两种策略都是可以接受的。但是都要针对不同的场景,做具体的分析。

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

    一般的低强度并发场景

    • 我们知道,在低并发的场景中,我们选择先删除缓存,再更新数据库的策略,什么都不做,就可以避免受到二次写操作失败的影响的。因为我们先删除了缓存,即使第二次写操作失败了,最终结果仅仅是缓存没了,只要下次读操作就可以重新生成,所以几乎没有任何损失。在这样的场景下,我们是可以使用
      “先删缓存,后更新数据库”
      的双写策略的

    高并发场景下就不行了

    • 虽然在低并发场景,先删缓存的策略能很好的应对写操作失败的情况,但是在高并发场景下,该方案很容易会被其他线程的读操作的影响,既其他线程在某个线程
      [删除缓存 , 更新数据库]
      之间的时间里访问了数据,就会造成旧数据被重刷到缓存中,从而引起数据不一致的问题。所以在这一点上,该策略是不可靠的。但是还是有改进措施的!!!

    针对高并发场景的改进

    • 因为"先删除缓存,后更新数据库" 在高并发场景,容易出现数据不一致问题,所以我们就需要对该方式进行一点点的改造,让其拥有应对写失败的能力,又可能抗住高并发的环境!!
    • 既将双写策略改为三写策略,
      “①先删除缓存,②再更新数据库,③延迟一段时间,再删除缓存”
    • 最后的一次删除操作,可以将其他线程的读操作造成的旧缓存给删除掉,从而达到避免数据不一致的情况跟。既虽然你在我未执行完所有操作期间,读了旧数据,还刷了上去,但是没有关系,最后我还会再删多一次。
    • 最后一个删除操作的间隔时间,可以是一小段时间,比如0.5s,1s。可以通过业务线程的休眠直接实现,也可以将最后一个删除请求转移到
      消息队列
      ,交给异步线程来做,底层 提供吞吐量!!
    • 当然三写策略也并非完美,它就是一个
      最终一致性
      的操作。既需要容忍一小段时间的数据不一致,既在更新了数据库之后,最后一次删除操作前的这段时间里,依然会存在一小段时间的数据不一致情况。但是最终会达到一致!!所以在高并发,且能容忍最终一致性的场景,可以考虑改进后的三写策略
    • 当然给数据设置过期时间,也可以实现最终一致性,只是剩余的过期时间不定,可长可短,长的有可能就太长了。另外再复杂点,就还要考虑最后一次删除要是失败了,要怎么应对(一般都是补偿了)

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

    “先更新数据库,再删除缓存”
    的双写策略实际就是老外所提倡的
    Cache Aside Pattern
    ,既

    • 读取数据的时候,先读缓存,缓存没有的话,再读数据库获取数据,更新到缓存,同时返回数据
    • 更新数据的时候,先更新数据库,然后再删除缓存

    如果我们选择了

    先更新数据库,再删除缓存
    策略,也会有出现数据不一致的可能,但是老外们为什么提倡呢?

    • 这是因为,如果我们仔细观察,就会发现这里的数据不一致虽然有概率发生,但其实是比较难发生的。为什么呢?
    • 因为一般情况下,读操作的速度肯定是快于写操作的。所以一个线程的读操作之后,肯定就立马更新缓存了,这个旧缓存一般也肯定会被另一个线程的更新数据库操作之后的删除缓存操作所删掉!所以相比其他双写策略,这里发生数据不一致概率还是小一些的。如果你愿意介绍这样的风险,也是可以考虑直接上的!

    (三) 总结一下

    如果是简单的低并发场景,我们可以采用

    • 先删除缓存,再更新数据库
      的策略, 它可以以最低代码的避免第二次写失败产生的影响

    如果是高并发场景,我们可以采用

    • 先更新数据库,再删除缓存
      的策略,虽然有一定概率发生数据不一致,但概率比较小,看业务是否能接受
    • 三写策略,
      先删除缓存,再更新数据库,隔一段时间,再删除缓存
      , 可以保证数据库与缓存的最终一致性,但也要看数据是否能容忍一小段时间的数据不一致。同时也要考虑最后一次删失败的情况

    说白了,只要你不加锁,无论你怎么进行写操作,高并发场景都有一定的概率发生数据不一致的问题,只是看你能接收多少


    最终的总结

    如何选择的总结

    弱一致性需求

    • 低并发场景,随意选择,但优先考虑
      先删除缓存,再更新数据库
      ,因为不用处理二次写失败的问题
    • 高并发场景,读多写少的情况,就相当写并发少,可以考虑
      更新缓存的两种策略
    • 高并发场景,写多读少的情况,就相当读并发少,可以考虑
      删除缓存的两种策略
    • 再严谨些,可以考虑三写策略和老外推荐的
      先更数据库,再删除缓存

    强一致性需求

    • 考虑为每个数据或一类的数据加
      ,串行执行,数据库和缓存的双写保证原子性。
    • 这个锁可以是
      分布式锁
      ,也可以是
      消息队列
      。因为消息队列单线程消费就保证顺序消费

    参考资料

  • 内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: 
    相关文章推荐