Redis缓存雪崩、缓存穿透、热点Key解决方案和分析
缓存穿透
缓存系统,按照KEY去查询VALUE,当KEY对应的VALUE一定不存在的时候并对KEY并发请求量很大的时候,就会对后端造成很大的压力。
(查询一个必然不存在的数据。比如文章表,查询一个不存在的id,每次都会访问DB,如果有人恶意破坏,很可能直接对DB造成影响。)
由于缓存不命中,每次都要查询持久层。从而失去缓存的意义。
解决方法:
1、缓存层缓存空值。
–缓存太多空值,占用更多空间。(优化:给个空值过期时间)
–存储层更新代码了,缓存层还是空值。(优化:后台设置时主动删除空值,并缓存把值进去)
2、将数据库中所有的查询条件,放到布隆过滤器中。当一个查询请求来临的时候,先经过布隆过滤器进行检查,如果请求存在这个条件中,那么继续执行,如果不在,直接丢弃。
备注:
比如数据库中有10000个条件,那么布隆过滤器的容量size设置的要稍微比10000大一些,比如12000.
对于误判率的设置,根据实际项目,以及硬件设施来具体决定。但是一定不能设置为0,并且误判率设置的越小,哈希函数跟数组长度都会更多跟更长,那么对硬件,内存中间的要求就会相应的高。
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.0001);
有了size跟误判率,那么布隆过滤器就会产生相应的哈希函数跟数组。
综上:我们可以利用布隆过滤器,将redis缓存击穿控制在一个可容忍的范围内。
缓存雪崩(缓存失效)
如果缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。
缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储
解决方法:
- 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
- 可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存
- 不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀
- 做二级缓存,或者双缓存策略。A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期。
热点key
(1) 这个key是一个热点key(例如一个重要的新闻,一个热门的八卦新闻等等),所以这种key访问量可能非常大。
(2) 缓存的构建是需要一定时间的。(可能是一个复杂计算,例如复杂的sql、多次IO、多个依赖(各种接口)等等)
于是就会出现一个致命问题:在缓存失效的瞬间,有大量线程来构建缓存(见下图),造成后端负载加大,甚至可能会让系统崩溃 。
解决方法:
1. 使用互斥锁(mutex key):这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了
2. "提前"使用互斥锁(mutex key):在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。
3. "永远不过期":
这里的“永远不过期”包含两层意思:
(1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期
4. 资源保护:可以做资源的隔离保护主线程池,如果把这个应用到缓存的构建也未尝不可。
四种方案对比:
作为一个并发量较大的互联网应用,我们的目标有3个:
1. 加快用户访问速度,提高用户体验。
2. 降低后端负载,保证系统平稳。
3. 保证数据“尽可能”及时更新(要不要完全一致,取决于业务,而不是技术。)
所以第二节中提到的四种方法,可以做如下比较,还是那就话:没有最好,只有最合适。
解决方案 | 优点 | 缺点 |
简单分布式锁(Tim yang) | 1. 思路简单 2. 保证一致性 | 1. 代码复杂度增大 2. 存在死锁的风险 3. 存在线程池阻塞的风险 |
加另外一个过期时间(Tim yang) | 1. 保证一致性 | 同上 |
不过期(本文) | 1. 异步构建缓存,不会阻塞线程池 | 1. 不保证一致性。 2. 代码复杂度增大(每个value都要维护一个timekey)。 3. 占用一定的内存空间(每个value都要维护一个timekey)。 |
资源隔离组件hystrix(本文) | 1. hystrix技术成熟,有效保证后端。 2. hystrix监控强大。
| 1. 部分访问存在降级策略。 |
总结
1. 热点key + 过期时间 + 复杂的构建缓存过程 => mutex key问题
2. 构建缓存一个线程做就可以了。
3. 四种解决方案:没有最佳只有最合适。
分析和相关解决
什么是缓存穿透
一般的缓存系统,都是按照key值去缓存查询,如果不存在对应的value,就应该去DB中查找 。这个时候,如果请求的并发量很大,就会对后端的DB系统造成很大的压力。这就叫做缓存穿透。关键词:缓存value为空;并发量很大去访问DB。
造成的原因
1.业务自身代码或数据出现问题;2.一些恶意攻击、爬虫造成大量空的命中,此时会对数据库造成很大压力。
解决方法
1.设置布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,
从避免了对底层存储系统的查询压力。
2. 如果一个查询返回的数据为空,不管是数据不存在还是系统故障,我们仍然把这个结果进行缓存,但是它的过期时间会很短
最长不超过5分钟。
二、雪崩
1.什么是雪崩
因为缓存层承载了大量的请求,有效的保护了存储 层,但是如果缓存由于某些原因,整体不能够提供服务,于是所有的请求,就会到达存储层,存储层的调用量就会暴增,造成存储层也会挂掉的情况。缓存雪崩的英文解释是奔逃的野牛,指的是缓存层当掉之后,并发流量会像奔腾的野牛一样,大量后端存储。
存在这种问题的一个场景是:当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,大量数据会去直接访问DB,此时给DB很大的压力。
2.解决方法
(1)设置redis集群和DB集群的高可用,如果redis出现宕机情况,可以立即由别的机器顶替上来。这样可以防止一部分的风险。
(2)使用互斥锁
在缓存失效后,通过加锁或者队列来控制读和写数据库的线程数量。比如:对某个key只允许一个线程查询数据和写缓存,其他线程等待。单机的话,可以使用synchronized或者lock来解决,如果是分布式环境,可以是用redis的setnx命令来解决。
(3)不同的key,可以设置不同的过期时间,让缓存失效的时间点不一致,尽量达到平均分布。
(4)永远不过期
redis中设置永久不过期,这样就保证了,不会出现热点问题,也就是物理上不过期。
(5)资源保护
使用netflix的hystrix,可以做各种资源的线程池隔离,从而保护主线程池。
3.使用
四种方案,没有最佳只有最合适, 根据自己项目情况使用不同的解决策略。
懂的越多,不会的也就越多,知识之路是不断进取的
把redis作为缓存使用已经是司空见惯,但是使用redis后也可能会碰到一系列的问题,尤其是数据量很大的时候,经典的几个问题如下:
(一)缓存和数据库间数据一致性问题
分布式环境下(单机就不用说了)非常容易出现缓存和数据库间的数据一致性问题,针对这一点的话,只能说,如果你的项目对缓存的要求是强一致性的,那么请不要使用缓存。我们只能采取合适的策略来降低缓存和数据库间数据不一致的概率,而无法保证两者间的强一致性。合适的策略包括 合适的缓存更新策略,更新数据库后要及时更新缓存、缓存失败时增加重试机制,例如MQ模式的消息队列。
(二)缓存击穿问题
缓存击穿表示恶意用户模拟请求很多缓存中不存在的数据,由于缓存中都没有,导致这些请求短时间内直接落在了数据库上,导致数据库异常。这个我们在实际项目就遇到了,有些抢购活动、秒杀活动的接口API被大量的恶意用户刷,导致短时间内数据库c超时了,好在数据库是读写分离,同时也有进行接口限流,hold住了。
解决方案的话:
方案1、使用互斥锁排队
业界比价普遍的一种做法,即根据key获取value值为空时,锁上,从数据库中load数据后再释放锁。若其它线程获取锁失败,则等待一段时间后重试。这里要注意,分布式环境中要使用分布式锁,单机的话用普通的锁(synchronized、Lock)就够了。
1 public String getWithLock(String key, Jedis jedis, String lockKey, String uniqueId, long expireTime) {
2 // 通过key获取value
3 String value = redisService.get(key);
4 if (StringUtil.isEmpty(value)) {
5 // 分布式锁,详细可以参考
6 //封装的tryDistributedLock包括setnx和expire两个功能,在低版本的redis中不支持
7 try {
8 boolean locked = redisService.tryDistributedLock(jedis, lockKey, uniqueId, expireTime);
9 if (locked) {
10 value = userService.getById(key);
11 redisService.set(key, value);
12 redisService.del(lockKey);
13 return value;
14 } else {
15 // 其它线程进来了没获取到锁便等待50ms后重试
16 Thread.sleep(50);
17 getWithLock(key, jedis, lockKey, uniqueId, expireTime);
18 }
19 } catch (Exception e) {
20 log.error("getWithLock exception=" + e);
21 return value;
22 } finally {
23 redisService.releaseDistributedLock(jedis, lockKey, uniqueId);
24 }
25 }
26 return value;
27 }
这样做思路比较清晰,也从一定程度上减轻数据库压力,但是锁机制使得逻辑的复杂度增加,吞吐量也降低了,有点治标不治本。
方案2、接口限流与熔断、降级
重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
方案3、布隆过滤器
bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小,下面先来简单的实现下看看效果,我这里用guava实现的布隆过滤器:
1 <dependencies>
2 <dependency>
3 <groupId>com.google.guava</groupId>
4 <artifactId>guava</artifactId>
5 <version>23.0</version>
6 </dependency>
7 </dependencies>
8 public class BloomFilterTest {
9
10 private static final int capacity = 1000000;
11 private static final int key = 999998;
12
13 private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity);
14
15 static {
16 for (int i = 0; i < capacity; i++) {
17 bloomFilter.put(i);
18 }
19 }
20
21 public static void main(String[] args) {
22 /*返回计算机最精确的时间,单位微妙*/
23 long start = System.nanoTime();
24
25 if (bloomFilter.mightContain(key)) {
26 System.out.println("成功过滤到" + key);
27 }
28 long end = System.nanoTime();
29 System.out.println("布隆过滤器消耗时间:" + (end - start));
30 int sum = 0;
31 for (int i = capacity + 20000; i < capacity + 30000; i++) {
32 if (bloomFilter.mightContain(i)) {
33 sum = sum + 1;
34 }
35 }
36 System.out.println("错判率为:" + sum);
37 }
38 }
成功过滤到999998
布隆过滤器消耗时间:215518
错判率为:318
可以看到,100w个数据中只消耗了约0.2毫秒就匹配到了key,速度足够快。然后模拟了1w个不存在于布隆过滤器中的key,匹配错误率为318/10000,也就是说,出错率大概为3%,跟踪下BloomFilter的源码发现默认的容错率就是0.03:
1 public static <T> BloomFilter<T> create(Funnel<T> funnel, int expectedInsertions /* n */) {
2 return create(funnel, expectedInsertions, 0.03); // FYI, for 3%, we always get 5 hash functions
3 }
我们可调用BloomFilter的这个方法显式的指定误判率:
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), capacity,0.01);
我们断点跟踪下,误判率为0.02和默认的0.03时候的区别:
对比两个出错率可以发现,误判率为0.02时数组大小为8142363,0.03时为7298440,误判率降低了0.01,BloomFilter维护的数组大小也减少了843923,可见BloomFilter默认的误判率0.03是设计者权衡系统性能后得出的值。要注意的是,布隆过滤器不支持删除操作。用在这边解决缓存穿透问题就是:
1 public String getByKey(String key) {
2 // 通过key获取value
3 String value = redisService.get(key);
4 if (StringUtil.isEmpty(value)) {
5 if (bloomFilter.mightContain(key)) {
6 value = userService.getById(key);
7 redisService.set(key, value);
8 return value;
9 } else {
10 return null;
11 }
12 }
13 return value;
14 }
(三)缓存雪崩问题
缓存在同一时间内大量键过期(失效),接着来的一大波请求瞬间都落在了数据库中导致连接异常。
解决方案:
方案1、也是像解决缓存穿透一样加锁排队,实现同上;
方案2、建立备份缓存,缓存A和缓存B,A设置超时时间,B不设值超时时间,先从A读缓存,A没有读B,并且更新A缓存和B缓存;
方案3、设置缓存超时时间的时候加上一个随机的时间长度,比如这个缓存key的超时时间是固定的5分钟加上随机的2分钟,酱紫可从一定程度上避免雪崩问题;
1 public String getByKey(String keyA,String keyB) {
2 String value = redisService.get(keyA);
3 if (StringUtil.isEmpty(value)) {
4 value = redisService.get(keyB);
5 String newValue = getFromDbById();
6 redisService.set(keyA,newValue,31, TimeUnit.DAYS);
7 redisService.set(keyB,newValue);
8 }
9 return value;
10 }
(四)缓存并发问题
这里的并发指的是多个redis的client同时set key引起的并发问题。其实redis自身就是单线程操作,多个client并发操作,按照先到先执行的原则,先到的先执行,其余的阻塞。当然,另外的解决方案是把redis.set操作放在队列中使其串行化,必须的一个一个执行,具体的代码就不上了,当然加锁也是可以的,至于为什么不用redis中的事务,留给各位看官自己思考探究。
一、缓存
Redis做缓存是最常见的应用场景。客户端请求在缓存层命中就直接返回,如果miss就去读取存储层,存储层读取到就写入缓存层,然后再返回到客户端。
优点:
加速读写
降低后端负载
缺点:
数据的不一致性
代码维护成本
运维成本
二、缓存穿透优化
然而缓存可能会遇到这种问题:请求cache拿不到数据,就会去存储层拿,都拿不到时,返回空值(可能会返回大量空值)。或者代码有问题,拿不到数据。就会一直请求数据。导致后端打崩。
优化方法:
1、缓存层缓存空值。
–缓存太多空值,占用更多空间。(优化:给个空值过期时间)
–存储层更新代码了,缓存层还是空值。(优化:后台设置时主动删除空值,并缓存把值进去)
三、缓存雪崩优化
redis挂了,客户端直接请求到数据库里面。数据库负载非常高。甚至数据库拖挂了。
优化方法:
1、保持缓存层服务器的高可用。
–监控、集群、哨兵。当一个集群里面有一台服务器有问题,让哨兵踢出去。
2、依赖隔离组件为后端限流并降级。
比如推荐服务中,如果个性化推荐服务不可用,可以降级为热点数据。
3、提前演练。
演练 缓存层crash后,应用以及后端的负载情况以及可能出现的问题。
对此做一些预案设定。
四、热点key 重建优化:
A、B、C、D同时请求一个资源,不存在时都要去请求存储层,有可能会拖挂。
优化方法:
1、互斥锁:
只允许一个请求重建缓存。
其他请求等待缓存重建执行完,重新从缓存获取数据即可。
2、用户过期
–“物理”不过期
–逻辑设置过期时间(根据上一次更新时间,构建一个队列,主动去更新)