Redis 拥有高性能的数据读写功能,被我们广泛用在缓存场景,一是能提高业务系统的性能,二是为数据库抵挡了高并发的流量请求,点我 -> 解密 Redis 为什么这么快的秘密。
把 Redis 作为缓存组件,需要防止出现以下的一些问题,否则可能会造成生产事故。
Redis 缓存满了怎么办?
缓存穿透、缓存击穿、缓存雪崩如何解决?
Redis 数据过期了会被立马删除么?
Redis 突然变慢了如何做性能排查并解决?
Redis 与 MySQL 数据一致性问题怎么应对?
今天跟大家一起深入探索缓存的工作机制和缓存一致性应对方案。
在本文正式开始之前,我觉得我们需要先取得以下两点的共识:
缓存必须要有过期时间;
保证数据库跟缓存的最终一致性即可,不必追求强一致性。
1. 什么是数据库与缓存一致性
数据一致性指的是:
缓存中存有数据,缓存的数据值 = 数据库中的值;
缓存中没有该数据,数据库中的值 = 最新值。
反推缓存与数据库不一致:
缓存的数据值 ≠ 数据库中的值;
缓存或者数据库存在旧的数据,导致线程读取到旧数据。
为何会出现数据一致性问题呢?
把 Redis 作为缓存的时候,当数据发生改变我们需要双写来保证缓存与数据库的数据一致。
数据库跟缓存,毕竟是两套系统,如果要保证强一致性,势必要引入 2PC 或 Paxos 等分布式一致性协议,或者分布式锁等等,这个在实现上是有难度的,而且一定会对性能有影响。
如果真的对数据的一致性要求这么高,那引入缓存是否真的有必要呢?
2. 缓存的使用策略
在使用缓存时,通常有以下几种缓存使用策略用于提升系统性能:
Cache-Aside Pattern(旁路缓存,业务系统常用)
Read-Through Pattern
Write-Through Pattern
Write-Behind Pattern
2.1 Cache-Aside (旁路缓存)
所谓「旁路缓存」,就是读取缓存、读取数据库和更新缓存的操作都在应用系统来完成,业务系统最常用的缓存策略。
2.1.1 读取数据
读取数据逻辑如下:
当应用程序需要从数据库读取数据时,先检查缓存数据是否命中。
如果缓存未命中,则查询数据库获取数据,同时将数据写到缓存中,以便后续读取相同数据会命中缓存,最后再把数据返回给调用者。
如果缓存命中,直接返回。
时序图如下:
优点
缓存中仅包含应用程序实际请求的数据,有助于保持缓存大小的成本效益。
实现简单,并且能获得性能提升。
实现的伪代码如下:
String cacheKey = "公众号:码哥字节";
String cacheValue = redisCache.get(cacheKey);
//缓存命中
if (cacheValue != null) {
return cacheValue;
} else {
//缓存缺失, 从数据库获取数据
cacheValue = getDataFromDB();
// 将数据写到缓存中
redisCache.put(cacheValue)
}
缺点
由于数据仅在缓存未命中后才加载到缓存中,因此初次调用的数据请求响应时间会增加一些开销,因为需要额外的缓存填充和数据库查询耗时。
2.1.2 更新数据
使用 cache-aside 模式写数据时,如下流程。
写数据到数据库;
将缓存中的数据失效或者更新缓存数据;
使用 cache-aside 时,最常见的写入策略是直接将数据写入数据库,但是缓存可能会与数据库不一致。
我们应该给缓存设置一个过期时间,这个是保证最终一致性的解决方案。
如果过期时间太短,应用程序会不断地从数据库中查询数据。同样,如果过期时间过长,并且更新时没有使缓存失效,缓存的数据很可能是脏数据。
最常用的方式是删除缓存使缓存数据失效。
为啥不是更新缓存呢?
性能问题
当缓存的更新成本很高,需要访问多张表联合计算,建议直接删除缓存,而不是更新缓存数据来保证一致性。
安全问题
在高并发场景下,可能会造成查询查到的数据是旧值,具体待会码哥会分析,大家别急。
2.2 Read-Through(直读)
当缓存未命中,也是从数据库加载数据,同时写到缓存中并返回给应用系统。
虽然 read-through 和 cache-aside 非常相似,在 cache-aside 中应用系统负责从数据库获取数据和填充缓存。
而 Read-Through 将获取数据存储中的值的责任转移到了缓存提供者身上。
Read-Through 实现了关注点分离原则。代码只与缓存交互,由缓存组件来管理自身与数据库之间的数据同步。
2.3 Write-Through 同步直写
与 Read-Through 类似,发生写请求时,Write-Through 将写入责任转移到缓存系统,由缓存抽象层来完成缓存数据和数据库数据的更新,时序流程图如下:
Write-Through 的主要好处是应用系统的不需要考虑故障处理和重试逻辑,交给缓存抽象层来管理实现。
优缺点
单独直接使用该策略是没啥意义的,因为该策略要先写缓存,再写数据库,对写入操作带来了额外延迟。
当Write-Through 与 Read-Through 配合使用,就能成分发挥 Read-Through 的优势,同时还能保证数据一致性,不需要考虑如何将缓存设置失效。
这个策略颠倒了 Cache-Aside 填充缓存的顺序,并不是在缓存未命中后延迟加载到缓存,而是在数据先写缓存,接着由缓存组件将数据写到数据库。
优点
缓存与数据库数据总是最新的;
查询性能最佳,因为要查询的数据有可能已经被写到缓存中了。
缺点
不经常请求的数据也会写入缓存,从而导致缓存更大、成本更高。
2.4 Write-Behind
这个图一眼看去似乎与 Write-Through 一样,其实不是的,区别在于最后一个箭头的箭头:它从实心变为线。
这意味着缓存系统将异步更新数据库数据,应用系统只与缓存系统交互。
应用程序不必等待数据库更新完成,从而提高应用程序性能,因为对数据库的更新是最慢的操作。
这种策略下,缓存与数据库的一致性不强,对一致性高的系统不建议使用。
3. 旁路缓存下的一致性问题分析
业务场景用的最多的就是 Cache-Aside (旁路缓存) 策略,在该策略下,客户端对数据的读取流程是先读取缓存,如果命中则返回;未命中,则从数据库读取并把数据写到缓存中,所以读操作不会导致缓存与数据库的不一致。
重点是写操作,数据库和缓存都需要修改,而两者就会存在一个先后顺序,可能会导致数据不再一致。针对写,我们需要考虑两个问题:
先更新缓存还是更新数据库?
当数据发生变化时,选择修改缓存(update),还是删除缓存(delete)?
将这两个问题排列组合,会出现四种方案:
先更新缓存,再更新数据库;
先更新数据库,再更新缓存;
先删除缓存,再更新数据库;
先更新数据库,再删除缓存。
接下来的分析大家不必死记硬背,关键在于在推演的过程中大家只需要考虑以下两个场景会不会带来严重问题即可:
其中第一个操作成功,第二个失败会导致什么问题?
在高并发情况下会不会造成读取数据不一致?
为啥不考虑第一个失败,第二个成功的情况呀?
你猜?
既然第一个都失败了,第二个就不用执行了,直接在第一步返回 50x 等异常信息即可,不会出现不一致问题。
只有第一个成功,第二个失败才让人头痛,想要保证他们的原子性,就涉及到分布式事务的范畴了。
3.1 先更新缓存,再更新数据库
如果先更新缓存成功,写数据库失败,就会导致缓存是最新数据,数据库是旧数据,那缓存就是脏数据了。
之后,其他查询立马请求进来的时候就会获取这个数据,而这个数据数据库中却不存在。
数据库都不存在的数据,缓存并返回客户端就毫无意义了。
该方案直接 Pass。
3.2 先更新数据库,再更新缓存
一切正常的情况如下:
先写数据库,成功;
再 update 缓存,成功。
更新缓存失败
这时候我们来推断下,假如这两个操作的原子性被破坏:第一步成功,第二步失败会导致什么问题?
会导致数据库是最新数据,缓存是旧数据,出现一致性问题。
该图我就不画了,与上一个图类似,对调下 Redis 和 MySQL 的位置即可。
高并发场景
谢霸歌经常 996,腰酸脖子疼,bug 越写越多,想去按摩推拿放提升下编程技巧。
疫情影响,单子来之不易,高端会所的技师都争先恐后想接这一单,高并发啊兄弟们。
在进店以后,前台会将顾客信息录入系统,执行 set xx的服务技师 = 待定的初始值表示目前无人接待保存到数据库和缓存中,之后再安排技师按摩服务。
如下图所示:
98 号技师先下手为强,向系统发送 set 谢霸歌的服务技师 = 98 的指令写入数据库,这时候系统的网络出现波动,卡顿了,数据还没来得及写到缓存。
接下来,520 号技师也向系统发送 set 谢霸哥的服务技师 = 520写到数据库中,并且也把这个数据写到缓存中了。
这时候之前的 98 号技师的写缓存请求开始执行,顺利将数据 set 谢霸歌的服务技师 = 98 写到缓存中。
最后发现,数据库的值 = set 谢霸哥的服务技师 = 520,而缓存的值= set 谢霸歌的服务技师 = 98。
520 号技师在缓存中的最新数据被 98 号技师的旧数据覆盖了。
所以,在高并发的场景中,多线程同时写数据再写缓存,就会出现缓存是旧值,数据库是最新值的不一致情况。
该方案直接 pass。
如果第一步就失败,直接返回 50x 异常,并不会出现数据不一致。
3.3 先删缓存,再更新数据库
按照「码哥」前面说的套路,假设第一个操作成功,第二个操作失败推断下会发生什么?高并发场景下又会发生什么?
第二步写数据库失败
假设现在有两个请求:写请求 A,读请求 B。
写请求 A 第一步先删除缓存成功,写数据到数据库失败,就会导致该次写数据丢失,数据库保存的是旧值。
接着另一个读请 B 求进来,发现缓存不存在,从数据库读取旧数据并写到缓存中。
高并发下的问题
还是 98 号技师先下手为强,系统接收请求把缓存数据删除,当系统准备将 set 肖菜鸡的服务技师 = 98写到数据库的时候发生卡顿,来不及写入。
这时候,大堂经理向系统执行读请求,查下肖菜鸡有没有技师接待,方便安排技师服务,系统发现缓存中没数据,于是乎就从数据库读取到旧数据 set 肖菜鸡的服务技师 = 待定,并写到缓存中。
这时候,原先卡顿的 98 号技师写数据 set 肖菜鸡的服务技师 = 98到数据库的操作完成。
**这样子会出现缓存的是旧数据,在缓存过期之前无法读取到最数据。**肖菜鸡本就被 98 号技师接单了,但是大堂经理却以为没人接待。
该方案 pass,因为第一步成功,第二步失败,会造成数据库是旧数据,缓存中没数据继续从数据库读取旧值写入缓存,造成数据不一致,还会多一次 cahche。
不论是异常情况还是高并发场景,会导致数据不一致。 miss。
3.4 先更新数据库,再删缓存
经过前面的三个方案,全都被 pass 了,分析下最后的方案到底行不行。
按照「套路」,分别判断异常和高并发会造成什么问题。
该策略可以知道,在写数据库阶段失败的话就直返返回客户端异常,不需要执行缓存操作了。
所以第一步失败不会出现数据不一致的情况。
删缓存失败
重点在于第一步写最新数据到数据库成功,删除缓存失败怎么办?
可以把这两个操作放在一个事务中,当缓存删除失败,那就把写数据库回滚。
高并发场景下不合适,容易出现大事务,造成死锁问题。
如果不回滚,那就出现数据库是新数据,缓存还是旧数据,数据不一致了,咋办?
所以,我们要想办法让缓存删除成功,不然只能等到有效期失效那可不行。
使用重试机制。
比如重试三次,三次都失败则记录日志到数据库,使用分布式调度组件 xxl-job 等实现后续的处理。
在高并发的场景下,重试最好使用异步方式,比如发送消息到 mq 中间件,实现异步解耦。
亦或是利用 Canal 框架订阅 MySQL binlog 日志,监听对应的更新请求,执行删除对应缓存操作。
高并发场景
再来分析下高并发读写会有什么问题……
98 号技师先下手为强,接下肖菜鸡的这笔生意,数据库执行 set 肖菜鸡的服务技师 = 98;还是网络卡顿了下,没来得及执行删除缓存操作。
主管 Candy 向系统执行读请求,查下肖菜鸡有没有技师接待,发现缓存中有数据 肖菜鸡的服务技师 = 待定,直接返回信息给客户端,主管以为没人接待。
原先 98 号技师接单,由于卡顿没删除缓存的操作现在执行删除成功。
读请求可能出现少量读取旧数据的情况,但是很快旧数据就会被删除,之后的请求都能获取最新数据,问题不大。
还有一种比较极端的情况,缓存自动失效的时候又遇到了高并发读写的情况,假设这会有两个请求,一个线程 A 做查询操作,一个线程 B 做更新操作,那么会有如下情形产生:
缓存的过期时间到期,缓存失效。
线程 A 读请求读取缓存,没命中,则查询数据库得到一个旧的值(因为 B 会写新值,相对而言就是旧的值了),准备把数据写到缓存时发送网络问题卡顿了。
线程 B 执行写操作,将新值写数据库。
线程 B 执行删除缓存。
线程 A 继续,从卡顿中醒来,把查询到的旧值写到入缓存。
码哥,这咋玩,还是出现了不一致的情况啊。
不要慌,发生这个情况的概率微乎其微,发生上述情况的必要条件是:
步骤 (3)的写数据库操作要比步骤(2)读操作耗时短速度快,才可能使得步骤(4)先于步骤(5)。
缓存刚好到达过期时限。
通常 MySQL 单机的 QPS 大概 5K 左右,而 TPS 大概 1k 左右,(ps:Tomcat 的 QPS 4K 左右,TPS = 1k 左右)。
数据库读操作是远快于写操作的(正是因为如此,才做读写分离),所以步骤(3)要比步骤(2)更快这个情景很难出现,同时还要配合缓存刚好失效。
所以,在用旁路缓存策略的时候,对于写操作推荐使用:先更新数据库,再删除缓存。
4. 一致性解决方案有哪些?
最后,针对 Cache-Aside (旁路缓存) 策略,写操作使用先更新数据库,再删除缓存的情况下,我们来分析下数据一致性解决方案都有哪些?
4.1 缓存延时双删
如果采用先删除缓存,再更新数据库如何避免出现脏数据?
采用延时双删策略。
先删除缓存。
写数据库。
休眠 500 毫秒,再删除缓存。
这样子最多只会出现 500 毫秒的脏数据读取时间。关键是这个休眠时间怎么确定呢?
延迟时间的目的就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
所以我们需要自行评估项目的读数据业务逻辑的耗时,在读耗时的基础上加几百毫秒作为延迟时间即可。
4.2 删除缓存重试机制
缓存删除失败怎么办?比如延迟双删的第二次删除失败,那岂不是无法删除脏数据。
使用重试机制,保证删除缓存成功。
比如重试三次,三次都失败则记录日志到数据库并发送警告让人工介入。
在高并发的场景下,重试最好使用异步方式,比如发送消息到 mq 中间件,实现异步解耦。
第(5)步如果删除失败且未达到重试最大次数则将消息重新入队,直到删除成功,否则就记录到数据库,人工介入。
该方案有个缺点,就是对业务代码中造成侵入,于是就有了下一个方案,启动一个专门订阅 数据库 binlog 的服务读取需要删除的数据进行缓存删除操作。
4.3 读取 binlog 异步删除
更新数据库;
数据库会把操作信息记录在 binlog 日志中;
使用 canal 订阅 binlog 日志获取目标数据和 key;
缓存删除系统获取 canal 的数据,解析目标 key,尝试删除缓存。
如果删除失败则将消息发送到消息队列;
缓存删除系统重新从消息队列获取数据,再次执行删除操作。
总结
缓存策略的最佳实践是 **Cache Aside Pattern。**分别分为读缓存最佳实践和写缓存最佳实践。
读缓存最佳实践:先读缓存,命中则返回;未命中则查询数据库,再写到数据库。
写缓存最佳实践:
先写数据库,再操作缓存;
直接删除缓存,而不是修改,因为当缓存的更新成本很高,需要访问多张表联合计算,建议直接删除缓存,而不是更新,另外,删除缓存操作简单,副作用只是增加了一次 chache miss,建议大家使用该策略。
在以上最佳实践下,为了尽可能保证缓存与数据库的一致性,我们可以采用延迟双删。
防止删除失败,我们采用异步重试机制保证能正确删除,异步机制我们可以发送删除消息到 mq 消息中间件,或者利用 canal 订阅 MySQL binlog 日志监听写请求删除对应缓存。
那么,如果我非要保证绝对一致性怎么办,先给出结论:
没有办法做到绝对的一致性,这是由 CAP 理论决定的,缓存系统适用的场景就是非强一致性的场景,所以它属于 CAP 中的 AP。
所以,我们得委曲求全,可以去做到 BASE 理论中说的最终一致性。
其实一旦在方案中使用了缓存,那往往也就意味着我们放弃了数据的强一致性,但这也意味着我们的系统在性能上能够得到一些提升
之前也看了很多相关的文章,但是感觉讲的都不好,很多文章都会去讲各种策略,比如(旁路缓存)策略、(读穿 / 写穿)策略和(写回)策略等,感觉意义真的不大,然后有的文章也只讲了部分情况,也没有告诉最优解。
我直接先抛一下结论:在满足实时性的条件下,不存在两者完全保存一致的方案,只有最终一致性方案。 根据网上的众多解决方案,总结出 6 种,直接看目录:
#不好的方案
#1. 先写 MySQL,再写 Redis
图解说明:
这是一幅时序图,描述请求的先后调用顺序;橘黄色的线是请求 A,黑色的线是请求 B;橘黄色的文字,是 MySQL 和 Redis 最终不一致的数据;数据是从 10 更新为 11;后面所有的图,都是这个含义,不再赘述。
请求 A、B 都是先写 MySQL,然后再写 Redis,在高并发情况下,如果请求 A 在写 Redis 时卡了一会,请求 B 已经依次完成数据的更新,就会出现图中的问题。
这个图已经画的很清晰了,我就不用再去啰嗦了吧,不过这里有个前提,就是对于读请求,先去读 Redis,如果没有,再去读 DB,但是读请求不会再回写 Redis。 大白话说一下,就是读请求不会更新 Redis。
#2. 先写 Redis,再写 MySQL
同“先写 MySQL,再写 Redis”,看图可秒懂。
#3. 先删除 Redis,再写 MySQL
这幅图和上面有些不一样,前面的请求 A 和 B 都是更新请求,这里的请求 A 是更新请求,但是请求 B 是读请求,且请求 B 的读请求会回写 Redis。
请求 A 先删除缓存,可能因为卡顿,数据一直没有更新到 MySQL,导致两者数据不一致。
这种情况出现的概率比较大,因为请求 A 更新 MySQL 可能耗时会比较长,而请求 B 的前两步都是查询,会非常快。
#好的方案
#4. 先删除 Redis,再写 MySQL,再删除 Redis
对于“先删除 Redis,再写 MySQL”,如果要解决最后的不一致问题,其实再对 Redis 重新删除即可,这个也是大家常说的“缓存双删”。
为了便于大家看图,对于蓝色的文字,“删除缓存 10”必须在“回写缓存10”后面,那如何才能保证一定是在后面呢?网上给出的第一个方案是,让请求 A 的最后一次删除,等待 500ms。
对于这种方案,看看就行,反正我是不会用,太 Low 了,风险也不可控。
那有没有更好的方案呢,我建议异步串行化删除,即删除请求入队列
异步删除对线上业务无影响,串行化处理保障并发情况下正确删除。
如果双删失败怎么办,网上有给 Redis 加一个缓存过期时间的方案,这个不敢苟同。个人建议整个重试机制,可以借助消息队列的重试机制,也可以自己整个表,记录重试次数,方法很多。
简单小结一下:
“缓存双删”不要用无脑的 sleep 500 ms;通过消息队列的异步&串行,实现最后一次缓存删除;缓存删除失败,增加重试机制。
#5. 先写 MySQL,再删除 Redis
对于上面这种情况,对于第一次查询,请求 B 查询的数据是 10,但是 MySQL 的数据是 11,只存在这一次不一致的情况,对于不是强一致性要求的业务,可以容忍。(那什么情况下不能容忍呢,比如秒杀业务、库存服务等。)
当请求 B 进行第二次查询时,因为没有命中 Redis,会重新查一次 DB,然后再回写到 Reids。
这里需要满足 2 个条件:
缓存刚好自动失效;
请求 B 从数据库查出 10,回写缓存的耗时,比请求 A 写数据库,并且删除缓存的还长。
对于第二个条件,我们都知道更新 DB 肯定比查询耗时要长,所以出现这个情况的概率很小,同时满足上述条件的情况更小。
#6. 先写 MySQL,通过 Binlog,异步更新 Redis
这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。
这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。
所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性。
#几种方案比较
我们对比上面讨论的 6 种方案:
先写 Redis,再写 MySQL
这种方案,我肯定不会用,万一 DB 挂了,你把数据写到缓存,DB 无数据,这个是灾难性的;
我之前也见同学这么用过,如果写 DB 失败,对 Redis 进行逆操作,那如果逆操作失败呢,是不是还要搞个重试?
先写 MySQL,再写 Redis
对于并发量、一致性要求不高的项目,很多就是这么用的,我之前也经常这么搞,但是不建议这么做;
当 Redis 瞬间不可用的情况,需要报警出来,然后线下处理。
先删除 Redis,再写 MySQL
这种方式,我还真没用过,直接忽略吧。
先删除 Redis,再写 MySQL,再删除 Redis
这种方式虽然可行,但是感觉好复杂,还要搞个消息队列去异步删除 Redis。
先写 MySQL,再删除 Redis
比较推荐这种方式,删除 Redis 如果失败,可以再多重试几次,否则报警出来;
这个方案,是实时性中最好的方案,在一些高并发场景中,推荐这种。
先写 MySQL,通过 Binlog,异步更新 Redis
对于异地容灾、数据汇总等,建议会用这种方式,比如 binlog + kafka,数据的一致性也可以达到秒级;
纯粹的高并发场景,不建议用这种方案,比如抢购、秒杀等。
个人结论:
实时一致性方案:采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。
最终一致性方案:采用“先写 MySQL,通过 Binlog,异步更新 Redis”,可以通过 Binlog,结合消息队列异步更新 Redis,是最终一致性的最优解。
有话要说