前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【并发进阶】大厂高并发下,后删缓存依然会存在数据不一致的问题,怎么办?

【并发进阶】大厂高并发下,后删缓存依然会存在数据不一致的问题,怎么办?

作者头像
TodoCoder
发布2022-12-28 18:36:31
5100
发布2022-12-28 18:36:31
举报
文章被收录于专栏:Coder栏Coder栏

 大家好,我是Coder哥,我们继续来聊分布式思想,今天我们来聊一下分布式缓存一致性的问题。这篇比较全面,记得收藏哟!!!如果觉得有帮助点个赞也不是不可以的,^_^

前言

  在写代码的时候,你会发现有很多重复的代码可以提取出来,做成公共的方法。这样,在下次用的时候,就不用再费劲写一遍了。这种思想就是复用。在软件系统中,对于数据存取来说,有同样的复用情况。谈到数据复用,我们首先想到的就是缓存。那么对于分布式系统来讲,有一个重要的分布式缓存组件就是Redis,几乎每家公司都在用。它的 qps 可以达到 10 万每秒,吞吐量是非常可观的。但不论是什么业务,都不得不面对一个棘手的问题:那就是 Redis 和源数据的缓存一致性问题。那么今天我们就从多个维度场景来聊聊是怎么保证缓存一致性的。我们先列出今天要讨论的问题:

  1. 双更新模式,操作不合理,导致数据一致性问题
  2. “后删缓存”能解决多数不一致
  3. 高并发下,“后删缓存”依旧不一致
  4. 如何解决高并发下的数据不一致问题?
  5. 如何解决缓存击穿的问题?

双更新模式,操作不合理,导致数据一致性问题

我们先来看一下要想解决这个问题最先想到的操作是什么:

  • 数据来了,先更新Mysql, 再更新Redis先更新Redis,再更新Mysql (这个我们称为双更新模式)
先更新Mysql, 再更新Redis

如下代码,这也是代码review时重点关注的

代码语言:javascript
复制
public void putValue(key,value){    
 putToRedis(key,value);    
 putToDB(key,value);//操作失败了
}

如上代码,我要更新一个值,先刷新缓存再更新数据库,如果数据库更新失败了,发生了回滚,导致“缓存里的数据”和“数据库的数据”就不一样了,也就是出现了数据一致性问题。

那有人说,我先更新Mysql 等Mysql成功后再更新Redis,这样不就没事了。

我们来分析一下

先更新Redis, 再更新Mysql

如下代码

代码语言:javascript
复制
public void putValue(key,value){    
 putToDB(key,value);    
 putToRedis(key,value);
}

这个代码,单线程下是没有任何问题(忽略redis异常)的,但多线程下依然会有问题。

考虑到下面的场景:操作 1 更新 a 的值为 1,操作 2 更新 a 的值为 2。由于Mysql和 Redis 的操作,并不是原子的,它们的执行时长也不是可控制的。当两个请求的时序发生了错乱,就会发生缓存不一致的情况。

就如上图所示:操作1在更新数据库成功后,再更新 Redis;但在更新 Redis 之前,另外一个更新操作2 执行完毕。那么操作1 的这个 Redis 更新动作,就和数据库里面的值不一样了。

其实在实际操作中,更新模式的问题,主要不是在并发下导致的一致性问题,而是更新模式在业务操作上的不合理。什么意思呢?

我们大多数缓存业务场景,一个缓存的值,并不是单个的一个数据库的值,可能是从多个地方查询后经过计算得出的一个结果,比如,一个用户的余额,是由“钱包里的值”加上“基金里的值”计算得出来的。如果采用更新模式,需要在钱包里面的值有变动的时候、基金变动的时候都去更新这个余额,那这个计算代码就分散在项目的多个地方,这就不合理了。

那怎么办呢?其实,我们把“更新”改成“删除”就好了。

“后删缓存”能解决多数不一致

我们读取Redis缓存的时候,如果Redis里面没有数据,我们会重新查数据库来更新Redis,这样更新的操作就不会被分散到多个地方了,这样是合理的。那么现在问题是删除缓存的时机到底要在 入库之前还是入库之后呢?

很显然,得在入库之后删除,为什么呢?

先删缓存

我们来看一下先删除缓存会有什么问题:

代码语言:javascript
复制
public void putValue(key,value){    
 deleteRedis(key);    
 putToDB(key,value);
}

如上图,操作2先删除缓存,然后操作1查询结果的时候把缓存更新为 a= 1, 然后操作2继续更新Mysql:a = 2, 此时无论操作2 更新数据库的操作持续多长时间,都会产生不一致的情况。

后删缓存

把删除的动作放在后面,就能够保证每次读到的值都是新的,从数据库里面拿到最新的。

代码语言:javascript
复制
public void putValue(key,value){    
 putToDB(key,value);    
 deleteRedis(key);
}

后删缓存也是我们用的最多的方式,我们可以看一下它的具体实现方式:

写请求,规则是“先更新 db,再删除缓存”,详细步骤如下
  1. 将变更写入到数据库中;
  2. 删除缓存里对应的数据。
读请求,规则是“先读 cache,再读 db”,详细步骤如下
  1. 每次读取数据,都从 cache 里读;
  2. 如果读到了直接反回;
  3. 如果读不到 cache 的数据,则从 db 里面读取一份;
  4. 将读取到的数据写入到缓存中,下次读取时,就可以直接命中。

为什么说最常用呢?因为 Spring cache 就是默认实现了这个模式。一般来讲,按上面的操作就可以应对绝大多数场景。但是在高并发的情况下,这种方式仍然是有问题的。

高并发下,“后删缓存”依旧不一致

在高并发下,后删缓存依然可能会有问题,接下来我们看一下这个场景:

如图所示,一系列的高并发操作,一直执行着更新、删除的动作。某个时刻,操作1更新数据库的值为 1,然后删除了缓存,此时db(a)=1、redis(a)=null操作2操作3同时过来,而操作2此时从库中获取到的数据为 db(a)=1, 操作3更新db(a)=2,并删除缓存 redis(a)=null, 操作2由于STW,停顿时间较长,在操作3结束后才更新的缓存 redis(a)=1, 此时缓存中和数据库中的结果就不一致了: db(a)=2,redis(a)=1

其实我们在开发中不处理这种情况,因为redis更新非常快,这种情况发生的条件是非常苛刻的,它要求在一系列“并发写”的同时,还有“并发读”的参与,并且在putToRedis(key) 的时候停顿时间比较长才会出现这个问题,很多公司的并发是达不到这个量级的,也就没必要处理这个问题了。但我们面试的时候如果能答到这一步,是个加分项。

那么这个问题我们要怎么处理呢?

如何解决高并发下的数据不一致问题?

我们可以看一下发生这个问题的场景,主要原因还是 更新操作 在 删除操作 之前,那么我们如果把删除操作延后,这个问题是不是就迎刃而解了。

延时双删

假如有一种机制,能够确保删除动作延后且一定被执行,那就可以解决这个问题了。常用的方法就是延时双删,依然是先更新再删除:我们把这个删除动作延后再执行一次,比如 5 秒之后。

代码语言:javascript
复制
public void putValue(key,value){    
 putToDB(key,value);    
 deleteRedis(key);    
 ...deleteRedis(key,after5sec);
}

那么又有新的问题了,延后的这个删除到底用什么样的方式呢?常用的有两种:

  1. 放在 DelayQueue 中,这个编码简单,但会随着 JVM 进程的死亡,丢失更新的风险;
  2. 放到MQ 中,这个会增加编码的复杂性;

对于延迟双删大多是这两种处理方式,具体要用哪种我们得综合评价很多因素去做设计,比如团队的水平、工期、不一致的忍受程度等。

当然解决不一致的问题,除了延时双删,我们还有另外一种最终一致性的处理方案,那就是统一更新。

统一更新

这个方案很美好,但这对业务架构的设计要求非常高。比如我们可以通过 Binlog 方式,如 Canal。我们不会在代码里做任何 Redis 更新和删除的操作,而是会设计一个服务,订阅最新的 binlog 更新信息,然后解析它们,主动去更新缓存。这个一般在大并发大厂才会采用。

当然除此之外,如果老板不差钱的话,可以弱化数据库,就是把redis作为第一存储,mysql作为第二存储,数据进来后先放到redis里面,然后再更新到Mysql。这样也是比较好的方案。

聊到这里,数据不一致的问题基本上已经处理的差不多了,但是在高并发下还有一个问题是我们不得不考虑的,那就是删除缓存后会面临另一个严重的问题:缓存击穿。那么在高并发一下我们又是怎么来处理这个问题的呢?

如何解决缓存击穿的问题?

我们先来了解一下,缓存击穿,指的是缓存中没有数据但数据库中有,由于同一时刻请求量特别大,但是没有读到缓存数据,就会到数据库中读取,造成数据库处理不过来导致的假死进而造成服务不可以严重时还会造成服务雪崩的情况。

我们考虑一下,缓存击穿的前提是什么?第一,缓存没有数据。第二,大量请求涌入数据库。那么我们只要能解决其中一个问题就能避免缓存击穿的发生。

那么针对缓存没有数据这情况,我们可以用上面的统一更新的方案来处理不一致的问题,因为这种方式是不会删除缓存的,但是他比较复杂。

对于大量请求涌入数据库 的情况,我们可以采取读操作互斥,什么意思呢,就是不让大量请求去读数据库。我们可以在读数据的时候通过加互斥锁的方法来处理这个问题。

  • 读操作互斥,使用本地锁或者分布式锁来控制;

分布式锁的话,需要引入中间件,增加编码的复杂性而且效率也不是很高。本地锁编写简单,效率很高,但可能某些节点执行得非常慢,更新了旧的值到 Redis;

但是我更倾向于使用本地锁,因为分布式锁在极高并发下也可能会造成中间件过载的风险。

最后

  我们可以思考一下,其实缓存一致性的问题,归根结底还是CAP的问题,我们可以把Mysql和Redis看成两个分区,那么保证缓存一致性其实就是要保证分区容错性(P)和可用性(A)的同时要达到一致性(C), 那么后删缓存、统一更新方案其实都是最终一致性的体现,有兴趣的同学可以看一下CAP理论,这里就不展开讨论了。

  感谢各位能看到最后,希望本篇的内容对你有帮助,有什么意见或者建议可以留言一起讨论,看到后第一时间回复,也希望大家能给个赞,你的赞就是我写文章的动力,再次感谢。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2022-10-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 TodoCoder 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 双更新模式,操作不合理,导致数据一致性问题
    • 先更新Mysql, 再更新Redis
      • 先更新Redis, 再更新Mysql
      • “后删缓存”能解决多数不一致
        • 先删缓存
          • 后删缓存
            • 高并发下,“后删缓存”依旧不一致
            • 如何解决高并发下的数据不一致问题?
              • 延时双删
                • 统一更新
                • 如何解决缓存击穿的问题?
                • 最后
                相关产品与服务
                云数据库 SQL Server
                腾讯云数据库 SQL Server (TencentDB for SQL Server)是业界最常用的商用数据库之一,对基于 Windows 架构的应用程序具有完美的支持。TencentDB for SQL Server 拥有微软正版授权,可持续为用户提供最新的功能,避免未授权使用软件的风险。具有即开即用、稳定可靠、安全运行、弹性扩缩等特点。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档