那些使用缓存不得不面对的坑

当业务发展到一定规模的时候,数据库的性能往往会成为我们整个系统的性能瓶颈,这种情况下,引入一个缓存层往往是系统架构发展的必经之路。但是缓存层的引入并不是完全能够一帆风顺的,这其中依然会存在很多坑等着我们。接下来就由木子同学给大家具体介绍一下使用缓存过程中的一些可能存在的坑以及相应的常见解决方法,包括缓存雪崩、缓存穿透、缓存击穿、big key、hot key、数据一致性等六大问题。

全文共计约 6000 字

缓存雪崩

产生原因

缓存雪崩的主要表现就是在同一时刻有大量 key 同时失效,导致后续请求无法命中缓存,穿透到数据库,使数据库压力飙升,更严重的情况甚至会导致数据库宕机。缓存雪崩的主要原因就是在写缓存的时候,我们一般会给缓存设置一个过期时间,如果是批量写缓存,并且每个缓存都用了相同的过期时间,那么就会导致缓存的数据在相同的时间同时失效。

除了数据过期的原因之外,缓存雪崩还有另外一个原因,那就是缓存实例发生故障宕机,导致它无法处理请求,从而使请求全部打到了数据库,这应该是更可怕的一种情况。

解决方法

方法一–针对 key 失效情况

雪崩的主要原因就是同时设置了一批具有相同过期时间的 key,那么第一个解决方法就是我们可以为每个 key 设置不同的过期时间,例如在基准时间的基础上添加一个随机数,使不同 key 的过期时间相对离散,使得同时只有少量 key 会穿透到数据库。我们也可以为缓存设置一个自动回源时间,让它小于缓存失效时间,从而可以在缓存失效之前及时更新缓存,保持缓存中可以一直有数据。这里的自动回源一般都是伴随着持续访问来进行的,因此也就比较适合访问具有持续性的请求。通过这种方式我们也可以将回源数据库的请求分散开来。

方法二–针对缓存实例宕机

针对缓存实例宕机,我们可以通过主从的方式来配置一个高可用架构,如果主节点故障宕机,那么从节点也能够很快补上。

缓存穿透

产生原因

我们正常的数据首先一定在数据库中存在,其次可以写进缓存。注意这里的前提,就是数据库中肯定有数据。那么如果有一些特殊的访客,自己构造了一些我们数据库中没有的数据会产生什么结果。相信大家都已经能想到,对于这些数据的所有请求,都会从缓存中查不到这条数据,从而穿透到数据库。接下来更刺激的来了:如果他用这些 key 以极高的 QPS 进行访问,那么所有请求都会走到数据库,数据库压力飙升,更严重的情况甚至会导致数据库宕机,影响正常用户的访问。

可以看到,缓存雪崩针对的是正常的用户请求,而缓存穿透针对的是异常情况,两者产生的后果是很像的,都是请求从缓存中查不到数据,打到了数据库中。

解决方法

针对缓存穿透,其实我们要做的就是如何甄别出访问的数据是正常的数据还是用户恶意构造的不存在的数据。当识别出这些数据了,我们就可以有针对性的进行处理,这里我们提出三种解决方法:

方法一

正常情况下,如果缓存和数据库中都不存在,我们就不会去回写缓存,这就构成了一个死循环:缓存一直没有,数据库一直被无效请求占领。那么我们可以换一种思路,即使请求的数据是不存在的,我们也可以把请求的 key 记录到缓存中,只是它缓存的是一个无效值(这个无效值可以自己来定,只要自己能够区分出来就行),这样的话,每次访问这个 key 的时候,我们就会知道它是一个无效请求,直接舍弃就好了。

也许有的同学会问:如果这个不存在的数据后面真的有了呢?那由于缓存中缓存的是无效值,会不会导致它永远或者长时间不会被查询到呢?当然,其实这种情况也是有可能存在的。解决的方法很简单,我们可以给无效值缓存设置一个较短的过期时间(例如 1 s)就行了,这样的话请求很快就会再次查询数据库,如果还不存在,那么继续设置无效值缓存就行了。

可能还有的同学会问:如果这种无效的 key 特别多怎么办,因为恶意用户有可能会一直生成这种无效值,那么会不会占满我们的缓存空间,因为毕竟缓存都是基于内存实现的,空间很宝贵。这里的答案也是肯定的,的确会有这种情况存在。解决的办法也很简单,一方面我们同样可以减短无效缓存的时间,保证同时存在的无效缓存数据量不会很大;另一方面,我们可以将这些缓存数据存到一个专门的缓存集群,请求的时候,先查询正常的缓存,如果没有命中,再查询无效值缓存集群,如果还是没有命中,再回源数据库。当然第二种方法对性能会有一些影响,因为网络请求多了一跳。

方法二

上面我们提到,如果有大量无效 key-value 存在,对缓存的内存占用是会很高的,会大大降低有效内存的使用率。那么有没有什么办法能够尽可能减少无效 key 对缓存的内存占用呢?这个时候就该祭出我们的“布隆过滤器”(Bloom Filter)了。

布隆过滤器是基于位图(BitMap)实现的一种存储结构,它包含两部分内容,分别是一个很长的二进制向量和 n 个随机映射函数(哈希函数),它可以用来检查一个元素是否存在。接下来我们一起看一下使用布隆过滤器的工作流程。

首先我们需要将数据库中的所有数据全部写进布隆过滤器中,对于每条数据,主要操作如下:

  • 首先使用 n 个哈希函数计算出 n 个哈希值;
  • 然后把这 n 个哈希值和 bit 向量的长度取模,得到每个函数映射到向量中的位置;
  • 将这 n 个位置的 bit 位置为 1。

当需要查询某个数据的时候,我们先用哈希函数进行计算得到对应的 bit 位,如果这些 bit 位不全为 1,说明这条数据在数据库中不存在,直接返回异常即可。

其实我们可以发现,使用布隆过滤器是会有误判的概率的。如果我们发现一个数据在经过多次哈希之后映射到布隆过滤器中的 bit 位不全是 1 的时候,那么它肯定是不存在,但是如果映射到的 bit 位全是 1 的话,就能够证明它一定存在吗?答案显然是否定的。因为这些映射到的全是 1 的 bit 位有可能是来自多个不同数据映射的结果。

虽然使用布隆过滤器会有一定概率的误判,但是在防缓存击穿的场景中对于存在的数据我们是肯定不会误伤的,仅仅是有小概率让对一些不存在的 key 的请求穿透到数据库,这样的话其实我们已经达到了保护数据库的目的了,因为穿透到数据库的非法请求数量已经特别低了。也正是因为布隆过滤器有误判的概率,我们没法用它来记录非法 key,所以网上有很多文章讲到的可以用布隆过滤器来记录非法 key 的方式其实是有问题的。

方法三

前端加密,后端校验。这种思路就是在前端发起请求的时候,通过加密算法对所有请求参数进行加密得到一个加密 token 一起传给服务端,服务端接收到请求先按照同样的方式对请求参数进行加密,然后比较前后端两次加密的结果,如果结果不一致,那就说明参数被篡改了,服务端可以直接舍弃这个请求。这种加密的方式在很多需要处理用户敏感数据的场景特别常见,也是判断请求是否是恶意请求的重要依据。

缓存击穿

产生原因

缓存雪崩是大规模的 key 失效,而缓存击穿是某个热点的 key 失效,大并发集中对其进行请求,就会造成大量请求读缓存没读到数据,从而导致高并发访问数据库,引起数据库压力剧增。

解决方法

方法一

既然是由于热点 key 过期导致的,那么我们可以设置这个热点 key 永不过期,这里永不过期包含两方面含义:

  • 物理上不设置过期时间;
  • 逻辑过期,将过期时间也缓存到 value 中,这样如果取出来的值发现已经过期了,那么主动去查询一次数据库然后更新缓存。

方法二

key 依然设置过期时间,当缓存数据过期的时候,通过互斥锁或者一个请求队列来控制同时回源数据库并更新缓存的请求数量,这种方式会阻塞住其他线程的请求,从而降低吞吐量。我们可以发现对同一个 key 的请求参数是一致的,那么我们是不是能够考虑将多个并发请求合并成一个,只让一个线程真正发起回源请求,其他线程只需要等待它执行结果就行了呢?

在 golang 中,有一个防缓存击穿的小利器,叫 singleflight,它的实现思路就是上面讲到的请求合并,使用起来也很简单。通过 singleflight,我们可以很轻易地将对同一个资源的访问 QPS 降低很多,大大降低数据库压力。感兴趣的同学可以自行去了解一波~传送门

大key

大key(big key) 是指单个 key 的 value 过大,使得缓存读写容易发生超时,大key 影响的不仅仅是当前的 key,对于一些缓存中间件,例如 redis,大key 往往也会影响其他数据的读写。我们就以最常见的 redis 为例,来具体讲一下大key 的定义,以及产生的原因和解决方案。

redis 中大key 定义

redis 对大key 的定义主要包含以下几个方面的维度:

  • 单个简单 key 的 value 长度非常大,例如 string 类型的 value 长度超过 10kb。
  • hash/set/zset/list 数据结构中元素个数过多,例如超过 5k 个。

以上几种情况只要符合一种,那就产生了大key。

影响

redis 大key 带来的影响主要包括如下几个方面:

  • 读写命令超时:redis 是单线程模型,对于客户端发起的命令,redis 是在单个工作线程中执行的。大key 会引起对操作这个 key 的指令执行时间变长,那么就会导致同一个 redis slot 上的用户其他指令的阻塞。
  • 数据存储方面:因为大 key 的存在,会导致 slot 分片数据存储不均匀,部分分片空间消耗特别严重,从而产生数据倾斜。
  • 删除指令耗时严重:redis del 命令的时间复杂度是 O(n),其中 n 就是 key 中所包含的元素个数,因此如果对 hash/set/zset/list 等数据结构做 del 操作的时候,如果元素个数很多,必将导致执行时间特别长,从而会严重阻塞其他命令的操作。

解决方法

解决大key 问题,一般是使用拆分的方式,即将单个 key 拆分成多个,分散单次请求的压力:

  • 简单的 key-value 数据结构:我们可以将其拆分成多个 k-v 存储,在需要获取整个 value 的值的时候,可以使用 multiGet 指令。同时对于每次只需要操作 value 中某个 model 的部分字段的情况来说,我们可以将其中的每个 model 存储为 hash 数据结构,然后通过 hset/hget 来操作每个 model 的某个属性,而不是整个 model 的 set/get。
  • 对于 hash / set 这类数据结构,我们可以通过增加一层 hash 来将其分散在多个 key 中。例如对于 hash 数据结构,我们可以对 hash 中的 field hash 取模来确定其分桶,然后去对应的桶中执行操作:
newHashKey = hashKey + (hash(field) % 10000)  // 对 field hash 取模,找对当前 field 落在的 redis key
hset(newHashKey, field, value) 
hget(newHashKey, field)
  • zset 数据结构:zset 是根据 score 排序的,因此我们不太好任意拆分。但是我们还是可以有所作为的。在使用 zset 之前,我们可以预先分配好多个 zset 桶,每个桶维护一定范围的 score。例如假设我们的 score 范围是 0~100,我们可以分成十个桶,编号是 0~9,第 i 个桶存放的 score 分值是 [i*10+1, (i+1)*10](第一个桶还包括 0),然后在存入数据之前,我们先计算它所对应的分桶 key,然后执行 zadd 操作即可。但是这样分桶太过粗糙,因为我们的数据分布往往不是均匀的(得 1 分的人比得 80 分的人少很多),因此如果只是单纯的按照区间来分,往往得到的结果还是会有一些桶产生了大key。所以在实际使用中,我们应该预判 score 实际得分分布区间来确定我们分桶的取值范围,例如得分在 [80, 90] 区间的人特别多,那我们就可以继续去将这个区间分成更小粒度的桶。

对于大key 的处理,需要我们在选型期间就确定我们应该使用的数据结构和算法,否则一旦产生大key,对线上的影响难以预估,并且修改起来还是很耗费精力的。

热key

产生原因

大多数互联网应用数据都遵循 2-8 定律,即数据是分冷热的,而且热数据又相对来说比较集中。比如当我们搞促销活动,可能会有大量用户同时去访问同一个 key,那么这个 key 就变成了一个 热 key(hot key),它所在的缓存分片(例如 redis 的某个节点)就会承受更大压力,一旦过载严重,甚至有可能发生宕机等现象,严重影响线上业务。

解决方法

想要解决 hot-key 问题,我们首先得找出有可能成为 hot-key 的数据。如果是由我们自己发起的活动产生的,例如促销活动、秒杀活动等,我们很容易就能提前评估出 hot-key。而对于一些不是我们主动发起的事件,例如抖音热榜、微博热搜等等,我们无法提前评估,这个时候就需要通过公司的大数据平台,例如 Spark 等进行实时流计算、分析来发现潜在的热点数据了。

当找到 hot-key 之后,就可以有针对性地去处理了:

方法一

对这些 hot-key 进行分散处理,可以存为多个 key,例如 key_1、key_2、key_3 …每个 key 存储的数据完全一致,这些 key 可以分布在缓存的不同节点,每次客户端发起请求的时候就会随机选择一个节点进行访问,这样就可以把对热点的请求打散到不同的节点中,防止单个节点过载。

方法二

方法一是对 key 纬度进行多副本保存的,其实我们也可以对缓存节点本身进行多副本处理。例如 redis 本身支持 master-slave 架构,我们可以让 slave 节点负责处理读请求,并且通过增加 slave 节点副本数量的方式来提高其吞吐量。

方法三

添加一个本地缓存层,将热数据请求分散到应用服务单节点纬度,通过对本地内存的快速访问,可以极大提高系统吞吐量。同时,本地缓存作为一级缓存,分布式缓存作为二级缓存,能够极大降低分布式缓存的负载压力。

数据不一致

一旦加了缓存层,那么数据就会同时出现在两个不同的系统之间,这个时候,数据一致性的问题便成为了我们不得不面对的问题。数据不一致主要体现在缓存中的数据不是我们想要的最新的数据,即缓存产生了“脏数据”。

产生原因

导致缓存和数据库数据不一致的原因有很多,它们大都与使用的缓存更新策略息息相关。接下来我们就以几种常见的缓存更新策略,来看一下缓存脏数据是如何产生的。

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

假设我们现在缓存中的 x 值是 1,现在想要更新成 2,如果在整个更新期间没有别的读操作那么一切相安无事,但是我们的缓存场景往往是读多写少的,伴随着更新的流程,往往会有其他线程执行读操作,让我们来看一下一写一读的并发操作是如何使缓存产生脏数据的。

更新操作读操作
删除缓存
读缓存未命中
查询数据库 x=1
更新数据库 x=2
更新缓存 x=1

可以发现,当两个操作的执行顺序是按照上述流程的时候,我们缓存中最后存的值依然是 x=1,那么将会导致后续在下次更新操作之前,所有读请求读到的值都是旧值,如果一直没有更新操作,除非缓存过期,那么将会一直错下去。由于读请求要比写请求多得多,上面的读写操作顺序发生的概率是很高的,因此这种更新缓存的方式一般不会被选用。

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

假设有两个并发更新操作,分别需要将 x 更新成 1 和 2:

更新操作 1更新操作 2
更新数据库 x=1
更新数据库 x=2
更新缓存 x=2
更新缓存 x=1

可以看到,缓存和数据库由于更新操作先后倒置了,使得缓存中的结果和数据库产生了不一致。

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

这种操作是被使用的比较广泛的一种操作,因为它出现意外的概率比较小,但是即使是比较小,也还是有可能的。

它的具体操作流程是更新操作先更新数据库,更新成功之后,再删除缓存;对于读操作,如果没有命中缓存,则读数据库,然后用读出来的值更新缓存。我们依然以一写一读两个操作为例,来看一下这种方式可能产生的意外情况:

更新操作读操作
读缓存未命中
读数据库 x=1
更新数据库
删除 x=2
更新缓存 x=1

从操作结果大家可以看到,读操作由于先于更新操作读取数据库,并且后于更新操作删除缓存,将读到的旧数据更新到了缓存而产生了数据不一致情况。

  1. read/write through

read/write through 是指用户只需要与缓存打交道,查数据的时候先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据;更新数据的时候先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,有两种解决方式:

  • 先写缓存,然后写数据库;
  • 只写数据库。

其实这种方式只是屏蔽了数据库操作,在处理并发问题的时候,还是会出现上述情况,大家可以自行推导一下。

上述针对的都是缓存能够正常更新或者删除的场景,其实除此之外缓存和数据库数据不一致还有一个重要原因,那就是更新缓存或者删除缓存失败,这里失败原因可能有方方面面,例如网络丢包、服务还没来得及调用更新或删除缓存方法就意外宕机等等。

当然,如果我们的缓存是以多副本的方式对外提供的,那么缓存内部副本数据复制失败的话也会导致读出来的结果和数据库不一致,而且表现为有时候能够读到正确的值,有时候又不能。

因此我们可以总结出如下几点产生数据不一致的原因:

  • 并发请求,缓存被旧数据覆盖
  • 更新/删除缓存失败,导致缓存中依然是旧数据
  • 缓存多副本数据不一致

解决方法

方法一

最简单的方式就是缩短缓存生效的时间,通过过期强制刷新的机制来保证数据最终一致性。这种方法比较适合对数据一致性有一定容忍度的场景。

方法二

为了解决缓存被并发请求的旧数据覆盖问题,我们可以添加延迟删除机制,也就是在某个线程更新完数据库之后,让它休眠一小会,给足够的时间让其他并发线程写入缓存,再执行一次删除操作,这样以来,后续的请求便会由于无法命中缓存而回源数据库读到最新的值。这里的休眠时间可以根据实际业务的读数据和写数据所消耗的时间来自行决定。

方法三

实际场景中使用先更新数据库再删除缓存的方式,为了解决删除缓存失败的问题,我们可以添加重试机制,或者将失败的请求写入消息队列,由消息队列的重试机制来保证操作最终能够成功。

方法四

对于有多副本的缓存,选择只让主节点来处理请求,其他节点作为冷备份,并使用一些常见的分布式一致性算法例如 raft 等实现主从节点的数据一致性。然后当主节点宕机的时候,通过选举机制选择一个数据最完整的节点升级为主节点,保证数据不丢。关于 raft 算法,可以看一下笔者之前的文章,有详细的介绍。

总结

以上向大家介绍了六种常见的缓存问题和解决方案,其实在了解到原因之后解决方案也就顺理成章地出来了。最后我们用一张表格来给大家做个简单的总结吧:

欢迎点赞关注呦~

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值