本文主要是介绍防止缓存击穿、缓存穿透和缓存雪崩,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
使用Redis缓存防止缓存击穿、缓存穿透和缓存雪崩
在高并发系统中,缓存击穿、缓存穿透和缓存雪崩是三种常见的缓存问题。本文将介绍如何使用Redis、分布式锁和布隆过滤器有效解决这些问题,并且会通过Java代码详细说明实现的思路和原因。
1. 背景
缓存穿透:指的是大量请求缓存中不存在且数据库中也不存在的数据,导致大量请求直接打到数据库上,形成数据库压力。
缓存击穿:指的是某个热点数据在高并发时失效,大量请求同时穿透缓存,导致数据库负载瞬间激增。
缓存雪崩:指的是缓存集中过期或宕机,导致短时间内大量请求打到数据库,压垮后端服务。
为了应对这三种情况,本文介绍了三种方法:
- 缓存穿透:使用空值缓存和布隆过滤器。
- 缓存击穿:使用分布式锁保证只有一个线程访问数据库并重建缓存。
- 缓存雪崩:合理设置缓存过期时间,防止大规模缓存同时失效。
2. 缓存穿透处理
我们首先来看queryWithPassThrough
方法,它处理的是缓存穿透问题。
public <R, P> R queryWithPassThrough(String key, Class<R> clazz, Function<P, R> bdCallback, P params, Duration duration) {// 1. 从缓存中查询数据String dataJsonStr = stringRedisTemplate.opsForValue().get(key);// 2. 如果缓存中存在数据,直接返回if (StrUtil.isNotBlank(dataJsonStr)) {return JSONUtil.toBean(dataJsonStr, clazz);}// 3. 如果缓存存在的是空字符串,表示数据库中也没有该数据,直接返回null,防止缓存穿透if ("".equals(dataJsonStr)) {return null;}// 4. 缓存没有命中,调用数据库查询R data = bdCallback.apply(params);// 5. 如果数据库中没有数据,缓存空字符串以防止缓存穿透,并设置较短的过期时间if (data == null) {stringRedisTemplate.opsForValue().set(key, "", Duration.ofSeconds(20));return null;}// 6. 数据库查询有结果,将结果缓存并设置过期时间stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(data), duration);return data;
}
解析:
- 缓存查询:首先从Redis缓存中查询数据,如果有数据,直接返回。
- 缓存空值机制:如果缓存中存储的是空字符串(即以前数据库查询也没有该数据),防止缓存穿透(同一个不存在的请求反复击穿缓存,查询数据库)。
- 查询数据库:当缓存没有命中且没有空值时,查询数据库并缓存结果。如果数据库中没有数据,缓存空值并设置短期过期时间(例如20秒)。
为什么要缓存空值?
防止缓存穿透。如果没有缓存空值,对于不存在的数据的查询会反复击穿缓存,导致数据库压力过大。
3. 缓存击穿处理
缓存击穿问题通常发生在热点数据过期时,同时有大量请求到达数据库。为了解决这个问题,我们使用分布式锁来保证在缓存失效时,只有一个线程能够访问数据库并更新缓存。
private <R, P> R queryWithMutex(String key, Class<R> clazz, Duration expireSeconds, Function<P, R> bdCallback, P params) {// 1. 从缓存查询数据String dataJson = stringRedisTemplate.opsForValue().get(key);// 2. 如果缓存中有数据,直接返回if (StrUtil.isNotBlank(dataJson)) {return JSONUtil.toBean(dataJson, clazz);}// 3. 如果缓存中是空值,返回null,防止穿透if ("".equals(dataJson)) {return null;}// 4. 缓存没有命中,尝试加锁String lock = "lock:" + key;try {while (!tryLock(lock)) {// 如果获取不到锁,等待并重复检查缓存Thread.sleep(50L);dataJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(dataJson)) {return JSONUtil.toBean(dataJson, clazz);}if ("".equals(dataJson)) {return null;}}// 5. 加锁后再次检查缓存,防止其他线程已经重建缓存dataJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(dataJson)) {return JSONUtil.toBean(dataJson, clazz);}if ("".equals(dataJson)) {return null;}// 6. 如果缓存没有数据,查询数据库R apply = bdCallback.apply(params);if (apply == null) {// 如果数据库没有数据,缓存空值stringRedisTemplate.opsForValue().set(key, "", Duration.ofMinutes(2));return null;}// 7. 将数据库数据存入缓存stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(apply), expireSeconds);return apply;} catch (Exception e) {throw new RuntimeException(e);} finally {// 8. 释放锁releaseLock(lock);}
}
解析:
- 缓存查询:如果缓存中有数据,直接返回,避免击穿。
- 加锁机制:如果缓存没有数据,尝试获取锁。如果获取不到锁,说明其他线程正在重建缓存,此时线程等待并轮询缓存,直到锁释放或缓存更新。
- 缓存重建:拿到锁的线程查询数据库,并将结果存入缓存。
- 锁的释放:确保在缓存重建完成后释放锁,避免死锁。
为什么需要加锁?
防止缓存击穿时,多个线程同时查询数据库,导致数据库压力激增。通过分布式锁,保证只有一个线程可以查询数据库并更新缓存。
4. 逻辑过期处理
为了解决缓存雪崩问题,可以通过逻辑过期方式处理热点数据的过期。即使数据过期了,系统也能继续返回旧数据,同时后台线程异步更新缓存。
private <R, P> R queryWithLogicalExpire(String key, Class<R> clazz, Duration expireSeconds, Function<P, R> bdCallback, P params) {// 1. 从缓存查询数据String dataJson = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(dataJson)) {return null;}// 2. 反序列化数据RedisData data = JSONUtil.toBean(dataJson, RedisData.class);R r = JSONUtil.toBean((JSONObject) data.getData(), clazz);// 3. 检查数据是否过期,如果没有过期,直接返回数据if (data.getExpireTime().isAfter(LocalDateTime.now())) {return r;}// 4. 如果数据已过期,尝试加锁,进行缓存重建String lock = "lock:" + key;if (tryLock(lock)) {try {dataJson = stringRedisTemplate.opsForValue().get(key);data = JSONUtil.toBean(dataJson, RedisData.class);if (data.getExpireTime().isAfter(LocalDateTime.now())) {return JSONUtil.toBean((JSONObject) data.getData(), clazz);}// 5. 异步重建缓存CACHE_REBUILD_EXECUTOR.submit(() -> {try {R newData = bdCallback.apply(params);RedisData redisData = new RedisData(newData, LocalDateTime.now().plusSeconds(expireSeconds.getSeconds()));stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));} catch (Exception e) {throw new RuntimeException(e);} finally {releaseLock(lock);}});} finally {releaseLock(lock);}}// 6. 返回过期数据,保证系统可用性return r;
}
解析:
- 缓存逻辑过期:数据存入缓存时,同时存储逻辑过期时间。即使数据过期了,系统可以继续返回旧数据,防止雪崩。
- 后台异步更新:通过线程池,异步执行缓存重建,防止阻塞用户请求。
- 双重检查:在获取锁后,再次检查缓存
,避免重复重建缓存。
为什么需要异步更新?
即使数据过期,也可以返回旧数据,保证服务可用性。同时在后台异步更新缓存,减少对前台服务的影响。
5. 分布式锁
为确保只有一个线程能重建缓存,我们使用了Redis的setIfAbsent
方法实现分布式锁,并且为锁设置了过期时间,防止死锁。
private boolean tryLock(String key) {// 尝试获取锁,并设置锁的过期时间,防止死锁Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(10L));return Boolean.TRUE.equals(flag);
}private void releaseLock(String key) {// 释放锁stringRedisTemplate.delete(key);
}
解析:
- 获取锁:通过
setIfAbsent
,确保只有一个线程能获取锁,并设置锁过期时间,防止死锁。 - 释放锁:任务完成后,释放锁,允许其他线程继续操作。
为什么要设置锁的过期时间?
防止由于异常情况(例如服务器宕机)导致锁无法释放,产生死锁问题。
6. 总结
通过使用Redis缓存、分布式锁和逻辑过期策略,我们可以有效解决缓存穿透、缓存击穿和缓存雪崩问题。通过这套方案,我们能够在保证系统高可用的同时,减少数据库压力,并提高服务的性能和稳定性。
关键点包括:
- 缓存空值机制:防止缓存穿透。
- 分布式锁:防止缓存击穿时多个线程同时访问数据库。
这套方案对于处理高并发场景下的缓存问题是非常有效的。希望这篇文章能帮助大家更好地理解和应对缓存问题。
这篇关于防止缓存击穿、缓存穿透和缓存雪崩的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!