缓存穿透、雪崩、热点key集中失效等问题

一、缓存穿透

1.1 概念

  • 缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中。

通常出于容错的考虑,如果从存储层查不到数据则不写入缓存层,整个过程分为如下3步:

1)缓存层不命中。

2)存储层不命中,不将空结果写回缓存。

3)返回空结果。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。

  • 缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。

  • 造成缓存穿透的基本原因有两个。

    第一,自身业务代码或者数据出现问题,第二,一些恶意攻击、爬虫等造成大量空命中。

1.2 如何解决缓存穿透?

1.2.1 增加校验

比如在接口层增加校验,比如用户鉴权校验,参数做校验,不合法的参数直接代码 Return,比
如:id做基础校验,id<=0 的直接拦截等。

1.2.2 缓存空对象

当存储层不命中后(不管是数据不存在还是系统故障),仍然将空结果写回缓存(但它的过期时间会很短,最长不超过 5 分钟),之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。

redis-cache-null

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

  • 第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重)。

    比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除

  • 第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。

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

因此,缓存空对象,需要设置一个过期时间或者当有值的时候将缓存中的空对象替换掉。
缓存空对象的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String get(String key) {
// 从缓存中获取数据
String cacheValue = cache.get(key);
// 缓存为空
if (StringUtils.isBlank(cacheValue)) {
// 从存储中获取
String storageValue = storage.get(key);
cache.set(key, storageValue);// 无论存储层是否命中,都将结果写回缓存
// 如果存储数据为空,需要设置一个过期时间(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 缓存非空
return cacheValue;
}
}

1.2.3 布隆过滤器

在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。一个一定不存在的数据会被 这个 bitmap 拦截掉。

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

有关布隆过滤器的相关知识,可以参考:https://en.wikipedia.org/wiki/Bloom_filter。**可以利用Redis的Bitmaps实现布隆过滤器**,GitHub上已经开源了类似的方案,读者可以进行参考:https://github.com/erikdubbelboer/redis-lua-scaling-bloom-filter。

redis-bloomfilter

Bloom Filter 原理:当一个元素被加入集合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点,把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了:如果这些点有任何一个 0,则被检元素一定不在;如果都是 1,则被检元素很可能在。

Bloom Filter 缺点:

  • 存在误判,可能要查到的元素并没有在容器中,但是 hash 之后得到的 k 个位置上值都是 1。如果 bloom filter 中存储的是黑名单,那么可以通过建立一个白名单来存储可能会误判的元素。
  • 删除困难。一个放入容器的元素映射到 bit 数组的 k 个位置上是 1,删除的时候不能简单的直接置为 0,可能会影响其他元素的判断。可以采用 Counting Bloom Filter

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

redis_cachenull-bloomfilter

二、缓存击穿

2.1 概念

是指一个 key 非常热点,大量的请求同时查询这个 key 时,当这个 key 正好失效了。这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。

2.2 解决缓存击穿问题

2.2.1 使用互斥锁

此方法只允许一个线程重建缓存(比如 Redis 的 SETNX ),其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。

SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
String get(String key) {
// 从Redis中获取数据
String value = redis.get(key);
// 如果value为空,则开始重构缓存
if (StringUtils.isBlank(value)) {
// 只允许一个线程重构缓存,使用nx,并设置过期时间ex
String mutexKey = "mutext:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 从数据源获取数据
value = db.get(key);
// 回写Redis,并设置过期时间
redis.setex(key, timeout, value);
// 删除key_mutex
redis.delete(mutexKey);
}
// 其他线程休息50毫秒后重试
else {
Thread.sleep(50);
get(key);
}
}
return value;
}

2.2.2 热点 key 永远不过期

“永远不过期” 包含两层意思:

  • 1)从缓存层面来看,确实没有设置过期时间,所以不会出现热点 key 过期后产生的问题,也就是“物理”不过期。

  • 2)从功能层面来看,为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

此方法有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
String get(final String key) {
V v = redis.get(key);
String value = v.getValue();
// 逻辑过期时间
long logicTimeout = v.getLogicTimeout();
// 如果逻辑过期时间小于当前时间,开始后台构建
if (v.logicTimeout <= System.currentTimeMillis()) {
String mutexKey = "mutex:key:" + key;
if (redis.set(mutexKey, "1", "ex 180", "nx")) {
// 重构缓存
threadPool.execute(new Runnable() {
public void run() {
String dbValue = db.get(key);
redis.set(key, (dbvalue, newLogicTimeout));
redis.delete(mutexKey);
}
});
}
}
return value;
}

redis_hotkey_mutex-noexpire

三、缓存雪崩

3.1 概念

缓存雪崩是指当某一时刻发生大规模的缓存失效,(比如缓存服务宕机、大量缓存集中在某一个时间段失效),大量的请求到达存储层,存储层的调用量暴增,造成存储层宕机的情况。

缓存雪崩与缓存击穿的区别在于缓存雪崩针对很多 key 缓存,大规模失效,缓存击穿则是某一个 key。

3.2 预防和解决缓存雪崩问题

若是针对大量缓存集中在某一个时间段失效,一种简单的方案是失效时间随机,为 key 设置不同的缓存失效时间,在批量往 Redis 存数据的时候把每个 Key 的失效时间都加个随机值。

缓存层高可用、客户端限流降级、提前演练是解决雪崩问题的重要方法。

3.2.1 事前

  • 保证缓存层服务的高可用性

在发生雪崩前把缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务。

例如 Redis Sentinel(哨兵)和 Redis Cluster(集群)都实现了高可用。

  • 提前演练

3.2.2 事中

依赖隔离组件为后端限流并降级。

  • ehcache 本地缓存 + Hystrix 限流并降级

使用 ehcache 作为本地缓存的目的也是考虑在 Redis Cluster 完全不可用的时候,ehcache 本地缓存还能够支撑一阵。

使用 Hystrix 进行限流 & 降级 ,比如一秒来了 5000 个请求,我们可以设置假设只能有一秒 2000 个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑。

然后去调用我们自己开发的降级组件(降级),比如设置的一些默认值之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。

3.2.3 事后

  • 开启 redis 持久化机制,尽快恢复缓存集群

一旦重启,自动从磁盘上加载数据,恢复内存中的数据