开发中常见的缓存方案:在数据库中存储一份,在缓存中同步存储一份。当请求过来时,先从缓存中取数据,如果存在,直接返回缓存数据。如果缓存中没有数据,则查询数据库取出正确数据,返回结果,同时将数据更新到缓存中。(数据库也没有数据,可直接返回空)
redis 支持数据的持久化,可以将内存中的数据保存在磁盘中,而且不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储,且其读写速度极快,因此作为我们开发中最常用的缓存方案被广泛应用。
但在实际应用过程中,redis会存在缓存穿透、缓存雪崩、缓存击穿、缓存预热和缓存降级等异常情况,如果忽视这些情况可能会带来灾难性的后果,下面主要对这些缓存异常和常见处理方案进行相应分析与总结。
缓存穿透
缓存穿透:数据既不在 redis 中,也不在数据库中,这样就导致每次请求过来的时候,在缓存中找不到对应key之后,每次都还要去数据库再查询一遍,发现数据库也没有,相当于进行了两次无用的查询。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。
现象原因:业务逻辑里面如果用户对某些信息还没有进行相应的操作或者处理,那对应的存放信息的数据库或者缓存中自然也就没有相应的数据,也就容易出现上述问题。
解决办法:
- 限制非法请求,主要是指参数校验、鉴权校验等,从而一开始就把大量的非法请求拦截在外,这在实际业务开发中是必要的手段。
- 布隆过滤器,把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过 来,我会先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信 息给客户端,存在的话才会走下面的流程。
- 缓存无效数据,如果缓存和数据库都查不到某个 key 的数据就写一个到 redis 中去并设置过期时 间,具体命令如下:SET key value EX 10086。这种方式防止有大量恶意请求是反复用同一个key进行攻击;如果黑客恶意攻击,每次构建不同的请求key,会导致 redis 中缓存大量无效的 key 。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。伪代码实现:
public Object getObjectInclNullById(Integer id) {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue WX null) {
// 从数据库中获取
Object storageValue = storage.get(key);
// 缓存空对象
cache.set(key, storageValue);
// 如果存储数据为空,需要设置一个过期时间(300秒)
if (storageValue WX null) {
// 必须设置过期时间,否则有被攻击的⻛险
cache.expire(key, 60 * 5);
}
return storageValue;
}
return cacheValue;
}
缓存雪崩
缓存雪崩:缓存同一时间大面积的失效,一段时间内本应在redis缓存中处理的大量请求,都落到了数据库处理,导致对数据库的压力迅速增大,严重时甚至可能导致数据库崩溃,从而导致整个系统崩溃。
原因:大量缓存数据同时过期,导致本应请求到缓存的需重新从数据库中获取数据;或者redis本身出现故障,无法处理请求,那自然会再请求到数据库那里。
解决办法:
针对大量缓存数据同时过期的情况:
- 实际设置过期时间时,应当尽量避免大量 key 同时过期的场景,key过期时间设置为波动随机值,有效防止大量key同一时间过期。
- 互斥锁,即在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。(存在死锁的风险)伪代码:
// 获取key方法
String get(String key) {
String value = redis.get(key);
if (value == null) {
if (redis.setnx(key_mutex, "1")) {
// 3 min timeout to avoid mutex holder crash
redis.expire(key_mutex, 3 * 60)
value = db.get(key);
redis.set(key, value);
redis.delete(key_mutex);
} else {
//其他线程休息50毫秒后重试
Thread.sleep(50);
get(key);
}
}
}
- 双缓存策略,主key是原始缓存,备key为拷贝缓存,主key失效时,可以访问备key,主key设置过期时间,备key永久。
- 后台更新缓存,采用定时任务或者消息队列的方式进行redis缓存更新或移除等。
针对redis本身出现故障的情况:
- 热点key打散到不同机房服务器上,不要把鸡蛋都放到一个篮子里。
- 通过主从节点的方式构建高可用的集群,实现主 Redis 实例挂掉后,能有其他从库快速切换为主库,继续提供服务。
- 业务端处理:请求限流、服务熔断。如果事情已经发生了,首要任务是防止数据库崩溃,服务熔断停止服务直到redis服务恢复或者请求限流都行,限流相对温和一些,保证一些请求可以处理,不是一刀切,不过还是看具体业务情况选择合适的处理方案。
缓存击穿
缓存击穿:一般出现在高并发系统中,是大量并发用户同时请求到缓存中没有但数据库中有的数据,也就是同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是大量数据同时过期,大量请求直接落到数据库。
原因:某个热点数据缓存过期,由于是热点数据,请求并发量又大,所以过期的时候还是会有大量请求同时过来,来不及更新缓存就全部打到数据库那边了。
解决办法:
- 热点key设置永不过期,后台更新、清楚缓存。
- 非热点key可用互斥锁。
缓存预热
缓存预热:系统上线前后,将相关的缓存数据直接加载到缓存系统中去。避免了用户请求时,先查询数据库,然后再缓存数据的问题。用户直接查询事先被预热的缓存数据,这样可以避免那么系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
- 数据量不大:项目启动的时候自动进行加载。
- 数据量较大:后台定时刷新缓存。
- 数据量极大:只针对热点数据进行预加载缓存操作。
缓存降级
缓存降级:当缓存失效或缓存服务出现问题时,为了防止缓存服务故障,导致数据库跟着一起发生雪崩问题,所以也不去访问数据库,但因为一些原因,仍然想要保证服务还是基本可用的,虽然肯定会是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。降级的最终目的是保证核心服务可用,即使是有损的。
- 直接访问内存部分的数据缓存。
- 直接返回系统设置的默认值。