Spring Boot学习第七天
Redis作为当前市面上最流行的缓存中间件之一,很大的原因之一是因为在内存中有着极高的读写速度,作为后端开发人员,我们自然希望用户访问的数据都在内存而非一些持久化的数据库中(例如MySQL),这样会大大提高系统的响应速度。
但还是有一些特殊情况出现,拉跨系统性能甚至造成系统崩溃。
1.Redis缓存穿透
前面我们也说了,我们希望用户访问的数据都在内存中,这样就不用去磁盘中查询,提升了响应速度。如果用户想要访问的数据不在Redis中,只能去访问MySQL了,这是我们不希望看到的(Redis的读写速度优于MySQL)
假如发送的请求传进来的key是不存在Redis中的,那么就查不到缓存,查不到缓存就会去数据库查询。假如有大量这样的请求,这些请求像“穿透”了缓存一样直接打在数据库上,这种现象就叫做缓存穿透
假如有不怀好心的用户使用某种手段在短时间内大量请求不存在的key,那么大量的请求就会被直接打在数据库上,给数据库带来巨大压力
常见的解决方案有两种:
1)缓存空对象
在对于一个请求,如果发现这个请求的key是不存在的(即不再数据库也不在Redis中),我们就在Redis中创建这个key,值为空。这样在下一次请求这个key时,在缓冲中就一定会被命中,返回空值。
这样做的好处在于,避免请求直接打到数据库中,给数据库减少压力
2)布隆过滤
在收到请求的时候,我们可以提前判断这个Key存不存在(不管它在Redis还是数据库中),如果存在我们就放行该请求,即便这个Key不在Redis在MySQL中,由于缓存机制,这个数据也会被写入Redis,对于下一次同样的请求,就不会被打到数据中(因为在Redis已经命中了),这个判断Key是否存在的玩意,我们叫布隆过滤器
2.Redis缓存击穿
对于经常被访问的数据,我们称之为热点Key,显然热点Key会被写入Redis,写入缓存。但由于某种原因我们需要给它设置过期时间。缓存击穿就是热点 Key 的缓存过期后,短时间内大量请求同时查询该 Key,导致这些请求同时访问数据库,造成数据库压力骤增。
常见的解决方案有两种:
1)互斥锁
对于多个请求同一个Key的请求,同一时间内只能由一个请求被处理。显然这个请求也会被打到数据中,但是当这个请求被处理完成之后,由于缓存机制,请求的Key会被写入Redis,接下来的其他请求都会在Redis中命中,不会被打到数据库中,避免了缓存击穿
为了使得**“同一时间内只能由一个请求被处理”**,我们需要加锁,一旦某个请求加锁后,其他请求只能等该请求完成,释放锁之后才能加上自己的锁,完成自己的功能。这个就叫互斥锁
2)逻辑过期
如果Key永远不过期,我们就不用担心不被命中的问题,但是前面也说过了,由于某种原因我们需要给它设置过期时间。所以我们并不显示的指定永不过期,而是在数据内容上加上一段其他的内容,这个其他的内容就指的是过期时间。
当拿到数据时,首先判断该数据是否过期,如果过期了,就获取锁,开启一个新的线程,让这个新的线程重置过期时间并且写入缓存,而自己返回过期的数据
3.Redis缓存雪崩
当某一个时刻出现大规模的缓存失效(例如多个Key同时到期)的情况,那么就会导致大量的请求直接打在数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。
和缓存击穿有点类似,缓存雪崩是大规模的key失效,而缓存击穿则是一个热点Key失效
一个粗糙的的解决方案是让这些Key过期时间随机,避免同一时刻出现大规模的缓存失效
具体实现
1.逻辑过期
1. queryWithLogicalExpire
public Shop queryWithLogicalExpire(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
if(StrUtil.isBlank(shopJson)&&!("empty".equals(shopJson))){
return null;
}
//存在 接着判断缓存是否过期
//首先获取过期参数expiretime
RedisData redisData = JSONUtil.toBean(shopJson,RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject)redisData.getData(),Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())){
//未过期 返回店铺信息
return shop;
}
//过期,需要缓存重建
//接着获取互斥锁
String localKey = LOCK_SHOP_KEY+id;
boolean islock = trylook(localKey);
if(islock){
//成功获取,开启独立线程
CACHE_REBUILD_EXECUTOR.submit(()->{
try{
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//不管怎么样 都释放锁
unlock(localKey);
}
});
}
return shop;
}
功能
基于 逻辑过期 的思想,避免缓存击穿问题。当缓存过期时,不立即删除数据,而是采用异步重建缓存的方式,保证系统的高可用性和一致性。
实现逻辑
-
从 Redis 查询缓存数据:
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
- 如果缓存中没有数据且不是占位符,返回
null
。 - 如果数据存在,将其解析为
RedisData
对象。
- 如果缓存中没有数据且不是占位符,返回
-
判断数据是否过期:
-
检查缓存中数据的逻辑过期时间
expireTime
是否已经超过当前时间:if (expireTime.isAfter(LocalDateTime.now())) { return shop; }
- 如果未过期,直接返回缓存中的数据。
- 如果已过期,继续执行缓存重建流程。
-
-
缓存重建:
-
通过获取互斥锁(分布式锁)保证只有一个线程进行缓存重建。
-
获取锁后,通过独立线程异步重建缓存:
CACHE_REBUILD_EXECUTOR.submit(() -> { try { this.saveShop2Redis(id, 20L); } catch (Exception e) { throw new RuntimeException(e); } finally { unlock(localKey); } });
-
即使缓存过期,但在锁未释放之前,仍会返回旧缓存数据,保证高并发情况下的系统稳定性。
-
适用场景
- 热点数据:某些高频访问的数据(例如商品详情页),缓存即使过期,也需要保证高可用性。
- 不允许缓存击穿:需要保证缓存总是存在,不直接查库。
2. queryWithMutex
public Shop queryWithMutex(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
if(StrUtil.isNotBlank(shopJson)&&!("empty".equals(shopJson))){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if ("empty".equals(shopJson)) {
return null;
}
//尝试获取互斥锁
String lockKey = "lock:shop" + id;
Shop shop = null;
try{
boolean isLock = trylook(lockKey);
if(!isLock){
Thread.sleep(50);
return queryWithMutex(id);
}
shop = getById(id);
log.info("-----------------------------------------------------");
//不存在,根据Id查询数据库
if (shop == null) {
// 将 "empty" 作为占位符存入 Redis,避免缓存击穿
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "empty", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
//释放互斥锁
unlock(lockKey);
}
//返回
return null;
}
功能
通过 互斥锁 的机制解决缓存击穿问题。缓存数据过期后,使用分布式锁确保只有一个线程去查询数据库并更新缓存,其余线程等待。
实现逻辑
-
从 Redis 查询缓存:
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
- 如果缓存命中,直接返回数据。
- 如果缓存未命中且不是占位符,继续执行缓存重建逻辑。
-
尝试获取分布式锁:
-
使用
trylook(lockKey)
尝试获取互斥锁:boolean isLock = trylook(lockKey);
- 如果获取失败,线程休眠一段时间并递归重试。
-
如果获取成功,则查询数据库并更新缓存:
shop = getById(id); stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
-
-
处理空值:
- 如果数据库中没有对应数据,将
"empty"
作为占位符写入缓存,设置较短的过期时间,避免缓存穿透。
- 如果数据库中没有对应数据,将
-
释放锁:
- 无论成功与否,均在
finally
块中释放锁。
- 无论成功与否,均在
适用场景
- 适用于对一致性要求较高的场景,尤其是缓存频繁过期时的高并发查询。
- 不需要复杂的逻辑过期机制,且可以承受一定程度的线程等待。
3. queryWithPassThrough
public Shop queryWithPassThrogh(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY+id);
if(StrUtil.isNotBlank(shopJson)&&!("empty".equals(shopJson))){
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if ("empty".equals(shopJson)) {
return null;
}
Shop shop = getById(id);
if (shop == null) {
// 将 "empty" 作为占位符存入 Redis,避免缓存击穿
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "empty", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
//返回
return null;
}
功能
直接从数据库查询数据,并写入缓存,属于最简单的缓存访问逻辑设计。主要用于防止缓存穿透。
实现逻辑
-
从 Redis 查询缓存:
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
- 如果缓存命中,直接返回数据。
- 如果缓存未命中且不是占位符,继续查询数据库。
-
查询数据库:
-
如果数据库中存在数据,将其写入缓存:
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
-
如果数据库中不存在数据,将
"empty"
写入缓存,并设置较短的过期时间,避免缓存穿透。
-
-
直接返回查询结果。
适用场景
- 数据库访问量低、缓存更新频率较高的场景。
- 对一致性要求不高的简单场景。