以下是针对缓存击穿(Hot Key失效)的两种工业级解决方案:分布式互斥锁(Redis SETNX)与逻辑过期(Logical Expiration),包含高并发下的工程实现细节与容灾设计。
一、缓存击穿的现象与本质
1.1 攻击模型
时间点T0: Hot Key(如商品库存)突然过期
时间点T1: 10万并发同时查询该Key
结果: 10万请求穿透到DB,DB瞬时CPU 100%,服务雪崩
与缓存穿透的区别:
- 穿透:Key不存在(恶意随机ID),BF可防
- 击穿:Key存在但刚过期(热点数据自然失效),无法通过BF拦截
二、方案一:分布式互斥锁(Redis SETNX)
2.1 核心原理
单线程重建:利用Redis SETNX原子性,确保同一时刻只有一个线程回源DB,其他线程等待或降级。
2.2 生产级代码(Redisson分布式锁)
不推荐裸用Jedis SETNX(需处理续期、可重入、主从延迟),推荐Redisson:
@Service
public class HotKeyService {
@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate redisTemplate;
// 缓存Key格式
private static final String CACHE_KEY = "product:%s";
private static final String LOCK_KEY = "lock:product:%s";
public Product getProduct(String productId) {
String cacheKey = String.format(CACHE_KEY, productId);
String lockKey = String.format(LOCK_KEY, productId);
// 1. 查缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, Product.class);
}
// 2. 获取分布式锁(等待3秒,持有10秒,看门狗自动续期)
RLock lock = redisson.getLock(lockKey);
try {
boolean isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!isLocked) {
// 获取锁失败:快速失败或返回旧数据(根据业务)
log.warn("获取锁失败, productId={}", productId);
return getProductFromBackup(productId); // 降级方案
}
// 3. 双重检查(Double Check):防止加锁期间其他线程已重建
json = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, Product.class);
}
// 4. 查询DB并重建缓存
Product product = productDao.findById(productId);
if (product == null) {
// 缓存空值防穿透(与BF配合)
redisTemplate.opsForValue().set(cacheKey, "", 1, TimeUnit.MINUTES);
return null;
}
// 5. 写入缓存(随机TTL防止雪崩)
long ttl = 30 + RandomUtil.randomInt(10); // 30-40分钟随机
redisTemplate.opsForValue().set(
cacheKey,
JSONUtil.toJsonStr(product),
ttl,
TimeUnit.MINUTES
);
return product;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return getProductFromBackup(productId);
} finally {
// 6. 释放锁(检查持有状态,避免误删)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
2.3 高可用细节
锁续期(Watch Dog机制):
- Redisson内部启动定时任务,每leaseTime/3续期一次(默认10秒锁,3秒续期一次)
- 避免业务执行超时导致锁失效,其他线程闯入
锁粒度控制:
- 细粒度:lock:product:1001(推荐,并发高)
- 粗粒度:lock:product:*(重建时阻塞所有商品查询,不推荐)
主从延迟问题:
- 若Redis主从架构,SETNX成功后主库宕机,从库晋升,可能丢失锁标记
- 解决方案:
- RedLock算法(多Master节点过半成功)
- 或容忍小概率击穿(业务方兜底)
三、方案二:逻辑过期(Logical Expiration)
3.1 核心原理
永不过期:Redis Key设置物理永不过期(TTL=-1),但在Value中存储逻辑过期时间。发现过期时异步重建,老数据立即返回(可用性优先)。
适用场景:强可用性、弱一致性(如秒杀库存、配置中心)。
3.2 数据结构设计
@Data
public class CacheWrapper<T> {
private T data; // 实际数据
private Long expireTime; // 逻辑过期时间戳(毫秒)
private Long refreshTime; // 最后刷新时间(用于防抖)
}
3.3 生产级代码(异步线程池重建)
@Service
public class LogicalExpireService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ThreadPoolExecutor cacheRebuildExecutor; // 自定义线程池
@Autowired
private RedissonClient redisson;
private static final String CACHE_KEY = "product:logical:%s";
private static final String LOCK_REBUILD = "lock:rebuild:%s";
public Product getProductLogical(String productId) {
String cacheKey = String.format(CACHE_KEY, productId);
// 1. 查询Redis(永不过期)
String json = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank(json)) {
// 数据从未加载过(冷启动),需要同步加载一次
return loadDataSync(productId);
}
// 2. 反序列化并检查逻辑过期时间
CacheWrapper<Product> wrapper = JSONUtil.toBean(json,
new TypeReference<CacheWrapper<Product>>() {}, true);
if (wrapper.getExpireTime() > System.currentTimeMillis()) {
// 2.1 未过期,直接返回
return wrapper.getData();
}
// 2.2 已过期,尝试获取重建锁(非阻塞,获取失败直接返回旧数据)
String lockKey = String.format(LOCK_REBUILD, productId);
RLock lock = redisson.getLock(lockKey);
boolean isLocked = false;
try {
// 尝试获取锁,0秒等待,5秒持有(快速失败)
isLocked = lock.tryLock(0, 5, TimeUnit.SECONDS);
if (isLocked) {
// 3. 双重检查:获取锁后再次检查过期时间(防止多线程重建)
String json2 = redisTemplate.opsForValue().get(cacheKey);
CacheWrapper<Product> wrapper2 = JSONUtil.toBean(json2,
new TypeReference<CacheWrapper<Product>>() {}, true);
if (wrapper2.getExpireTime() <= System.currentTimeMillis()) {
// 4. 提交异步重建任务(不阻塞主线程)
cacheRebuildExecutor.submit(() -> {
try {
rebuildCache(productId);
} catch (Exception e) {
log.error("异步重建缓存失败", e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
});
} else {
// 已被其他线程重建,释放锁
lock.unlock();
}
}
// 5. 无论是否获取锁,都返回过期数据(保证可用性)
return wrapper.getData();
} catch (Exception e) {
log.error("逻辑过期处理异常", e);
// 异常时返回过期数据,不抛异常
return wrapper.getData();
}
}
/**
* 异步重建缓存
*/
private void rebuildCache(String productId) {
String cacheKey = String.format(CACHE_KEY, productId);
try {
// 查询DB
Product product = productDao.findById(productId);
// 封装新的Wrapper(30分钟逻辑过期)
CacheWrapper<Product> newWrapper = new CacheWrapper<>();
newWrapper.setData(product);
newWrapper.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
newWrapper.setRefreshTime(System.currentTimeMillis());
// 写入Redis(永不过期)
redisTemplate.opsForValue().set(
cacheKey,
JSONUtil.toJsonStr(newWrapper)
);
} catch (Exception e) {
log.error("重建缓存失败: {}", productId, e);
// 重建失败不删除旧数据,等待下次重试
}
}
/**
* 数据预热(首次加载)
*/
private Product loadDataSync(String productId) {
// 同步加载,走DB查+写缓存
Product product = productDao.findById(productId);
if (product != null) {
CacheWrapper<Product> wrapper = new CacheWrapper<>();
wrapper.setData(product);
wrapper.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
redisTemplate.opsForValue().set(
String.format(CACHE_KEY, productId),
JSONUtil.toJsonStr(wrapper)
);
}
return product;
}
}
3.4 线程池隔离设计
@Bean("cacheRebuildExecutor")
public ThreadPoolExecutor cacheRebuildExecutor() {
return new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000), // 缓冲队列
new ThreadFactoryBuilder().setNameFormat("cache-rebuild-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:由调用线程执行
);
}
关键设计:
- 拒绝策略:若队列满,由主线程执行重建(降级为同步),避免丢数据
- 线程隔离:重建任务与业务线程池分离,防止重建耗时拖垮主业务
四、方案对比与选型决策
| 一致性 | 强一致性(重建期间无脏读) | 弱一致性(返回过期数据) |
| 可用性 | 中(可能等待或降级) | 高(永远有数据返回) |
| 延迟 | P99可能突增(等待锁+重建) | 稳定低延迟(直接返回) |
| 代码复杂度 | 低(基于Redisson简单) | 高(需异步线程池管理) |
| 适用场景 | 强一致性要求(库存、交易) | 高可用优先(商品详情、配置) |
| 风险点 | 锁失效/死锁导致击穿 | 长期未访问Key可能物理过期(需兜底) |
混合架构(推荐):
public Product getProduct(String id) {
// 1. 先查逻辑过期缓存(先保证可用性)
Product p = getProductLogical(id);
if (p != null && isFresh(p)) return p; // 数据新鲜
// 2. 数据过期或不存在,获取互斥锁重建(强一致性兜底)
return getProductWithLock(id); // 走SETNX方案
}
五、高级防御:多级缓存 + 熔断
5.1 多级缓存架构
用户 → Caffeine本地(L1) → Redis逻辑过期(L2) → 互斥锁重建 → DB
本地缓存(Caffeine)配置:
LoadingCache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存5分钟
.refreshAfterWrite(1, TimeUnit.MINUTES) // 异步刷新
.build(key -> getProductLogical(key)); // 兜底查L2
5.2 熔断兜底(Sentinel)
当击穿已发生时,拒绝后续流量保护DB:
@SentinelResource(value = "getProduct",
blockHandler = "handleBlock",
fallback = "handleFallback")
public Product getProduct(String id) {
// 正常逻辑…
}
// 熔断后返回本地默认值或缓存最后已知值
public Product handleBlock(String id, BlockException ex) {
return getProductFromLocalBackup(id);
}
六、生产 checklist
□ 互斥锁是否使用Redisson(处理续期与可重入),而非裸SETNX
□ 锁的TTL是否大于业务执行时间(防止死锁)
□ 逻辑过期缓存是否设置物理永不过期(或极长TTL兜底)
□ 重建线程池是否隔离(防止重建任务打满业务线程)
□ 是否处理锁的“惊群效应”(获取锁后Double Check缓存)
□ 冷启动时是否有缓存预热机制(避免首次访问全部穿透)
□ 是否监控“重建耗时”与“过期数据返回率”(SLA指标)
□ 是否配置熔断 fallback(击穿发生时的最后一道防线)
核心认知:互斥锁是一致性优先的防御,逻辑过期是可用性优先的防御。在高并发场景下,逻辑过期+异步重建的体验往往优于阻塞等待,但需接受秒级数据延迟。选择取决于业务对数据新鲜度与系统可用性的权衡。

