聊一聊安全且正确使用缓存的那些事 —— 关于缓存可靠性、关乎数据一致性

大家好,又见面了。


本文是笔者作为掘金技术社区签约作者的身份输出的缓存专栏系列内容,将会通过系列专题,讲清楚缓存的方方面面。如果感兴趣,欢迎关注以获取后续更新。


在上一篇文档《聊一聊作为高并发系统基石之一的缓存,会用很简单,用好才是技术活》中,我们对缓存的庞大体系进行了个初步的探讨,浮光掠影般的介绍了本地缓存集中缓存多级缓存的不同形式,也走马观花似的初识了缓存设计的关键原则与需要关注的典型问题

作为《深入理解缓存原理与实战设计》系列专栏的第二篇内容,从本篇开始,我们将聚焦缓存体系中的具体场景,分别进行深入的阐述与探讨。本篇我们就一起具体地聊一聊缓存使用中需要关注的典型问题可靠性防护措施。

在分布式系统盛行的今天,尤其是在一些用户体量比较大的互联网业务系统里面,缓存充当着扛压屏障的作用。当前各互联网系统可以扛住动辄数万甚至数十万的并发请求量,缓存机制功不可没。而一旦缓存出现问题,对系统的影响往往也是致命的。所以在缓存的使用时必须要考虑完备的兜底与灾难应对策略。

热点数据与淘汰策略

大部分服务端使用的抗压型缓存,为了保证缓存执行速度,普遍都是将数据存储在内存中。而受限于硬件与成本约束,内存的容量不太可能像磁盘一样近乎无限的去随意扩容使用。对于实际数据量极其庞大且无法将其全部存储于缓存中的时候,我们需要保证存储在缓存中的有限部分数据要尽可能的命中更多的请求,即要求缓存中存储的都是热点数据

说到这里,就会存在一个不得不面对的问题:当数据量超级大而缓存的内存容量有限的情况下,如果容量满了该怎么办

断舍离!

缓存实现的时候,必须要有一种机制,能够保证内存中的数据不会无限制增加 —— 也即数据淘汰机制数据淘汰机制,是一个成熟的缓存体系所必备的基础能力。这里有个概念需要厘清,即数据淘汰策略与数据过期是两个不同的概念。

  • 数据过期,是缓存系统的一个正常逻辑,是符合业务预期的一种数据删除机制。即设定了有效期的缓存数据,过期之后从缓存中移除。

  • 数据淘汰,是缓存系统的一种“有损自保”的降级策略,是业务预期之外的一种数据删除手段。指的是所存储的数据没达到过期时间,但缓存空间满了,对于新的数据想要加入缓存中时,缓存模块需要执行的一种应对策略。

我们把缓存当做一个容器,试想一下,一个容器已满的情况下,继续往里面放东西,可以有什么应对之法?无外乎两种:

  1. 直接拒绝,因为满了,放不下了。

  2. 从容器里面扔掉一些已有内容,然后腾挪出部分空间出来,将新的东西放进来。

进一步地,当决定采用先从容器中扔掉一些已有内容的时候,又会面临一个新的抉择,应该扔掉哪些内容?实践中常用的也有几种方案:

  1. 一切随缘,随机决定。从容器中现有的内容中随机扔掉剔除一些。

  2. 按需排序,保留常用。即基于LRU策略,将最久没有被使用过的数据给剔除掉。

  3. 提前过期,淘汰出局。对于一些设置了过期时间的记录,将其按照过期时间点进行排序,将最近即将过期的数据剔除(类似让其提前过期)。

  4. 其它策略。自行实现缓存时,除了上述集中常见策略,也可以根据业务的场景构建业务自定义的淘汰策略。比如根据创建日期、根据最后修改日期、根据优先级、根据访问次数等等。

一些主流的缓存中间件的淘汰机制大都也是遵循上述的方案来实现的。比如Redis提供了高达6种不同的数据淘汰机制,供使用方按需选择,将有限的空间仅用来存储热点数据,实现缓存的价值最大化。如下:

从上图可以看出,Redis对随机淘汰和LRU策略进行的更精细化的实现,支持将淘汰目标范围细分为全部数据和设有过期时间的数据,这种策略相对更为合理一些。因为一般设置了过期时间的数据,本身就具备可删除性,将其直接淘汰对业务不会有逻辑上的影响;而没有设置过期时间的数据,通常是要求常驻内存的,往往是一些配置数据或者是一些需要当做白名单含义使用的数据(比如用户信息,如果用户信息不在缓存里,则说明用户不存在),这种如果强行将其删除,可能会造成业务层面的一些逻辑异常。

缓存雪崩:避免缓存的集中失效

为了限制缓存的数量,很多的缓存记录都会设置一定的有效期,到期后自动失效。这种在一些批量缓存构建或者全量缓存重建时,因为设置了相同的失效时间,会导致大量甚至全部的缓存数据在短时间内集体失效,这样会导致大量的请求无法命中缓存而直接流转到了下游模块,导致系统瘫痪,也即缓存雪崩

其实解决的思路也很简单,避免出现集中失效就好咯。如何避免呢?

一种简单的策略,就是批量加载的场景,将过期时间在一个固定时间段内以毫秒级别进行随机打散,比如本来要设置每条记录过期时间为5分钟,则批量加载的时候可以设置过期时间为5~10分钟之间的任意一个毫秒数。这样就可以有效的避免数据集中失效,避免出现缓存雪崩而影响业务稳定。

此外,在一些大型系统里面,尤其是一些分布式微服务化的系统中,很多情况下都会有多个独立的缓存服务,而最终持久化数据则集中存储。如果某个独立缓存真的出现了缓存雪崩,业务层面应该如何将受损范围控制在仅自身模块、避免殃及数据库以及下游公共服务模块,进而避免业务出现系统性瘫痪呢?这个就需要结合服务治理中的一些手段来综合防范了,比如服务降级服务熔断、以及接口限流等策略。

缓存击穿:有效的冷数据预热加载机制

正如前面所提到的,基于内存的缓存,受内存容量限制,往往都会加载一些热点数据。而这些热点缓存数据,可以命中大部分的业务请求。少部分没有命中缓存的数据,则直接转由业务模块进行处理(比如从MySQL里面进行查询)。

先来看一个例子:

互动论坛系统,使用Redis作为缓存,缓存最近1年的帖子信息。如果用户查看的帖子是最近1年的,则直接从Redis中查询并返回,如果用户查看的帖子是1年前的,则从MySQL中进行捞取并返回。

因为论坛系统中,大部分人会阅读或者查看的都是最近新发的帖子,只有极少数的人可能会偶尔“挖坟”查看一年前的历史帖子。系统上线前会根据冷热请求的比例与总量情况,评估需要部署的硬件规模,以确保可以支撑住线上正常的访问请求。但为了避免缓存数据被无限撑满,一般业务缓存数据都会设置一个过期时间,来保证缓存数据的定期清理与更新。

近段时间,娱乐圈的雷声不断,各种新鲜的大瓜也让吃瓜群众撑到打嗝。

有一天,娱乐圈当红流量明星李某某突然被爆料与某网红存在某些不正当的关系,甚至被爆有多次PC被捕的惊天大瓜,引起粉丝和路人的强烈关注。

吃瓜群众们群情高涨、热搜一波盖过一波、帖子的浏览量光速攀升,论坛系统在缓存模块的加持下,虽然整体CPU和内存占用都飙升上去了,倒也相安无事。

但天有不测风云,恰好这个时候,这条帖子的记录在缓存中过期被删除了。然后狂涛巨浪般的请求涌向了后端的数据库,让数据库原地瘫痪,进而陆陆续续殃及了整个论坛系统。这就是典型的一个缓存击穿的问题。

缓存击穿和前面提到的缓存雪崩产生的原因其实很相似。区别点在于:

  • 缓存雪崩是大面积的缓存失效导致大量请求涌入数据库。

  • 缓存击穿是少量缓存失效的时候恰好失效的数据遭遇大并发量的请求,导致这些请求全部涌入数据库中。

针对这种情况,我们可以为热点数据设置一个过期时间续期的操作,比如每次请求的时候自动将过期时间续期一下。此外,也可以在数据库记录访问的时候借助分布式锁来防止缓存击穿问题的出现。当缓存不可用时,仅持锁的线程负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。如下图所示:

对上面的处理过程描述说明如下:

  1. 没有命中缓存的时候,先请求获取分布式锁,获取到分布式锁的线程,执行DB查询操作,然后将查询结果写入到缓存中;

  2. 没有抢到分布式锁的请求,原地自旋等待一定时间后进行再次重试;

  3. 未抢到锁的线程,再次重试的时候,先尝试去缓存中获取下是否能获取到数据,如果可以获取到数据,则直接取缓存已有的数据并返回;否则重复上述123步骤。

按照上面的策略,经过一番通宵紧急上线操作后,系统终于恢复了正常。正当开发人员长舒了口气准备下班回家睡觉的时候,系统警报再次响起,系统再次宕机了。

有人扒出了一个2年前的帖子,这个帖子在2年前就已经爆料李某某由于PC被警方拘捕,当时大家都不信。于是这个2年前的帖子得到了众人狂热的转发与阅读查看。

其实宕机的原因很明显,因为系统只规划缓存了最近1年的所有帖子信息,而对超过1年的帖子的操作,都会直接请求到数据库上。这个2年前的帖子突然爆火导致大量的用户来请求直接打到了下游,再次将数据库压垮 —— 也就是说又一次出现了缓存击穿,在同一块石头上摔倒了两次!

对于业务中最常使用的旁路型缓存而言,通常会先读取缓存,如果不存在则去数据库查询,并将查询到的数据添加到缓存中,这样就可以使得后面的请求继续命中缓存。

但是这种常规操作存在个“漏洞”,因为大部分缓存容量有限制,且很多场景会基于LRU策略进行内存中热点数据的淘汰,假如有个恶意程序(比如爬虫)一直在刷历史数据,容易将内存中的热点数据变为历史数据,导致真正的用户请求被打到数据库层。因而又出现了一些业务场景,会使用类似上面所举的例子的策略,缓存指定时间段内的数据(比如最近1年),且数据不存在时从DB获取内容之后也不会回写到缓存中。针对这种场景,在缓存的设计时,需要考虑到对这种冷数据的加热机制进行一些额外处理,如设定一个门槛,如果指定时间段内对一个冷数据的访问次数达到阈值,则将冷数据加热,添加到热点数据缓存中,并设定一个独立的过期时间,来解决此类问题。

比如上面的例子中,我们可以约定同一秒内对某条冷数据的请求超过10次,则将此条冷数据加热作为临时热点数据存入缓存,设定缓存过期时间为30天(一般一个陈年八卦一个月足够消停下去了)。通过这样的机制,来解决冷数据的突然窜热对系统带来的不稳定影响。如下图所示:

又是一番紧急上线,终于,系统又恢复正常了。

缓存穿透:合理的防身自保手段

我们的系统对外开放并运行的时候,面对的环境险象环生。你不知道请求是来自一个正常用户还是某些别有用心的盗窃者、亦或是个纯粹的破坏者。

还是上面的论坛的例子:

用户在互动论坛上点击帖子并查看内容的时候,界面调用查询帖子详情接口时会传入帖子ID,然后后端基于帖子ID先去缓存中查询,如果缓存中存在则直接返回数据,否则会尝试从MySQL中查询数据并返回。

有些人盯上了论坛的内容,便搞了个爬虫程序,模拟帖子ID的生成规则,调用查询详情接口并传入自己生成的ID去遍历挖取系统内的帖子数据,这样导致很多传入的ID是无效的、系统内并不存在对应ID的帖子数据。

所以,上面大量无效的ID请求到系统内,因为无法命中缓存而被转到MySQL中查询,而MySQL中其实也无法查询到对应的数据(因为这些ID是恶意生成的、压根不存在)。大量此类请求频繁的传入,就会导致请求一直依赖MySQL进行处理,极易冲垮下游模块。这个便是经典的缓存穿透问题(缓存穿透缓存击穿非常相似,区别点在于缓存穿透的实际请求数据在数据库中也没有,而缓存击穿是仅仅在缓存中没命中,但是在数据库中其实是存在对应数据的)。

缓存穿透的情况往往出现在一些外部干扰或者攻击情景中,比如外部爬虫、比如黑客攻击等等。为了解决缓存穿透的问题,可以考虑基于一些类似白名单的机制(比如基于布隆过滤器的策略,后面系列文章中会详细探讨),当然,有条件的情况下,也可以构建一些反爬策略,比如添加请求签名校验机制、比如添加IP访问限制策略等等。

缓存的数据一致性

缓存作为持久化存储(如数据库)的辅助存在,毕竟属于两套系统。理想情况下是缓存数据与数据库中数据完全一致,但是业务最常使用的旁路缓存架构下,在一些分布式或者高并发的场景中,可能会出现缓存不一致的情况。

数据库更新+缓存更新

在数据有变更的时候,需要同时更新缓存和数据库两个地方的数据。因为涉及到两个模块的数据更新,所以会有2种组合情况:

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

单线程场景下,如果更新缓存和更新数据库操作都是成功的,则可以保证数据库与缓存数据是一致的。但是在多线程场景下,由于由于更新缓存和更新数据库是两个操作,不具备原子性,就有可能出现多个并发请求交叉的情况,进而导致缓存和数据库中的记录不一致的情况。比如下面这个场景:

这种情况下,有很多的人会选择结合数据库的事务来一起控制。因为数据库有事务控制,而Redis等缓存没有事务性,所以会在一个DB事务中封装多个操作,比如先执行数据库操作,执行成功之后再进行缓存更新操作。这样如果缓存更新失败,则直接将当前数据库的事务回滚,企图用这种方式来保证缓存数据与DB数据的一致。

乍看似乎没毛病,但是细想一下,其实是有前提条件的。我们知道数据库事务的隔离级别有几种不同的类型,需要保证使用的事务隔离级别为Serializable或者Repeatable Read级别,以此来保证并发更新的场景下不会出现数据不一致问题,但这也降低了并发效率,提高数据库的CPU负载(隔离级别与并发性能存在一定的关联关系,见下图所示)。

所以对于一些读多写少、写操作并发竞争不是特别激烈且对一致性要求不是特别高的情况下,可以采用事务(高隔离级别) + 先更新数据库再更新缓存的方式来达到数据一致的诉求。

数据库更新+缓存删除

在旁路型缓存的读操作分支中,从缓存中没有读取到数据而改为从DB中获取到数据之后,通常都会选择将记录写入到缓存中。所以我们也可以在写操作的时候选择将缓存直接删除,等待后续读取的时候重新加载到缓存中。

这样也会有两种组合情况:

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

这种也会出现前面说的先操作成功,后操作失败的问题。
我们先看下先删除缓存再更新数据库的操作策略。如果先删除缓存成功,然后更新数据库失败,这种情况下,再次读取的时候,会从DB里面将旧数据重新加载回缓存中,数据是可以保持一致的。

虽然更新数据库失败这种场景下不会出现问题,但是在数据库更新成功这种正常情况下,却可能会在并发场景中出现问题。因为常见的缓存(如Redis)是没有事务的,所以可能会因为并发处理顺序的问题导致最终数据不一致。如下图所示:

上图中,因为删除缓存更新DB非原子操作,所以在并发场景下可能的情况:

  1. A请求执行更新数据操作,先删除了缓存中的数据;

  2. A这个时候还没来及往DB中更新数据的时候,B查询请求恰好进入;

  3. B先查询缓存发现缓存中没有数据,又从数据库中查询记录并将记录写入缓存中(相当于A刚删了缓存,B又将原样数据写回缓存了);

  4. A执行完成更新逻辑,将变更后的数据写入到DB中。

一番操作完成后,实际上缓存中存储的是A修改前的内容,而DB中存储的是A修改后的数据,两者因此出现了不一致的问题。这样导致后面的查询请求依旧是从缓存中获取到旧数据,而更新后的新数据无法生效。

那么,如果采用先更新数据库,再删除缓存的策略,又会有何种表现呢?假设数据库更新成功,但是缓存删除失败,我们也可以通过数据库事务回滚的方式将数据库更新操作回滚掉,这样在非并发状态下,可以确保数据库与缓存中数据是一致的。

当然,因为基于数据库事务机制来控制,需要注意下事务的粒度不能过大,避免事务成为阻塞系统性能的瓶颈。在对并发性能要求极高的情况下,可以考虑非事物类的其余方式来实现,如重试机制、或异步补偿机制、或多者结合方式等。

比如下图所示的这种策略:

上图的数据更新处理策略,可以有效的保证数据的最终一致性,降低极端情况可能出现数据不一致的概率,并兜底增加了数据不一致时的自恢复能力。

具体处理逻辑说明如下:

  • 先执行数据库的数据更新操作。

  • 更新成功,再去执行缓存记录删除操作。

  • 缓存如果删除失败,则按照预定的重试策略(比如对于指定错误码进行重试,最多重试3次,每次重试间隔100ms等)进行重试。

  • 如果缓存删除失败,且重试依旧失败,则将此删除事件放入到MQ中。

  • 独立的补偿逻辑,会去消费MQ中的消息事件请求,然后按照补偿策略继续尝试删除。

  • 每个缓存记录设定过期事件,极端情况下,重试删除、补偿删除等策略全部失败时,等到数据记录过期自动从缓存中淘汰,作为兜底策略

这种处理方式,虽然依旧无法百分百保证数据一致,但是整体出现数据不一致情况的概率与可能性非常的小。

实际使用场景中,对于一致性要求不是特别高、且并发量不是特别大的场景,可以选择基于数据库事务保证的先更新数据库再更新/删除缓存。而对于并发要求较高、且数据一致性要求较好的时候,推荐选择先更新数据库,再删除缓存,并结合删除重试 + 补偿逻辑 + 缓存过期TTL等综合手段

小结回顾

本篇内容中,我们主要探讨了下缓存的使用过程中的一些典型异常的触发场景防护策略,并一起聊了下保持缓存与数据库数据一致性的一些保障手段。

关于这些内容,我们本篇就聊到这里。

那么,你是否在使用缓存的时候遇到过类似的问题呢?你是如何解决这些问题的呢?你关于这些问题你是否有更好的理解与应对策略呢?欢迎评论区一起交流下,期待和各位小伙伴们一起切磋、共同成长。

📣 补充说明

本文属于《深入理解缓存原理与实战设计》系列专栏的内容之一。该专栏围绕缓存这个宏大命题进行展开阐述,全方位、系统性地深度剖析各种缓存实现策略与原理、以及缓存的各种用法、各种问题应对策略,并一起探讨下缓存设计的哲学。

如果有兴趣,也欢迎关注此专栏。

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。