Redisson分布式锁实战:可重入锁、读写锁与看门狗原理详解 做后端开发的几乎绕不开一个话题分布式锁。我第一次对它有体感是接手一个订单扣库存的接口。两台应用实例一起扛流量代码里用synchronized锁定了扣减方法逻辑看着没毛病压测的时候还是超卖了两单。问题不在代码本身在于synchronized只是 JVM 进程内的锁两台实例各管各的根本锁不住同一个库存。后来在 SpringBoot 项目里接入了 Redisson用它的分布式锁才把并发问题真正压下去。这也是今天这篇想聊的核心Redisson 是什么、为什么要用分布式锁、它的可重入锁和读写锁到底怎么回事以及怎样在 SpringBoot 项目里把它用稳、用对。这篇文章适合三类人刚接触分布式项目、想在 SpringBoot 里快速落地分布式锁的开发者看过锁理论但对看门狗、读写锁细节还没实际踩过的人以及正在准备面试、需要把分布式锁原理讲透的候选人。下文会从底层原理讲到工程实践最后还附上我在真实项目里踩过的坑可以直接抄作业。1. 先搞明白为什么单机锁管不住集群以及 Redisson 为什么能做锁1.1 从 synchronized 说起锁失效的根源Java 里最常见的锁就是synchronized还有ReentrantLock但它们只能协调同一个 JVM 里的线程。为什么因为这类锁的实现依赖 JVM 内存里的监视器对象线程 A 拿了锁本质是在当前进程的堆内存里做了一个标记另一个进程里的线程 B 根本看不到这个标记。举个生活化的例子一栋楼里每个家庭都有一个门卫门卫只能管自己家进出。现在楼道里放了一把公共钥匙两个门卫都以为钥匙归自己管于是同时把人放进去了。分布式系统就是这样服务一旦部署多实例每个实例就是一个独立 JVM它们之间没有共享内存单机锁天然失效。我最早做分布式锁犯过的错是在 Redis 里手动SET key value NX PX 30000看起来能防住并发但踩了一堆细节坑后面会详细说。1.2 Redisson 是什么不只是一个 Redis 客户端Redisson 是 Redis 官方推荐的 Java 客户端之一准确说它不只是一个客户端而是一个基于 Redis 的分布式 Java 框架。底层用 Netty 和 Redis 通信上层把 Redis 的数据结构封装成了 Java 风格的对象比如RMap、RList、RAtomicLong、RLock。你用RLock加锁不用关心加锁的 Redis 命令是怎么组织的Redisson 替你处理好。很多人会把 Redisson 和 Jedis、Lettuce 做对比。Jedis 和 Lettuce 是偏底层的客户端你可以执行SET、GET、EXPIRE这些命令但并发控制、分布式对象这些能力得自己封装。Redisson 则站在更高的抽象层直接提供开箱即用的分布式锁、信号量、限流器、分布式队列把并发编程的心智负担降下来。另外Redisson 最常见的拼写是“Redisson”不是“Redission”这个词在很多文章里被打错实际开发时包名和类名也都是Redisson别记岔了。1.3 分布式锁真正派上用场的场景分布式锁不是一个炫技工具它解决的核心问题是多个进程同时操作同一个共享资源比如数据库里的同一条记录、Redis 里的同一个 key需要保证互斥避免并发写入造成数据不一致。我整理过一张清单覆盖了大多数使用场景典型场景锁的建议类型锁 Key 设计示例说明库存扣减可重入锁/独占锁stock:product:{id}写操作必须强互斥订单防重复提交可重入锁order:create:{userId}:{orderNo}防止同一用户短时间内重复下单定时任务集群防重公平锁/尝试锁job:{jobName}多实例同时触发时只让一个实例执行缓存回填读写锁cache:product:{id}读多写少缓存更新时避免重复回源秒杀/活动可重入锁限流seckill:{skuId}热点资源配合限流和队列效果更好注意能用数据库唯一索引、乐观锁、状态机解决并发问题的优先用这些方案分布式锁不是“银弹”。它适合读多写多、需要较强互斥且用数据库约束不好实现的场景。2. 深入 Redisson 的可重入锁从 Lua 脚本到看门狗2.1 为什么自己用 SETNX 不靠谱先说一个很多人走过的弯路直接用SET resourceName token NX PX timeout实现分布式锁。这个命令本身没问题它能在 key 不存在时写入并设置过期时间但工程上会有几个致命问题释放锁之前必须校验持有者。如果线程 A 获得锁执行业务超时锁自动过期线程 B 拿到锁然后 A 操作完了直接DEL key就会把 B 的锁误删。正确的做法是先用 Lua 脚本比较 value 是否还是自己的 token再决定是否删除。不可重入。同一个线程里递归加锁第二次加锁会直接失败因为 key 已经存在。锁过期时间不好定。设短了业务没跑完锁就提前释放设长了进程宕机后锁长期不释放。加锁和续期不是原子的。你没法在业务执行过程中安全地给锁续期。Redisson 把这些细节全封装在 Lua 脚本里。Redis 单线程执行脚本的特性保证了脚本里多个命令的原子性。2.2 可重入锁的计数原理Redisson 的可重入锁底层用的是 Hash 结构key 是锁名称field 是线程标识UUID 线程 IDvalue 是重入次数。这跟 Java 的ReentrantLock是一模一样的心思只是把“当前线程”和“重入计数”从 JVM 内存挪到了 Redis。加锁的 Lua 脚本核心逻辑如下if redis.call(exists, KEYS[1]) 0 or redis.call(hexists, KEYS[1], ARGV[1]) 1 then redis.call(hincrby, KEYS[1], ARGV[1], 1) redis.call(pexpire, KEYS[1], ARGV[2]) return 1 else return 0 end翻译成人话如果锁不存在或者当前线程已经在这个锁的 field 里就对重入次数加 1然后刷新过期时间否则说明锁被别的线程占着返回失败。ARGV[1]是线程标识ARGV[2]是过期时间毫秒数。释放锁的逻辑刚好相反if redis.call(hexists, KEYS[1], ARGV[1]) 0 then return nil else local counter redis.call(hincrby, KEYS[1], ARGV[1], -1) if counter 0 then redis.call(pexpire, KEYS[1], ARGV[2]) return 0 else redis.call(del, KEYS[1]) return 1 end end每次解锁先把重入次数减 1如果计数还大于 0说明线程还没完全退出只刷新过期时间如果计数归零才真正删除锁。这个设计保证了同一个线程嵌套加锁多少次就需要解锁多少次不会因为一次unlock就把别人的锁删掉。使用时的表现也很直接RLock lock redissonClient.getLock(anyLock); lock.lock(); lock.lock(); // 同一个线程可重入Redis 里的 value 会变成 2 lock.unlock(); lock.unlock(); // 必须成对出现2.3 看门狗锁不会在业务跑完前提前失效的秘密很多新手会问锁总有一个过期时间如果业务执行时间超过了这个时间锁不就提前释放了吗Redisson 的答案是看门狗机制。当你用lock.lock()或lock.lock(leaseTime, TimeUnit)且不指定leaseTime时Redisson 会启动一个后台定时任务默认每 10 秒把锁的自动过期时间重置为 30 秒。这个定时任务在业务线程持有锁期间持续运行业务执行完unlock之后自动取消。线程还活着锁就一直在续期进程宕机了锁最多再过 30 秒也会自动过期不会永久死锁。这里有一个极其常见的坑如果你想控制锁的持有时间用了lock.tryLock(waitTime, leaseTime, TimeUnit)并且明确传了leaseTime那看门狗就关闭了锁到期自动释放不续期。如果你的业务在leaseTime内没跑完其他线程就会趁虚而入。看门狗确实是个好东西但也不能盲目依赖。如果业务里长时间持有锁比如在锁内调外部接口看门狗会把锁一直续期其他请求就会一直等待压垮整个服务。所以正确姿势是锁内代码越短越好通常只在临界区加锁不要在锁里做重 IO。3. 读写锁读共享、写独占场景最典型的就是缓存更新3.1 读写锁的语义和适合的场景可重入锁是“独占锁”写操作需要独占资源避免并发修改。但有些场景是读多写少比如商品详情缓存绝大多数请求只是读只有缓存过期或主动更新时才写。如果用独占锁把所有读请求全挡住并发性能会很难看。读写锁的语义有三条读读不互斥多个线程可以同时持有读锁。读写互斥有线程持有写锁时其他线程不能获取读锁有线程持有读锁时其他线程不能获取写锁。写写互斥写锁是独占的同一时刻只能有一个线程持有。用生活类比就是大楼门禁卡读锁可以很多人一起刷大家都进得去而某个房间的房卡写锁一次只能一个人拿别人拿了你就进不去。Facebook、电商首页这类读多写少的业务读写锁能把并发能力最大化。3.2 Redisson 的 RReadWriteLock 实操Redisson 提供RReadWriteLock代码很短RReadWriteLock rwLock redissonClient.getReadWriteLock(product:detail:123); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock(); // 读锁多个线程可同时获取 readLock.lock(); try { // 读缓存或读取本地内存 } finally { readLock.unlock(); } // 写锁独占 writeLock.lock(); try { // 回源数据库并重建缓存 } finally { writeLock.unlock(); }注意几个细节读锁和写锁必须基于同一个锁 Key。你把product:detail:123换成别的 Key锁就不互斥了等于白加。锁内代码同样要短。写锁持有期间所有读锁都在排队写锁内做慢查询会拖垮整体响应。tryLock依然建议优先使用不要死等lock()。3.3 为什么缓存回填是最典型的读写锁场景缓存回填是一个容易出并发事故的场景。假设商品详情缓存失效一瞬间来了 100 个请求如果用简单分布式锁所有请求排队回源数据库缓存就能逐渐恢复但如果不加锁100 个请求全都打进 MySQL轻则慢查询重则拖垮数据库。读写锁改造后的流程是所有请求先尝试获取读锁。拿到读锁后读缓存如果命中直接返回不触底。缓存未命中释放读锁去获取写锁。拿到写锁后再次检查缓存是否已被其他线程重建double check没有则回源数据库并写入缓存。这个模式本质上是缓存重建的“单飞”机制同时允许读请求在锁外继续并发访问兼顾了一致性和吞吐量。我在实践中体会是缓存回填场景比单纯加独占锁爽太多读多写少的并发量一下子就上去了。4. SpringBoot 整合 Redisson从依赖到封装的完整实践4.1 引入依赖和版本选择SpringBoot 整合 Redisson 最简单的方式是引入官方 starterdependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency版本选择要特别注意。redisson-spring-boot-starter与 SpringBoot 版本存在兼容性并不是最新的就好。实践中常见的搭配Spring Boot 2.6 / 2.7用 Redisson 3.17.x ~ 3.23.xSpring Boot 3.0用 Redisson 3.27.x 或更新的稳定版我之前遇到过 SpringBoot 版本偏高但 Redisson 版本没跟上导致自动装配失效的情况。落地的第一步不是写代码而是先把版本对清楚。如果项目里 SpringBoot 版本特殊我更推荐不引入 starter而是用redisson核心包 自己定义RedissonClientBean可控性更强。4.2 配置 RedissonClient引入了 starter 之后既可以在 SpringBoot 的application.yml里直接配置也可以定义RedissonClient的 Bean。两种方式我都用过推荐自定义配置因为可以显式控制连接池、超时和序列化Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setPassword(yourPassword) .setConnectionPoolSize(16) .setConnectionMinimumIdleSize(8) .setTimeout(3000); return Redisson.create(config); } }配置文件方式也可以spring: data: redis: host: 127.0.0.1 port: 6379 password: yourPassword如果项目里本身就有RedisTemplate而且不是 Redis ClusterRedisson 的 starter 会自动读取 Redis 配置并创建一个RedissonClientBean。但集群环境、哨兵环境下建议还是用Config显式声明避免配置串线。4.3 三种会被高频用到的锁 API在 SpringBoot 里写业务最常用的锁 API 就三个lock()、tryLock()、unlock()。它们的语义区别很大RLock lock redissonClient.getLock(order:create:1001); // 方式一阻塞获取锁拿不到就死等 lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); } // 方式二尝试获取锁等待 waitTime 后没拿到就放弃 boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 快速失败或降级 throw new BizException(系统繁忙请稍后再试); } // 方式三只尝试一次拿不到立即返回 boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS);重要lock()和tryLock()后要确保unlock()一定在finally里执行否则一旦业务中抛出异常锁就会一直持有到过期时间结束其他线程被长时间卡住。tryLock(3, 30, TimeUnit.SECONDS)的第一个参数是等待时间第二个参数是锁自动过期时间第三个是单位。这个过期时间如果传了看门狗就失效业务必须在 30 秒内跑完否则锁会被提前释放。4.4 更优雅的封装通过锁服务模板避免重复代码实际项目中锁代码如果散落在各个 Service 方法里很容易出现“忘了 finally 解锁”或者“锁 key 起名混乱”的问题。我习惯把锁逻辑封装成一个模板方法调用方只需要传业务 Key 和业务逻辑Service public class DistLockService { Resource private RedissonClient redissonClient; public T T doLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit, SupplierT action) { RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(waitTime, leaseTime, unit); if (!locked) { throw new BizException(系统繁忙请稍后再试); } try { return action.get(); } finally { lock.unlock(); } } public void doLock(String lockKey, Runnable action) { doLock(lockKey, 3, 30, TimeUnit.SECONDS, () - { action.run(); return null; }); } }业务里使用orderService.lock(order:create: userId, () - { // 扣库存、创建订单 });这个模板的收益是统一了加锁、解锁、异常处理逻辑降低了写错finally的概率锁 Key 的命名规范也容易强制约束。如果想更进一步可以配合自定义注解 AOP 做声明式锁但注意切面在同类内部调用时不会生效这一点需要提醒团队。4.5 锁参数到底怎么选waitTime 和 leaseTime 的取舍参数设计其实决定了分布式锁在线上是“好用”还是“灾难”。waitTime等待时间决定线程最多等多久。高并发抢单场景我一般给 3 秒等待超过 3 秒说明资源竞争严重直接返回失败让用户重试。如果业务要求强一致性且请求量不大可以用lock()死等但要有熔断意识。leaseTime锁自动过期时间决定线程最多持有锁多久。我通常按业务最大耗时的 1.5 到 2 倍估算。举个例子一个扣库存接口正常情况下耗时 300ms压测背景下可能飙到 2s那leaseTime设 5s 就比较合理。如果是调用外部 API、数据库大批量写入这类耗时不可控的操作干脆不传leaseTime走 Redisson 的看门狗续期机制。两个参数组合起来的经验快速失败场景tryLock(1, 5, TimeUnit.SECONDS)拿不到就放弃。普通业务tryLock(3, 30, TimeUnit.SECONDS)等 3 秒持锁 30 秒。耗时可控且需要在锁内做重操作tryLock(5, 0, TimeUnit.SECONDS)注意第二个参数传 0 不代表不需要过期时间而是让 Redisson 走看门狗默认 30 秒续期逻辑。5. 真实踩坑记录六个高频分布式锁问题5.1 锁释放先于事务提交这是我在 SpringBoot 项目里踩过最严重的一个坑。场景是这样的Service 方法上加了Transactional方法内加了锁并执行业务逻辑方法结束前解锁。看起来没什么问题但实际上事务提交发生在方法返回之后锁已经解了事务可能还没提交。结果就是线程 A 很快释放锁线程 B 立即拿到锁却读到了线程 A 尚未提交的旧数据两个线程的操作出现数据不一致。解法有两个方向把解锁逻辑放到事务提交后比如用TransactionSynchronizationManager.registerSynchronization在afterCommit里解锁。把加了事务的写入逻辑拆成独立方法锁包在事务外层持有锁直到事务提交完成。我后来统一用“锁在外、事务在内”的写法因为更直观也不容易误改。5.2 tryLock 后误调 unlock先看一段错误代码lock.tryLock(3, 30, TimeUnit.SECONDS); try { // 执行业务 } finally { lock.unlock(); }这段代码的问题十分隐蔽tryLock返回false时当前线程并没有持有锁但finally里依然调了unlockRedisson 会抛出IllegalMonitorStateException把异常链打乱。更危险的是如果代码里在try中 调用了多次lock()只unlock一次锁重入计数没有清零也会导致后续线程进不来。规范写法是始终用一个布尔变量记录是否成功获取锁boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑 } finally { lock.unlock(); } }5.3 指定 leaseTime 后看门狗不生效这是上一节反复强调的点tryLock(waitTime, leaseTime, unit)只要明确传了第二个参数Redis 里的锁就是固定过期时间不会续期。很多人在测试环境觉得没问题上线后业务偶发超时就再也拿不到锁排查八成发现是业务耗时超过了锁的过期时间。看门狗只存在于未指定leaseTime的加锁路径。所以如果你能准确预估业务耗时就传参数不能准确预估就走看门狗至少它不会让锁在你业务还在跑的时候提前消失。5.4 Redis 主从切换导致锁丢失Redis 主从架构下锁的写入发生在 Master 节点通过异步复制到 Slave。如果 Master 写完锁但还没来得及复制就给挂掉了这时候从节点晋升为主节点锁就丢了另一个线程就可以加同一把锁互斥失效。Redisson 提供了红锁RedLock算法来应对但 RedLock 本身需要部署多台独立的 Redis 主节点架构复杂度高、性能损耗大。实际业务中我见过的绝大多数项目都没有用红锁而是接受极小概率的锁丢失配合业务上的幂等设计来兜底。面试如果被问到这个问题能说出权衡取舍比背方案更值分。5.5 锁粒度不合理导致全局热点有段时间运营活动改造同事图省事直接用一把锁lock(activity)锁住了整个秒杀接口。结果所有商品的请求都在抢同一把锁Redis 单节点负载飙升服务响应越来越慢。其实锁的粒度要细化到具体的业务对象每个商品 ID 一把锁每个用户 ID 一把锁热点分散到不同 Key 上并发能力才能上来。锁 Key 设计成业务域:场景:目标ID的三段式是比较稳妥的命名规范。比如activity:seckill:sku_10001。它既能避免多个业务互相干扰也能让热点均匀分布。5.6 Redis 客户端故障时锁直接抛异常Redisson 的lock()在 Redis 连接不上时不是返回false而是直接抛异常。这会导致一个现象Redis 一抖动依赖锁的接口全部报错。我是这样做的给RedissonClient设置合理的连接超时比如 3 秒和重试次数不能让客户端无限等下去。在tryLock返回false或抛异常时进入降级逻辑返回“系统繁忙”或者用本地限流兜底而不是让整个接口崩溃。对核心业务加 Redis 监控告警锁相关 Key 的慢查询和连接失败第一时间暴露。Redisson 这些坑整理成速查表长这样现象可能原因排查思路解决方案锁没生效并发照旧未加同一把锁 Key/锁粒度错检查 Redis 里锁 Key 是否统一规范 Key 命名业务卡死线程池打满锁未释放/看门狗未续期查看 Redis 中锁 Key 的 TTL检查 unlock 是否在 finally 中异常IllegalMonitorStateExceptiontryLock 未持有锁却 unlock查看代码是否判断持有状态只有成功获取锁才解锁业务还没结束锁就过期指定了 leaseTime查看锁 TTL 和业务耗时不传 leaseTime 走看门狗Redis 一故障接口崩溃客户端连接超时未配置检查 Redisson 超时配置设置超时和降级策略同一把锁被多个实例同时获取主从切换锁丢失查看 Redis 主从架构RedLock 或幂等兜底6. 经验小结把锁用稳的几个原则最后分享一点个人在实际项目中的体会。分布式锁能解决很多并发问题但它不是一劳永逸的银弹。我每次接到并发需求都会先问自己这个问题能不能用数据库唯一索引解决能不能用乐观锁的版本号/状态机解决如果能就不用分布式锁。因为分布式锁本身引入了 Redis 的高可用、网络延迟、一致性风险它应该只在必须互斥却没有更轻量办法的场景里使用。一旦决定用分布式锁我的几条习惯是锁 Key 尽量精细锁内代码尽量短解锁永远放在finally能走看门狗就不手写固定的过期时间所有锁代码收敛在一个公共组件里同时给 Redis 加好监控。踩过几次坑之后回头看分布式锁的难点从来不是“会用lock()”而是什么时候用、怎么设计 Key、怎么处理异常边界。希望这篇文章能帮你把这些经验一次补齐。