4、缓存击穿与互斥锁:热点Key失效问题、SETNX实现分布式互斥锁、锁的超时与续期

4.1 缓存击穿:热点Key失效的连锁反应

缓存击穿是指一个热点Key在缓存中恰好过期失效,此时大量并发请求同时穿透到后端数据库,导致数据库瞬间压力剧增甚至崩溃。与缓存雪崩不同,击穿只针对单个热点Key,但破坏力同样巨大。

核心特征:
  • 单点失效:仅一个热点Key过期,但该Key承载极高并发(如秒杀商品、热搜新闻)。
  • 并发穿透:所有请求同时发现缓存Miss,同时查询数据库。
  • 数据库打满:数据库连接池耗尽,响应超时,引发级联故障。

下图展示了缓存击穿的典型流程:

缓存击穿流程示意图 并发客户端 (N个) Redis 缓存 后端数据库 请求热点Key 缓存 Miss (Key已过期) 穿透查询 并发穿透放大效应 N个请求同时Miss → N个查询同时到达数据库 → 数据库连接耗尽 数据库过载 → 服务雪崩
避坑提示:不要依赖“热点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 互斥锁实现流程

  1. 尝试加锁:使用SET lock_key unique_id NX EX 10尝试获取锁。
  2. 加锁成功:查询数据库,将结果写入缓存,然后释放锁(删除lock_key)。
  3. 加锁失败:等待一小段时间(如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线程终止。

Watch Dog 锁续期时序图 时间轴 T0 T0+1s T0+2s T0+3s 锁获取成功 (EX 2s) Watch Dog续期 (EX 2s) Watch Dog续期 (EX 2s) 业务逻辑执行中(耗时约3秒) 释放

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等成熟客户端,避免重复造轮子。