4、缓存击穿与互斥锁:热点Key失效问题、SETNX实现分布式互斥锁、锁的超时与续期
4.1 缓存击穿:热点Key失效的连锁反应
缓存击穿是指一个热点Key在缓存中恰好过期失效,此时大量并发请求同时穿透到后端数据库,导致数据库瞬间压力剧增甚至崩溃。与缓存雪崩不同,击穿只针对单个热点Key,但破坏力同样巨大。
核心特征:
- 单点失效:仅一个热点Key过期,但该Key承载极高并发(如秒杀商品、热搜新闻)。
- 并发穿透:所有请求同时发现缓存Miss,同时查询数据库。
- 数据库打满:数据库连接池耗尽,响应超时,引发级联故障。
下图展示了缓存击穿的典型流程:
避坑提示:不要依赖“热点Key永不过期”策略。如果数据发生变更,永不过期的Key会导致数据不一致。正确的做法是使用互斥锁控制重建缓存的并发,或结合逻辑过期主动刷新。
4.2 SETNX实现分布式互斥锁
分布式互斥锁是解决缓存击穿的标准方案。当热点Key失效时,只允许一个请求去数据库加载数据并重建缓存,其他请求等待或快速返回。Redis的SETNX命令是实现分布式锁的基础。
4.2.1 SETNX命令原理
| 命令 | 语义 | 返回值 | 原子性 |
|---|---|---|---|
SETNX key value |
当key不存在时设置成功,存在则不做任何操作 | 1(成功)/ 0(失败) | 是(单命令原子) |
SET key value NX EX seconds |
原子组合:不存在时设置 + 过期时间 | OK / nil | 是(推荐使用) |
4.2.2 互斥锁实现流程
- 尝试加锁:使用
SET lock_key unique_id NX EX 10尝试获取锁。 - 加锁成功:查询数据库,将结果写入缓存,然后释放锁(删除lock_key)。
- 加锁失败:等待一小段时间(如50ms)后重试,或直接返回降级数据。
// 伪代码:缓存击穿互斥锁方案
public String getData(String key) {
// 1. 尝试从缓存获取
String value = redis.get(key);
if (value != null) {
return value;
}
// 2. 缓存Miss,尝试加锁
String lockKey = "lock:" + key;
String requestId = UUID.randomUUID().toString();
Boolean locked = redis.set(lockKey, requestId, "NX", "EX", 10);
if (locked) {
try {
// 3. 双重检查:防止等待期间其他线程已重建缓存
value = redis.get(key);
if (value != null) {
return value;
}
// 4. 查询数据库
value = db.query(key);
// 5. 写入缓存(设置合理过期时间)
redis.set(key, value, "EX", 300);
return value;
} finally {
// 6. 释放锁(使用Lua脚本保证原子性)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redis.eval(luaScript, 1, lockKey, requestId);
}
} else {
// 7. 加锁失败,等待重试或返回降级
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getData(key); // 递归重试
}
}
最佳实践:使用
SET key value NX EX seconds替代SETNX + EXPIRE组合,避免因EXPIRE失败导致锁无法自动释放。释放锁时必须使用Lua脚本校验value(唯一标识),防止误删其他线程持有的锁。
4.3 锁的超时与续期
分布式锁面临一个经典困境:如果业务执行时间超过锁的过期时间,锁自动释放后其他线程会获取锁,导致并发冲突。如果过期时间设置过长,一旦持有锁的线程崩溃,锁会长时间占用。解决方案是锁续期(Watch Dog机制)。
4.3.1 超时时间设置策略
| 策略 | 过期时间 | 适用场景 | 风险 |
|---|---|---|---|
| 固定短超时 | 100ms ~ 1s | 查询类操作,执行时间稳定 | 业务未完成锁已释放 |
| 预估最大时间 | 5s ~ 30s | 批量写入,执行时间波动大 | 线程崩溃后锁残留时间长 |
| 动态续期 | 初始1s,每1/3时间续期 | 长任务、不确定执行时间 | 续期线程本身可能崩溃 |
4.3.2 Watch Dog续期机制
Watch Dog是一个守护线程,在获取锁成功后启动,定期(如每过期时间的1/3)检查锁是否仍然被当前线程持有,如果是则延长过期时间。当业务执行完毕释放锁后,Watch Dog线程终止。
4.3.3 续期实现要点
- 续期时机:在锁过期时间的1/3处发起续期(如过期时间3s,则每秒续期一次)。
- 续期条件:必须校验锁的value仍为当前线程的唯一标识,防止续期到其他线程的锁。
- 终止条件:业务执行完毕主动释放锁时,Watch Dog线程应立即中断。
- 兜底策略:如果Watch Dog线程本身崩溃,锁会在过期后自动释放,避免死锁。
// 伪代码:Watch Dog续期逻辑
public class WatchDog {
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private ScheduledFuture> future;
public void startRenewal(String lockKey, String requestId, long expireSeconds) {
long delay = expireSeconds * 1000 / 3; // 每1/3过期时间续期一次
future = scheduler.scheduleAtFixedRate(() -> {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
redis.eval(luaScript, 1, lockKey, requestId, expireSeconds);
}, delay, delay, TimeUnit.MILLISECONDS);
}
public void stopRenewal() {
if (future != null) {
future.cancel(true);
}
}
}
生产注意事项:
- Watch Dog续期会增加Redis的QPS,建议续期间隔不低于1秒。
- 如果使用Redisson客户端,其内置的Watch Dog机制已经成熟,优先使用现成实现。
- 对于可预知执行时间的短任务,直接设置足够长的过期时间(如5s)比续期更简单高效。
4.4 互斥锁方案对比总结
| 方案 | 实现复杂度 | 锁粒度 | 续期支持 | 推荐场景 |
|---|---|---|---|---|
| SETNX + 手动释放 | 低 | Key级别 | 无 | 短查询、执行时间稳定 |
| SET NX EX + Lua释放 | 中 | Key级别 | 无 | 大多数缓存重建场景 |
| SET NX EX + Watch Dog | 高 | Key级别 | 有 | 长任务、不确定执行时间 |
| Redisson RLock | 低(引入依赖) | Key级别 | 内置 | 生产环境首选 |
核心结论:缓存击穿的本质是热点Key失效后的并发穿透。分布式互斥锁通过“只让一个请求重建缓存”来保护数据库。锁的超时与续期是平衡“防止死锁”和“业务未完成”的关键设计。在实际项目中,优先使用Redisson等成熟客户端,避免重复造轮子。