分布式场景下防止【缓存击穿】的不同方案
在高并发的业务场景中,缓存作为缓解数据库压力的关键组件,其稳定性直接影响整个系统的性能。而缓存击穿,作为缓存体系中常见的问题之一,始终是开发者需要重点攻克的难关。当缓存未命中时,大量请求会瞬间涌向数据库,极易造成数据库过载甚至宕机。本文将介绍三种方案来有效应对缓存击穿。
Redission RLock
Redission的RLock就是分布式场景下的ReentrantLock。
用 ReentrantLock 当锁组件时,底层是AQS,锁的作用只在一台JVM主机里。在分布式微服务场景下,可能存在多台JVM同时调用一个服务,那么锁将不再起作用。因此需要RLock来作为分布式场景下的全局锁,它的底层是Redis,可以做到跨 JVM、跨服务器共享资源控制,因此这是分布式场景锁的最好选择。
以 “热点综艺节目的详情查询” 为例:
- 某热门综艺更新后,短时间内有 10 万用户同时查询该节目的嘉宾、播放时间等信息。
- 该节目的缓存因设置了 24 小时过期时间,恰好在此刻失效。
- 10 万请求瞬间穿透缓存,全部涌向节目数据库的
program
表,导致数据库连接池耗尽、查询超时,最终引发 “服务不可用”。
此时,若不做任何防护,数据库将成为整个系统的 “单点故障点”。接下来,我们从基础方案开始,逐步优化这一问题。
基础有两种方案:
- 设置热点数据永不过期:虽然缓存不会失效导致缓存击穿,但缓存数据不会得到及时更新,存在数据不一致的问题
- 分布式锁:当检测到缓存失效时,先加上分布式锁,获取锁成功后再进入操作数据库、重新构建缓存的流程。这样之后其他请求访问时能够直接获取到缓存数据返回。
以下是分布式锁的代码示例
public String getData(String id){RedisTemplate<String,String> redisTemplate = redisCache.getInstance();String cachedValue = redisTemplate.opsForValue().get(id);if (StringUtil.isEmpty(cachedValue)) {//分布式锁RLock lock = serviceLockTool.getLock(LockType.Reentrant, id);lock.lock();try {Program program = programMapper.selectById(id);if (Objects.nonNull(program)) {redisTemplate.opsForValue().set(id,JSON.toJSONString(program));cachedValue = JSON.toJSONString(program);}} finally {lock.unlock();}}return cachedValue;
}
Redission RLock + 双重检测
但上述方法也存在很大的问题,当大量请求进行锁竞争,只有一个请求能获取到锁并重新构建缓存,其他阻塞的请求获取锁后依然会访问数据库,并没有用到新构建好的缓存。数据库的压力依旧很大。
其实完全没有必要都去查询数据库的,当第一个请求从数据库查询出来放入缓存后,之后的请求都应该从缓存中查询。
因此就需要在原有的基础上加上双重检测,在获取锁后请求访问数据库前在检测一次缓存中是否存在想要的数据,如果有则直接返回。
public String getDataV3(String id){RedisTemplate<String,String> redisTemplate = redisCache.getInstance();String cachedValue = redisTemplate.opsForValue().get(id);if (StringUtil.isEmpty(cachedValue)) {RLock lock = serviceLockTool.getLock(LockType.Reentrant, id);lock.lock();try {cachedValue = redisTemplate.opsForValue().get(id);if (StringUtil.isEmpty(cachedValue)) {Program program = programMapper.selectById(id);if (Objects.nonNull(program)) {redisTemplate.opsForValue().set(id,JSON.toJSONString(program));cachedValue = JSON.toJSONString(program);}}} finally {lock.unlock();}}return cachedValue;
}
同样的想法也可以见单例模式的构建。
public class DCLSingleton {// 单例private static volatile Singleton singleton = null;// 私有构造方法private Singleton() {}public static Singleton getInstance() {if (null == singleton) {synchronized (Singleton.class) {if (null == singleton) {singleton = new Singleton();}}}return singleton;}
}
Redission RLock + 双重检测 + tryLock
但上面的方法依旧存在问题,按理说当第一个请求成功构建完缓存后,其他请求就不应该竞争锁了,他们可以直接访问缓存来获取数据。Redission RLock + 双重检测之后的请求依然要锁竞争、串行执行,浪费了很多时间。
假设某热门节目的并发请求有 100 万:
- 第一个请求获取锁,查库、重建缓存(耗时约 50ms)。
- 剩余 999,999 个请求会阻塞在
lock.lock()
处,等待锁释放。 - 即使 Redis 处理锁请求的耗时仅 1ms,最后一个请求也需要等待约 999,999ms(约 16 分钟)才能获取锁 —— 这显然无法满足高并发场景的低延迟需求。
问题的核心在于:lock.lock()
是 “无限等待” 的,即使缓存已重建,后续请求仍需排队获取锁,而不是直接从缓存读取数据。
tryLock可以帮助解决问题。tryLock
是 Redisson RLock 的核心方法之一,其逻辑是:
- 尝试在指定时间内(如 1 秒)获取锁;
- 若在超时前获取到锁,则执行后续逻辑(查缓存、查库);
- 若超时仍未获取到锁,则直接返回 “获取失败”,此时请求可再次尝试查缓存(因第一个请求可能已重建缓存),无需继续阻塞。
通过tryLock
,我们将 “无限阻塞” 改为 “有限等待”,让大部分请求在等待一小段时间后,直接从缓存获取数据,大幅降低延迟。
因此这个方法就是针对减少锁竞争做的优化。
private ProgramVo getById(Long programId,Long expireTime,TimeUnit timeUnit) {//先从缓存中查询ProgramVo programVo = redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM, programId), ProgramVo.class);//如果存在直接返回数据if (Objects.nonNull(programVo)) {return programVo;}//加锁RLock lock = serviceLockTool.getLock(LockType.Reentrant, GET_PROGRAM_LOCK, new String[]{String.valueOf(programId)});boolean lockResult;try {//等待锁的时间为1s,超过1s后,继续执行lockResult = lock.tryLock(1, TimeUnit.SECONDS);} catch (InterruptedException e) {throw new DaMaiFrameException("线程中断异常",e);}try {//再从缓存中查询,如果缓存不存在则从数据库中查询再放入到缓存中return redisCache.get(RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM,programId),ProgramVo.class,() -> createProgramVo(programId),expireTime,timeUnit);}finally {if (lockResult) {lock.unlock();}}
}
这里是将锁的竞争方式从lock改为了tryLock,等待时间为1s,意思也就是说请求等待锁的时间为1s,如果1s后还没有获得到锁的后,就不再继续等待,直接返回获得锁的结果,程序接着往下执行.
但这种方法依然存在着问题,如何判断第一个请求构建缓存的时间?如果1s没有构建完,将要会有大量请求打入数据库,依然会有缓存击穿的问题。如果构建时间过长,又会造成不必要的资源浪费。
这就需要根据业务来设置等待锁的时间,设置一个比较底限的值,保证不会超出的一个时间,但想设置一个百分之百不能超过的一个值,确实需要经过大量时间的业务运算来进行估算。