Marco's Java【面试系列篇之 如何设计缓存系统避免缓存雪崩和击穿】

前言

Redis缓存中心是我们日常开发中必不可少的工具,使用缓存使得我们的数据能够更快返回给用户,缓解数据库的压力,可以应对类似抢票、商品秒杀的高并发的场景。但是任何产品都不是十全十美的,当我们在设计一个缓存系统时不得不面对几个问题:缓存穿透、缓存击穿以及缓存雪崩。

设计缓存系统的三大难题

正如前言所述,在设计缓存系统之初,我们会遇到三大难题,在第一期文章中,我们已经分析过如何使用布隆过滤器来解决缓存穿透的问题,很多初学者会将这三个问题混淆,因此在这里我们稍加回顾一下。

缓存系统面临的问题解释
缓存穿透查询的key对应的数据在数据库并不存在,每次针对此key的请求从缓存获取不到,大量请求都会到数据库,进而压垮数据库
缓存击穿key存在于数据库,但在Redis中过期,此时若有大量并发请求过来,并发现Redis查找不到,因此所有请求会去数据库查找数据,进而压垮数据库
缓存雪崩在某一个时刻,Redis中有大批量的key在某一时间段失效,并瞬时增加数据库压力,可能导致数据库崩溃

之前我们分析过如何避免缓存穿透 ,一般可以将所有查询到的值缓存起来,查询不到的值可以缓存一个null值,并设置合理的缓存失效时间(虽然这种方式简单粗暴,但是凭空的添加了很多无用的数据),或者使用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中(譬如在商城中,每一个商品都会有一个商品id,此时可以将数据库中存在的商品id置入map中),一个一定不存在的数据会被这个bitmap拦截掉,没有被拦截的数据在数据库中必定存在,此时会先去查询Redis,倘若Redis中不存在(可能这个key刚好过期了),再去查询数据库,进而减轻数据库的压力。
在这里插入图片描述
另外两个问题特别容易混淆,都是key在Redis中过期导致的,只不过缓存击穿场景下,是针对于某一个key的数据访问,并且key可能很早就已经过期了,但是一直没有请求进来并去刷新缓存(惰性刷新),当某一时刻,针对于这个key有大批量的请求访问进来,那么同一时刻所有的请求必定会渗透到数据库中,那么瞬时的高并发请求可能会压垮数据库。
而缓存雪崩的则是针对于某一个时刻,大量的缓存同时失效了,失效的面比较大,那么当不同的请求进来时,发现这些key并不在Redis中,进而全部去请求数据库。

了解了缓存击穿以及缓存雪崩的场景之后,接下来我们具体分析一下它们对应的解决方案。

如何应对缓存击穿

结合刚才的分析,很容易能够想到缓存击穿的本质就是,当某一个key失效时,针对于该key瞬时的请求压垮数据库,那么我们可以试想一下,既然只是一个key失效,那么我们只让其中一个线程去数据库拉取数据,其他的线程等待,待数据库中的数据同步到了缓存,再让其他的线程去缓存获取数据,这样是不是会大大的减少数据库的访问次数,进而减轻数据库的压力呢?

因此,这里给到的第一个方案就是使用互斥锁(mutex key)的方式控制某一个时刻的线程访问数据库的次数。

使用互斥锁(mutex key)

当缓存失效时,此时有大量的请求去获取商品id为10的商品信息,此时先使用Redis的setnx(Set if not exists)方法,或者在set方法中指定过期时间设置一个互斥锁,当设置锁成功时,去数据库加载数据,返回对应的数据并将该数据刷到缓存中,否则,就重试该操作。实现代码如下。
在这里插入图片描述

// 根据key获取对应的数据
public String get(key) {
	// 先从缓存中获取数据
    String value = redis.get(key);
    // 若缓存中数据不存在,或者该缓存值过期
    if (value == null) { 
        // 设置超时时间为5min,避免当锁remove失败时,锁被一直占用
        // 后续的线程无法执行从数据库中获取数据
        if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
				// 当锁设置成功时,从数据库中获取数据
        		value = db.get(key);	
        		// 将数据置入redis中,并设置过期时间
              	redis.set(key, value, expire_secs);
              	// 释放锁
              	redis.remove(key_mutex);
         } else {   
         		// 若设置锁失败,说明之前已经有线程去数据库拉取数据了 
         		sleep(100);
         		// 重试获取缓存值`在这里插入代码片`
                get(key);
         }
    } 
    return value;            
}

当然,我们也可以使用memcache做处理,逻辑同上面是一样的,将加锁部分代码替换为如下代码

// 当缓存中的数据为空时
if (memcache.get(key) == null) {  
    // 尝试去加锁,并设置加锁时常为5min,与redis不同的是memcache的返回值为true
    // 代表加锁成功,而redis的setnx方式返回值为1时代表加锁成功
    if (memcache.add(key_mutex, 5 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        // 若未获取到锁,则重试之前的操作
        retry();  
    }  
}
互斥锁配合双重过期时间

上面使用互斥锁的方式完全可以解决缓存击穿的问题,但是必须等到所有的请求去缓存中获取这个key的时候我们才能发现Redis中的这个key是否是已经过期了,那么有什么办法可以提前预知这个Redis中的key有没有过期呢?
试想我们在key对应的value上设置一个timeout字段,单独记录value的过期时间,且value的过期时间短于Redis设置的key的过期时间,那么每次获取value成功的时候都去检测一下这个value的timeout时间是否小于当前时间(value是否过期),如果说value过期了,那么就重新从数据库中拉取最新的数据,并且延长过期时间,重新载入缓存中,这样做的好处就是每次拿到的值基本上都是最新的,并且保证值基本不过期。

因此,我们可以在上面的基础上我们可以做一些优化。

// 根据key获取对应的数据
public String get(key) {
	// 先从缓存中获取数据
    v = redis.get(key);
    // 若缓存中数据不存在,或者该缓存值过期
    if (v == null) { 
        // 设置超时时间为5min,避免当锁remove失败时,锁被一直占用
        // 后续的线程无法执行从数据库中获取数据
        if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
				// 当锁设置成功时,从数据库中获取数据
        		v = db.get(key);	
        		// 将数据置入redis中
              	redis.set(key, v);
              	// 释放锁
              	redis.remove(key_mutex);
         } else {   
         		// 若设置锁失败,说明之前已经有线程去数据库拉取数据了 
         		sleep(100);
         		// 重试获取缓存值`在这里插入代码片`
                get(key);
         }
     } else {
     	// 如果redis中的数据不为空,判断该数据是否超时
     	if (v.timeout() <= now()) {
	     	if (redis.set(key_mutex, 1, 5 * 60) == 1) {  
					// 从数据库中载入数据
	        		v = db.get(key);	
	        		v.timeout += expire_secs;
	        		// 将数据置入redis中,并设置过期时间
	              	redis.set(key, v, expire_secs * 2);
	              	// 释放锁
	              	redis.remove(key_mutex);
	         } else {   
	         		// 若设置锁失败,说明之前已经有线程去数据库拉取数据了 
	         		sleep(100);
	         		// 重试获取缓存值`在这里插入代码片`
	                get(key);
	         }
     	}     
     }   
     return v;   
}
设置标志位提前过期

我们可以通过预设一个缓存标志位,设置标志位的过期时间为实际缓存数据的一半(这一点和上面的操作有些类似),当我们每次去缓存中取值的时候,都会先去查看之前设置的标志位是否过期,如果没有过期,则可以确定实际缓存数据也没有过期,反之重新给标志位加缓存,并通过fork子线程的方式去数据库拉去数据,更新实际的缓存数据,并返回旧数据。这样做的好处就是会使得缓存"永不过期",但是有一定几率会拉取旧的数据。并且每一个实际存储数据都需要额外维护一个标志位,会占用额外的内存空间。
这种方式相较上一种,在性能方面会有很大的优势,如果你的产品能够接受一定程度会读取到脏数据(如微博等),那么使用这种方式还是可行。
在这里插入图片描述

// 根据key获取对应的数据
public String get(key) {
    int cacheTime = 30;
    String cacheSign = key + "sign";
    // 获取缓存标记
    String sign = redis.get(cacheSign);
    //获取缓存值
    String value = redis.get(key);
    if (sign != null) {
        // 当缓存的值未过期时,直接返回
        return value;
    } else {
        redis.add(sign, "1", cacheTime);
        ThreadPool.workItem((arg) -> {
            // 这里一般是 sql查询数据
            value = db.get(key);
            // 过期时间设置为缓存时间的2倍,用于脏读
            redis.add(key, value, cacheTime * 2);
        });
        return cacheValue;
    }
}
如何应对缓存雪崩

刚才我们也提到了何为缓存雪崩,并与缓存击穿做了对比,加以区分,雪崩问题本质上就是某一时刻大量的缓存(key)同时失效,请求统统转发到数据库的问题。因此解决缓存雪崩的思路就是尽可能的减少同一个时刻失效缓存的数量。
在这里插入图片描述
因此我们首当其冲能够想到的解决方案就是将缓存的失效时间离散开,在原有的缓存失效时间上加一个随机值,当然具体的随机值的大小该定义多少,还是得看数据的量级,数据量越大,随机值的浮点后的值要越精确,这样做的话,缓存的过期时间的重复率会大大降低,偶尔几个重复值也不会引起雪崩效应的产生。

在这里插入图片描述
当然,应对缓存雪崩问题也可以通过加锁的方案(见缓存击穿的方案)来解决,亦或是通过队列的方式,保证缓存是单线程写入的,那么此时缓存的失效时间就必定不会重复,避免因缓存雪崩引发的"血案"。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值