如何解决Redis雪崩、穿透、并发等5大难题
缓存雪崩
那么,当大量缓存数据在同一时间过期(失效)
或者 Redis 故障宕机
时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩
的问题。
比如一个雪崩的简单过程:
1、redis集群大面积故障
2、缓存失效,但依然大量请求访问缓存服务redis
3、redis大量失效后,大量请求转向到mysql数据库
4、mysql的调用量暴增,很快就扛不住了,甚至直接宕机
5、由于大量的应用服务依赖mysql和redis的服务,这个时候很快会演变成各服务器集群的雪崩,最后网站彻底崩溃。
如何预防缓存雪崩:
可以看到,发生缓存雪崩有两个原因:
1. 大量数据同时过期;
2. Redis 故障宕机;
不同的诱因,应对的策略也会不同。
大量数据同时过期
1. 均匀设置过期时间
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时, 给这些数据的过期时间加上一个随机数
,这样就保证数据不会在同一时间过期。
2. 互斥锁
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁
,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里)
,当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存
,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间
,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞
,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
3. 后台更新缓存
业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”
,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
解决上面的问题的方式有两种。
第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。
这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作
;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。
Redis 故障宕机
针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:
1. 服务熔断或请求限流机制;
2. 构建 Redis 缓存高可靠集群;
- 服务熔断或请求限流机制
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制
,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库
,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作。
为了减少对业务的影响
,我们可以启用请求限流机制
,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
缓存击穿
我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。
如果缓存中的某个热点数据过期
了,此时大量的请求访问了该热点数据
,就无法从缓存中读取,直接访问数据库
,数据库很容易就被高并发的请求冲垮,这就是缓存击穿
的问题。
可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。
应对缓存击穿可以采取前面说到两种方案:
-
互斥锁方案
:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。 -
后台更新缓存
:不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
缓存穿透
缓存穿透是指查询一个数据库
不存在的数据。例如:从缓存redis没有命中,需要从mysql数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
正常接口查询肯定查询的是数据库存在的数据,如果数据库不存在,只能说明俩种可能,第一种自身业务出现问题,第二种恶意攻击。
解决方法有俩个:
- 返回空对象
如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库。设置一个过期时间或者当有值的时候将缓存中的值替换掉即可。
- 布隆过滤器拦截
查看相关布隆过滤器相关只是可查看我的文章。
在访问缓存层和存储层之前,将所有存在的key用布隆过滤器先存储起来,做第一层拦截
比如有个用户下单接口,该接口传递参数userid,然后使用userid为key查询缓存进行下单操作。
那么该接口就可以使用布隆过滤器来过滤userid,我们首先将系统的所有userid都加入到布隆过滤器中,当请求的userid使用布隆过滤器过滤之后发现不存在,那么直接返回客户端即可,并不需要查询缓存层和数据层了;如果布隆过滤器过滤之后发现useid可能存在(布隆控制器存在误判情况,只能判断可能存在,而不能断定一定存在)
的话就可以继续执行流程:先读取缓存,如果缓存存在直接返回客户端,如果不存在,则查询后端存储,如果后端存储查询到了数据就写入缓存,最后返回客户端,由于布隆过滤器存在误判情况
,所以如果后端存储查询不到了数据,则结合第一种方式返回给客户端空对象,并写入缓存
。
总结:
由于布隆过滤器存在误判情况,所以使用过滤器方式和返回空对象方式必须结合使用。
排除自身业务的问题,假设遇到恶意攻击,传递的userid都不是真实的,也就是数据库中不存在的。针对这俩种结合的方案分析如下,如果第一层布隆过滤器判断不存在,
则直接在缓存层之前就给过滤了,就不会到达缓存层和数据层了,可以大大减少压力。
如果布隆过滤器认为可能存在后,那么就会到达缓存层和数据层了,由此可见使用布隆过滤器方案的必要性。
缓存并发
其实redis自身就是单线程操作,redis本身并没有锁的概念,按照先到先执行的原则,先到的先执行,其余的阻塞。但是利用predis phpredis等客户端对Redis进行并发访问时会出现问题。典型的例子就是库存超卖,解决方案有以下俩种
- 这里可以使用redis的分布式锁可以解决并发问题。如命令set k v px ms nx,该命令在k不存在时才赋值k。也就是说如果返回true,则代表获取锁成功,如果返回false则代表已有资源获取锁,此时需要轮训,处于阻塞状态。
- 可以将redis操作放在队列中使其串行化,必须的一个一个执行,如果放到队列进行串行化的话,效率会急剧下降。
缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。
这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
解决思路:
1、直接写个缓存刷新页面,上线时手工操作下;
2、数据量不大,可以在项目启动的时候自动进行加载;
目的就是在系统上线前,将数据加载到缓存中。
以上就是缓存雪崩、预热、降级等的介绍。
缓存热点
加节点
有些热点数据访问量会特别大,单个缓存节点(例如Redis)无法支撑这么大的访问量。如果是读请求访问量大,可以考虑读写分离,一主多从的方案,用从节点分摊读流量;如果是写请求访问量大,可以采用集群分片方案,用分片分摊写流量。以秒杀扣减库存为例,假如秒杀库存是100,可以分成5片,每片存20个库存。拆分复杂结构
比如当前key是一个hash结构,可以将该key分解为多个string类型,让其分布在不同节点,进行降低压力迁移热点key
以redis cluster为例,可以新增一台全新的节点,将热点key的slot迁移到该节点上,该节点的cpu,内存,io都需要好点。-
本地缓存加发布订阅机制
要解决单点存在hot key的问题,就通过多机分流来解决,而本地缓存就是一种分流方案。由本地缓存来分担流量,这样即使有热点key存在,有多少业务系统进程就有多少相互独立的缓存来分担流量,可以很好的解决热点key的问题。
本地缓存解决了热点,但同时也带来了数据一致性的问题,在短时间内同一客户端访问业务系统可能会取到不一致的缓存结果,通常可使用redis的发布订阅功能来通知所有本地缓存。
降级服务
如果节点读压力过大,我们可以关闭一些不重要的读方面的服务,以保证核心业务的正常运行,必须可以关闭商品查看评论功能或者关闭查看物流信息功能。
如果节点写压力过大,我们可以关闭一些次要功能,累死用户商品评价功能熔断机制
如果redis服务阻塞,将会阻塞业务线程,为了避免消耗完线程池内的所有的线程,我们需要有熔断机制, 熔断机制的本质就是fail fast,如果在一定内时间没有返回数据,我们可以触发熔断机制,触发回调函数,回调函数内用户可以自己定义,上面介绍的使用本地缓存方案就是一种熔断机制。
热点key重建优化
开发人员使用“缓存+过期时间”的策略即可以加速数据读写,又保证了数据的定时更新,这种模式基本可以满足绝大部分需求,但是如果3个条件同时出现,可能会对系统造成致命的危害
- 当前key是热点key,并发量非常大
- 当前key缓存正好失效
- 重建缓存不能在短期时间内完成,可能是一个复杂计算
正常业务流程为,先读取缓存,如果缓存存在直接返回客户端,如果不存在,则查询后端存储并进行计算,然后写入缓存,最后返回客户端,伪代码如下
//先读取缓存
value=redis.get(key)
if(value == null){
// 读取数据库等系列操作,并计算,获得变量v
value=计算获得value
redis.set(key,value)
}
....
处理业务
.....
return value
比如此代码并发很高,就出现如下问题:
如果第一个线程还有执行完redis的set操作时,第二个线程在执行到get操作时,返现value为null,所以第二个线程也会执行redis的set操作,如果并发很高,可能第三个,第四个。。。。线程都会执行redis的set操作,而set操作通常涉及到存储层查询,这样就给mysql大大增加了压力,最坏的情况可能直接导致mysql宕机,总结一句话就是有大量线程来重建缓存,造成后端负载加大,甚至让应用直接奔溃.
我们可以使用redis的分布式锁来解决这一个问题,伪代码如下
//先读取缓存
value=redis.get(key)
if(value == null){
mutexKey=time()
if(redis.set("mutex".key,mutexKey,'px',1000,'nx')){
// 读取数据库等系列操作,并计算,获得变量v
value=计算获得value
redis.set(key,value)
}else{
//进入这个分支的就是并发线程
sleep(0.1)//阻塞0.1s,时间需要根据实际情况而定,必须保证大于value复杂计算的时间
value=redis.get(key)
}
}
....
处理业务
.....
return value
上面这段代码就保证了只有一个线程会到达后端的存储层,成功缓解了存储层的压力。n