尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Redisson分布式锁原理详解:加锁、续期与解锁机制

发布时间:2026/9/29 2:13:06

资讯中心
01
ARTICLE

Redisson分布式锁原理详解:加锁、续期与解锁机制

Redisson分布式锁原理详解:加锁、续期与解锁机制
先聊个真实的场景。你负责的支付服务在做活动库存只有100件结果订单却生成了120个。排查下来发现不是数据库问题而是三个应用实例同时读了库存、同时扣减、同时写回典型的并发覆盖。单机上的synchronized或ReentrantLock只能锁住当前 JVM 里的线程三个实例各锁各的根本拦不住跨进程的竞争。这时候你会想到用 Redis 做一把跨 JVM 的分布式锁。但自己用SETNX简单实现又会踩到锁误删、锁超时、不可重入、无法续期这些坑。Redisson 这个 Java 客户端的分布式锁方案恰好把这些问题都封装了底层用 Lua 脚本保证原子性再加一个看门狗机制自动续期是目前生产环境里用得最多的分布式锁实现之一。这篇文章我把 Redisson 分布式锁从加锁、续期到解锁的完整链路拆开讲清楚适合正在做分布式系统、准备面试或者被线上锁问题折磨过的同学。1. 为什么需要分布式锁先想清楚锁到底锁什么1.1 业务场景里的竞争发生在哪分布式锁不是万金油它的出现是因为多实例部署之后原本单机内的同步机制失效了。假设你有一个定时任务每天凌晨跑一次对账如果部署了三个节点三个节点会同时触发任务。数据库里可以用唯一索引兜底但很多场景没有这么恰到好处的约束比如两个线程同时给同一笔订单退款先读余额、再扣款、再写流水这个读改写的过程不保证原子就有超扣的风险。更常见的是库存扣减。用户下单代码大致是这样的查库存、判断库存大于零、减一、更新数据库。并发量一上来多个请求同时读到库存为1各自判断“大于零”各自减一最后都写回库存变成0而不是负数但订单却超卖了。关系型数据库的行锁能解决同一条记录的并发但前提是你把整个判断和更新放在一个事务里并且真的锁住了行。有些场景你没法靠数据库行锁比如操作 Redis 中的计数器、调用外部接口做幂等控制、多个服务协作处理同一份文件这时候就需要一把分布式的锁让多个进程在同一时刻只有一方能进入临界区。1.2 自己用 SETNX 实现锁的问题在哪很多人第一反应是Redis 有SETNX设成功就是抢到锁设失败就是没抢到。再配合EXPIRE设过期时间防止持有锁的进程挂了导致死锁。这套思路是对的但落地时会碰到几个非常具体的坑。第一个坑是锁误删。A 线程抢到锁业务执行超过了锁的过期时间锁自动释放了。此时 B 线程抢到锁开始执行。A 线程业务终于跑完调DEL删锁结果把自己刚设置的、实际上是 B 线程持有的锁删掉了。经典解法是在 value 里放一个唯一标识比如 UUID删除前先比对是自己设置的才删。但“检查再删除”是两步操作不是原子的依然有竞态。除非你写 Lua 脚本把“比对删除”做成一步。第二个坑是锁超时导致并发进入。业务执行时间不可控锁过期了但业务没跑完另一个线程就能拿到锁两个线程同时执行临界区分布式锁形同虚设。这时候你希望锁能自动续期业务没结束锁就不该过期。自己实现续期又是一个定时器加续期命令的组合还得处理并发安全。第三个坑是不可重入。一个线程在持有锁的情况下递归调用加锁逻辑或者同一个线程在一个锁内调用另一个需要同一把锁的方法SETNX会直接失败因为 key 已经存在。单机锁的重入是靠线程持有计数实现的Redis 锁要实现同样的效果得记录持有者信息和计数。这三个问题Redisson 全部用设计好的数据结构加 Lua 脚本解决了。下面拆解它具体是怎么做的。2. Redisson 的锁数据模型为什么用 Hash 而不是 String2.1 一个 Lock 对应一个 HashRedisson 里的分布式锁对象RLock用法和 JDK 的Lock很像lock()加锁unlock()解锁还支持tryLock。不过底层数据结构不是普通的 String而是 Redis 的 Hash 类型。假设锁的 key 是myLock那么 Redis 里存储的 Hash 结构是这样的myLock: { a3f2b1c9-6f2e-4c7a-9d8b-1d2e3f4a5b6c:12: 1 }Hash 的 field 是持有者标识由 UUID 加线程 ID 组成。UUID 是 Redisson 客户端启动时生成的全局唯一 ID线程 ID 是当前加锁线程在 JVM 内的线程 ID。Hash 的 value 是重入计数第一次加锁为 1同一线程再次加锁则加 1。为什么用 Hash 而不是简单的 String因为 String 只能存一个值没法记录“谁持有”和“重入了几次”。Hash 天然适合做计数。field 相当于锁的持有者凭证value 是重入次数解锁时每次减一减到 0 才真正删除 key。这里说一个识别锁持有者的细节。field 里包含线程 ID是为了区分同一个客户端里不同线程的加锁请求。如果只用 UUID同一 JVM 内两个线程在同一个客户端实例下加锁field 是相同的就无法区分了。加上线程 ID 后每个线程都有自己的 field重入和释放的判断就精确了。2.2 锁的过期时间与看门狗的默认值Redisson 加锁时如果不显式指定过期时间会启用看门狗机制。默认的锁超时时间是 30 秒这个值由lockWatchdogTimeout配置控制。看门狗会每 10 秒超时时间的三分之一检查一次如果锁还被当前线程持有就把过期时间重置为 30 秒。如果你调用的是lock(10, TimeUnit.SECONDS)这种带 leaseTime 的加锁方法Redisson 会认为你已经明确了业务最长时间此时不会启动看门狗锁在 10 秒后自动释放不管业务有没有跑完。这是最容易踩坑的地方带超时时间加锁业务实际执行时间超过锁时间锁就会提前释放。2.3 Redisson 客户端的连接语义Redisson 是一个基于 Netty 的 Redis 客户端它和 Jedis、Lettuce 最大的不同是它把 Redis 的底层数据结构封装成了一个个可复用的 Java 对象。RLock就是从RedissonClient里获取出来的Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient redisson Redisson.create(config); RLock lock redisson.getLock(myLock);每个RLock实例在客户端内部对应一个分布式锁的封装真正执行命令时通过同一个连接池和 Redis 通信。Redisson 的底层大量使用异步和命令批量执行这也是它能高效实现复杂 Lua 脚本调用的基础。3. 加锁与看门狗核心原理拆解3.1 加锁的 Lua 脚本到底做了什么Redisson 加锁的逻辑全部封装在 Lua 脚本里一次脚本调用完成所有检查和写入保证原子性。下面是简化后的加锁脚本以tryLock为例-- KEYS[1] 锁的 key如 myLock -- ARGV[1] 锁的过期时间毫秒默认 30000 -- ARGV[2] 持有者标识如 uuid:threadId if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end return redis.call(pttl, KEYS[1])逻辑分三步第一步检查 key 是否存在。不存在说明当前没有任何线程持有锁直接用HSET写入持有者信息和计数 1再用PEXPIRE设置过期时间返回 nil 表示加锁成功。第二步如果 key 存在检查当前线程的 field 是否已经在这个 Hash 里。如果在说明是同一线程重入HINCRBY把计数加一同时重置过期时间返回 nil。这是可重入锁的关键。第三步如果 key 存在但 field 不是当前线程的说明锁被别人持有返回当前锁的剩余过期时间毫秒调用方根据这个时间决定要不要等待。这段脚本的精髓在于把“检查”和“设置”放在同一个 Lua 脚本里执行。Redis 的 Lua 脚本执行是单线程的脚本执行过程中不会有其他命令插入所以整个加锁过程不存在竞态。自己写代码分两步走无论如何都会有间隙。3.2 WatchDog 自动续期的实现细节看门狗是 Redisson 分布式锁最亮眼的设计。它的存在是为了解决“业务没执行完锁已经过期”的经典问题。调用不带 leaseTime 的lock()方法时Redisson 会在成功加锁后启动一个后台定时任务。这个任务每隔lockWatchdogTimeout / 3默认 10 秒执行一次检查当前锁是否仍然存在且持有者还是当前线程如果是就用PEXPIRE把锁的过期时间重新设置为 30 秒。看门狗续期用的 Lua 脚本大致如下if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(pexpire, KEYS[1], ARGV[1]) return 1 end return 0这个脚本只做一件事确认锁还是自己的然后续期。如果锁已经被释放了比如手动解锁脚本返回 0后台任务就停止。看门狗的任务在 Redisson 内部是用 Netty 的Timeout和TimerTask实现的属于客户端行为而不是 Redis 服务端行为。这意味着即使 Redis 服务端没有提供任何特殊功能Redisson 也能通过客户端定时任务实现锁的自动续期。实际使用中要理解一个边界看门狗能续期的前提是持有锁的客户端进程还活着而且 JVM 里的定时任务还能正常执行。如果客户端进程长时间 GC 停顿或者网络分区定时任务本身可能无法按时执行锁依然会过期。好在锁过期最坏的结果是其他线程提前进入临界区不会造成死锁。3.3 可重入锁的实现机制可重入是现代锁的基本要求。你在一个事务方法里加了锁方法内部又调用了一个同样需要加锁的方法如果锁不可重入第二次加锁就会自己把自己锁死。Redisson 的可重入靠 Hash 的 value 实现。每次同一个线程加锁HINCRBY计数加一解锁时HDECR计数减一。只有计数减到 0才真正删除锁的 key。这里有一个非常容易忽略的点hincrby之后必须同时pexpire重置过期时间。因为重入意味着业务逻辑的执行时间在延长锁的过期时间也应该相应顺延。Redisson 的加锁脚本确实是这么做的每次重入都同步重置过期时间。还有一个细节是重入的深度由 value 记录但 value 是有上限的默认最大重入次数是 Integer.MAX_VALUE基本不用考虑这个限制。真正要注意的是解锁的次数必须和加锁的次数对应多解一次会导致计数异常甚至把别人持有的锁删掉少解一次会导致锁永远无法释放最终依赖过期时间兜底。4. 解锁与等待完整闭环怎么运转4.1 解锁的 Lua 脚本与持有者校验解锁比加锁更考验对细节的处理。Redisson 的解锁脚本同样用 Lua 封装保证校验和删除的原子性-- KEYS[1] 锁的 key -- KEYS[2] 发布订阅的 channel 名称 -- ARGV[1] 发布订阅的 message -- ARGV[2] 锁的过期时间 -- ARGV[3] 持有者标识 if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil end local counter redis.call(hincrby, KEYS[1], ARGV[3], -1) if (counter 0) then redis.call(pexpire, KEYS[1], ARGV[2]) return 0 else redis.call(del, KEYS[1]) redis.call(publish, KEYS[2], ARGV[1]) return 1 end解读一下这段脚本第一步检查 Hash 里是否存在当前线程的 field。如果不存在直接返回 nil表示当前线程不是锁持有者。这一步非常重要它杜绝了非持有者调用unlock()导致误删锁的可能。第二步HINCRBY把计数减一。如果减一后计数仍然大于 0说明还有重入的层数没有释放此时不删除 key只重置过期时间返回 0。第三步如果减一后计数变成 0说明锁已经没有任何重入层了直接DEL删除 key同时向KEYS[2]指定的 channel 发送一条广播消息通知等待这把锁的其他线程“锁已经释放了你们可以来抢了”。返回 1。这里重点看publish这一步。它有实际意义当锁被释放时正在等待的线程不需要轮询或等到超时才重新尝试加锁可以通过订阅 channel 立即收到通知马上发起抢锁。这是 Redisson 锁等待性能较好的核心原因之一。4.2 lock 和 tryLock 的等待机制Redisson 的lock()和tryLock()在获取锁失败时行为不同。lock()会阻塞等待直到拿到锁为止没有超时时间限制。tryLock(long waitTime, long leaseTime, TimeUnit unit)则会在等待waitTime之后主动放弃防止线程无限期挂起。等待过程的实现很有意思。Redisson 在获取锁失败时不会简单地sleep然后重试而是先计算当前锁的剩余存活时间从 Lua 脚本返回的pttl然后订阅锁释放的 channel通过 Java 的信号量机制阻塞在Semaphore上。当锁被释放时publish的消息触发订阅监听器信号量被释放线程被唤醒再次尝试加锁。实际源码里用的是RPromise和Semaphore的组合。加锁失败时返回一个带超时的异步结果同时注册一个释放事件的监听器真正阻塞的是 JVM 内的线程而不是 Redis 命令。这种设计避免了大量线程空转轮询 Redis对 Redis 服务端的压力小很多。这里有个值得注意的细节tryLock设置了leaseTime时锁的自动释放时间是你指定的值看门狗不会启动。如果你业务执行时间超过leaseTime锁会在业务跑完前释放导致其他线程进入。所以使用tryLock时leaseTime必须大于预估的最大业务执行时间。4.3 锁的公平性与非公平性Redisson 默认的锁是非公平锁即新来的线程和等待中的线程一起竞争没有先来后到的概念。非公平锁的好处是吞吐量高因为不需要维护等待队列抢锁成本低。但在某些严格要求公平的场景比如资源分配严格按请求顺序非公平锁可能让等待很久的线程一直抢不到锁。Redisson 提供了RedissonFairLock通过一个有序集合记录等待者的顺序加锁时严格按照排队顺序分配。公平锁的实现更复杂底层依赖 Redis 的 ZSet 结构和额外的 Lua 脚本性能比非公平锁低但在业务上能保证“先到先得”。生产环境用非公平锁的就足够绝大多数业务场景不需要公平性。我的建议是默认用lock()追求高吞吐用非公平锁只有明确要求请求顺序一致时才用公平锁。5. 高可用场景分布式锁的几个真实隐患5.1 主从切换导致锁丢失Redis 主从架构下如果锁写入主节点主节点还没来得及同步到从节点就宕机了此时从节点被提升为主节点锁数据丢失其他线程可以重新加锁仍然会出现并发问题。这个问题本质上不是 Redisson 能解决的因为它是 Redis 主从异步复制的固有限制。Redisson 之父提出的 RedLock 算法就是为此设计的向多个独立的 Redis 节点申请加锁超过半数成功才算加锁成功。但 RedLock 在业界一直有争议因为它在极端情况如 GC 停顿、时钟跳跃下依然可能失效。实际生产中一般业务用单节点 Redis 加 Sentinel 高可用就够。如果你的业务对锁的可靠性要求极高比如涉及资金的强一致场景优先考虑用数据库行锁或者 ZooKeeper/Etcd 的分布式锁而不是在 Redis 上纠结主从切换。这取决于你对“极少发生但后果严重”的接受度。5.2 锁超时时间的设置原则看门狗自动续期时默认 30 秒的超时时间通常够用。但如果你显式指定了leaseTime这个值的设置要基于业务的 P999 执行时间而不是平均执行时间。拿一次外部接口调用来说平均 200ms但线上可能出现 5 秒的毛刺如果你把leaseTime设为 500ms锁会频繁提前释放。一个可参考的做法是先统计业务临界区的最大执行时间加上 20% 到 50% 的缓冲作为leaseTime。同时设置tryLock的waitTime比如 3 秒即最多等 3 秒拿不到锁就快速失败返回避免线程大量堆积。这里的关键是leaseTime和waitTime是两个维度前者是持锁时间上限后者是等待时间上限不要混淆。5.3 锁粒度与并发性能分布式锁如果粒度太粗比如整个用户维度加一把锁会导致大量无关请求互相阻塞如果粒度太细比如按订单的每个明细项加锁锁的数量会暴涨管理成本大幅上升。我见过一个典型的案例一个退款接口用订单号作为锁的 key结果同一个用户的多个订单退款要排队执行因为 code 里用的是用户 ID 加锁。后来改成“订单号退款类型”作为 key并发立刻上去了。锁的粒度本质上是对并发度和安全性的权衡。建议按业务资源的自然边界来定 key比如库存扣减按 SKU 加锁订单操作按订单号加锁账户操作按账户 ID 加锁。同时注意锁 key 必须有业务语义不能是一个无意义的递增 ID否则排查问题时根本不知道锁的是哪笔业务。5.4 锁内做远程调用的风险这是个老生常谈但永远有人踩的坑。拿到分布式锁后在锁内调用外部 HTTP 接口如果外部接口响应缓慢锁的持有时间被拉长其他线程一直拿不到锁。更糟的是如果外部接口又调用了你的服务形成死循环依赖锁永远无法释放。加锁的临界区应该只包含真正需要互斥的操作。比如库存扣减临界区只是“读库存、判断、减一、写回”这几个内存或数据库操作其他业务逻辑发短信、通知、写审计日志应该放在锁外面。这样锁的持有时间能压缩到毫秒级。如果业务逻辑实在无法拆分且执行时间不可控那就要配合看门狗自动续期并设置合理的leaseTime同时监控锁的持有时间超过阈值就报警。6. 面试追问与避坑清单这些细节能让你和面试官深度对话6.1 面试高频追问与考察点“Redisson 分布式锁的实现原理”是一个覆盖面很广的面试题面试官通常会从表面问到细节再到扩展。核心追问包括第一为什么用 Lua 脚本这个问题考察的是对原子性的理解。Redis 的 Lua 脚本在服务端是单线程执行的脚本运行期间不会插入其他命令所以多个命令打包在脚本里天然原子。这也是 Redisson 解决“检查再操作”竞态的根本手段。第二看门狗续期的原理要能说清楚默认 30 秒每隔 10 秒续期续期是客户端定时任务通过 Lua 脚本重置过期时间前提是锁还被当前线程持有。第三可重入是怎么实现的回答核心是 Hash 结构加计数field 是 UUID线程 IDvalue 是重入次数加锁加一解锁减一减到零才删 key。第四锁误删的防护机制强调解锁 Lua 脚本里先校验hexists只有持有者才能删锁从根本上杜绝误删。第五主从切换锁丢失怎么解决这部分可以谈 RedLock 的局限也可以说实际工程中怎么权衡。面试官往往更看重你是否意识到这个问题的存在而不是要求你一定用 RedLock。6.2 使用时的几个典型坑第一个坑是忘记在 finally 中解锁。lock()成功之后如果业务中间抛出异常导致没有执行unlock()锁会一直持有到过期。即使有看门狗续期也要在finally中解锁因为正常流程下锁应该被及时释放而不是等过期兜底。RLock lock redisson.getLock(myLock); lock.lock(); try { // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }第二个坑是tryLock返回 false 之后的处理。很多新手拿到 false 就直接抛异常导致大量请求失败。更合理的做法是区分“重试几次仍失败”的降级策略比如返回缓存数据、异步化、或者提示稍后重试。第三个坑是 Redisson 连接配置不当。默认的连接池是 24 个连接高并发场景下可能不够用。如果观察到 Redis 连接等待时间过长检查连接池配置适当调大。但也要注意连接数不是越大越好连接太多 Redis 服务端压力会增大。第四个坑是锁的 key 过期时间与业务时长的匹配。我之前遇到过一个问题某个定时任务批量处理数据单个任务跑完需要约 2 分钟但代码里用的是lock(30, TimeUnit.SECONDS)结果每 30 秒锁就释放一次多个节点交替进入任务导致数据重复处理。后来换成不带 leaseTime 的lock()配合看门狗问题才解决。另一个我在实际排查中遇到的案例更隐蔽一个服务中同时使用了两个 RedissonClient 实例分别连接不同环境的 Redis然后两个实例同时获取同一把锁。每个实例都有自己的看门狗定时任务但锁的 key 和 channel 都指向不同的 Redis两边各持一把锁业务照样互相干扰。排查时看 Redis 里的 key 才能发现问题。所以规划锁时一定要统一 Redis 实例和 key 的命名空间不要用两套配置去访问同一个业务锁。7. Redisson 与原生 Redis 命令实现分布式锁的对比把 Redisson 和纯命令行的SET key value NX EX放一起对比能更清晰地看出封装的价值。能力维度原生 SETNX EXPIRERedisson RLock加锁原子性单条命令原子Lua 脚本原子锁误删防护需自行比对 value内置持有者校验可重入不支持Hash 计数支持续期不支持看门狗自动续期等待通知需自行轮询Pub/Sub 通知锁超时设置固定过期时间支持动态与固定公平锁无可选公平锁实现原生SETNX适合非常简单的互斥场景比如防止重复提交这种允许几十毫秒误差的操作。但一旦涉及真正的资源竞争Redisson 提供的原子性、可重入、自动续期和释放通知能让你的代码简洁一个数量级而且把分布式锁最容易出错的那几个点都挡住了。8. 公平锁、读写锁等扩展方案锁不止一种形态Redisson 除了最常用的RLock还提供了多种分布式锁的变体。RedissonReadWriteLock在读多写少的场景下很实用。读写锁的核心是读锁和读锁之间可以共存读锁和写锁互斥写锁和写锁互斥。Redisson 为读锁和写锁分别维护两个 Hash 结构读锁记录持有者信息和读计数写锁记录持有者信息和写计数读写之间的关系通过 Lua 脚本判断和阻塞。RedissonFairLock前面提到过它用 ZSet 维护加锁请求的排队顺序。值得说的是它的实现细节每个请求在 ZSet 中有一个分数分数是请求时间戳代表排队次序加锁时检查 ZSet 头部是否有属于当前线程的请求有则获取锁没有则等待。公平锁在业务上很少用到但在需要保证请求处理顺序一致的合规场景中是绕不开的选择。RedissonMultiLock用来同时获取多把锁全部成功才算成功。这个能力在跨多个业务资源的场景中有意义比如同时操作 A 资源和 B 资源要对两个 key 同时加锁避免死锁和资源不一致。但使用 MultiLock 时要特别注意获取锁的顺序一致性否则可能造成交叉等待。9. 工程实践中的调优与监控分布式锁不能只写代码不监控。我建议至少监控三个指标锁获取失败率、锁持有时间、锁等待时间。锁获取失败率高说明竞争激烈或waitTime太短锁持有时间异常说明临界区存在慢操作等待时间长说明锁粒度可能太粗。监控锁持有时间可以用 AOP 统一封装加锁逻辑在锁内记录时间超过阈值打印日志。Redisson 的RLock也提供了getHoldCount()方法可以查询当前线程持有锁的次数在调试时很有用。调优方面有几个可以落地的点第一选择正确的锁类型大部分场景用非公平锁第二合理设置waitTime避免线程无限等待建议设置一个业务可接受的等待上限比如 3 到 5 秒第三避免锁内调用远程服务和慢 SQL第四确保 Redisson 连接池有足够的连接数避免连接等待成为瓶颈。最后分享一个我在实际项目中总结的小技巧给锁的 key 加上业务前缀和租户信息比如lock:order:refund:10001。这样不仅方便排查问题时定位是哪一类业务也可以防止多租户场景下的 key 冲突。另外在压测前一定要确认锁配置的 Redis 实例和业务 Redis 是同一个否则锁本身就成了另一个高可用隐患。我自己用下来的体会是Redisson 的分布式锁设计非常成熟它把分布式锁最容易出错的原子性、误删、续期、等待通知都处理好了。你能做的是把业务上的锁粒度、超时时间和监控做好剩下的事情交给它。需要注意的是没有哪把分布式锁能解决所有问题。锁的本质是把并发问题串行化副作用是牺牲吞吐量。能用数据库乐观锁解决的就不要引入分布式锁能用消息队列削峰的就不要让锁承受所有压力。分布式锁是最后的兜底手段而不是优先选项。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。