3
0
0

Redis 学习记录:缓存击穿与分布式锁

2021-03-01
2026-10-25
文章摘要
|

一、缓存击穿

缓存击穿是指:高并发场景下,某个热点 Key 突然失效,大量请求同时访问数据库,导致数据库压力激增。

例如:某个热门商品每秒有 1000 次请求,当其 Redis 缓存过期时,这些请求可能同时查询 MySQL。

1. 互斥锁方案

核心思路:只允许一个线程查询数据库并重建缓存,其他线程等待或重试。

执行流程:

  1. 查询 Redis,发现缓存不存在。

  2. 尝试获取互斥锁。

  3. 获取成功后,再次检查缓存,避免重复重建。

  4. 如果缓存仍不存在,则查询 MySQL 并写入 Redis。

  5. 释放互斥锁。

  6. 其他线程重新查询 Redis,获取重建后的数据。

优点:

  • 避免大量请求同时访问数据库。

  • 数据新鲜度相对较高。

缺点:

  • 缓存重建期间,其他请求可能阻塞。

  • 高并发下可能造成线程等待和响应延迟。

2. 逻辑过期方案

核心思路:缓存不依赖 Redis TTL 自动删除,而是保存逻辑过期时间。过期后异步重建缓存,请求继续返回旧数据。

缓存结构示例:

{
  "data": {
    "id": 1001,
    "name": "商品A"
  },
  "expireTime": "2026-10-25 12:00:00"
}

执行流程:

  1. 查询 Redis,获取缓存数据及逻辑过期时间。

  2. 如果未过期,直接返回缓存。

  3. 如果已过期,尝试获取互斥锁。

  4. 获取锁成功后,提交异步任务,查询 MySQL 并重建缓存。

  5. 当前请求直接返回旧数据。

  6. 其他并发请求不等待,继续返回旧数据。

  7. 异步任务完成后释放锁。

注意:这里是查询数据库并重建缓存,不是更新数据库。

优点:

  • 响应速度快,不需要等待缓存重建。

  • 适合高并发热点数据。

缺点:

  • 可能短暂返回过期数据。

  • 实现复杂,需要处理异步线程池、重建失败、锁释放等问题。

3. 两种方案对比

对比项

互斥锁

逻辑过期

过期方式

Redis TTL

自定义逻辑过期时间

缓存重建

同步

异步

其他请求

等待或重试

返回旧数据

响应速度

相对较慢

相对较快

数据新鲜度

较高

允许短暂过期

实现复杂度

较低

较高

总结:互斥锁更注重数据新鲜度,逻辑过期更注重响应速度和可用性。

两种方案都不能直接保证 Redis 与 MySQL 的强一致性。


二、互斥锁的高并发问题

1. 大量线程不断抢锁怎么办?

假设 1000 个请求同时访问已经过期的热点 Key。

线程 A 获得锁,查询数据库并重建缓存需要 200ms。

其余 999 个线程如果一直循环抢锁,会导致:

  • Redis 请求数量增加。

  • CPU 资源浪费。

  • 大量 Tomcat 工作线程被占用。

推荐方案:有限重试 + 短暂休眠 + 重新检查缓存。

执行流程:

  1. 获取锁失败,短暂休眠,例如 50ms。

  2. 重新查询 Redis,判断缓存是否已经重建。

  3. 如果缓存存在,直接返回数据。

  4. 如果缓存仍不存在,在有限次数内重新尝试。

  5. 如果负责重建的线程失败,允许其他线程重新竞争锁。

  6. 超过最大等待时间,执行降级处理。

重点:其他线程的主要目标是读取重建后的缓存,而不是一直争抢锁。


三、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 看门狗自动续期机制。

执行流程:

  1. 线程 A 获取分布式锁。

  2. 看门狗定期检查锁是否仍由当前线程持有。

  3. 如果任务尚未完成,则自动延长锁的有效期。

  4. 任务完成后主动释放锁。

  5. 如果服务宕机,续期停止,锁最终自动过期。

目的:减少业务尚未完成时,锁提前过期的风险。


四、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: 防止业务未完成时锁因超时而提前失效。

一句话总结:缓存击穿解决的是大量请求同时访问数据库的问题;分布式锁解决的是并发控制问题,而锁过期、唯一标识和自动续期解决的是分布式锁使用过程中的安全性问题。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论