3. 缓存击穿与互斥锁:热点key击穿场景、分布式互斥锁实现、SETNX与Redisson方案、锁超时与续期机制
3.1 热点Key击穿场景与问题分析
缓存击穿(Cache Breakdown)特指单个热点Key在缓存中过期失效的瞬间,大量并发请求同时穿透缓存直达数据库,导致数据库负载瞬间飙升甚至宕机。与缓存雪崩(大量Key同时失效)不同,击穿聚焦于单一热点资源的失效瞬间。
典型击穿特征:
- 单点热点:如秒杀商品库存、微博热搜、大V粉丝列表。
- 瞬时高并发:失效瞬间QPS可达数万至数十万。
- 数据库压力尖峰:穿透请求直接打到DB,引发连接池耗尽、慢查询堆积。
下图展示了热点Key失效瞬间的并发穿透路径:
3.2 分布式互斥锁实现原理
解决缓存击穿的核心思路是互斥锁(Mutex Lock):只让一个请求去数据库加载数据,其他请求等待或快速失败。在分布式环境下,必须使用分布式互斥锁来协调多个进程/实例。
| 维度 | 本地锁(synchronized/ReentrantLock) | 分布式锁(Redis/Redisson) |
|---|---|---|
| 作用范围 | 单进程内 | 跨进程、跨实例 |
| 锁状态存储 | JVM内存 | Redis/ZooKeeper等外部存储 |
| 适用场景 | 单体应用 | 微服务、多实例部署 |
| 容错性 | 进程崩溃锁自动释放 | 需额外机制(超时/续期) |
设计原则:分布式锁在缓存击穿场景中只用于回源加载的临界区保护,不应包裹整个业务逻辑。锁粒度越小,系统吞吐越高。
3.3 SETNX方案:基础实现与缺陷
Redis的SETNX(SET if Not eXists)是最基础的分布式锁实现方式。其核心命令为:
// 加锁:key不存在时设置成功,返回1;已存在则返回0
SETNX lock_key unique_value
// 设置过期时间(防止死锁)
EXPIRE lock_key 30
// 解锁:校验唯一值后删除
if redis.get("lock_key") == unique_value:
redis.del("lock_key")
基础SETNX方案存在以下关键缺陷:
| 缺陷 | 问题描述 | 后果 |
|---|---|---|
| 非原子操作 | SETNX与EXPIRE分两步执行,SETNX成功后进程崩溃,EXPIRE未执行 | 锁永久存在,形成死锁 |
| 锁超时不可控 | 业务执行时间超过锁过期时间,锁自动释放 | 其他线程获取锁,导致并发冲突 |
| 误删锁 | 线程A锁超时释放,线程B加锁成功,线程A执行完误删线程B的锁 | 锁失效,数据不一致 |
| 不可重入 | 同一线程在持有锁时再次加锁会失败 | 递归或嵌套调用死锁 |
生产环境警告:不要直接使用SETNX+EXPIRE组合。Redis 2.6.12+提供了原子化的
SET key value NX EX seconds命令,可解决原子性问题,但超时续期、可重入、误删等问题仍需自行处理。
3.4 Redisson方案:成熟的企业级实现
Redisson是Redis官方推荐的Java分布式锁框架,提供了完整的互斥锁、读写锁、信号量等实现。其核心优势在于自动续期(Watchdog)和可重入机制。
// Redisson分布式锁使用示例
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RLock lock = redisson.getLock("hotKey:lock");
try {
// 尝试加锁,最多等待10秒,锁自动过期30秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 回源加载数据到缓存
String value = loadFromDB("hotKey");
cache.set("hotKey", value, 60);
} else {
// 获取锁失败,从缓存获取旧数据或快速失败
return cache.get("hotKey");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson与基础SETNX方案的核心差异:
| 特性 | 基础SETNX | Redisson |
|---|---|---|
| 原子性 | 需自行保证 | 内置原子操作 |
| 锁超时续期 | 不支持 | Watchdog自动续期 |
| 可重入 | 不支持 | 支持(基于计数器) |
| 锁误删防护 | 需自行校验唯一值 | 内置唯一标识校验 |
| 等待机制 | 轮询+自旋 | 发布订阅+信号量 |
| 集群兼容性 | 主从切换锁丢失 | RedLock算法(可选) |
3.5 锁超时与续期机制(Watchdog)
锁超时是分布式锁最棘手的工程问题:超时时间设置过短,业务未完成锁已释放;设置过长,锁持有者崩溃后其他线程长时间等待。Redisson的Watchdog机制优雅地解决了这一矛盾。
Watchdog工作原理:
- 加锁时设置默认过期时间(30秒),不要求业务预估执行时长。
- 后台启动一个定时任务(每10秒执行一次),检查锁是否仍被当前线程持有。
- 若锁仍持有,则将锁的过期时间重置为30秒(续期)。
- 若线程崩溃或锁已释放,Watchdog自动停止续期,锁在30秒后自然过期。
下图展示了Watchdog的续期流程与锁状态转换:
3.6 互斥锁方案对比与选型建议
| 方案 | 复杂度 | 可靠性 | 性能 | 推荐场景 |
|---|---|---|---|---|
| SET NX EX(原子命令) | 低 | 中 | 高 | 简单场景、锁持有时间可预估 |
| SETNX + Lua脚本 | 中 | 中高 | 高 | 需自定义逻辑、避免网络开销 |
| Redisson | 低(封装完善) | 高 | 中高 | 生产环境首选、复杂业务 |
| RedLock(多节点) | 高 | 极高 | 中 | 金融级、强一致性要求 |
最佳实践:
- 优先使用Redisson,其Watchdog机制和可重入特性大幅降低工程风险。
- 锁的粒度控制在回源加载阶段,不要包裹整个请求生命周期。
- 设置合理的等待超时(如100ms),避免大量请求阻塞在锁等待上。
- 结合本地缓存(如Caffeine)做二级防护,减少对分布式锁的依赖。
3.7 缓存击穿互斥锁的完整流程
综合上述技术,一个健壮的缓存击穿防护流程应包含以下步骤:
- 读缓存:尝试从Redis获取热点Key,命中则直接返回。
- 本地标记:检查本地内存标记(如Caffeine),防止同一实例内重复回源。
- 分布式锁:尝试获取Redisson锁(key=热点Key+锁后缀)。
- 双重检查:获取锁后再次检查缓存(防止等待期间其他线程已加载)。
- 回源加载:查询数据库,将结果写入缓存,设置合理TTL。
- 释放锁:业务完成后释放分布式锁,Watchdog自动停止续期。
- 返回数据:将数据返回给调用方。
避坑提醒:
- 锁的Key必须与缓存Key关联但不同名,避免锁被误删。
- 锁的唯一标识(UUID+线程ID)必须全局唯一,防止跨实例误释放。
- Watchdog续期不是无限期的——如果业务逻辑出现死循环或长时间阻塞,锁会一直持有,需配合全局超时熔断。