引入缓存竟然会带来这么多问题??!

作者:小牛呼噜噜 ,首发于公众号「小牛呼噜噜

哈喽,大家好呀,我是呼噜噜,最近很忙好久没更新了,今天我们通过缓存与数据库之间的一致性这个老生常谈的问题来切入,聊聊如何合理的设计一个缓存系统?

如今互联网应用,无论是web还是app,都基本遵循”前端-后端-数据库”的架构模型


当业务处于起步阶段,流量比较小的时候,上述能够支撑;但随着业务的扩张,用户数和流量越来越大,也就需要整个架构支撑起更大的并发量,但我们服务器上的资源总是有限的,当每天流量达到高峰时,往往这个时候数据库最先顶不住

当我们分析这些互联网应用的流量时候,发现大部分的流量实际上都是读请求,而且大部分数据并没有频繁被改变**(即读多写少场景,注意本文全文讨论的方案都是基于这个前提**)。这个时候引入缓存,是提升性能的一种行之有效的方式,缓存在计算机的世界中处处可见,比如CPU缓存,浏览器缓存,操作系统缓存,程序代码中自定义缓存

由于数据库每秒能接受的请求次数QPS是有限的,当我们在数据库前面,引入缓存来充当缓冲层;如果命中缓存就直接获取目标数据并返回,不仅能减少对数据库的直接访问带来的计算压力,还能提升响应速度,充分压榨有效的资源,其本质是额外消耗更高速的空间来换时间

凡是有利有弊,引入缓存后,享受缓存带来的种种好处的优点,但缓存系统其实是非常复杂的,缓存和数据库的一致性也是个绕不开,让人脑阔疼的问题;还需要考虑缓存的稳定性、命中率、热点数据、过期时间等等,我们下文慢慢道来

本地缓存、分布式缓存

缓存有各种分类,常见的是与应用耦合程度划分为:本地缓存local cache分布式缓存remote cache

本地缓存

本地缓存,由于存在于应用程序的本地内存,应用和缓存在同一个进程内,且没有网络延迟,所以速度快

但本地缓存的大小通常受到物理内存的限制,而且还要兼顾应用程序正常运行,容量有限,扩展性差,无法轻松扩展到多个节点。
还有就是多个应用实例下无法直接的共享缓存,数据的一致性难以保证,复杂度高。数据会随着应用程序的重启而丢失

适合读写密集、对数据一致性要求较低、网络环境不稳定的场景

分布式缓存

主要是指与应用分离的独立缓存组件,比如redis,可扩展性强,容量大,可以通过集群水平扩展;通过通过一致性哈希等技术,保证多节点之间的数据一致性,而且都集成好了,开发者一般直接使用这些特性

当然由于存在网络延迟,与本地缓存相比,速度较慢;硬件成本也需要较高,来保证其高可用、高可靠性

更适合电商平台、社交网络等流量并发大的平台,或者互联网这种随着业务增长,需要弹性扩展以满足需求的场景

还有综合二者特点的多级缓存,将本地缓存和分布式缓存结合起来,本地缓存作为一级缓存,存储更新频率低,访问频率高数据;分布式缓存作为二级缓存,存储更新频率很高的数据

当用户获取数据时,先从一级缓存中获取数据,如果一级缓存有数据则返回数据,否则从二级缓存中获取数据。如果二级缓存中有数据则更新一级缓存,然后将数据返回客户端。如果二级缓存没有数据则去数据库查询数据,然后更新二级缓存,接着再更新一级缓存,最后将数据返回给客户端。这里逻辑其实和CPU内部的缓存很像,大家感兴趣地可以自行查阅笔者之前的一篇文章-CPU缓存

但缓存相关的问题逻辑挑战,无论本地缓存还是分布式缓存都是一样的,为方便起见,本文将全文以redis为例,来代称缓存

缓存穿透、缓存击穿、缓存雪崩

在将缓存和数据库的一致性之前,我们需要保证,引入的缓存,即构建的缓存系统是稳定的,这是保证数据一致性的前提

关于缓存的稳定性,有3种经典问题:缓存穿透、缓存击穿、缓存雪崩,聊这3个问题前,我们得知晓缓存最常见的应用模式Cache-Aside Pattern旁路缓存的读模式

旁路缓存模式,是指优先查询缓存,查询不到再去查询数据库。如果这时候数据库查到数据了,就将缓存的数据回写更新,这样缓存可以为后续请求服务!

缓存穿透

缓存穿透: 当请求过来,访问不存在的数据时(即既不在缓存中,也不在数据库中),这会导致访问缓存,未命中,继续访问数据库db,然后发现在数据库中还是未查询到数据,这个时候也就不能回写缓存,来为后续的请求服务;也就是说,当这种请求过来,每次都会去查数据库,缓存形同虚设,一旦流量暴增,容易直接带崩数据库

这种不存在的数据可能被管理员误删,也有可能被黑客恶意利用(恶意请求),不断地去试,一旦发现一个不存在的数据,就拼命发请求访问这个数据,直到数据库锁住

那解决办法也很简单,常见的有:

  1. 比如每次访问数据如果既不在缓存中,也不在数据库中,那就缓存一个占位符或者空值过期时间也不要设置过长,比如1分钟就行,这样的话,在1分钟内,这么多请求只有一次能直接访问数据库,这样就能显著降低数据库的压力;如果缓存过期时间过长,会出现大量的空缓存,进而导致缓存资源的浪费
  2. 还可以针对请求携带的参数,比如是那种特殊字符、非法字符等,我们数据库肯定不会存这些东西,直接在应用服务层进行限制,不允许访问
  3. 还可以通过第三方组件来实现,比如布隆过滤器,其主要是其特性:布隆过滤器判断一个元素不在集合中,那肯定就不在。如果判断存在,那有一定可能性它在说谎,具体原理可以参考笔者以前的一篇文章海量数据处理的利器-布隆过滤器。在缓存和数据库之间再加上布隆过滤器,通过布隆过滤器快速判断数据是否存在,从而避免多次之间请求数据库

缓存击穿

在我们正常的业务之中,总有一些数据会被频繁访问,这就是热点数据

所谓的缓存击穿指的是,缓存中热点数据的key过期失效,由于是热点数据,在过期的一瞬间会有大量的请求过来(高并发),这些请求,最终都会直接访问数据库,这样数据库很容易被打垮,缓存仿佛被”击穿”了

常见的解决方案:

  1. 加锁,进程锁/分布式锁,当请求过来时,缓存未命中时,会通过锁将这个缓存key锁上,等当这个请求从数据库获取数据后再回写到缓存中后,再释放锁;期间其他请求过来,会获取锁失败,等待一段时间重试,就可以直接读取缓存了。需要注意的是,如果业务量不大,进程锁就够了的话,也就没必要上分布式锁,多引入额外组件,就会增加系统的不稳定性


还可以继续改进,将请求2未获得锁,直接返回,升级成自旋锁,它不直接返回,而是等待一会重新尝试获取锁,这种高并发情况下,只有唯一请求是db请求,所有请求共享结果

  1. 给缓存的Key设置合理的过期时间并加上随机值,尽量减少缓存短期大量失效,出现大量访问数据库的情况,实现”削峰填谷”
  2. 网上有文章提出,可以让热点数据的缓存不设置过期时间,这样不就可以永不过期嘛,但这其实是个很危险的操作


使用缓存的前提是一定要设置过期时间,因为由于项目会不断迭代更新,业务不断复杂,开发人员更替,缓存会变得越来越难以维护,另外缓存和数据库无法避免的数据不一致的情况,缓存的过期时间其实就是兜底,防止缓存和数据库数据长时间不一致

我们还可以通过消息队列来间接地让热点数据的缓存延期,当热点缓存过期时,后台服务再检测更新缓存,防止缓存击穿;至于是否延期,得做访问量分析与统计,当然引入新的组件也会带来额外的稳定性问题,还是得根据业务情况,实事求是

缓存雪崩

缓存雪崩,指定是大量请求未命中缓存,直接访问数据库,导致数据库压力过大,倘若请求足够的多,会直接将数据库压垮,继而影响整个系统,如同”雪崩”

个人感觉缓存击穿是缓存雪崩的一个子集,缓存雪崩一般有2种诱因:缓存服务异常,比如redis故障宕机或者缓存服务是正常的,但大量缓存数据在同一时间过期

一般解决redis故障宕机,是搭建集群由单节点到多节点,提升redis的容灾能力,当主节点宕机后,从节点可以切换成为主节点,继续提供缓存服务;若是真的宕机了,那我们应该使用熔断机制,同时当流量到达一定的阈值,直接禁止请求对数据库的访问,返回系统拥挤之类的提示,维持系统稳定,等待缓存恢复再允许对数据库访问

防止大量缓存数据在同一时间过期,一般是给缓存的Key设置合理的过期时间并加上随机偏差,尽量让缓存失效时间均匀分布,实现”削峰填谷”,简单而有效

要么加锁,唯一db请求,所有同类请求共享结果,与缓存击穿的解决方法一致,我们就不再赘述了

还有一种方式就是,当每天系统访问的流量高峰来临之前,先提前将热点数据入缓存,避免直到用户请求的时候,再先查询数据库,然后将数据缓存的过程,这个也叫缓存预热

CAP原则 和 如何保证缓存一致性

由于在数据库层前,引入缓存,主要是通过空间去换时间,享受缓存带来的种种好处的优点,但此时一份数据存在不同的副本,且在不同空间中,此时更新缓存、db就会带来缓存一致性的挑战

我们还需要了解一下著名的CAP原则,指在一个分布式系统中,一致性Consistency、可用性Availability、分区容错性Partition tolerance这3者最多同时满足2项,不可能同时满足3项!!!

图片来源于网络

  1. 一致性Consistency,即所有节点在同一时间具有相同的数据,强一致性
  2. 可用性Availability,即服务必须一直处于可用的状态,每次请求都能获取到正常的响应,高可用
  3. 分区容错性Partition tolerance,即分区故障时,要求在一定时限内,仍然或者恢复到能对外提供满足一致性和可用性的服务,系统继续正常运行

还记得本文的一开始吗?

为了应对高流量,我们的系统选择了高性能和高吞吐量,所以只能满足AP

而缓存与数据库的缓存一致性难以避免的具体原因是:由于无法保证同时更新db和缓存不在同一个事务中,所以其不是原子操作缓存不一致是无法避免的

要保证强一致性,我们可以上分布式锁,但会导致整个系统的并发性能下降,还记得我们引入缓存的初衷吗?是为了提升系统的整体性能呐!!!所以这种方案我们一般不采用~

但我们可以通过一些方案,来实现缓存的最终一致性,其次尽可能减小缓存不一致的时间窗口,我们下面分别来聊聊常见的几种方式及其它们的问题:

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

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

先更新数据库,再更新缓存,可能会遇到下面这种情况:

当请求(或者可以说线程)并发的情况,比如2个请求1、2同时去更新db时,请求1快一点;但当程序延迟或者其他情况,导致当请求去更新缓存时,请求2快一点,这就会导致最终db=20,缓存=10这种数据不一致的情况,不一致的情况将持续到下次缓存失效,或者去更新数据库缓存的时候,在此期间还不能保证更新缓存一定就可以成功

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

这种和先更新数据库,再更新缓存是类似的情况:


这种更新缓存的方式,是无法避免并发导致的数据不一致问题,而且出现的频率也不低,所以我们应该尽量不更新缓存

先删缓存,再更新数据库 和 延迟双删

前一个更新请求,先删除缓存,再更新数据库,当后面读请求来发现没有命中缓存,去数据库读数据,然后再回写到缓存中,给后续请求服务,这是个很不错的设想,但它还是会出现下面这种情况:


当2个并发请求过来,请求1是更新请求,当请求1删除调缓存后,还没去db更新数据,期间请求2来获取数据,缓存未命中(刚被请求1删了嘛),去数据库获取数据10后,后回写缓存,把缓存更新为10;这个时候请求1终于去更新db了,把db更新为20,这个时候还是会出现缓存和数据库不一致的情况

一旦发生数据不一致,脏数据会一直在缓存中,直到下一次更新请求过来

补充:延迟双删
关注,我再多讲几句~如今在先删缓存,再更新数据库的基础上,还有个优化版叫延迟双删

既然请求可能会把脏数据重新写入缓存中,脏数据会一直在缓存中,直到下一次更新请求过来,这个数据不一致的时间窗口较长,如果这个时候休眠指定时间N,我们另起线程(异步化)去删除这个脏数据缓存,这个时候不就能缩短极端情况下不一致的时间窗口了嘛,一般N设为5s左右,需要根据项目实际情况而定。

另外也可以通过消息队列MQ来删除缓存,利用消息队列的可靠性,来保证删除缓存的操作能够成功执行,并异步化进行复杂逻辑的解耦

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

那先更新数据库,再删除缓存呢?它也被称为Cache Aside Pattern旁路缓存的写模式,我们再来看一种情况:


从上面时序图,我们可以看出,先更新数据库,再删除缓存这种方案是可以保证缓存的最终一致性,但它在某一时间内,还是存在缓存不一致的时间窗口(上图请求2命中缓存与数据库不一致)

但这个不一致的时间窗口很短,通常不超过1ms,在互联网项目中通常可以忽略这么短时间的不一致

但你觉得这就是终极方案了?

别急我们再看它有可能发生的一种情况:


当2个并发请求过来,请求1是读请求,正好缓存不存在,直接读取db=20,在回写缓存期间,请求2又过来更新db=10,在删除缓存(没缓存),然后请求1再姗姗来迟地更新缓存=20,这就导致了缓存与数据的不一致情况

但实际上这种情况,触发的概率非常低,因为缓存的存取速度(内存),要远远快于数据库(磁盘)。关于储存介质的速度差异,感兴趣地可以去看看计算机储存器的读写速度差异

所以很难出现请求1已经更新了数据库并且删除了缓存,请求2才更新完缓存的情况;为防止删除缓存失败,给缓存加个过期时间简单而有效

但这其实也反映了:

  1. 先更新数据库,再删除缓存这种模式并不太适合写请求远远多于读请求的场景下,而且当并发量特别高的情况下,缓存删除的代价也会较大(容易缓存击穿),这个时候更新数据库后更新缓存可能是更适合的方案,还能进而通过MQ异步来优化
  2. 如果读请求远远大于写请求的场景下,先更新数据库,再删除缓存是个较好的方案,背后是lazy计算的思想:不要每次都重新做复杂的计算,而是等到它需要用的时候再重新计算
  3. 本文提到的这4种方案,无论是哪种方案都是无法绝对保证缓存的一致性,只能保证最终一致性,缩短不一致的时间窗口。所以缓存必须要设置过期时间,这就是对缓存不一致的兜底措施
  4. 最后如果对数据一致性要求极高的话,就不要再额外引入缓存,不引入缓存就没有这么多烦恼!

如何保证删除缓存能执行成功

另外在实际环境中,执行删除缓存,也会有问题,因为无法保证系统会一定去删除缓存,如果删除缓存失败,也会造成缓存与数据库的不一致,下面介绍几种常见的方案:

基于消息队列删除缓存

由于删除缓存不一定能成功,一般会采用多次重试删除的方案,需要一个队列来记录,是否删除成功,如果没有成功就继续回队列中,一般会引入中间件消息队列MQ来,利用其高可靠性来保证删除操作的执行,同时还能异步化,实现复杂业务逻辑的解耦

我们来看下其主要流程:

更新数据库的同时,发送删除缓存的消息到消息队列中,首次消费消息去执行删除缓存的操作,如果成功就直接返回业务,并把这个消息消费掉;如果由于各种原因导致缓存删除失败,那就重新将这个消息放进消息队列中,等待下一次的消费

当第二次消费删除该缓存的消息时,如果删除成功就把该消息消费掉,并返回;如果没有删除成功就继续放回消息队列中,每个消息都有消费次数的上限,超出就报错告警


另外一般将更新数据库的模块和同时发生删除缓存消息的模块放在同一个服务里,因为这样后期维护起来,才不会发现莫名奇妙,不然就是给排查和维护上强度~~

当然再引入mq,也要额外考虑mq的高可用性,所以需要根据实际情况,考虑是否有必要引入mq,如果不引入怎么办?

最简单的我们可以通过内存队列、线程池等方式实现,性能更高,毕竟在本地没有网络延迟,代价就是更考验程序员的心智,啥都要操心~

基于binlog来删除缓存

还有一种比较有意思的方式,我们上面需要在程序中显式去发送消息,讲人话就是程序需要额外承担发送消息的压力
而通过订阅数据库比如Mysql的binlog,来监听数据的真实变化直接去处理有关的缓存,让程序专心地去操作数据库

binlog用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog是mysql的逻辑日志,并且由Server层进行记录,使用任何存储引擎的mysql数据库都会记录binlog日志。可通过解析binlog文件来查看数据库的操作历史记录

业内比较成熟的有中间件Canal,我司也用的这个,Canal会模拟MySQL主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向MySQL主节点发送dump请求,MySQL收到请求后,就会开始推送Binlog给Canal,Canal解析Binlog字节流,解析出其中有关数据库中数据更新的日志,解析日志并执行对应数据的删除缓存操作,然后再引入MQ,通过消息队列的ACK机制,来确保这条消息的执行成功

关注我,小牛呼噜噜,我再说几句:

希望大家通过这些方案的学习,能够领悟为什么只能满足AP

为什么缓存的数据一致性问题是无法避免的挑战?

引入缓存后,我们该如何监控起来呢?进一步分析过期时间是否合适,缓存的命中率

或者是否必需引入缓存?不引入缓存可就没有缓存的数据一致性,这些都需要数据分析作为支撑

或者引入缓存如何进一步优化,缓存的key如何花式设置,缓存预热有讲究,还有团队如果规范使用缓存等等,有太多可以深究

奈何文章的篇幅有限,笔者最近还疯狂加班,本文到这,已经连续爆肝熬夜几周耗尽心血

各位读者感觉有对你们帮助的话,点赞转发收藏在看,能否一气呵成

祝大家假期快乐,可以下方评论下次想学什么方面的内容,当然我不一定了解:dog:

我们下期再见~


感谢阅读,原创不易,如果有收获的话,就点个免费的[]or[转发],你的支持会激励我输出更高质量的文章,感谢!


作者:小牛呼噜噜 ,首发于公众号小牛呼噜噜,系列文章还有:

  1. 聊聊x86计算机启动发生的事?
  2. Linux0.12内核源码解读(2)-Bootsect.S
  3. Linux0.12内核源码解读(3)-Setup.S
  4. 图解CPU的实模式与保护模式
  5. Linux0.12内核源码解读(5)-head.s
  6. Linux0.12内核源码解读(6)-main.c
  7. Linux0.12内核源码解读(7)-陷阱门初始化
  8. 图解计算机中断
  9. Linux0.12内核源码解读(9)-blk_dev_init和chr_dev_init
  10. 什么是系统调用机制?结合Linux0.12源码图解