引言
随着业务的不断发展,数据和访问量在不断地增大,性能问题开始逐步摆在了我们的眼前,仔细分析会发现我们的性能瓶颈大致出在了我们的关系型数据库上,我们的关系型数据库虽然会将数据加载到缓存,但是大部分数据还是在磁盘上,你知道磁盘很慢,参考资料[1]对机械硬盘和固态硬盘进行了测试,参考资料[1]的机械硬盘读写速度只有70MB/s左右, 参考资料[1]固态最好成绩达到了2773MB/s, 相差大概三十倍。所以我们提升数据库的访问速度可以从提升硬盘的读写性能入手,但是硬盘还是不够快,那么还可以供我们使用的大规模存储介质就是内存了,而Redis的数据就在内存里面,所以Redis非常快,单实例的读QPS可以可以高达10w/s ,而像MySQL这样的传统关系型数据库就只能承担"千"这个级别的QPS, 所以我们大部分数据可以选择让应用程序先从Redis里面获取,但是Redis也有网络I/O,如果你觉得还不够快,可以直接将数据缓存在应用程序里面,但单机的内存也是有限的,只能存储非常少量的数据,通常是最热点的那些key对应的数据。这就相当于消耗宝贵的服务内存去换高速的读取性能。
引入缓存之后带来的问题
引入了缓存之后,意味着数据就被存储了多份,我们的查询操作变成了下面这样:
List data = queryDataFromRedis(key); if (data == null){ data = queryDataFromDB(key); updateDBToRedis(data,key); }
这种缓存策略我们一般称之为cache aside(旁路缓存感觉怪怪的)策略,如果不巧很多缓存都在这个时候过期,那很多请求都会被打到数据库上,如果你的应用本身就依赖于缓存来支撑这种巨大的访问量,本身数据库无法支撑,那么就会导致数据库的压力骤增,严重一点就会导致数据库宕机,这也就是缓存雪崩。针对缓存雪崩,解决方案一般是在设置失效时间的时候,加上一个随机数,避免开发设置失效时间的时候,出现大量相同的过期时间。这段代码并没有什么问题,因为对数据库和缓存只有读请求,真实的业务不会只有读,出了读之外还会有写,那问题来了,处理写请求的时候是先更新缓存,还是先更新数据库, 还是更新数据库后删除缓存,还是更新数据库之前删除缓存。排列组合就有四种策略:
- 更新数据库后更新缓存
- 更新数据库前更新缓存
- 更新数据库后删除缓存
- 更新数据库前删除缓存
我们下面将会分别讲述,这四种策略会有什么问题。
更新数据库后更新缓存
理想的情况是,我们在更新数据库之后直接将对应的最新数据更新进缓存中, 但是数据库和Redis之间并没有事务保证,也就是说我们无法保证写入数据和写入Redis同时成功或者失败,或者我们即使在写入数据库成功之后,写入Redis成功,那么在数据库写入成功后Redis写入成功前的这段时间里,Redis数据肯定是和MySQL不一致的:
所以总会存在一个时间窗口,MySQL和Redis不一致,那我们用上分布式事务等手段,让数据库和Redis维持强一致?但是这样会使得系统的整体下降,甚至比不用缓存还慢,那我们为什么还要引入缓存? 我们再分析一下,我们真的需要那么强的一致性嘛,所谓数据库和Redis之间的强一致就是,不同的是时间窗口为0,我们也许并不需要那么强的一致性,即我们将数据不一致的时间窗口做到尽可能短,比如1ms之内,这个不一致性就很难被意识到,这个不一致性带来的影响就可以忽略不计。那我们就按照先更新数据库再更新缓存这样来设计,代码逻辑简化之后如下所示:
updateDB(data); // time 缓存时间 updateRedis(key,data,time)
这个逻辑仍然存在问题,让我们来想一个场景,扣减库存,现在我们的库存是100, 现在有两个写请求都是将库存减一,每个写请求由一个线程执行,扣减完成之后,在数据库中最后的结果都是98。理想情况下,是写请求A先更新数据库,再更新缓存,此时数据库和Redis中存储的库存都是99,然后写请求B更新数据库,更新缓存,此时数据库和Redis都是98。这看起来像是排队执行,真实的场景还可能会交错执行,比如写请求A先更新完数据库,此时数据库是99,但此时写请求可能由于网络原因或者时间片耗尽,导致更新缓存晚于写请求B更新缓存,导致缓存最后存储的是99,这也同样导致数据库和缓存不一致。而且这个不一致只能等下一次缓存失效或者更新数据库才可能被修复。
时间 | 写请求A | 写请求B | 问题 |
T1 | 扣减库存为99 | ||
T2 | 更新库存为98 | ||
T3 | 更新缓存为98 | ||
T4 | 更新缓存数据为99 | 缓存与数据库不一致 |
更新数据库前更新缓存
那我们先更新缓存再更新数据库? 代码就改成了下面这样:
// time 缓存时间 updateRedis(key,data,time) updateDB(data);
那如果更新数据库失败了呢?更新缓存成功,缓存里面存储的就是错误数据,可能有同学会反驳我,你可以做事务管理啊,更新数据库失败的时候,我回滚Redis就行了呀,但是Redis的事务并非像MySQL一样,一条语句失败整个回滚,Redis 在事务失败时不进行回滚,而是继续执行余下的命令, 所以我们无法保证这个回滚操作百分之百被执行。
同时也存在更新数据库后更新缓存一样类似的问题,举一个例子:
- 现成A和线程B同时更新某个数据
- 更新缓存的顺序是先A后B
- 更新数据库的顺序是先B后A
我们举一个例子来说明,现成A希望把计数器设置为0,现成B希望置为1。按照上面的场景,缓存的结果没问题,数据库的结果就被设置为0。
时间 | 写请求A | 写请求B | 问题 |
T1 | 更新缓存为0 | ||
T2 | 更新缓存为1 | ||
T3 | 更新数据为1 | ||
T4 | 更新数据库数据为0 | 此时缓存是对的,数据库的数据倒是不对了 |
所以通常情况下,更新缓存再更新数据是我们应该避免使用的一种策略。我们也可以用分布式锁来控制更新顺序,为了保证对数据库更新成功,我们可以使用MQ来进行确认,但这种策略较为复杂。那别更新了呗,我们删除缓存,那对于删除缓存,也仍然有两种策略, 也就是先更新数据库前删除缓存,更新数据库后删除缓存。
更新数据库前删除缓存
更新数据库前删除缓存,我们的代码结构就变成了下面这样:
deleteRedis(key); updateDB();
这样即使更新数据库失效,写写请求也都会导致缓存失效,最后根据我们查询缓存的策略,也是能够保证数据库和缓存一致性的。但是写写请求没问题了,船上又出现了个新的窟窿,也就是并发读写。还是扣减库存,我们现在缓存是100,数据库也是100,然后扣减库存请求过来,更新数据成功应该是99, 然后触发读请求,再从数据库加载一遍到缓存,那么缓存里面也被更新为99才是正确的。这是理想的情况,如果线程A在删除缓存之后,还没来得及更新数据库,此时线程B开始执行读请求,发现缓存失效,触发cache-aside策略,去数据库取值,然后缓存里面的值就是100。然后线程A更新数据库完毕,也造成了数据库和Redis不一致。
时间 | 线程A(写请求) | 线程B(读请求) | 问题 |
T1 | 删除缓存成功 | ||
T2 | 读取缓存失败,再从数据库读取数据100 | ||
T3 | 更新数据库中数据的值为99 | ||
将读到的数据100写入缓存 | 缓存和数据库不一致 |
读写并发的情况下,一样会有不一致的问题,针对场景,有个比较出名的做法是"延迟双删",也是面试题的高频部分,就是说,既然可能因为读请求把一个旧的值又写回去,那么我在写请求处理完之后,等到差不多的时间延迟再重新删除这个缓存值。但这时间最难把握的也许就是差不多了,刚刚好,这个差不多时间,是跟谁比差不多。那为啥要延迟双删呢?请问你立刻删除会有什么问题?延迟双删的前提是你的存储架构MySQL和Redis都是主从的,这些节点之间同步需要时间。
时间 | 线程A写请求 | 线程B读请求 | 线程D新的读请求 | 问题 |
T5 | 等待一段时间再删除缓存 | 缓存旧值存在,读取到旧数据 | ||
T6 | 删除缓存 | |||
T7 | 缓存缺失重新从数据库读取到最新值 | 其他线程也可能读到脏数据 |
更新数据库前删除缓存策略的关键在于对等待时间的判断,如果时间过短,早于读请求,但是此时事务还未提交,就会导致线程B依旧会读到脏数据,代码简化如下:
start transaction; deleteRedis(key); updateDB(); sleep(N) #沉睡N秒 deleteRedis(key); commit
如果N过长,这就是个大事务,而且在触发再次删除之前,导致读取请求读到的都是脏数据。
更新数据库后删除缓存
那更新数据库之后删除缓存呢,问题是否解决呢? 我们接着分析一下看看有没有类似的问题,还是以更新库存为例:
时间 | 线程A(写请求) | 线程B(读请求) | 线程C(读请求) | 问题 |
T1 | 原始库存为100,更新为99 | |||
T2 | 读取数据,查询到缓存还未失效,返回100 | 线程 C 实际上读取到了和数据库不一致的数据 | ||
T3 | 删除缓存 | |||
T4 | 查询缓存,缓存缺失,从数据库查询,得到最新值 | |||
T5 | 将数99写入到缓存 |
总体来说,采取先更新数据库再删除缓存的策略是没问题的,仅在[T3,T2)的时间窗口,可能会被其他线程读取到老值。我们上面已经进行了充足的讨论,在追求性能的前提下,缓存和数据库一定会存在不一致,我们要做的就是缩小这个窗口。内网状态下,这个时间窗口不过1ms(数据来自腾讯技术工程), 大部分情况下,一个用户很难在1ms内再发起一次。
但是在读写并发的场景下,还是会有一个情况存在不一致的情况,这个场景是读线程发现缓存不存在,于是读写并发时,读线程回写进去老值,我们还是以扣减库存为例:
时间 | 线程A写请求 | 线程B 读请求 | 问题 |
T1 | 缓存过期,读取数据的值到缓存。 | ||
T2 | 扣减库存,将库存从100变为99 | ||
T3 | 删除缓存 | ||
T4 | 此时缓存的值为100,数据库的值为99 |
这个不一致的情况出现的场景非常严格,如果碰到了就是写场景很多,事实上写请求高频,并不适合删除缓存。
总结一下数据库和缓存一致的策略
- 先更新数据库后更新缓存
- 写读并发
线程A未更新完缓存之前,现成B的读请求会短暂读到旧值
- 写写并发
更新数据库的顺序是先A后B,但更新缓存时的顺序是先B后A,数据库和缓存数据不一致。
- 我们可以用分布式锁来控制顺序,但是会比较影响性能。
- 更新缓存再更新数据库
- 写写并发
更新缓存的顺序是先A后B,更新数据时的顺序是先B后A。
- 同样使用分布式锁来进行控制,但这也对性能影响很大。除此之外,由于数据库和Redis不能在一个事务里面导致数据库更新可能失败。我们也可以利用MQ 确认数据库更新是否成功。
- 先删除缓存再更新数据库
- 写读并发
写请求删除缓存之后,还未更新数据库成功,其他读请求到来,发现缓存缺失,则把当前数据读取出来放置到缓存,而后才更新数据库成功。
- 应对策略就是延迟双删,但是在延迟的时间依然会有不一致窗口。
- 先更新数据库再删除缓存
- 线程A完成数据库更新成功后,尚未删除缓存,线程B开始读就会到脏数据。时间窗口较小。
读请求未命中缓存,写请求处理完之后,才回写缓存,出现缓存和数据库不一致。
综合分析来看,采取更新数据库后删除缓存值,是更为适合的策略。因为出现不一致的场景的条件更为苛刻,概率比其他方案要低很多。但删除缓存值意味着很多请求会打到数据库上,如果写操作频繁,就会导致缓存的作用非常小。而如果这个时候某些缓存数据还是热点数据,可能因为扛不住数据量而导致系统不可用。
删除数据不利于读请求利用缓存,这里做个简单总结:
- 针对大部分读多写少场景,建议选择更新数据库后删除的策略
- 针对读写相当或者写多读少的场景,建议选择更新数据库后更新缓存的策略
那么最终一致性该如何保证
设置过期时间
因为我们无法确定MySQL更新完成后,缓存的更新/删除一定成功,例如Redis宕机或者网络出现故障、服务当时刚好发生重启,没有执行这一步的代码。这时候MySQL的数据没有刷到Redis,为了避免这种不一致性永久存在,使用缓存的时候,我们有必要给缓存设置一个过期时间。这是最终一致性的兜底方案,出现任何情况的不一致问题,最后都能通过缓存失效后重新查询数据库,然后回写到缓存,做到缓存与数据库最终一致。
如何减少缓存删除/更新的失败?
如果删除缓存这一步因为服务重启没有执行,或者因为网络抖动导致删除缓存失败了,就会导致有一个比较长的时间(缓存的剩余过期时间)是数据不一致的。针对这种情况我们可以借助可靠的消息中间件来帮助我们重试,我们可以在MQ,MQ在消费失败之后会自动重试。我们把删除Redis的请求以消费MQ消息的手段去失效对应的key值,如果网络抖动导致删除失败,我们依旧可以依靠MQ的重试机制来让最终Redis对应的key失效。
在极端常见下,是否存在更新数据后MQ消息没发送成功,或者没机会发送出去机器就重启的情况。如果MQ使用的是RocketMQ,我们可以借助RocketMQ的事务消息,来让删除缓存的消息最终一定发送出去,而如果你没有使用RocketMQ,或者使用的消息中间件没有事务消息的特性,则可以采取消息表的方式让更新数据库和发送消息一起成功。我会在分布式事务的实现中,进行完备的讨论,这里我们不做过多展开。
如何处理复杂的多缓存场景?
在真实的业务场景中有可能是一个数据库记录对应多个key的更新,更新一个数据库记录会牵扯多个key的更新、更新不同的数据库记录可能需要更新同一个key值,举一个数据库记录对应多个key的场景:
假如系统设计上我们缓存了一个粉丝的主页信息、主播打赏榜 TOP10 的粉丝、单日 TOP 100 的粉丝等多个信息。如果这个粉丝注销了,或者这个粉丝触发了打赏的行为,上面多个 Key 可能都需要更新。只是一个打赏的记录,你可能就要做:
updateMySQL();//更新数据库一条记录 deleteRedisKey1();//失效主页信息的缓存 updateRedisKey2();//更新打赏榜TOP10 deleteRedisKey3();//更新单日打赏榜TOP100
这就涉及多个 Redis 的操作,每一步都可能失败,影响到后面的更新。甚至从系统设计上,更新数据库可能是单独的一个服务,而这几个不同的 Key 的缓存维护却在不同的 3 个微服务中,这就大大增加了系统的复杂度和提高了缓存操作失败的可能性。最可怕的是,操作更新记录的地方很大概率不只在一个业务逻辑中,而是散发在系统各个零散的位置。
针对这个场景,解决方案和上文提到的保证最终一致性的操作一样,就是把更新缓存的操作以MQ的方式发送出去,由不同的系统或者专门一个系统进行订阅。
通过订阅 MySQL binlog 的方式处理缓存
上面讲到的 MQ 处理方式需要业务代码里面显式地发送 MQ 消息。还有一种优雅的方式便是订阅 MySQL 的 binlog,监听数据的真实变化情况以处理相关的缓存。
例如刚刚提到的例子中,如果粉丝又触发打赏了,这时候我们利用 binlog 表监听是能及时发现的,发现后就能集中处理了,而且无论是在什么系统什么位置去更新数据,都能做到集中处理。
目前业界类似的产品有 Canal,具体的操作图如下:
利用 Canel 订阅数据库 binlog 变更从而发出 MQ 消息,让一个专门消费者服务维护所有相关 Key 的缓存操作。
写在最后
这篇文章基本就是微信公众号 腾讯技术工程《万字图文讲透数据库缓存一致性问题》的内容,我用了自己的方式将其改写了一下。
参考资料
[1] 机械硬盘、SATA协议和NVME协议固态的三块硬盘读写速度实测 https://zhuanlan.zhihu.com/p/348455035
[2] 顺序io和随机io分别在什么情况下出现?为什么日志是顺序io,数据是随机io?https://www.zhihu.com/question/370950509/answer/2248704865
[4] 分布式事务(2)---强一致性分布式事务解决方案 https://www.cnblogs.com/nijunyang/p/15631443.html
[5] 延时双删(redis-mysql)数据一致性思考 https://zhuanlan.zhihu.com/p/467410359
[6] 延时双删(redis-mysql)数据一致性思考 https://wenfh2020.com/2022/02/14/data-consistency/
[7] difference between exactly-once and at-least-once guarantees https://stackoverflow.com/questions/44204973/difference-between-exactly-once-and-at-least-once-guarantees