Redisson分布式锁深度解析:从设计原理到源码与实战

发布时间:2026/10/26 4:41:01
Redisson分布式锁深度解析:从设计原理到源码与实战 1. 为什么分布式锁这件事绕不开Redis做后端开发的朋友大概都有过这样的经历某个接口在单机环境下测得好好的一上集群就出幺蛾子库存扣成负数、优惠券被重复领取、定时任务被多个节点同时触发。排查半天发现是并发写共享资源惹的祸于是你开始琢磨加锁。单机时代一个synchronized或者ReentrantLock就搞定了可一旦服务拆成多个实例部署JVM 级别的锁就彻底失效了——每个进程各锁各的谁也管不着谁。这时候就需要一把“跨进程”的锁也就是分布式锁。它的核心诉求很朴素在分布式系统里多个节点争抢同一份资源时保证同一时刻只有一个节点能拿到操作权。实现分布式锁的路子有好几条基于数据库唯一索引、基于 ZooKeeper 临时顺序节点、基于 Redis 的 SETNX 命令各有各的适用场景。而 Redis 因为部署轻、性能高、大多数团队本来就在用成了落地分布式锁最普遍的选择。但“能用 Redis 做”和“能把 Redis 分布式锁做对”之间隔着一条鸿沟。手写 SETNX 加过期时间看似几行代码搞定实际藏着锁误删、锁续期、可重入、原子性、脑裂等一系列坑。Redisson 这个框架之所以在圈子里口碑不错就是因为它把这些坑基本都填了还封装了可重入锁、公平锁、读写锁、红锁等一整套工具。这篇内容我就围绕 Redisson 的分布式锁实现从设计思路到源码细节再到实操踩坑完整拆一遍。适合已经用过 Redis、想搞清楚 Redisson 底层到底怎么运转的中高级开发者也适合正准备在项目里引入分布式锁、想避开常见雷区的同学。2. 整体设计与方案选型拆解2.1 从手写 SETNX 到 Redisson 的演进逻辑先说说最原始的做法理解了这个才能明白 Redisson 到底在解决什么问题。早期大家用 Redis 做分布式锁基本是这么写的用SET key value NX PX 30000尝试占锁占到了就执行业务执行完删掉 key。NX保证只有 key 不存在时才设置成功PX给锁加个过期时间防止死锁。这套逻辑本身没毛病问题出在细节上。第一个坑是锁误删。假设 A 拿到锁后业务执行时间超过了过期时间锁自动释放了B 趁机拿到锁。这时候 A 执行完去删 key删的其实是 B 的锁。解决办法是 value 存一个唯一标识比如 UUID删之前先比对是不是自己的。但“比对再删除”这两步不是原子的中间可能被打断于是又得用 Lua 脚本把比对和删除打包成一个原子操作。第二个坑是锁续期。业务到底要执行多久谁也说不准过期时间设短了容易提前释放设长了万一节点宕机锁迟迟不释放。理想状态是锁快过期时如果业务还没跑完就自动续期这就是所谓的“看门狗”机制。第三个坑是可重入。同一个线程嵌套调用需要再次拿同一把锁如果直接 SETNX 会把自己锁死。得用 Hash 结构记录“哪个线程持有了几次”。Redisson 的价值就在于它把上面这些坑全部内化成了框架能力。你调用lock.lock()一行代码背后是唯一标识、Lua 原子脚本、看门狗续期、可重入计数一整套机制在协同工作。这也是为什么我建议团队里别自己造轮子——这些边界情况自己实现极容易漏而 Redisson 经过大量生产环境验证稳定性有保障。2.2 Redisson 锁的核心数据结构设计理解 Redisson 源码第一步是搞清楚它在 Redis 里到底存了什么。这跟手写方案有本质区别手写方案通常是一个 String 类型的 key而 Redisson 用的是 Hash 结构。锁的 key 一般形如业务前缀:锁名Hash 里的 field 是“客户端唯一标识 线程 ID”拼成的字符串value 是重入次数。举个例子某个客户端 UUID 是8f3a...线程 ID 是88那么 field 就是8f3a...:88value 初始为 1。同一线程再次加锁时value 变成 2释放一次减 1减到 0 才真正删除这个 key。为什么用 Hash 而不是 String答案就在可重入上。String 只能存一个值没法同时表达“谁持有”和“持有几次”两个信息。Hash 的 field 天然适合做持有者标识value 天然适合做计数一个结构把两件事都办了。而且判断“锁是否被当前线程持有”只需要HEXISTS key field判断重入只需要HINCRBY操作语义非常清晰。这里有个细节值得注意field 里为什么要带客户端 UUID因为不同客户端不同 JVM 实例的线程 ID 可能撞车比如两个进程里都有线程 ID 为 88 的线程。加上客户端唯一标识后field 全局唯一不会误判。这个 UUID 是 Redisson 客户端启动时生成的存在每个连接管理器里生命周期跟客户端一致。2.3 加锁流程的原子性保障思路分布式锁最怕的就是“检查”和“设置”之间被打断。比如先EXISTS判断锁在不在再决定要不要HSET这两步之间如果别的客户端插进来判断就失效了。Redisson 的做法是把整个加锁逻辑写进一段 Lua 脚本交给 Redis 单线程原子执行。加锁脚本的核心逻辑大致是这样先判断 key 是否存在不存在就直接HSET并设置过期时间返回成功存在的话再判断 field 是不是当前线程的是就HINCRBY重入计数加一并刷新过期时间返回成功否则返回当前锁的剩余过期时间告诉调用方“锁被别人占着你等多久再来试”。这段脚本的精妙之处在于它把“判断存在性、判断持有者、设置值、设过期、重入计数”五个动作压缩成一次原子调用。Redis 执行 Lua 时不会被其他命令打断所以不存在中间状态被观测到的问题。这也是为什么所有靠谱的 Redis 分布式锁实现都离不开 Lua——不是炫技是原子性的刚需。提示自己写 Lua 脚本时一定要注意脚本里用到的所有 key 都要通过KEYS[]传入不要在脚本里拼接 key 字符串。Redis 集群模式下会根据 KEYS 计算槽位拼接的 key 可能导致脚本路由到错误节点而报错。3. 核心源码细节与实操要点3.1 加锁入口lock() 与 tryLock() 的分工Redisson 暴露给上层的加锁方法主要有两类lock()和tryLock()。别看名字接近行为差别很大用错了直接影响系统表现。lock()是阻塞式加锁拿不到就一直等直到成功为止。它内部其实是个循环先尝试加锁失败就订阅锁释放的频道然后阻塞等待通知被唤醒后继续尝试。这个“订阅 等待”的机制避免了无脑自旋空耗 CPU是比较优雅的做法。源码里对应的是RedissonLock.lock()调用lockInterruptibly()再进入tryAcquire的循环。tryLock()则给了你更多控制权常见的有三个参数版本tryLock(waitTime, leaseTime, unit)。waitTime是最多等多久leaseTime是拿到锁后持有多久自动释放。如果waitTime内没拿到就返回 false业务自己决定怎么处理。这里有个关键点一旦你显式传了leaseTime看门狗续期机制就失效了。因为框架认为你明确知道业务要跑多久不需要它来兜底。很多人踩坑就踩在这——传了个 10 秒的 leaseTime结果业务跑了 15 秒锁提前释放并发问题照旧。我的建议是能用lock()不传 leaseTime 的场景尽量让看门狗管续期确实需要限时的场景比如防止某个操作无限挂起再显式传 leaseTime并且这个值要留足余量宁可长一点也别卡着业务时间设。3.2 看门狗机制锁续期的实现原理看门狗是 Redisson 分布式锁最值得讲的设计之一。它的核心问题是客户端怎么知道业务还没跑完、需要续期答案藏在lock()不传 leaseTime 时的默认行为里。Redisson 默认锁过期时间是 30 秒可通过配置调整加锁成功后会在客户端本地起一个定时任务每隔leaseTime / 3也就是 10 秒去检查一次锁是否还被当前线程持有是的话就把过期时间重置回 30 秒。这样只要业务还在跑、客户端还活着锁就不会过期。这个机制的实现依赖ExpirationRenewalMap它维护了“锁 key → 续期任务”的映射。加锁成功时注册续期任务释放锁时取消任务。续期任务本身也是通过 Lua 脚本执行的脚本里会判断 field 是否还存在存在才PEXPIRE续期避免给已经释放的锁续命。这里有个容易被忽略的细节看门狗续期是客户端本地定时任务不是 Redis 服务端的能力。这意味着如果客户端进程挂了比如被 kill、机器宕机续期任务自然停止锁在 30 秒后自动过期释放。这正是我们想要的——节点故障时锁能自动兜底释放不会永久卡死。反过来说如果客户端只是网络抖动短暂失联续期任务可能失败锁也可能提前过期这是分布式锁固有的局限后面讲 RedLock 时会再展开。注意看门狗只在lock()或tryLock(waitTime, unit)这种不指定 leaseTime 的调用下生效。一旦你传了 leaseTime续期任务不会启动锁到点就释放务必确认业务耗时可控。3.3 可重入与释放锁的 Lua 脚本拆解可重入的实现前面提过数据结构这里把释放逻辑补全。释放锁的 Lua 脚本大致做三件事判断 key 是否存在不存在直接返回判断 field 是否是当前线程不是就返回说明锁已经不属于自己可能已过期被别人拿走是自己的就把重入计数减一减完如果大于 0 就刷新过期时间返回等于 0 就DEL掉 key 并发布一条释放消息。为什么释放时要发布消息因为lock()阻塞等待的线程是靠订阅频道唤醒的。锁一释放往对应频道PUBLISH一条消息所有等待该锁的客户端收到通知后重新竞争。这套“订阅发布 阻塞等待”的组合比轮询自旋省资源得多响应也更及时。重入计数减到 0 才删 key 这个设计保证了嵌套调用不会提前释放锁。比如方法 A 加锁后调用方法 BB 也加同一把锁此时计数是 2B 执行完释放一次变 1锁还在A 执行完再释放变 0锁才真正删除。如果没这个计数B 一释放锁就没了A 后续的操作就裸奔在无锁状态非常危险。实操中还有个点要留意加锁和释放锁必须在同一个线程里。因为 field 里带了线程 ID跨线程释放会因为 field 对不上而失败锁会一直挂到过期。异步编程、线程池场景下尤其要注意别在 A 线程加锁、丢到线程池里执行完在 B 线程释放。3.4 锁等待与订阅唤醒的协作流程把加锁失败后的等待流程单独拎出来讲因为这块是理解 Redisson 性能表现的关键。当一个线程lock()失败时它并不是傻等而是走这么一套流程先拿到当前锁的剩余过期时间ttl然后订阅锁对应的频道频道名基于锁 key 生成接着在ttl时间内阻塞等待信号。如果期间收到释放消息立刻被唤醒重新尝试加锁如果等到超时还没消息也重新尝试。这个循环会一直持续到加锁成功。这套机制的好处是等待线程几乎不消耗 CPU全靠 Redis 的发布订阅驱动。但有个边界情况要注意如果锁的持有者一直不释放比如业务卡死但看门狗还在续期等待线程会一直阻塞下去。所以生产环境里lock()最好配合业务层的超时控制或者干脆用带waitTime的tryLock()避免线程无限堆积。另外订阅频道这个动作本身也有开销。高并发抢锁场景下大量客户端同时订阅同一个频道Redis 的发布订阅压力不小。如果锁竞争极其激烈可以考虑用tryLock加短间隔重试的方式替代阻塞等待把压力分散开。具体选哪种得看你的并发量和业务对实时性的要求。4. 完整实操过程与关键环节实现4.1 环境准备与依赖配置动手之前先把环境搭起来。Redis 服务端建议 5.0 以上版本因为 Redisson 的部分特性依赖较新的命令支持。客户端这边如果你用 Maven 管理依赖引入 Redisson 的 starter 即可。以 Spring Boot 项目为例加这么一段dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.0/version /dependency版本号选当前较新的稳定版就行别用太老的版本早期版本在看门狗和 RedLock 上有过一些修正。配置文件里配好 Redis 地址、密码、数据库索引这些基础信息。如果是集群模式配置里要列出所有节点地址Redisson 会自动做槽位路由。单机模式下配置很简单一个application.yml片段搞定spring: redis: host: 127.0.0.1 port: 6379 password: your_password database: 0配完之后注入RedissonClient就可以开始用了。这里提醒一句RedissonClient是重量级对象全局一个实例即可别每次用都 new 一个那样连接池和看门狗任务都会乱套。4.2 一个可复现的库存扣减加锁示例光讲原理太虚上个能直接跑的示例。假设有个商品库存扣减场景多个实例并发扣同一件商品用 Redisson 锁保护。Service public class StockService { Autowired private RedissonClient redissonClient; Autowired private StockMapper stockMapper; public boolean deductStock(Long productId, int count) { String lockKey stock:lock: productId; RLock lock redissonClient.getLock(lockKey); try { // 最多等 3 秒不指定 leaseTime交给看门狗续期 boolean acquired lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { throw new RuntimeException(获取锁失败请稍后重试); } int stock stockMapper.selectStock(productId); if (stock count) { return false; } stockMapper.updateStock(productId, stock - count); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { // 释放前必须判断是否当前线程持有避免误释放 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这段代码有几个点值得展开。tryLock(3, TimeUnit.SECONDS)这个重载只传了等待时间没传 leaseTime所以看门狗是生效的业务跑多久锁就续多久不用担心提前释放。finally里先isHeldByCurrentThread()判断再unlock()是为了防止加锁失败时误调释放导致抛IllegalMonitorStateException。这个判断看着多余实际能挡掉不少线上异常。锁的粒度也要斟酌。上面按productId分锁不同商品互不影响并发度好。如果图省事用一把全局锁所有商品扣减串行化性能直接崩。锁的粒度越细并发越高但也要注意别细到失去保护意义这个度得结合业务定。4.3 参数选择等待时间与租约时间的权衡tryLock的两个时间参数怎么定是实操里最常纠结的问题。我按经验给个参考思路。等待时间waitTime取决于业务能容忍多久的排队。比如下单接口用户等个两三秒还能接受设 3 秒合理如果是后台批处理任务等个几十秒也无所谓可以设长一点。设太短会导致大量请求拿不到锁直接失败设太长会让请求线程长时间挂起占用连接资源。一般建议从 3 到 5 秒起步压测后再调。租约时间leaseTime则要看业务执行耗时的分布。如果你对业务耗时心里没底就别传 leaseTime让看门狗兜底。如果非要传建议设成 P99 耗时的 2 到 3 倍。比如库存扣减 P99 是 200 毫秒leaseTime 设 1 秒以上比较稳妥。千万别设成跟平均耗时差不多长尾请求分分钟超时。还有个隐藏参数是看门狗的续期间隔默认是leaseTime / 3。这个一般不用改改了要确保续期间隔明显小于过期时间否则续期还没执行锁就过期了看门狗形同虚设。4.4 释放锁的正确姿势与异常兜底释放锁这块除了前面说的isHeldByCurrentThread判断还有几个兜底要考虑。第一unlock()本身可能抛异常比如 Redis 连接断了。这时候锁在服务端可能还挂着只能等过期自动释放。所以unlock()最好包在 try-catch 里记录日志别让释放异常影响主流程返回。第二业务代码里如果抛了未捕获异常finally一定要保证执行到。上面示例用 try-finally 就是这个目的。如果用 AOP 或者注解方式加锁要确认切面在异常情况下也能正确释放。第三分布式锁不是万能的它保护的是“同一时刻只有一个节点操作”但保护不了“操作本身出错”。比如扣库存时数据库更新失败锁释放了但数据没变这属于业务事务问题得靠数据库事务或者补偿机制解决别指望锁来兜底。5. 常见问题与排查技巧实录5.1 锁误删与锁失效的典型场景线上排查分布式锁问题最常见的两类就是锁误删和锁失效。锁误删的根源是释放时没校验持有者或者校验和删除不是原子的。Redisson 内部用 Lua 脚本保证了原子性正常用不会误删。但如果有人绕过 Redisson 直接操作 Redis key或者用了不兼容的旧版本就可能出问题。排查时看 Redis 里锁 key 的 field 是否还带着正确的客户端标识对不上就是被误操作了。锁失效更隐蔽表现为“明明加了锁并发问题还是出现了”。常见原因有三个一是显式传了过短的 leaseTime业务没跑完锁就释放了二是看门狗续期失败比如客户端与 Redis 网络中断超过续期间隔三是锁的 key 设计有误不同业务用了同一个 key 导致互相干扰。排查时先确认调用方式有没有传 leaseTime再看客户端日志里有没有续期失败的告警最后检查 key 的命名规范。5.2 看门狗不续期的排查路径看门狗不续期是高频问题我整理了一个排查顺序按这个走基本能定位。先确认调用的是不是lock()或不带 leaseTime 的tryLock()。传了 leaseTime 的调用压根不启动看门狗这是设计如此不是 bug。再确认锁是不是真的被当前线程持有如果加锁其实失败了但代码继续往下走续期任务根本没注册。然后看客户端与 Redis 的连接是否正常连接断了续期脚本执行不了。最后检查 Redisson 版本极老版本在看门狗实现上有过缺陷升级能解决。现象可能原因排查动作锁提前释放显式传了过短 leaseTime检查 tryLock 参数锁一直不释放看门狗续期但业务卡死查业务线程栈加超时控制续期日志缺失加锁未成功或连接异常查加锁返回值和连接状态偶发并发问题锁 key 冲突或粒度太粗检查 key 命名和分锁逻辑5.3 高并发下的锁竞争优化经验锁竞争激烈时系统表现会明显变差等待线程堆积、响应时间飙升。我踩过的坑和对应的优化手段有这么几条。第一缩小锁粒度。能按业务 ID 分锁的绝不用全局锁。第二缩短持锁时间。锁内只放必须串行的核心逻辑日志、远程调用、复杂计算都挪到锁外。第三用tryLock加短重试替代无限阻塞把等待压力分散到多次尝试上。第四评估是否真的需要分布式锁有些场景用数据库乐观锁、唯一索引或者消息队列串行化更合适锁不是唯一解。还有个容易忽略的点Redis 本身的性能。如果锁操作 QPS 极高单节点 Redis 可能成为瓶颈这时候要考虑集群部署但集群模式下 RedLock 的争议又来了得权衡一致性和可用性。5.4 RedLock 的争议与适用边界最后聊聊 RedLock这个话题在社区里争论了很多年。RedLock 的思路是向多个独立的 Redis 节点加锁超过半数成功才算加锁成功以此降低单节点故障导致的锁失效风险。它的争议点在于依赖各节点时钟基本同步且网络延迟可控。如果某个节点时钟跳变或者网络分区导致部分节点响应延迟RedLock 的安全性就打折扣。有观点认为它在极端情况下不能提供强一致保证不如直接用基于共识算法的方案。我的实践看法是RedLock 适合对锁安全性要求较高、又能接受一定复杂度的场景比如金融类操作。普通业务用单节点或主从架构的 Redisson 锁就够了别为了追求理论上的完美把架构搞复杂。真到了需要强一致的级别可能得考虑换用其他协调服务。选型没有银弹匹配业务需求才是关键。6. 我个人在实际操作中的几点体会用了几年 Redisson最大的感受是框架能帮你填坑但填不了你对业务的理解。锁的粒度、超时参数、异常兜底这些都得结合具体业务来定没有一套参数能打天下。我见过太多团队直接抄一段示例代码上线结果因为 leaseTime 设错或者锁粒度太粗线上照样出并发问题。另一个体会是分布式锁要当成“最后一道防线”来用而不是“第一选择”。很多并发问题其实用数据库约束、幂等设计、状态机就能解决上锁反而增加了系统复杂度和故障点。每次想加锁之前先问问自己这个资源真的需要串行访问吗有没有更轻量的方案最后分享一个小技巧给锁的 key 加上业务前缀和版本号比如order:lock:v2:12345。这样排查问题时一眼能看出是哪个业务、哪个版本的锁升级锁逻辑时也能平滑过渡老 key 自然过期新老逻辑不打架。这个习惯帮我省了不少排查时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询