【Redis】Redis 缓存穿透、缓存击穿、缓存雪崩解决方案

一、Redis 缓存穿透、缓存击穿、缓存雪崩解决方案

解决方案 Redis 的常规使用,这里不再阐述,直接切入主题,Redis 的缓存穿透、缓存击穿、缓存雪崩问题怎么来的?怎么解决?

对 Redis 没有初步了解和使用的,可以看这篇文章学习一下基础,在继续深入学习并发导致的问题,https://blog.csdn.net/qq_38762237/article/details/121608026

我这里记录这篇技术文章的目的就是提醒大家,这个知识点的重要性,且是面试的必备技巧之一,因为我的其中一个简答题就是请简述 Redis 缓存穿透、缓存击穿、缓存雪崩是什么,并写出解决方案

1、缓存穿透

(1)缓存穿透是什么?

key 对应的数据在数据源并不存在,每次针对此 key 的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户 id 获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库

通俗来说,指缓存和数据库中都没有的数据

(2)缓存穿透解决方案

一个一定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义

方式一:布隆过滤器:把所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力

方式二:粗暴方式:查询返回,不管返回的数据是否为空(数据不存在、系统故障),仍然把空值进行缓存,单它的过期时间会很短,最常不超过 5 分钟

	/**
	 * 缓存穿透解决方案
	 *
	 * @param cacheKey
	 * @return
	 */
	public String testRedis(String cacheKey) {
    
    
		RedisUtil redisUtil = SpringUtils.getBean(RedisUtil.class);
		int cacheTime = 30;
		String cacheValue = redisUtil.get(cacheKey);
		if (StringUtils.isNotEmpty(cacheValue)) {
    
    
			System.out.println("拿到了缓存值:" + cacheValue);
			return cacheValue;
		} else {
    
    
			// 这里表示从数据库中查询出来,但还是为空
			cacheValue = "";
			if (StringUtils.isEmpty(cacheValue)) {
    
    
				cacheValue = "";
			}
			redisUtil.put(cacheKey, cacheValue, cacheTime);
			System.out.println("拿到了缓存值:" + cacheValue);
			return cacheValue;
		}
	}

2、缓存击穿

(1)缓存击穿是什么?

key 对应的数据存在,但在 redis 中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮

通俗来说,指缓存中没有但数据库中有的数据

(2)缓存击穿解决方案

使用互斥锁(mutex key)

业界比较常用的做法,是使用 mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存;否则,就重试整个 get 缓存的方法

SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果

	/**
	 * 缓存击穿解决方案
	 *
	 * @param cacheKey
	 * @return
	 */
	public String testRedis2(String cacheKey) {
    
    
		RedisUtil redisUtil = SpringUtils.getBean(RedisUtil.class);
		int cacheTime = 30;
		String keyMutex = "key_mutex";
		String cacheValue = redisUtil.get(cacheKey);
		// 代表缓存值过期
		if (StringUtils.isEmpty(cacheValue)) {
    
    
			// 设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
			if (redisUtil.setIfAbsent(keyMutex, 1, 3, TimeUnit.MINUTES)) {
    
    
				cacheValue = "数据库中获取的";
				redisUtil.put(cacheKey, cacheValue, cacheTime);
				redisUtil.delete(keyMutex);
				return cacheValue;
			} else {
    
    
				try {
    
    
					// 这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
					// 重试
					Thread.sleep(100L);
				} catch (InterruptedException e) {
    
    
					e.printStackTrace();
				}
				return testRedis2(cacheKey);
			}
		} else {
    
    
			return cacheValue;
		}
	}

3、缓存雪崩

(1)缓存雪崩是什么?

当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如 DB)带来很大压力

通俗来说,指缓存同一时间大面积的失效

(2)缓存雪崩解决方案

缓存失效时的雪崩效应对底层系统的冲击非常可怕!大多数系统设计者考虑用加锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。还有一个简单方案就时讲缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件

	/**
	 * 缓存雪崩解决方案-加锁
	 *
	 * @param cacheKey
	 * @return
	 */
	public String testRedis3(String cacheKey) {
    
    
		RedisUtil redisUtil = SpringUtils.getBean(RedisUtil.class);
		int cacheTime = 30;
		String cacheValue = redisUtil.get(cacheKey);
		if (StringUtils.isNotEmpty(cacheValue)) {
    
    
			System.out.println("拿到了缓存值:" + cacheValue);
			return cacheValue;
		} else {
    
    
			synchronized (cacheKey) {
    
    
				cacheValue = redisUtil.get(cacheKey);
				if (StringUtils.isNotEmpty(cacheValue)) {
    
    
					return cacheValue;
				} else {
    
    
					// sql查询数据
					cacheValue = "模拟数据库中获取的";
					redisUtil.put(cacheKey, cacheValue, cacheTime);
				}
			}
			return cacheValue;
		}
	}

加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间 key 是锁着的,这是过来 1000 个请求 999 个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!

注意:加锁排队的解决方式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用!

	/**
	 * 缓存雪崩解决方案-加标记
	 *
	 * @param cacheKey
	 * @return
	 */
	public String testRedis4(String cacheKey) {
    
    
		RedisUtil redisUtil = SpringUtils.getBean(RedisUtil.class);
		int cacheTime = 30;
		AtomicReference<String> cacheValue = new AtomicReference<>(redisUtil.get(cacheKey));
		//缓存标记
		String cacheSign = cacheKey + "_sign";
		String sign = redisUtil.get(cacheSign);
		if (StringUtils.isNotEmpty(sign)) {
    
    
			// 未过期,直接返回
			return cacheValue.get();
		} else {
    
    
			redisUtil.put(cacheSign, "1", cacheTime);
			// 开启一个新线程执行,或者使用ThreadPool.QueueUserWorkItem
			new Thread(() -> {
    
    
				cacheValue.set("模拟数据库中获取的");
				// 日期设缓存时间的2倍,用于脏读
				redisUtil.put(cacheKey, cacheValue, cacheTime * 2);
			}).start();
			return cacheValue.get();
		}
	}

解释说明:

  • 缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际 key 的缓存;
  • 缓存数据:它的过期时间比缓存标记的时间延长 1 倍,例:标记缓存时间 30 分钟,数据缓存设置为 60 分钟。这样,当缓存标记 key 过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存

关于缓存崩溃的解决方法,这里提出了三种方案:使用锁或队列、设置过期标志更新缓存、为 key 设置不同的缓存失效时间,还有一种被称为“二级缓存”的解决方法

技术分享区

猜你喜欢

转载自blog.csdn.net/qq_38762237/article/details/121919725