目录
一 缓存穿透
1.1 缓存穿透概念
缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常出于容错的考虑,如果从存储层拿不到数据就不写入缓存层。整个过程是:
- 缓存层没有命中
- 存储层没有命中,不将空结果写入缓存
- 返回空结果
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储层的意义。缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕机。
1.2 缓存穿透发现
通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现缓存穿透问题。
1.3 缓存穿透原因
造成缓存穿透的基本原因:
- 自身业务代码或者数据出现问题。
- 恶意攻击、爬虫等造成大量命中。
1.4 缓存穿透解决
- 参数校验
比如对 id 做基础校验,id<=0 的直接拦截。
- 缓存空对象
缓存空对象有两个问题:
第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除;
第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。比如,过期时间是5分钟,此时存储层添加了这个数据,那这段时间就会出现缓存层和存储层的数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
- 布隆过滤器拦截
将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。
二 缓存雪崩
2.1 缓存雪崩概念
缓存层由于某些原因不能提供服务,所有的请求都达到存储层,存储层的压力暴增,造成存储层宕机的情况。
2.2 缓存雪崩解决
- 情况一:我们设置缓存时采用了相同的过期时间
解决:
- 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待。
- 不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
- 做二级缓存,A1 为原始缓存,A2 为拷贝缓存,A1 失效时,可以访问 A2,A1 缓存失效时间设置为短期,A2 设置为长期。
- 情况二:缓存服务器宕机
解决:
- 保证缓存层服务高可用性。
- 依赖隔离组件为后端限流并降级。
- 针对线上的流量,进行演练。
三 缓存击穿
3.1 缓存击穿概念
- 对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。
- 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等。
在满足以上两个条件的时候,缓存在某个时间点过期的时候,恰好在这个时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。
缓存击穿针对的是某一个热 key ;缓存雪崩针对的是很多 key。
3.2 缓存击穿解决
我们的目标是:尽量少的线程构建缓存(甚至是一个) + 数据一致性 + 较少的潜在危险。
- 使用互斥锁(mutex key):
这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据即可。
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
Thread.sleep(50);
get(key); //重试
}
} else {
return value;
}
}
- 永远不过期
这里的“永远不过期”包含两层意思:
(1) 从 redis 上看,确实没有设置过期时间,这就保证了,不会出现热点 key 过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。
两种方案的比较:
- 互斥锁:方案思路简单,存在一定的隐患,如果构建缓存过程中出现问题或者时间较长,可能会出现死锁或者线程池阻塞风险,但是这种方法能够较好的降低后端存储负载,并在一致性上做的比较好。
- 永远不过期:这种方案没有设置过期时间,实际上不存在热点 key 产生的一系列危害,会出现数据不一致的情况,同时代码复杂度会增加。