Redis 分布式锁详解(Java 项目实战 + Redisson 看门狗机制)
这篇文章系统讲清楚 Redis 分布式锁的基本原理、Java 项目中的常见用法以及 Redisson 客户端最核心的看门狗Watch Dog机制到底是怎么实现的。 重点内容包括 - Redis 分布式锁为什么需要唯一 value 和过期时间 - 为什么释放锁必须使用 Lua 保证原子性 - Java 项目中为什么更推荐使用 Redisson - Redisson 可重入锁底层是如何设计的 - Redisson 看门狗机制的触发条件、续期流程和适用场景1. 什么是分布式锁分布式锁本质上是**让分布式系统中的多个进程、多个线程、多个服务实例在同一时刻只有一个执行者进入某段临界区代码**。单机应用里我们通常使用- synchronized- ReentrantLock- Semaphore但这些锁只能约束**同一个 JVM 进程内部**的线程。一旦应用部署成多实例比如- 服务 A 在机器 1 上- 服务 B 在机器 2 上- 服务 C 在机器 3 上这时 JVM 本地锁就失效了因为它们并不共享内存。所以这时候就需要一个**所有实例都能访问的共享协调组件**而 Redis 常常被拿来做这件事。2. 为什么用 Redis 实现分布式锁Redis 适合做分布式锁主要有几个原因1. **性能高**基于内存读写速度快。2. **支持原子命令**比如 SET key value NX PX 30000可以把“加锁 设置过期时间”合并成一个原子操作。3. **天然串行处理命令**单个 Redis 实例执行命令时天然具备较强的原子语义。4. **部署成本低**很多项目本身就已经在用 Redis不需要额外引入新的中间件。但也要明确一点**Redis 分布式锁不是银弹。**它适合大多数业务场景但如果业务对一致性要求极高还需要进一步考虑- Redis 主从切换问题- 网络分区问题- 锁误释放问题- 时钟问题- 长任务续期问题因此项目里通常不会自己手写一套复杂锁逻辑而是直接使用成熟客户端例如 **Redisson**。3. Redis 分布式锁最基础的实现3.1 加锁的核心命令最经典的 Redis 加锁命令如下SET lock_key unique_value NX PX 30000各部分含义如下- lock_key锁的 key- unique_value锁的唯一标识通常是 UUID 线程标识- NX仅当 key 不存在时才设置成功- PX 30000设置 30 秒过期时间防止死锁如果命令返回成功表示当前线程拿到锁如果返回失败表示锁已被别人占用。3.2 为什么必须设置过期时间如果只 SETNX 不设置过期时间会有死锁风险。例如1. 线程 A 拿到锁2. 业务还没执行完A 所在进程崩溃3. 锁永远不会释放4. 后续线程永久无法获得锁所以必须给锁设置 TTL让锁在持有者异常退出后自动过期。3.3 为什么 value 必须唯一假设锁的 value 不是唯一值而只是简单写成SET lock_key 1 NX PX 30000那么就会出现误删问题1. 线程 A 获得锁过期时间 30 秒2. 线程 A 执行太久锁过期3. 线程 B 获得同一个锁4. 线程 A 执行完毕后直接 DEL lock_key5. 结果把线程 B 的锁删掉了所以必须让 value 唯一。释放锁时必须先校验- 当前锁是不是自己加的- 只有是自己持有的锁才能删除4. 正确释放 Redis 锁为什么要用 Lua释放锁不能写成两步GET lock_key DEL lock_key因为这两步之间不是原子的。可能发生的情况是1. 线程 A GET 发现 value 是自己的2. 这时锁刚好过期3. 线程 B 立即抢到锁4. 线程 A 再执行 DEL5. 最终把线程 B 的锁删掉了所以必须使用 Lua 脚本来保证“比较 value 和删除 key”是一个原子操作。典型 Lua 脚本如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这就是 Redis 分布式锁最基础、也是最关键的正确释放方式。5. 手写 Redis 分布式锁的典型问题如果你自己基于 Jedis 或 Lettuce 手写分布式锁很快就会遇到这些问题1. **锁重入怎么做**同一个线程再次进入方法时是否允许再次获取同一把锁2. **锁续期怎么做**如果业务执行超过锁 TTL锁提前过期怎么办3. **阻塞等待怎么做**获取不到锁时是自旋、睡眠重试还是订阅通知4. **异常释放怎么保证**try-finally 如何封装得更稳妥5. **主从切换怎么办**主节点刚写入锁还没同步到从节点就挂了新主节点上可能根本没有这把锁。6. **公平锁、读写锁、联锁怎么实现**业务稍微复杂一点自己写的成本就会迅速升高。所以在 Java 项目里更推荐直接使用 **Redisson**。6. Java 项目为什么常用 RedissonRedisson 是 Redis 的 Java 高级客户端它不只是一个简单的 Redis 操作封装而是把很多分布式对象抽象好了比如- RLock可重入锁- RFairLock公平锁- RReadWriteLock读写锁- RSemaphore信号量- RCountDownLatch分布式闭锁- RMap、RBucket 等分布式对象它对分布式锁做了很多成熟封装1. 自动处理可重入2. 自动区分线程持有者3. 自动使用 Lua 保证释放原子性4. 支持等待锁、超时、续期5. 提供 **看门狗Watch Dog机制**自动给未显式设置 leaseTime 的锁续命其中最值得重点理解的就是 **Redisson 的看门狗机制。**7. Java 项目中如何使用 Redisson7.1 Maven 依赖我这里引入的是3.27.2版本的建议根据项目选择适配版本也可以无脑选最新版本。dependency groupIdorg.redisson/groupId artifactIdredisson/artifactId version3.27.2/version /dependency如果你使用 Spring Boot也常见直接配合 Redisson Starter但核心原理是一样的。7.2 基本配置单机 Redisimport org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class RedissonConfig { public static RedissonClient createClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://你的Redis地址:端口) .setPassword(Redis密码); return Redisson.create(config); } }Spring Boot 中常见配置思路通常会把 RedissonClient 注册为 Spring Bean然后业务里通过依赖注入获取Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); return Redisson.create(config); }7.3 最常见的加锁方式RLock lock redissonClient.getLock(order:create:lock); try { lock.lock(); // 业务逻辑 } finally { lock.unlock(); }这个写法很常见但需要理解 3 个点- lock.lock() 如果锁被别人持有会阻塞等待- 如果没有显式传 leaseTimeRedisson 会启用 **看门狗续期**- unlock() 只能由持锁线程调用否则会抛异常7.4 更推荐的业务写法tryLockRLock lock redissonClient.getLock(order:create:lock); boolean locked false; try { locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(获取分布式锁失败); } // 执行业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(等待锁被中断, e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } }参数含义如下- 5最多等待 5 秒获取锁- 30获取成功后锁在 30 秒后自动释放- TimeUnit.SECONDS时间单位这里要特别注意 由于显式传入了 leaseTime 30s**Redisson 不会启用看门狗自动续期**。7.5 lock() 与 tryLock() 的差别#### lock()lock.lock();特点- 一直等直到拿到锁- 如果不指定 leaseTime看门狗会自动续期- 适合明确需要执行且允许等待的场景#### tryLock(waitTime, leaseTime, unit)lock.tryLock(5, 30, TimeUnit.SECONDS);特点- 最多等待指定时间- 获取到锁后只持有固定时长- 到期自动释放- 通常不启用看门狗因为 leaseTime 已指定- 适合超时控制明确的业务场景8. Redisson 分布式锁底层数据结构怎么表示很多人以为 Redis 锁就是一个简单字符串 key。实际上为了支持**可重入**Redisson 底层并不只是简单的 SET key value而是更复杂一些。Redisson 的 RLock 底层通常使用 **Hash 结构** 来存储持锁信息。逻辑上类似这样- key锁名例如 order:create:lock- field客户端ID:线程ID- value重入次数例如key order:create:lockfield 4f3a2...uuid:23value 2表示- 某个 Redisson 客户端实例持有这把锁- 其中线程 ID 23- 该线程已经重入这把锁 2 次同时Redisson 还会给整个锁 key 设置过期时间。这样就能支持以下行为1. 同一线程第一次加锁成功2. 同一线程再次加锁时不会失败而是把重入次数加一3. 解锁时重入次数减一4. 当重入次数减到 0 时才真正删除锁 key9. Redisson 可重入锁的基本原理Redisson 获取锁时大致会做这些事情1. 判断锁 key 是否存在2. 如果不存在创建锁- 写入当前线程标识- 重入次数设为 1- 设置过期时间3. 如果锁已存在判断持有者是不是当前线程4. 如果是当前线程说明是重入- 重入次数 1- 刷新过期时间5. 如果不是当前线程说明锁被别人持有- 当前线程等待或返回失败解锁时则大致如下1. 判断当前线程是不是持有者2. 如果不是抛出 IllegalMonitorStateException3. 如果是重入次数减一4. 如果减一后仍大于 0不真正删除锁5. 如果减一后为 0删除锁并通知等待线程10. 看门狗机制到底解决了什么问题这是 Redisson 最关键的特性之一。10.1 问题背景如果你在拿锁时设置固定过期时间比如lock.tryLock(5, 30, TimeUnit.SECONDS);那么可能出现下面这种情况1. 线程 A 成功获取锁2. 业务预计 10 秒完成3. 但因为数据库慢查询、远程调用抖动、GC 停顿实际执行了 45 秒4. 锁在第 30 秒时自动过期5. 线程 B 抢到了锁6. 此时 A 其实还没执行完7. A 和 B 同时执行临界区业务数据出错这就是典型的 **锁提前过期问题。**10.2 一个简单但不优雅的办法有人会想到那我把 leaseTime 设大一点比如 10 分钟不就行了吗这样做当然可以缓解问题但又会带来新的问题- 如果线程真的挂了锁要很久才释放- 影响系统吞吐- 其他线程长时间阻塞- 故障恢复很慢所以理想情况应该是- 线程活着并且业务还在执行就继续持有锁- 线程挂了就不要再续期让锁自然过期而这正是看门狗机制要解决的问题。11. Redisson 看门狗机制的核心思想一句话概括 **当线程获取锁后如果没有显式指定 leaseTimeRedisson 会启动一个后台续期任务在锁快过期时自动延长 TTL。只要持锁线程还活着锁就不会过期。**默认情况下- lockWatchdogTimeout 30000ms也就是 30 秒Redisson 会先把锁设置成 30 秒过期但不会只放着不管而是周期性在后台执行“续命”。通常续期间隔约为- lockWatchdogTimeout / 3- 默认约等于每 10 秒续一次也就是说1. 线程拿到锁2. 锁初始 TTL 30 秒3. Redisson 每 10 秒检查并续期4. 每次把 TTL 再刷新回 30 秒5. 只要业务线程没结束、客户端没挂锁就会一直有效12. 看门狗机制的触发条件看门狗**不是任何情况下都开启**它通常只在以下场景开启lock.lock();或者lock.tryLock(waitTime, TimeUnit.SECONDS);也就是说- 没有显式指定 leaseTime- 锁的持有时间交给 Redisson 托管如果你这样写lock.lock(30, TimeUnit.SECONDS);或者lock.tryLock(5, 30, TimeUnit.SECONDS);由于你已经明确指定了锁的自动过期时间Redisson 会认为 这把锁最多持有多久已经由你自己决定了不需要它再帮你自动续期。因此此时不会启用看门狗。13. 看门狗机制的执行流程下面按步骤拆开来看。13.1 第一步线程获取锁业务线程执行lock.lock();Redisson 会通过 Lua 脚本在 Redis 中尝试加锁- 如果锁不存在创建锁- 记录当前线程标识- 设置 TTL lockWatchdogTimeout默认 30 秒13.2 第二步注册续期任务加锁成功后Redisson 会为这把锁登记一个“续期任务”。注意这个任务并不是每个线程各开一个无限循环线程而是由 Redisson 内部的调度机制统一管理。续期任务会记住这些信息- 锁名- 客户端实例 ID- 线程 ID- 当前持锁信息13.3 第三步定时续期在锁过期之前Redisson 会调度一个任务去执行续期。续期逻辑大致如下1. 先确认这把锁仍然由当前线程持有2. 如果仍是自己持有- 就把锁的 TTL 重新设置为 30 秒3. 如果已经不是自己持有- 停止续期13.3 第四步重复续期续期成功后Redisson 会再次为下一轮续期注册任务。于是整体流程就变成- 获取锁- 10 秒后续期一次- 再过 10 秒续期一次- 再过 10 秒续期一次- 直到业务完成并主动释放锁13.4 第五步释放锁并取消续期业务执行完后lock.unlock();Redisson 会1. 执行 Lua 脚本校验持锁线程2. 减少重入次数或删除锁3. 取消该锁对应的续期任务这时看门狗就停止了。14. 看门狗为什么能避免死锁它避免死锁并不是因为“锁永不过期”而是因为- **只有客户端活着时才会持续续期**- **客户端挂掉后续期任务就停止**- **Redis 中的锁会因为没有新的续期而自然过期**举个例子1. 线程 A 拿到锁TTL 30s2. 每 10s 看门狗续期一次3. 执行到第 25s 时服务实例突然宕机4. 续期任务不会再执行5. 锁会在最后一次续期后的 30s 到期6. 其他线程之后就可以重新获取锁所以它同时兼顾了两点- 正常长任务不丢锁- 异常宕机不会永久死锁15. 看门狗续期的底层本质从本质上说看门狗并不神秘它做的事情其实非常朴素 **不断执行“如果锁还是我持有就重置过期时间”这件事。**底层仍然依赖 Redis 的这些能力- PEXPIRE- Lua 脚本- 定时任务调度也就是说看门狗并没有改变 Redis 锁的模型而是在客户端层补上了“动态续命”的能力。16. 用伪代码理解 Redisson 看门狗下面用接近伪代码的形式说明它的工作过程。16.1 加锁public void lock() { boolean success tryAcquire(); if (success) { scheduleWatchdog(); } }16.2 启动续期任务public void scheduleWatchdog() { long timeout 30000; long renewInterval timeout / 3; scheduler.schedule(() - { boolean renewed renewExpiration(); if (renewed) { scheduleWatchdog(); } }, renewInterval); }16.3 续期逻辑public boolean renewExpiration() { // 判断锁是否仍归当前线程所有 // 如果是则把 TTL 重置回 30000ms // 如果不是则返回 false }16.4 解锁public void unlock() { // 重入次数减一或删除锁 // 取消 watchdog 任务 }这不是 Redisson 源码原文但已经非常接近它的核心思想了。17. Redisson 看门狗与显式 leaseTime 的关系这一点特别容易混淆。17.1 场景 A不指定 leaseTimelock.lock();特点- 默认 30 秒过期- 但会自动续期- 适合执行时长不确定的任务17.2 场景 B指定 leaseTimelock.lock(60, TimeUnit.SECONDS);特点- 锁固定 60 秒后过期- Redisson 不续期- 适合明确知道业务不会超过 60 秒的场景如何选择如果业务执行时长**不稳定**优先考虑lock.lock();如果业务执行时长**有明确上限**并且你希望到时自动释放可以考虑lock.tryLock(waitTime, leaseTime, unit);18. Redisson 看门狗的优点18.1 适合长任务例如- 大批量数据处理- 复杂订单流程- 外部接口调用链较长- 可能受 GC、网络、数据库抖动影响的任务这类任务通常很难提前准确评估执行时长。看门狗可以避免因为 TTL 配小而导致锁提前失效。18.2 兼顾安全性和可恢复性如果简单地把 TTL 设很长会让故障恢复变慢。而看门狗的好处是- 正常运行时一直续期- 异常退出时停止续期- 锁最终自动释放18.3 对业务代码透明业务层只需要这样写lock.lock(); try { // do something } finally { lock.unlock(); }无需自己维护后台续期线程。19. Redisson 看门狗的局限与风险19.1 它不能解决所有一致性问题看门狗解决的是“锁过期时间不好估算”的问题。它并不能解决以下问题- Redis 主从切换导致锁丢失- 网络脑裂- 业务执行幂等性缺失- 持锁线程暂停太久- 锁语义与业务事务边界不一致19.2 GC 停顿可能仍然有影响如果 JVM 发生超长 Stop-The-World时间长到超过看门狗续期窗口仍然可能出现1. 应用本应续期2. 但因为长时间 GC 停顿没有及时发出续期请求3. 锁过期4. 被其他线程拿走所以分布式锁永远不能替代业务幂等控制。19.3 锁不是事务拿到锁并不等于你的业务一定完全安全。例如下单场景中即使加了锁也仍可能需要- 唯一索引- 状态机控制- 幂等 token- 数据库事务锁通常只是“并发控制的一层”不是最终一致性的全部保证。20. Java 项目中的典型使用场景20.1 防止重复创建订单同一用户短时间多次点击提交可能触发多次下单。这时可以按用户维度加锁String lockKey order:create: userId; RLock lock redissonClient.getLock(lockKey);20.2 定时任务多实例抢占多个服务实例都在跑同一个定时任务但希望同一时刻只有一个实例执行RLock lock redissonClient.getLock(job:daily:settlement);20.3 库存扣减串行化针对某个商品RLock lock redissonClient.getLock(stock:deduct: skuId);注意这里的锁只能降低并发冲突数据库层面通常仍要配合- 乐观锁- 条件更新- 防超卖约束21. Spring Boot 中推荐的封装方式很多团队不会让业务代码到处直接写 lock.lock()而是会做一层统一封装。例如可以封装一个工具方法public T T executeWithLock(String lockKey, SupplierT supplier) { RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new IllegalStateException(系统繁忙请稍后重试); } return supplier.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }或者再进一步做成 **注解 AOP**。下次更新aopredisson注解实现分布式锁这样做的好处是- 锁模板逻辑统一- 错误处理统一- key 生成规则统一- 减少业务误用22. 使用 Redisson 分布式锁的实践建议22.1 一定要在 finally 中释放锁推荐写法如下finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这样可以避免- 获取锁失败却去解锁- 锁已过期或已被释放后误解锁22.2 锁粒度不要过大比如不要把所有订单都锁成一把order:lock更合理的是按业务实体分段order:create:{userId} stock:deduct:{skuId} coupon:claim:{couponId}:{userId}22.3 锁 key 要有业务语义避免随便写成lock1 lock2建议使用清晰命名order:create:1001 inventory:update:sku_888 job:monthly:statement22.4 不要把分布式锁当万能方案如果核心问题本质上是数据库唯一性约束优先考虑- 唯一索引- 幂等号- 乐观锁- 去重表锁只是补充而不是替代一切。23. 面试或设计题中如何概括 Redisson 看门狗如果是在面试或系统设计题里你可以这样概括Redisson 的分布式锁默认带有看门狗机制。当调用 lock() 且没有显式指定 leaseTime 时Redisson 会先给锁设置一个默认过期时间默认值通常是 30 秒。随后客户端会启动一个定时续期任务通常每隔 10 秒检查一次如果发现锁仍由当前线程持有就把锁的过期时间重新刷新为 30 秒。这样只要业务线程还在运行锁就不会因为固定 TTL 到期而提前失效如果应用宕机或线程结束续期任务就会停止锁最终会自然过期释放。这个机制兼顾了长任务场景下的锁安全性和异常情况下的自动恢复能力。24. 一句话总结Redis 分布式锁的核心是- **原子加锁**- **唯一标识**- **原子释放**- **过期防死锁**而 Redisson 在此基础上进一步提供了- **可重入锁模型**- **线程级持有者识别**- **Lua 保证原子性**- **看门狗自动续期机制**其中看门狗的本质就是 **当业务执行时间不可预估时由客户端持续为仍在持有的锁刷新过期时间避免锁在业务未完成前提前失效。**25. 补充一个完整的 Java 使用示例import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import java.util.concurrent.TimeUnit; public class OrderService { private final RedissonClient redissonClient; public OrderService(RedissonClient redissonClient) { this.redissonClient redissonClient; } public void createOrder(Long userId) { String lockKey order:create: userId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException(请求过于频繁请稍后再试); } // 1. 校验用户状态 // 2. 校验库存 // 3. 创建订单 // 4. 扣减库存 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }如果希望利用看门狗而不是固定 30 秒自动释放可以改为public void createOrder(Long userId) { String lockKey order:create: userId; RLock lock redissonClient.getLock(lockKey); lock.lock(); try { // 执行时间不确定的业务 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这个版本没有显式指定 leaseTime因此 Redisson 会启用看门狗机制。26. 结语如果你刚开始接触 Redis 分布式锁建议先把下面这几件事彻底搞明白1. 为什么要用 SET NX PX2. 为什么锁的 value 必须唯一3. 为什么释放锁一定要用 Lua 保证原子性4. 为什么业务执行时间不确定时更适合用 Redisson 的看门狗机制把这几个问题吃透之后你对 Redis 分布式锁的理解就已经超过只会背概念的大多数人了。