Redis 学习记录:缓存击穿与分布式锁
一、缓存击穿
缓存击穿是指:高并发场景下,某个热点 Key 突然失效,大量请求同时访问数据库,导致数据库压力激增。
例如:某个热门商品每秒有 1000 次请求,当其 Redis 缓存过期时,这些请求可能同时查询 MySQL。
1. 互斥锁方案
核心思路:只允许一个线程查询数据库并重建缓存,其他线程等待或重试。
执行流程:
查询 Redis,发现缓存不存在。
尝试获取互斥锁。
获取成功后,再次检查缓存,避免重复重建。
如果缓存仍不存在,则查询 MySQL 并写入 Redis。
释放互斥锁。
其他线程重新查询 Redis,获取重建后的数据。
优点:
避免大量请求同时访问数据库。
数据新鲜度相对较高。
缺点:
缓存重建期间,其他请求可能阻塞。
高并发下可能造成线程等待和响应延迟。
2. 逻辑过期方案
核心思路:缓存不依赖 Redis TTL 自动删除,而是保存逻辑过期时间。过期后异步重建缓存,请求继续返回旧数据。
缓存结构示例:
{
"data": {
"id": 1001,
"name": "商品A"
},
"expireTime": "2026-10-25 12:00:00"
}执行流程:
查询 Redis,获取缓存数据及逻辑过期时间。
如果未过期,直接返回缓存。
如果已过期,尝试获取互斥锁。
获取锁成功后,提交异步任务,查询 MySQL 并重建缓存。
当前请求直接返回旧数据。
其他并发请求不等待,继续返回旧数据。
异步任务完成后释放锁。
注意:这里是查询数据库并重建缓存,不是更新数据库。
优点:
响应速度快,不需要等待缓存重建。
适合高并发热点数据。
缺点:
可能短暂返回过期数据。
实现复杂,需要处理异步线程池、重建失败、锁释放等问题。
3. 两种方案对比
总结:互斥锁更注重数据新鲜度,逻辑过期更注重响应速度和可用性。
两种方案都不能直接保证 Redis 与 MySQL 的强一致性。
二、互斥锁的高并发问题
1. 大量线程不断抢锁怎么办?
假设 1000 个请求同时访问已经过期的热点 Key。
线程 A 获得锁,查询数据库并重建缓存需要 200ms。
其余 999 个线程如果一直循环抢锁,会导致:
Redis 请求数量增加。
CPU 资源浪费。
大量 Tomcat 工作线程被占用。
推荐方案:有限重试 + 短暂休眠 + 重新检查缓存。
执行流程:
获取锁失败,短暂休眠,例如 50ms。
重新查询 Redis,判断缓存是否已经重建。
如果缓存存在,直接返回数据。
如果缓存仍不存在,在有限次数内重新尝试。
如果负责重建的线程失败,允许其他线程重新竞争锁。
超过最大等待时间,执行降级处理。
重点:其他线程的主要目标是读取重建后的缓存,而不是一直争抢锁。
三、Redis 分布式锁的问题
1. 死锁问题
问题: 线程获得锁后突然宕机,没有执行释放锁操作,怎么办?
解决方案:给锁设置过期时间。
即使线程宕机,Redis 也会在锁到期后自动释放锁。
注意:获取锁和设置过期时间必须是原子操作。
错误写法:
redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1");
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);两次操作之间如果发生宕机,可能产生没有过期时间的锁。
推荐写法:
String requestId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);该操作具有 Redis SET NX EX 的原子加锁语义。
2. 锁误删问题
问题: 线程 A 的锁过期后,线程 B 获取了锁,此时线程 A 执行完成,可能误删线程 B 的锁。
解决方案:通过唯一标识判断锁的归属,并利用 Lua 脚本保证判断与删除的原子性。
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end只有锁属于当前请求,才允许删除。
注意:安全解锁不能阻止锁过期后的旧线程继续执行业务。对数据写入正确性要求很高的场景,还需要额外的并发控制。
3. 锁提前过期问题
问题: 锁设置 10 秒有效期,但业务执行了 15 秒,怎么办?
解决方案:使用 Watchdog 看门狗自动续期机制。
执行流程:
线程 A 获取分布式锁。
看门狗定期检查锁是否仍由当前线程持有。
如果任务尚未完成,则自动延长锁的有效期。
任务完成后主动释放锁。
如果服务宕机,续期停止,锁最终自动过期。
目的:减少业务尚未完成时,锁提前过期的风险。
四、Redisson 看门狗机制
Redisson 是 Java 项目中常用的 Redis 客户端,提供了成熟的分布式锁实现。
常用写法:
RLock lock = redissonClient.getLock("lock:product:1001");
boolean success = lock.tryLock();
if (!success) {
return;
}
try {
// 查询数据库并重建缓存
rebuildCache();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}重要知识点:
Redisson 默认看门狗超时时间通常为 30 秒。
未显式指定锁租约时间时,可以使用看门狗自动续期。
如果明确指定
leaseTime,一般不会启用看门狗续期。看门狗能够减少锁提前过期的问题,但不能保证锁绝对不会失效。
实际开发中,优先使用 Redisson,而不是自己从零实现分布式锁。
Redisson 和 Spring Data Redis 均是第三方依赖,不属于 Java 8 自带的类库。
五、总结
问题一:什么是缓存击穿?
热点 Key 在高并发情况下突然失效,大量请求同时访问数据库,可能导致数据库压力激增。
问题二:如何解决缓存击穿?
可以使用互斥锁或者逻辑过期。
互斥锁通过控制缓存重建线程数量,避免大量请求同时查询数据库,数据新鲜度较高,但请求可能阻塞。
逻辑过期通过异步任务重建缓存,其他请求继续返回旧数据,响应速度较快,但允许短暂的数据过期。
问题三:其他线程一直获取锁会怎样?
会增加 Redis 请求压力并占用线程资源。可以采用有限重试、短暂休眠、重新检查缓存,以及超时降级等方案。
问题四:线程获得锁后宕机怎么办?
给锁设置过期时间,并通过原子操作完成加锁和设置过期时间。
问题五:如何避免误删其他线程的锁?
通过唯一标识判断锁的持有者,并使用 Lua 脚本保证判断与删除的原子性。
问题六:业务执行时间超过锁过期时间怎么办?
可以使用 Redisson 的 Watchdog 看门狗机制,在任务尚未完成时自动续期。
六、核心记忆
缓存击穿: 热点 Key 过期,大量请求访问数据库。
互斥锁: 只允许一个线程重建缓存。
逻辑过期: 异步重建缓存,其他请求返回旧数据。 vLua 脚本: 防止误删其他线程的锁。
Watchdog: 防止业务未完成时锁因超时而提前失效。
一句话总结:缓存击穿解决的是大量请求同时访问数据库的问题;分布式锁解决的是并发控制问题,而锁过期、唯一标识和自动续期解决的是分布式锁使用过程中的安全性问题。
