关于java:掘地三尺搞定-Redis-与-MySQL-数据一致性问题

3次阅读

共计 6828 个字符,预计需要花费 18 分钟才能阅读完成。

Redis 领有高性能的数据读写性能,被咱们宽泛用在缓存场景,一是能进步业务零碎的性能,二是为数据库抵御了高并发的流量申请,点我 -> 解密 Redis 为什么这么快的机密。

把 Redis 作为缓存组件,须要防止出现以下的一些问题,否则可能会造成生产事变。

  • Redis 缓存满了怎么办?
  • 缓存穿透、缓存击穿、缓存雪崩如何解决?
  • Redis 数据过期了会被立马删除么?
  • Redis 忽然变慢了如何做性能排查并解决?
  • Redis 与 MySQL 数据一致性问题怎么应答?

明天「码哥」跟大家一起深刻摸索 缓存的工作机制和缓存一致性应答计划

在本文正式开始之前,我感觉咱们须要先获得以下两点的共识:

  1. 缓存必须要有过期工夫;
  2. 保障数据库跟缓存的最终一致性即可,不用谋求强一致性。

目录如下:

[toc]

1. 什么是数据库与缓存一致性

数据一致性指的是:

  • 缓存中存有数据,缓存的数据值 = 数据库中的值;
  • 缓存中没有该数据,数据库中的值 = 最新值。

反推缓存与数据库不统一:

  • 缓存的数据值 ≠ 数据库中的值;
  • 缓存或者数据库存在旧的数据,导致线程读取到旧数据。

为何会呈现数据一致性问题呢?

把 Redis 作为缓存的时候,当数据产生扭转咱们须要双写来保障缓存与数据库的数据统一。

数据库跟缓存,毕竟是两套零碎,如果要保障强一致性,势必要引入 2PCPaxos 等分布式一致性协定,或者分布式锁等等,这个在实现上是有难度的,而且肯定会对性能有影响。

如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?

2. 缓存的应用策略

在应用缓存时,通常有以下几种缓存应用策略用于晋升零碎性能:

  • Cache-Aside Pattern(旁路缓存,业务零碎罕用)
  • Read-Through Pattern
  • Write-Through Pattern
  • Write-Behind Pattern

2.1 Cache-Aside (旁路缓存)

所谓「旁路缓存」,就是 读取缓存、读取数据库和更新缓存的操作都在利用零碎来实现 业务零碎最罕用的缓存策略

2.1.1 读取数据

读取数据 逻辑如下:

  1. 当应用程序须要从数据库读取数据时,先查看缓存数据是否命中。
  2. 如果缓存未命中,则查询数据库获取数据,同时将数据写到缓存中,以便后续读取雷同数据会命中缓存,最初再把数据返回给调用者。
  3. 如果缓存命中,间接返回。

时序图如下:

长处

  • 缓存中仅蕴含应用程序理论申请的数据,有助于放弃缓存大小的老本效益。
  • 实现简略,并且能取得性能晋升。

实现的伪代码如下:

String cacheKey = "公众号:码哥字节";
String cacheValue = redisCache.get(cacheKey);// 缓存命中
if (cacheValue != null) {return cacheValue;} else {
  // 缓存缺失, 从数据库获取数据
  cacheValue = getDataFromDB();
  // 将数据写到缓存中
  redisCache.put(cacheValue)
}

毛病

因为数据仅在缓存未命中后才加载到缓存中,因而首次调用的数据申请响应工夫会减少一些开销,因为须要额定的缓存填充和数据库查问耗时。

2.1.2 更新数据

应用 cache-aside 模式写数据时,如下流程。

  1. 写数据到数据库;
  2. 将缓存中的数据生效或者更新缓存数据;

应用 cache-aside 时,最常见的写入策略是间接将数据写入数据库,然而缓存可能会与数据库不统一。

咱们应该给缓存设置一个过期工夫,这个是保障最终一致性的解决方案。

如果过期工夫太短,应用程序会一直地从数据库中查问数据。同样,如果过期工夫过长,并且更新时没有使缓存生效,缓存的数据很可能是脏数据。

最罕用的形式是 删除缓存使缓存数据生效

为啥不是更新缓存呢?

性能问题

当缓存的更新老本很高,须要拜访多张表联结计算,倡议间接删除缓存,而不是更新缓存数据来保障一致性。

平安问题

在高并发场景下,可能会造成查问查到的数据是旧值,具体待会码哥会剖析,大家别急。

2.2 Read-Through(直读)

当缓存未命中,也是从数据库加载数据,同时写到缓存中并返回给利用零碎。

尽管 read-throughcache-aside 十分类似,在 cache-aside 利用零碎负责 从数据库获取数据和填充缓存。

而 Read-Through 将获取数据存储中的值的责任转移到了缓存提供者身上。

Read-Through 实现了关注点拆散准则。代码只与缓存交互,由缓存组件来治理本身与数据库之间的数据同步。

2.3 Write-Through 同步直写

与 Read-Through 相似,产生写申请时,Write-Through 将写入责任转移到缓存零碎,由缓存形象层来实现缓存数据和数据库数据的更新,时序流程图如下:

Write-Through 的次要益处是利用零碎的不须要思考故障解决和重试逻辑,交给缓存形象层来治理实现。

优缺点

独自间接应用该策略是没啥意义的,因为该策略要先写缓存,再写数据库,对写入操作带来了额定提早。

Write-ThroughRead-Through 配合应用,就能成分施展 Read-Through 的劣势,同时还能保证数据一致性,不须要思考如何将缓存设置生效。

这个策略颠倒了 Cache-Aside 填充缓存的程序,并不是在缓存未命中后提早加载到缓存,而是在 数据先写缓存,接着由缓存组件将数据写到数据库

长处

  • 缓存与数据库数据总是最新的;
  • 查问性能最佳,因为要查问的数据有可能曾经被写到缓存中了。

毛病

不常常申请的数据也会写入缓存,从而导致缓存更大、老本更高。

2.4 Write-Behind

这个图一眼看去仿佛与 Write-Through 一样,其实不是的,区别在于最初一个箭头的箭头:它从实心变为线。

这意味着缓存零碎将 异步更新数据库数据,利用零碎只与缓存零碎交互

应用程序不用期待数据库更新实现,从而进步应用程序性能,因为对数据库的更新是最慢的操作。

这种策略下,缓存与数据库的一致性不强,对一致性高的零碎不倡议应用。

3. 旁路缓存下的一致性问题剖析

业务场景用的最多的就是 Cache-Aside (旁路缓存) 策略,在该策略下,客户端对数据的读取流程是先读取缓存,如果命中则返回;未命中,则从数据库读取并把数据写到缓存中,所以 读操作不会导致缓存与数据库的不统一。

重点是写操作,数据库和缓存都须要批改,而两者就会存在一个先后顺序,可能会导致数据不再统一。针对写,咱们须要思考两个问题:

  • 先更新缓存还是更新数据库?
  • 当数据发生变化时,抉择批改缓存(update),还是删除缓存(delete)?

将这两个问题排列组合,会呈现四种计划:

  1. 先更新缓存,再更新数据库;
  2. 先更新数据库,再更新缓存;
  3. 先删除缓存,再更新数据库;
  4. 先更新数据库,再删除缓存。

接下来的剖析大家不用死记硬背,关键在于在推演的过程中大家只须要思考以下两个场景会不会带来重大问题即可:

  • 其中第一个操作胜利,第二个失败会导致什么问题?
  • 在高并发状况下会不会造成读取数据不统一?

为啥不思考第一个失败,第二个胜利的状况呀?

你猜?

既然第一个都失败了,第二个就不必执行了,间接在第一步返回 50x 等异样信息即可,不会呈现不统一问题。

只有第一个胜利,第二个失败才让人头痛,想要保障他们的原子性,就波及到分布式事务的领域了。

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

如果先更新缓存胜利,写数据库失败,就会导致缓存是最新数据,数据库是旧数据,那缓存就是脏数据了。

之后,其余查问立马申请进来的时候就会获取这个数据,而这个数据数据库中却不存在。

数据库都不存在的数据,缓存并返回客户端就毫无意义了。

该计划间接 Pass

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

一切正常的状况如下:

  • 先写数据库,胜利;
  • 再 update 缓存,胜利。

更新缓存失败

这时候咱们来推断下,如果这两个操作的原子性被毁坏:第一步胜利,第二步失败 会导致什么问题?

会导致 数据库是最新数据,缓存是旧数据,呈现一致性问题。

该图我就不画了,与上一个图相似,对调下 Redis 和 MySQL 的地位即可。

高并发场景

谢霸歌常常 996,腰酸脖子疼,bug 越写越多,想去按摩推拿放晋升下编程技巧。

疫情影响,单子来之不易,高端会所的技师都争先恐后想接这一单,高并发啊兄弟们。

在进店当前,前台会将顾客信息录入零碎,执行 set xx 的服务技师 = 待定 的初始值示意目前无人接待保留到数据库和缓存中,之后再安顿技师按摩服务。

如下图所示:

  1. 98 号技师先发制人,向零碎发送 set 谢霸歌的服务技师 = 98 的指令写入数据库,这时候零碎的网络呈现稳定,卡顿了,数据还没来得及写到缓存
  2. 接下来,520 号技师也向零碎发送 set 谢霸哥的服务技师 = 520写到数据库中,并且也把这个数据写到缓存中了。
  3. 这时候之前的 98 号技师的写缓存申请开始执行,顺利将数据 set 谢霸歌的服务技师 = 98 写到缓存中。

最初发现,数据库的值 = set 谢霸哥的服务技师 = 520,而缓存的值 = set 谢霸歌的服务技师 = 98

520 号技师在缓存中的最新数据被 98 号技师的旧数据笼罩了。

所以,在高并发的场景中,多线程同时写数据再写缓存,就会呈现缓存是旧值,数据库是最新值的不统一状况。

该计划间接 pass。

如果第一步就失败,间接返回 50x 异样,并不会呈现数据不统一。

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

依照「码哥」后面说的套路,假如第一个操作胜利,第二个操作失败推断下会产生什么?高并发场景下又会产生什么?

第二步写数据库失败

假如当初有两个申请:写申请 A,读申请 B。

写申请 A 第一步先删除缓存胜利,写数据到数据库失败,就会导致该次 写数据失落,数据库保留的是旧值

接着另一个读请 B 求进来,发现缓存不存在,从数据库读取旧数据并写到缓存中。

高并发下的问题

  1. 还是 98 号技师先发制人,零碎接管申请把缓存数据删除,当零碎筹备将 set 肖菜鸡的服务技师 = 98写到数据库的时候产生卡顿,来不及写入。
  2. 这时候,大堂经理向零碎执行读申请,查下肖菜鸡有没有技师接待,不便安顿技师服务,零碎发现缓存中没数据,于是乎就从数据库读取到旧数据 set 肖菜鸡的服务技师 = 待定,并写到缓存中。
  3. 这时候,原先卡顿的 98 号技师写数据 set 肖菜鸡的服务技师 = 98到数据库的操作实现。

这样子会呈现缓存的是旧数据,在缓存过期之前无奈读取到最数据。肖菜鸡本就被 98 号技师接单了,然而大堂经理却认为没人接待。

该计划 pass,因为第一步胜利,第二步失败,会造成数据库是旧数据,缓存中没数据持续从数据库读取旧值写入缓存,造成数据不统一,还会多一次 cahche。

不论是异常情况还是高并发场景,会导致数据不统一。miss。

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

通过后面的三个计划,全都被 pass 了,剖析下最初的计划到底行不行。

依照「套路」,别离判断异样和高并发会造成什么问题。

该策略能够晓得,在写数据库阶段失败的话就直返返回客户端异样,不须要执行缓存操作了。

所以第一步失败不会呈现数据不统一的状况。

删缓存失败

重点在于第一步写最新数据到数据库胜利,删除缓存失败怎么办?

能够把这两个操作放在一个事务中,当缓存删除失败,那就把写数据库回滚。

高并发场景下不适合,容易呈现大事务,造成死锁问题。

如果不回滚,那就呈现数据库是新数据,缓存还是旧数据,数据不统一了,咋办?

所以,咱们要想方法让缓存删除胜利,不然只能等到有效期生效那可不行。

应用重试机制。

比方重试三次,三次都失败则记录日志到数据库,应用散布式调度组件 xxl-job 等实现后续的解决。

在高并发的场景下,重试最好应用异步形式,比方发送音讯到 mq 中间件,实现异步解耦。

亦或是利用 Canal 框架订阅 MySQL binlog 日志,监听对应的更新申请,执行删除对应缓存操作。

高并发场景

再来剖析下高并发读写会有什么问题……

  1. 98 号技师先发制人,接下肖菜鸡的这笔生意,数据库执行 set 肖菜鸡的服务技师 = 98;还是网络卡顿了下,没来得及执行删除缓存操作
  2. 主管 Candy 向零碎执行读申请,查下肖菜鸡有没有技师接待,发现缓存中有数据 肖菜鸡的服务技师 = 待定,间接返回信息给客户端,主管认为没人接待。
  3. 原先 98 号技师接单,因为卡顿没删除缓存的操作当初执行删除胜利。

读申请可能呈现大量读取旧数据的状况,然而很快旧数据就会被删除,之后的申请都能获取最新数据,问题不大。

还有一种比拟极其的状况,缓存主动生效的时候又遇到了高并发读写的状况,假如这会有两个申请,一个线程 A 做查问操作,一个线程 B 做更新操作,那么会有如下情景产生:

  1. 缓存的过期工夫到期,缓存生效。
  2. 线程 A 读申请读取缓存,没命中,则查询数据库失去一个旧的值(因为 B 会写新值,相对而言就是旧的值了),筹备 把数据写到缓存时发送网络问题卡顿了
  3. 线程 B 执行写操作,将新值写数据库。
  4. 线程 B 执行删除缓存。
  5. 线程 A 持续,从卡顿中醒来,把查问到的旧值写到入缓存。

码哥,这咋玩,还是呈现了不统一的状况啊。

不要慌,产生这个状况的概率微不足道,产生上述情况的必要条件是:

  1. 步骤(3)的写数据库操作要比步骤(2)读操作耗时短速度快,才可能使得步骤(4)先于步骤(5)。
  2. 缓存刚好达到过期时限。

通常 MySQL 单机的 QPS 大略 5K 左右,而 TPS 大略 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。

数据库读操作是远快于写操作的(正是因为如此,才做读写拆散),所以步骤(3)要比步骤(2)更快这个情景很难呈现,同时还要配合缓存刚好生效。

所以,在用旁路缓存策略的时候,对于写操作举荐应用:先更新数据库,再删除缓存。

4. 一致性解决方案有哪些?

最初,针对 Cache-Aside (旁路缓存) 策略,写操作应用先更新数据库,再删除缓存 的状况下,咱们来剖析下数据一致性解决方案都有哪些?

4.1 缓存延时双删

如果采纳先删除缓存,再更新数据库如何避免出现脏数据?

采纳延时双删策略。

  1. 先删除缓存。
  2. 写数据库。
  3. 休眠 500 毫秒,再删除缓存。

这样子最多只会呈现 500 毫秒的脏数据读取工夫。要害是这个休眠工夫怎么确定呢?

延迟时间的目标就是确保读申请完结,写申请能够删除读申请造成的缓存脏数据。

所以咱们须要自行评估我的项目的读数据业务逻辑的耗时,在读耗时的根底上加几百毫秒作为延迟时间即可

4.2 删除缓存重试机制

缓存删除失败怎么办?比方提早双删的第二次删除失败,那岂不是无奈删除脏数据。

应用重试机制,保障删除缓存胜利。

比方重试三次,三次都失败则记录日志到数据库并发送正告让人工染指。

在高并发的场景下,重试最好应用异步形式,比方发送音讯到 mq 中间件,实现异步解耦。

第(5)步如果删除失败且未达到重试最大次数则将音讯从新入队,直到删除胜利,否则就记录到数据库,人工染指。

该计划有个毛病,就是对业务代码中造成侵入,于是就有了下一个计划,启动一个专门订阅 数据库 binlog 的服务读取须要删除的数据进行缓存删除操作。

4.3 读取 binlog 异步删除

  1. 更新数据库;
  2. 数据库会把操作信息记录在 binlog 日志中;
  3. 应用 canal 订阅 binlog 日志获取指标数据和 key;
  4. 缓存删除零碎获取 canal 的数据,解析指标 key,尝试删除缓存。
  5. 如果删除失败则将音讯发送到音讯队列;
  6. 缓存删除零碎从新从音讯队列获取数据,再次执行删除操作。

总结

缓存策略的最佳实际是 Cache Aside Pattern。别离分为读缓存最佳实际和写缓存最佳实际。

读缓存 最佳实际:先读缓存,命中则返回;未命中则查询数据库,再写到数据库。

写缓存 最佳实际:

  • 先写数据库,再操作缓存;
  • 间接删除缓存,而不是批改,因为 当缓存的更新老本很高,须要拜访多张表联结计算,倡议间接删除缓存,而不是更新,另外,删除缓存操作简略,副作用只是减少了一次 chache miss,倡议大家应用该策略。

在以上最佳实际下,为了尽可能保障缓存与数据库的一致性,咱们能够采纳提早双删。

避免删除失败,咱们采纳异步重试机制保障能正确删除,异步机制咱们能够发送删除音讯到 mq 消息中间件,或者利用 canal 订阅 MySQL binlog 日志监听写申请删除对应缓存。

那么,如果我非要保障相对一致性怎么办,先给出论断:

没有方法做到相对的一致性,这是由 CAP 实践决定的,缓存零碎实用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP。

所以,咱们得忍辱负重,能够去做到 BASE 实践中说的 最终一致性

其实一旦在计划中应用了缓存,那往往也就意味着咱们放弃了数据的强一致性,但这也意味着咱们的零碎在性能上可能失去一些晋升。

所谓 tradeoff 正是如此。

最初,大家能够在评论区叫我靓仔么?不想叫我靓仔的「点赞」和「在看」也是一种激励。

加「码哥」微信:MageByte1024,进入专属读者技术群一起谈天说地聊技术。

鸣谢

https://docs.aws.amazon.com/w…

https://codeahoy.com/2017/08/…

https://blog.cdemi.io/design-…

https://hazelcast.com/blog/a-…

https://developer.aliyun.com/…

正文完
 0