news 2026/10/26 13:00:09

Redisson分布式锁源码剖析:从加锁到红锁的实战避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redisson分布式锁源码剖析:从加锁到红锁的实战避坑指南

分布式锁这个话题,只要你的系统里出现过“多个服务实例同时改同一份数据”的场景,就绕不开它。我最早接触分布式锁是在一个订单状态流转的服务里,当时两个实例同时收到同一笔订单的支付回调,结果状态被改了两次,库存扣了两遍,排查了大半天才定位到是并发写入没做互斥。后来试过用数据库唯一索引、用 SETNX 手写锁,最后稳定下来的方案还是基于 Redis 的 Redisson。这篇就围绕 Redisson 的分布式锁实现,把源码里那些真正影响你线上稳定性的细节掰开讲清楚——它到底怎么加锁、怎么续期、怎么释放、可重入是怎么做到的、红锁又解决了什么问题。适合已经用过 Redis、但对其锁机制只停留在“调个 API”层面的后端同学,也适合正在选型分布式锁方案、想搞清楚底层逻辑再决定用不用的人。

1. 为什么手写 SETNX 的锁迟早会出问题

很多人第一次实现分布式锁,都是SET key value NX EX 30一把梭,加锁成功就执行业务,执行完删 key。这个思路本身没错,Redis 的单线程模型保证了 SETNX 的原子性,看起来挺美。但只要业务稍微复杂一点,问题就会一个接一个冒出来。

1.1 三个最典型的翻车场景

第一个场景是锁过期了业务还没跑完。你设了 30 秒过期,结果某个请求因为慢查询或者下游接口抖动跑了 40 秒,锁在第 30 秒自动释放,第二个请求立刻拿到锁进来,两个线程同时操作共享资源,互斥直接失效。更糟的是,第一个请求跑完后还会去删 key,把第二个请求刚加的锁给删了——这就是经典的“误删他人锁”。

第二个场景是删除锁时没有校验持有者。上面说的误删,本质就是删之前没确认这把锁是不是自己的。正确做法是 value 存一个唯一标识(比如 UUID + 线程 ID),删之前先 get 比对,一致才删。但 get 和 del 是两条命令,中间有间隙,严格来说还得用 Lua 脚本保证原子性。

第三个场景是不可重入。同一个线程在业务里嵌套调用另一个需要同一把锁的方法,第二次加锁直接失败,因为 key 已经存在了。你可能会说那我在外层加一次不就行了,但实际业务里调用链一深,很难保证每层都清楚上层有没有加锁,可重入几乎是刚需。

提示:这三个问题不是理论推演,是真实线上会遇到的。尤其是锁过期,只要你的业务里有任何一次耗时超过锁 TTL 的操作,就一定会踩到。

1.2 Redisson 到底替你兜住了哪些底

Redisson 的价值不在于“帮你封装了 SETNX”,而在于它把上面这些坑全部在框架层解决了,并且解决得很彻底。它做的事情包括:用 Lua 脚本保证加锁和释放的原子性、用 Hash 结构存“锁持有者 + 重入次数”实现可重入、用后台定时任务做锁续期(看门狗)、用发布订阅机制避免无效自旋。这些机制组合起来,才构成一个能在生产环境用的分布式锁。

我个人的判断标准很简单:如果你的锁只需要保护一个几十毫秒内完成的简单操作,手写 SETNX 加个 Lua 删除也能凑合;但只要涉及业务逻辑、调用链、不确定耗时,就别自己造轮子了,直接用 Redisson。下面几节我会把它的源码逻辑拆开,让你知道它每一步在干什么,这样出问题时你才有能力定位,而不是只能重启试试。

2. 加锁入口:一条 tryLock 调用背后发生了什么

Redisson 加锁最常用的入口是RLock.lock()或tryLock(),看起来就是一行代码,但底层从获取锁对象到真正写入 Redis,中间经过了好几层。理解这条链路,是读懂后续所有机制的前提。

2.1 从 getLock 到 RedissonLock 对象

你调用redissonClient.getLock("order:lock:123")时,Redisson 并不会立刻去 Redis 做什么,它只是创建了一个RedissonLock对象,把锁名字存起来。真正的动作发生在lock()或tryLock()调用时。这个设计很关键——锁对象是轻量的,可以反复获取,真正的状态都在 Redis 里。

RedissonLock继承自RedissonBaseLock,核心字段包括锁名称、命令执行器(负责和 Redis 通信)、以及一个pubSub订阅器(后面讲等待机制会用到)。加锁时它会先算出锁在 Redis 里的实际 key,默认是锁名前加固定前缀,比如order:lock:123最终存成类似redisson_lock:order:lock:123的形式。这个前缀可以配置,多套业务共用一个 Redis 时建议区分开,避免 key 冲突。

2.2 lockInterruptibly 里的三段式逻辑

lock()最终会走到lockInterruptibly(-1, null),这个方法体不长,但信息量很大,可以拆成三段看。

第一段是尝试加锁,调用tryAcquire。如果返回的 TTL 为 null,说明加锁成功,直接返回。第二段是加锁失败后的订阅,如果 TTL 不为 null,说明锁被别人占着,此时它会订阅这个锁的释放频道,拿到一个 future。第三段是循环等待,在一个for(;;)里不断检查自己是否已经拿到锁(通过future.get()阻塞等待释放通知),拿到就退出循环,没拿到就继续等。

这里有个细节值得说:它没有用while(true)死循环去疯狂重试加锁,而是用发布订阅来等待。锁被释放时,持有者会往对应频道 publish 一条消息,等待者收到通知后再去尝试加锁。这样做的好处是等待期间不消耗 Redis 的 QPS,避免了大量客户端自旋把 Redis 打满。这是 Redisson 相比手写轮询方案的一个明显优势。

2.3 tryAcquire 里的 Lua 脚本逐行拆解

真正写 Redis 的是tryAcquire里的那段 Lua 脚本,这是整个加锁逻辑的核心,必须逐行看懂。脚本大致逻辑如下:

-- KEYS[1] 锁的 key -- ARGV[1] 锁过期时间 -- ARGV[2] 锁持有者标识(UUID:线程ID) if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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 不存在,说明没人持有,直接hincrby把持有者字段的值设为 1,并设置过期时间,返回 nil 表示成功。第二段判断 key 存在且当前持有者字段也存在,说明是同一个线程重入,把重入次数加 1,刷新过期时间,同样返回 nil。第三段是 key 存在但持有者不是自己,返回当前锁的剩余 TTL,让上层知道还要等多久。

用 Hash 而不是 String 存锁,就是为了支持可重入——field 是持有者标识,value 是重入次数。这个设计很巧妙,一个 key 同时表达了“谁持有”和“持有几次”两个信息。过期时间每次重入都会刷新,保证重入期间锁不会提前过期。

3. 看门狗机制:锁续期到底是怎么自动完成的

锁过期是手写方案最大的痛点,Redisson 用看门狗(watchdog)机制解决了它。但很多人对看门狗的理解停留在“它会自动续期”,具体怎么触发、续多久、什么情况下不续,并不清楚。这块搞不明白,线上很容易出现“以为有看门狗结果锁还是过期了”的情况。

3.1 续期的触发条件:leaseTime 必须为 -1

看门狗不是无条件工作的,它有一个明确的触发条件:加锁时没有显式指定 leaseTime。也就是说,你调lock()不传参数,或者tryLock()传的 leaseTime 是 -1,才会启用看门狗。如果你自己指定了lock(30, TimeUnit.SECONDS),那 Redisson 认为你清楚自己要锁多久,不会帮你续期,到点就释放。

这个设计逻辑是合理的:显式指定过期时间意味着你愿意为业务耗时兜底,框架不该擅自延长。但实际使用中很多人踩坑就踩在这——随手写了个tryLock(10, TimeUnit.SECONDS),结果业务跑了 15 秒,锁提前释放,还以为 Redisson 有 bug。所以我的建议是:除非你非常确定业务的最大耗时,否则一律用不带 leaseTime 的lock(),让看门狗兜底。

3.2 续期任务的调度与执行细节

当 leaseTime 为 -1 时,加锁成功后会调用scheduleExpirationRenewal启动续期任务。这个任务不是每个锁一个线程,而是通过一个ConcurrentHashMap记录所有需要续期的锁,配合一个定时调度器统一管理,避免线程爆炸。

续期的周期是internalLockLeaseTime / 3,默认 internalLockLeaseTime 是 30 秒,所以每 10 秒续一次,每次把过期时间重置回 30 秒。为什么是三分之一?因为这样即使某次续期因为网络抖动失败了,还有两次重试机会,不至于锁直接过期。这个比例是经过权衡的,续太频繁浪费资源,续太稀疏容错不够。

续期任务本身也是用 Lua 脚本执行的,逻辑是:检查 key 是否还存在、持有者是否还是自己,如果是就把过期时间重置,否则说明锁已经不属于自己了,停止续期。这里有个关键点——续期任务会检查持有者,如果锁因为某种原因被释放或被别人抢走,续期会失败并终止,不会出现“给别人的锁续期”这种荒唐情况。

3.3 看门狗失效的几种真实情况

看门狗不是万能的,有几种情况它救不了你。第一种是Redis 主从切换:锁写在主节点,还没同步到从节点主就挂了,从节点升主后锁丢了,看门狗续期时发现 key 不存在,续期失败。这是 Redis 异步复制的固有问题,Redisson 的红锁就是为缓解这个设计的,后面会讲。

第二种是客户端进程被 kill 或长时间 GC:续期任务跑不起来,锁到期自动释放。这种情况其实符合预期——客户端都失联了,锁本来就该释放,否则会死锁。第三种是显式指定了 leaseTime,前面说过,不续期。所以看门狗能兜住的是“客户端活着但业务耗时超预期”的场景,兜不住“客户端失联”和“Redis 本身故障”,这两类要靠其他机制。

注意:看门狗的续期是客户端行为,不是 Redis 服务端行为。客户端一旦和 Redis 断连超过锁 TTL,锁就会释放,这是设计使然,不要指望它能在网络分区时保住锁。

4. 释放锁与可重入:hincrby 减到零才算真释放

释放锁的逻辑比加锁简单,但同样有讲究。很多人以为释放就是 del key,实际上 Redisson 的释放是“重入次数减一”,减到零才真正删除 key。这个设计直接决定了可重入的正确性。

4.1 释放锁的 Lua 脚本逻辑

释放锁同样走 Lua 脚本,核心逻辑是:先判断 key 是否存在、持有者字段是否存在,如果不存在说明锁不是自己的(或者已经过期),直接返回 nil,什么都不做。如果存在,就hincrby把重入次数减 1,然后判断减完之后是否大于 0,大于 0 说明还有外层重入没释放,刷新一下过期时间即可;等于 0 说明这是最外层,直接del删除 key,并 publish 一条释放消息通知等待者。

这个逻辑保证了嵌套加锁的场景:外层加锁重入次数为 1,内层加锁变 2,内层释放减到 1(不删 key),外层释放减到 0(删 key)。如果内层释放时直接 del,外层还在用锁就没了,互斥就破了。所以“减到零才删”不是可有可无的优化,是可重入语义的基石。

4.2 为什么释放必须校验持有者

释放脚本里那个“持有者字段是否存在”的判断,就是防止误删他人锁的关键。假设线程 A 的锁过期了,线程 B 拿到了锁,此时 A 业务跑完来释放,如果脚本不校验持有者,A 就会把 B 的锁删掉。有了校验,A 发现 field 不是自己,直接返回,B 的锁安然无恙。

这里有个容易忽略的点:锁过期后,A 的释放操作会静默失败,不会报错。这意味着如果你的业务依赖“释放成功”来做某些后续动作,可能会出问题。实际使用中,释放锁失败通常意味着锁已经不属于你了,业务上应该视为异常情况处理,而不是当作正常流程。我在项目里会在释放后检查一下返回值,虽然 Redisson 的unlock()不直接返回这个信息,但可以通过日志或者监控锁的持有状态来间接发现。

4.3 强制释放与普通释放的区别

Redisson 除了unlock(),还提供了forceUnlock()。区别在于:unlock()会校验持有者,不是自己的锁不释放;forceUnlock()不校验,直接把 key 删掉,不管谁持有。这个方法要慎用,它绕过了所有安全校验,只适合在明确知道锁已经僵死、需要人工干预的场景,比如运维排查时确认某个业务已经挂了,手动清锁。日常业务代码里绝对不要用,否则等于把互斥保护拆了。

5. 红锁:多节点部署下如何对抗主从切换丢锁

单节点 Redis 加锁有个绕不过去的缺陷:主从异步复制。锁写主节点成功,主节点还没同步就宕机,从节点升主后锁没了,另一个客户端就能拿到同一把锁。Redisson 的红锁(RedLock)就是为缓解这个问题设计的,虽然它也不是银弹,但理解它的思路对选型很有帮助。

5.1 红锁的加锁流程与多数派原则

红锁要求部署多个独立的 Redis 节点(通常 5 个),加锁时客户端依次向所有节点发起加锁请求,每个节点独立加锁。只有当超过半数节点(比如 5 个里的 3 个)加锁成功,并且总耗时小于锁的有效期,才认为加锁成功。如果成功节点数不够半数,或者耗时超了,就对所有节点发起释放,回滚这次加锁。

这个多数派原则的思路和分布式共识算法类似:只要半数以上节点持有锁,即使少数节点故障或切换,锁的状态依然能被多数派保证。它对抗的正是“单节点主从切换丢锁”的问题——单节点丢了,其他节点还在,多数派还在,锁就没丢。

5.2 红锁的争议与适用边界

红锁不是没有争议。业界对它的讨论主要集中在两点:一是它依赖各节点时钟大致同步,如果某个节点时钟跳变,可能导致锁提前过期;二是它的加锁耗时比单节点高,因为要串行请求多个节点,对延迟敏感的场景不太友好。

我的实际经验是:大多数业务场景用单节点 Redisson 锁就够了,因为主从切换丢锁的概率本身不高,而且很多业务对锁的强一致性要求没那么极端。红锁适合那种“丢锁后果很严重、且能接受额外延迟”的场景,比如资金类的关键操作。选型时不要盲目上红锁,先评估你的业务对丢锁的容忍度,再决定要不要付出多节点部署和额外延迟的代价。

5.3 红锁配置的实操要点

如果确实要用红锁,配置上有几个点要注意。第一,节点数量建议奇数个,5 个是比较常见的配置,3 个的话容错能力偏弱。第二,各节点要独立部署,不能是同一台机器上的多个实例,否则机器挂了全挂,失去多节点意义。第三,锁的有效期要留足余量,因为红锁加锁要串行请求多个节点,耗时比单节点长,有效期设太短容易在加锁过程中就过期。

// 红锁的典型配置方式(示意) RedissonClient client = Redisson.create(config); RLock lock = client.getLock("biz:lock:key"); // 红锁对象通过 RedissonRedLock 组合多个 RLock

实际代码里红锁的用法和普通锁类似,只是底层会协调多个节点。需要提醒的是,红锁的tryLock语义和单节点略有差异,等待和重试的行为要结合具体版本文档确认,升级 Redisson 版本时这块要重点回归测试。

6. 实战中那些文档不会写的坑

源码看懂了,不代表线上就不出问题。下面这几个坑是我和身边同行实际踩过的,文档里基本不会提,但每一个都可能让你的锁失效或者性能骤降。

6.1 锁粒度过粗导致的性能塌方

最常见的性能问题不是锁本身慢,而是锁的粒度设计错了。比如用一把全局锁保护所有订单操作,所有请求串行化,QPS 直接掉到个位数。正确做法是按业务主键分锁,比如order:lock:{orderId},不同订单互不影响。这个道理大家都懂,但实际写代码时很容易图省事用一把大锁,等到压测才发现问题。

判断粒度是否合适有个简单方法:看这把锁保护的操作,是否只涉及同一个业务实体。如果涉及多个实体,要么拆成多把锁,要么重新审视业务逻辑是否真的需要这么强的互斥。另外,锁的 key 设计要避免热点,比如按用户 ID 分锁时,如果某个大 V 用户请求量特别大,他的锁就会成为热点,这种情况要考虑是否真的需要按用户维度加锁。

6.2 锁等待超时与业务超时的混淆

tryLock(waitTime, leaseTime, unit)里有两个时间参数,很多人分不清。waitTime 是等待获取锁的最长时间,超时没拿到锁就返回 false;leaseTime 是拿到锁后的持有时间,到点自动释放。这两个超时的语义完全不同,配错了后果也不一样。

waitTime 设太短,高并发下大量请求拿不到锁直接失败,业务成功率下降;设太长,请求堆积,线程池被占满。leaseTime 设太短,业务没跑完锁就释放,互斥失效;设太长,客户端挂了锁迟迟不释放,其他请求干等。我的经验是:waitTime 根据业务能容忍的排队时间来定,通常几百毫秒到几秒;leaseTime 尽量不设,交给看门狗,如果非要设,至少是业务 P99 耗时的 3 倍以上。

6.3 Redis 连接池与锁等待的相互影响

这个坑比较隐蔽:锁等待期间,客户端会保持一个订阅连接等待释放通知。如果 Redis 连接池配置过小,大量锁等待请求会把连接池占满,导致其他正常命令也拿不到连接,整个服务雪崩。表现就是明明 Redis 没挂,但所有 Redis 操作都超时。

解决办法是给锁相关的操作单独规划连接,或者确保连接池足够大。Redisson 的连接池配置里,connectionPoolSize和subscriptionConnectionPoolSize要分开考虑,后者专门给订阅用。高并发锁场景下,订阅连接数要留足,否则等待中的请求会互相挤占。这个参数调优没有固定值,要根据实际并发量和压测结果来定,但原则是宁可多留一些余量。

6.4 锁与事务、异步的边界问题

最后一个坑是语义层面的:锁的保护范围必须覆盖整个临界区。我见过有人在方法上加锁,但方法内部又开了异步线程去操作共享资源,异步线程跑的时候锁早就释放了,互斥形同虚设。还有人把加锁放在事务外层,事务提交是异步的,锁释放了事务还没提交,其他请求读到旧数据。

正确的做法是:锁的范围要严格包住所有对共享资源的读写,包括异步操作和事务提交。如果业务里有异步,要么把异步操作也纳入锁的保护范围(比如在异步任务里重新加锁),要么重新设计,避免异步和锁混用。这个原则说起来简单,但代码一复杂就容易破,review 时要特别留意锁的边界。

7. 从源码回到选型:什么场景该用 Redisson 锁

把源码和实战坑都过了一遍,最后回到一个实际问题:你的项目到底该不该用 Redisson 分布式锁。我的看法是分场景看,不要一刀切。

如果你的系统是单机部署,或者共享资源只在单进程内访问,那根本不需要分布式锁,本地锁(比如 JDK 的 ReentrantLock)性能好得多,别为了用而用。如果是多实例部署且确实存在跨进程的共享资源竞争,那分布式锁是刚需,Redisson 是目前 Java 生态里比较成熟的选择,它的可重入、看门狗、等待通知机制都经过大量生产验证。

但也要清楚它的边界:它保证的是“大多数情况下”的互斥,不是强一致性的分布式事务。Redis 主从切换、网络分区这些极端情况,它只能缓解不能根除。如果你的业务对丢锁零容忍,比如涉及资金扣减的强一致场景,那应该考虑基于数据库或者共识算法的方案,而不是把宝全押在 Redis 锁上。Redisson 锁更适合“丢锁会导致重复处理但可以通过幂等兜底”的场景,配合业务层的幂等设计,才能构建真正可靠的并发控制。

我在实际项目里的做法是:核心链路的锁用 Redisson,同时业务层做幂等(比如用唯一业务号去重),双保险。这样即使锁在极端情况下失效,幂等也能兜住重复操作,不至于造成数据错乱。锁是手段不是目的,最终要保证的是数据正确,想清楚这一点,选型和用法就不会跑偏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 12:59:52

libigl 构建指南:从源码编译到 OpenGL 可视化实战

简介:本资源为经CMake成功构建后的libigl几何处理库完整工程包,面向图形学研究者、计算机辅助设计(CAD)开发者及三维几何算法学习者,解决C项目中快速集成高质量几何处理能力的痛点。压缩包共2000个文件,总大…

作者头像 李华
网站建设 2026/10/26 12:59:46

AI热词日报:从Agent搭建到模型部署,把热搜变成实践

今天是2026年10月2日。我照例把今天的AI圈热词拉出来看了一遍,一眼扫过去,既有“无限制AI聊天”“无审核生成”这类靠关键词吸流量的词,也有“AI Agent搭建”“AI模型部署”“AI工程实践”这种实打实的内容。今天的日报,我打算换一…

作者头像 李华
网站建设 2026/10/26 12:58:57

从鹦鹉学舌到AI偏见:150期翻译沉淀的技术认知与学习路径

1. 第150期:一个翻译系列背后的AI内容版图做了这么久的TowardsArtificialIntelligence博客中文翻译,不知不觉已经整理到第一百五十期了。对这个系列稍有了解的读者应该知道,它追踪的不是某个单一产品的更新日志,而是人工智能这个技…

作者头像 李华
网站建设 2026/10/26 12:58:33

2026-2030工控AI趋势:从边缘应用到核心决策链的落地路径

过去几年,工控AI给人留下的印象一直是“概念很响、落地很少”:展会翻来覆去就是设备缺陷识别、能耗优化、视觉质检这几张老面孔,真正敢让AI介入控制决策的企业屈指可数。但从我最近跑的现场和跟进的研究方向来看,风向正在变——从…

作者头像 李华
网站建设 2026/10/26 12:57:46

m4s分片解析与fMP4音视频合并:MP4Box.js到ffmpeg

1. 先弄明白 m4s 到底是个什么东西1.1 前端工程师为什么绕不开 m4s 这套格式标题里挂着“前端甜点”,那我就从前端的视角切入。这几年做播放器相关的需求,越来越难绕开 m4s 这类文件。原因不复杂:主流的长视频、课程、直播回放类站点&#xf…

作者头像 李华
网站建设 2026/10/26 12:57:14

PPO算法调参困境与系统化解决方案

1. 项目背景与问题现象 最近在算法优化圈子里,一个现象引发了不少讨论:很多团队在使用PPO(Proximal Policy Optimization)算法进行项目优化时,随着调参次数增加,反而越来越不敢将模型部署上线。这种现象在强…

作者头像 李华