深入理解缓存之缓存存在的问题及应对措施

GitHub:https://github.com/JDawnF

1.缓存穿透

指查询一个一定不存在的数据,由于缓存是不命中时被动写( 被动写,指的是从 DB 查询到数据,则更新到缓存中 )的,并且处于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,失去了缓存的意义。

缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

参照:Redis架构之防雪崩设计:网站不宕机背后的兵法

在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。如下图:

有两种方案可以解决:

  • 方案一,缓存空对象:当从 DB 查询数据为空,我们仍然将这个空结果进行缓存,具体的值需要使用特殊的标识,能和真正缓存的数据区分开。另外,需要设置较短的过期时间,一般建议不要超过 5 分钟。

    缓存空对象会有两个问题:

    第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

    第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

    伪代码:

  • 方案二,BloomFilter 布隆过滤器:在缓存服务的基础上,构建 BloomFilter 数据结构,在 BloomFilter 中存储对应的 KEY 是否存在,如果存在,说明该 KEY 对应的值为空。那么整个逻辑的如下:

    • 1、根据 KEY 查询缓存。如果存在对应的值,直接返回;如果不存在,继续向下执行。

    • 2、根据 KEY 查询在缓存 BloomFilter 的值。如果存在值,说明该 KEY 不存在对应的值,直接返回空;如果不存在值,继续向下执行。

    • 3、查询 DB 对应的值,如果存在,则更新到缓存,并返回该值。如果不存在值,更新到 缓存 BloomFilter 中,并返回空。

    如下图所示,在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。例如: 一个个性化推荐系统有 4 亿个用户 ID,每个小时算法工程师会根据每个用户之前历史行为做出来的个性化放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有有个性化推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户 ID 不存在,那么就不会访问存储层,在一定程度保护了存储层。

    可以利用 Redis 的 Bitmaps 实现布隆过滤器,GitHub 上已经开源了类似的方案,可以进行参考:

    https://github.com/erikdubbelboer/Redis-Lua-scaling-bloom-filter

    这种方法适用于数据命中不高,数据相对固定实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

布隆过滤器是一个 bit 向量或者说 bit 数组,长这样:

对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。

  1. 首先需要k个hash函数,每个函数可以把key散列成为1个整数

  2. 初始化时,需要一个长度为n比特的数组,每个比特位初始化为0

  3. 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1

  4. 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中。

参照:https://www.jianshu.com/p/2104d11ee0a2

https://www.cnblogs.com/liyulong1982/p/6013002.html

两者比较:

  缓存空对象 BloomFilter 布隆过滤器
适用场景 1、数据命中不高 2、保证一致性 1、数据命中不高 2、数据相对固定、实时性低
维护成本 1、代码维护简单 2、需要过多的缓存空间 3、数据不一致 1、代码维护复杂 2、缓存空间占用小

2.缓存雪崩

缓存雪崩,是指缓存由于某些原因无法提供服务( 例如,缓存挂掉了 ),所有请求全部达到 DB 中,导致 DB 负荷大增,最终挂掉的情况。

从下图可以很清晰出什么是缓存雪崩:由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。 缓存雪崩的英文原意是 stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。

解决方案

1)缓存高可用

通过搭建缓存的高可用,避免缓存挂掉导致无法提供服务的情况,从而降低出现缓存雪崩的情况。

假设我们使用 Redis 作为缓存,则可以使用 Redis Sentinel 或 Redis Cluster 实现高可用。

2)本地缓存

如果使用本地缓存时,即使分布式缓存挂了,也可以将 DB 查询到的结果缓存到本地,避免后续请求全部到达 DB 中。当然,引入本地缓存也会有相应的问题,例如说:

  • 本地缓存的实时性怎么保证?

    • 方案一,可以引入消息队列。在数据更新时,发布数据更新的消息;而进程中有相应的消费者消费该消息,从而更新本地缓存。

      也可以使用 Redis Pub / Sub 取代消息队列来实现,但此时 Redis 可能已经挂了,所以也不一定合适。

    • 方案二,设置较短的过期时间,请求时从 DB 重新拉取。

    • 方案三,手动设置Redis过期时间。

  • 每个进程可能会本地缓存相同的数据,导致数据浪费?

    • 方案一,需要配置本地缓存的过期策略和缓存数量上限。

如果我们使用 JVM ,则可以使用 Ehcache、Guava Cache 实现本地缓存的功能。

3)请求 DB 限流

通过限制 DB 的每秒请求数,避免把 DB 也打挂了。这样至少能有两个好处:

  1. 可能有一部分用户,还可以使用,系统还没死透。

  2. 未来缓存服务恢复后,系统立即就已经恢复,无需在处理 DB 也挂掉的情况。

如果我们使用 Java ,则可以使用 Guava RateLimiter、Sentinel 实现限流的功能。

4)服务降级

如果请求被限流,或者请求 DB 超时,我们可以服务降级,提供一些默认的值,或者友情提示,甚至空白的值也行。

如果我们使用 Java ,则可以使用 Hystrix、Sentinel 实现限流的功能。

5)提前演练

在项目上线前,演练缓存宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

3.缓存击穿

缓存击穿,是指某个极度“热点”数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间 DB 压垮。

  • 对于一些设置了过期时间的 KEY ,如果这些 KEY 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑这个问题。

    重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等。

    在缓存失效的瞬间,有大量线程来重建缓存 ( 如下图),造成后端负载加大,甚至可能会让应用崩溃。

  • 缓存被“击穿”的问题,和缓存“雪崩“”的区别在于,前者针对某一 KEY 缓存,后者则是很多 KEY 。

  • 缓存被“击穿”的问题,和缓存“穿透“”的区别在于,这个 KEY 是真实存在对应的值的。

解决方案

  • 方案一,使用互斥锁:请求发现缓存不存在后,去查询 DB 前,使用分布式锁,保证有且只有一个线程去查询 DB ,并更新到缓存。流程如下:

    • 1、获取分布式锁,直到成功或超时。如果超时,则抛出异常,返回。如果成功,继续向下执行。

    • 2、再去缓存中。如果存在值,则直接返回;如果不存在,则继续往下执行。因为,获得到锁,可能已经被“那个”线程去查询过 DB ,并更新到缓存中了。

    • 3、查询 DB ,并更新到缓存中,返回值。

    下面代码使用 Redis 的 setnx 命令实现上述功能。

  • 方案二,手动过期:缓存上从不设置过期时间,功能上将过期时间存在 KEY 对应的 VALUE 里,如果发现要过期,通过一个后台的异步线程进行缓存的构建,也就是“手动”过期。通过后台的异步线程,保证有且只有一个线程去查询 DB。

    整个过程如下图所示:

    从实战看,此方法有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用 Redis 进行模拟:

作为一个并发量较大的应用,在使用缓存时有三个目标:第一,加快用户访问速度,提高用户体验。第二,降低后端负载,减少潜在的风险,保证系统平稳。第三,保证数据“尽可能”及时更新。下面将按照这三个维度对上述两种解决方案进行分析。

  • 互斥锁 (mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载并在一致性上做的比较好。

  • " 永远不过期 ":这种方案由于没有设置真正的过期时间,实际上已经不存在热点 key 产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

这两个方案,各有其优缺点。

  使用互斥锁 手动过期
优点 1、思路简单 2、保证一致性 1、性价最佳,用户无需等待
缺点 1、代码复杂度增大 2、存在死锁的风险 1、无法保证缓存一致性
原文地址:https://www.cnblogs.com/baichendongyang/p/13235430.html