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 脚本)
✅ 锁的 key 命名规范:lock:{业务域}:{资源标识}
✅ 所有锁操作记录审计日志
✅ 锁超时时间 < 业务最大执行时间 × 1.5
✅ 重试次数有限,且有降级兜底
✅ 释放锁时校验持有者身份(Lua 脚本)
3.6 总结与核心要点
| 维度 | 核心要点 |
|---|---|
| 问题识别 | 热点 Key 过期 + 高并发 = 缓存击穿,DB 瞬间被打满 |
| 锁选型 | Redis SET NX 适合大多数场景,Redisson 看门狗解决超时问题 |
| 重试策略 | 指数退避 + 最大重试次数 + 降级返回旧缓存 |
| 安全合规 | 审计日志、死锁防护、幂等性、数据一致性校验 |
| 性能考量 | 锁粒度尽量小(按 Key 粒度),避免全局锁 |