3. 缓存击穿与互斥锁:热点key击穿场景、分布式互斥锁实现、锁超时与重试机制

3.1 热点Key击穿场景与问题分析

缓存击穿(Cache Breakdown)特指单个热点Key在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机。与缓存雪崩(大量Key同时失效)不同,击穿是单点高并发问题。

典型击穿特征: 热点Key(如秒杀商品ID、热搜词)在失效瞬间,QPS 从缓存命中直接降为 0,所有请求直击数据库,DB 连接池瞬间耗尽。
对比维度 缓存击穿 缓存雪崩 缓存穿透
失效范围 单个热点Key 大量Key同时过期 查询不存在的数据
并发特征 极高并发集中在一个Key 分散但总量大 持续空查询
根本原因 热点Key过期 + 高并发 批量过期时间相同 缓存层无对应数据
核心解法 互斥锁 / 逻辑过期 过期时间加随机值 布隆过滤器 / 空值缓存

3.2 分布式互斥锁实现方案

解决缓存击穿的核心思路是:只让一个请求去数据库加载数据,其他请求等待或降级。在分布式环境下,必须使用分布式互斥锁来协调多个实例。

3.2.1 基于Redis的SET NX + EX实现

最基础的分布式锁使用 Redis 的 SET key value NX EX timeout 命令,保证原子性加锁与过期时间设置。

// 加锁:SET NX EX 原子操作
String lockKey = "lock:hotkey:product_123";
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS);

if (Boolean.TRUE.equals(locked)) {
    try {
        // 从数据库加载数据
        Object data = db.query("SELECT * FROM product WHERE id = ?", productId);
        // 回填缓存
        redisTemplate.opsForValue().set("cache:product_123", data, 30, TimeUnit.SECONDS);
        return data;
    } finally {
        // 释放锁:使用Lua脚本保证原子性
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";
        redisTemplate.execute(
            new DefaultRedisScript<Long>(luaScript, Long.class),
            Arrays.asList(lockKey), requestId
        );
    }
} else {
    // 未获取到锁:等待重试或直接返回旧缓存
    Thread.sleep(50);
    // 重试或降级逻辑
}
关键避坑: 释放锁时必须使用 Lua 脚本校验 value(requestId),防止误删其他线程持有的锁。不能先 get 再 del,非原子操作会导致锁误释放。

3.2.2 主流分布式锁方案对比

方案 实现方式 优点 缺点
Redis SET NX 单节点原子命令 实现简单,性能高 主从切换可能丢锁
Redisson 看门狗自动续期 解决锁超时问题,自动续期 依赖 Redis 集群
Redlock 多节点半数写入 高可用,容错性强 实现复杂,性能略低
ZooKeeper 临时顺序节点 + Watch 强一致性,无超时问题 性能低于 Redis,羊群效应
etcd Lease + TTL 强一致,适合云原生 生态相对较小

3.3 锁超时与重试机制设计

分布式锁在实际运行中面临两个核心挑战:锁超时导致业务未完成锁被释放,以及获取锁失败后的重试策略。设计不当会引发数据不一致或死锁。

3.3.1 锁超时处理:看门狗机制

业务执行时间超过锁的过期时间时,锁自动释放,其他线程获取锁后可能读到不一致数据。Redisson 的看门狗(Watch Dog)机制是业界主流解法:

  • 默认行为: 锁过期时间 30 秒,每 10 秒检查一次业务是否仍在执行。
  • 续期逻辑: 如果业务未完成,自动将锁过期时间重置为 30 秒。
  • 异常保护: 如果持有锁的线程崩溃,看门狗停止续期,锁在 30 秒后自动释放。
// Redisson 看门狗示例
RLock lock = redissonClient.getLock("lock:hotkey:product_123");
try {
    // 默认过期时间 30 秒,看门狗自动续期
    lock.lock(30, TimeUnit.SECONDS);
    // 业务逻辑可能执行 60 秒,看门狗会在 10 秒、20 秒、30 秒... 续期
    Object data = loadDataFromDB(productId);
    cacheData(productId, data);
} finally {
    // 业务完成,手动释放锁
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

3.3.2 重试机制设计

获取锁失败时,不能无限重试,必须设计合理的退避策略:

策略 实现方式 适用场景 风险
固定间隔重试 每 50ms 重试一次,最多 10 次 低并发,对延迟不敏感 可能加剧锁竞争
指数退避 第 n 次重试等待 2^n * 10ms 高并发场景 等待时间可能过长
随机退避 在 [0, maxWait] 范围内随机等待 避免惊群效应 不可预测性
直接降级 返回旧缓存或默认值 允许短暂数据不一致 可能返回过期数据
最佳实践: 采用"指数退避 + 最大重试次数 + 降级兜底"组合策略。例如:最多重试 5 次,每次等待 2^n * 10ms,第 6 次直接返回缓存中的旧数据(即使已过期),同时异步触发数据刷新。

3.4 互斥锁解决缓存击穿完整流程

下图展示了使用分布式互斥锁解决缓存击穿的完整决策流程:

分布式互斥锁解决缓存击穿完整流程 用户请求到达 检查缓存是否存在 命中 直接返回缓存数据 未命中 尝试获取分布式锁 成功 从数据库加载数据 数据回填缓存 释放分布式锁 返回数据给客户端 失败 重试或降级策略 指数退避重试 / 返回旧缓存 重试

3.5 安全合规视角下的锁设计约束

在金融、电商等合规敏感场景中,分布式锁的设计必须满足以下约束:

  • 可观测性: 所有加锁、解锁、续期、重试操作必须记录审计日志,包含 requestId、时间戳、业务标识。
  • 死锁防护: 锁必须有绝对最大生存时间(TTL),即使看门狗失效,锁也必须在 TTL 后自动释放。
  • 幂等性: 同一 requestId 重复加锁应返回成功(可重入锁),防止重试导致数据重复加载。
  • 数据一致性: 锁释放后,其他线程读取到的缓存数据必须是完整的、一致的,不能出现半写状态。
合规检查清单:
✅ 锁的 key 命名规范:lock:{业务域}:{资源标识}
✅ 所有锁操作记录审计日志
✅ 锁超时时间 < 业务最大执行时间 × 1.5
✅ 重试次数有限,且有降级兜底
✅ 释放锁时校验持有者身份(Lua 脚本)

3.6 总结与核心要点

维度 核心要点
问题识别 热点 Key 过期 + 高并发 = 缓存击穿,DB 瞬间被打满
锁选型 Redis SET NX 适合大多数场景,Redisson 看门狗解决超时问题
重试策略 指数退避 + 最大重试次数 + 降级返回旧缓存
安全合规 审计日志、死锁防护、幂等性、数据一致性校验
性能考量 锁粒度尽量小(按 Key 粒度),避免全局锁