常规的缓存处理流程是:客户端向服务端请求数据 ,服务端先从缓存中 get 数据,缓存命中则直接返回结果,否则从 DB 中取,DB 获取到数据就去更新缓存并返回结果,否则返回空。
这个流程放在本地环境研究学习还行,如果要部署到生产,就需要考虑缓存雪崩、缓存击穿和缓存穿透的致命问题!
一、缓存雪崩
缓存雪崩是指某一时刻发生了大规模的缓存失效,比如发生了 Redis 服务器宕机、缓存数据同时到期被删除这种情况,此时大量的请求直接转发到数据库,数据库一旦撑不住就会导致整个服务瘫痪。
缓存雪崩
解决方案:
- 分析用户行为,制定策略为 key 设置不同的过期时间,尽量让缓存失效的时间均匀分布
- 采用主从架构 + Sentinel 或者 Redis Cluster 实现HA,避免 Redis 单点故障
- 设置本地缓存(ehcache) + 限流(hystrix)。如果某个目标服务调用慢或者有大量超时,此时,熔断该服务的调用,对于后续调用请求,不再继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用。服务降级的最终目的是保证核心服务可用
- 开启 Redis 持久化机制,服务重启后快速恢复缓存数据
PS:对服务降级前需做系统梳理,比如:哪些业务是核心,哪些业务可以容许暂时不提供服务(利用静态页面替换)等,以及配合服务器核心指标,来设置整体预案,比如:
1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
二、缓存击穿
对于一些设置了过期时间的热点 key,需要考虑缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里是针对某个 key 的缓存,前者则是很多 key。
解决方案:
- 使用互斥锁(mutex key):让一个线程构建 Redis 缓存,其他线程等待构建缓存的线程执行完,然后直接从缓存获取数据
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_max, "1")) {
redis.expire(KEY_MUTEX, 3 * 60);
value = pg.get(key);
redis.set(key, value);
redis.delete(KEY_MUTEX);
} else {
// 其他线程 50ms 后重试
Thread.sleep(50);
get(key);
}
}
}
- 设置 key 永不过期:永不过期既要保证 key 一直持有在缓存中,又要能对 key 的 value 进行及时的更新。实现的方式是不为 key 设置物理过期时间,即不通过
redis.expire(key, timeout)
命令设置过期时间;但是要设置一个逻辑的过期时间,可以把 key 的过期时间拼接到 value 中,程序访问缓存时,从 value 中解析出过期时间并根据规则判定 key 是否需要更新
上述两种方案对比:
方案 | 优点 | 缺点 |
---|---|---|
使用互斥锁(mutex key) | 保证一致性 | 程序复杂度高,存在死锁和线程阻塞的风险 |
设置 key 永不过期 | 异步构建缓存,线程不会阻塞 | 无法保证一致性,代码复杂度增加,每个 key 占用的空间增加 |
三、缓存穿透
缓存穿透是指查询一个一定不存在的数据。出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义,也很容易被攻击者作为系统漏洞进行攻击。
解决方案:
- 由于请求的参数是不合法的(每次都请求不存在的数据),于是我们可以使用布隆过滤器(BloomFilter)或者压缩 filter 提前拦截,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个bitmap 过滤掉,从而缓解底层存储系统的查询压力
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截
- 即便存储层查不到这个数据,也将返回的空对象设置到缓存里。下次再请求的时候,直接从缓存取到空对象返回,这种情况一般会将空对象设置一个较短的过期时间,这样可以防止攻击者反复用同一个 id 暴力攻击
四、缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统,避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。
网友评论