我接手过不止一个这样的技术咨询线上定时任务明明加了分布式锁某个凌晨还是出现了两个实例同时执行同一份报表更诡异的是日志里两个节点拿到的锁key完全一致执行时间还重叠了五十多秒。查到最后往往不是锁失效而是使用方把可重入、可重试、超时续约这三个默认行为混在一起用错了。Redisson的分布式锁在日常开发里之所以能“开箱即用”靠的正是这三套机制一起把“锁我拿到了”这件事在分布式环境下变得可靠。可一旦理解不到位它们也会变成最隐蔽的定时炸弹。这篇东西我按源码机制加实战踩坑的方式来写以Redisson 3.17到3.20之间的代码组织为基准不同小版本可能有类名或方法结构上的差异但核心机制没有变过。适合正在排查锁问题的同学也适合准备把Redisson锁从“能用”提升到“用得明白”的同学。1. 从三张Redis指令看这三个机制为什么必须绑在一起1.1 分布式锁的天然短板状态、通知、存活三件事缺一不可单机版的ReentrantLock只需要在JVM内存里记录持有线程和重入次数但分布式锁面对的是多个进程状态必须落在所有人共同能看到的Redis里。问题随之而来一个Redis Key只能告诉所有人“锁在不在”但没法直接回答“谁拿着锁”“同一线程能不能再进一次”“下一个等待者什么时候被通知”“业务跑了很久锁会不会提前消失”这类问题。这就是Redisson把锁设计成Hash结构而不是简单String的原因。Hash天然适合存储两个维度的信息field存持有者标识value存重入次数。光有状态还不够等待方不能靠死循环轮询Redis来判断锁是否释放那样既浪费连接又制造无谓的QPS。Redisson的办法是借用Redis的发布订阅能力让解锁方主动广播“锁释放了”等待方收到信号后再尝试获取。存活问题则更隐蔽。如果锁只有固定过期时间业务执行时间一旦超过这个时间锁会提前失效另一个线程就能进来造成并发冲突如果锁不设过期时间进程又可能崩溃导致锁永远打不开。两者都不可取所以Redisson引入了看门狗机制默认给锁30秒有效期持有锁的线程在业务执行期间每隔10秒续约一次既防止永久死锁又尽量避免业务未完成锁已释放的尴尬。一句话概括可重入解决“怎么证明这个锁是我拿的”可重试解决“没抢到锁的线程该怎么等、等多久”超时续约解决“业务还没跑完锁凭什么不能消失”。这三件事缺了任何一件分布式锁在真实业务里都会出乱子。1.2 三个默认行为的第一视角lock、tryLock、watchdog是怎么协同的以一个最简单的调用为例RLock lock redissonClient.getLock(order:pay:12345); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }这段代码背后至少发生了四件事线程T1第一次调用lock()Redisson向Redis执行一段Lua脚本脚本里判断锁key不存在于是创建Hash并写入fieldUUID:T1value1同时设置30秒过期时间。同一线程T1在锁内再次调用lock()脚本发现fieldUUID:T1已经存在就把value从1加到2同时再次刷新过期时间。这就是可重入的完整动作。假设线程T2在T1持有锁期间也来lock()Lua脚本发现锁key存在且field不是UUID:T2于是返回当前锁剩余TTL。T2拿不到锁就进入订阅等待流程直到T1解锁发布消息后再竞争。T1的业务逻辑执行超过30秒看门狗每隔10秒把锁的有效期重新reset到30秒。T1最后一次调用unlock()时Redisson会把Hash删除并向等待方广播释放消息。这些步骤听起来简单但每一步都关系到线上稳定。接下来逐个机制拆开讲。2. 可重入hash结构加Lua脚本把“谁拿着锁”嵌入原子操作2.1 为什么Redis里的锁必须用Hash而不是String如果你只想实现“互斥”用SET key value NX EX seconds就够了。这个问题很多人都能答出来。但如果要支持可重入String结构就不够用了重入意味着同一个线程再次获取锁时不能拒绝它要给它加一个计数解锁时又要把计数减回来减到0才真正删除锁。这个“计数”必须和“持有者标识”绑定在一起。String结构只能存一份value就算value本身编码成JSON每次更新也要先GET再SET两步之间插入了网络延迟或并发竞争计数就可能丢失。Hash结构天然就是{field: value}的映射持有者标识放field重入次数放value一次脚本调用就能完成判断加更新这也是Redisson选择Hash的根本原因。// Redisson中加锁Key的命名规则一个典型结构 // key: myLock // field: 84d96a12-0c5e-4dfe-9b5a-0b74c74e8d1a:1 // value: 1 // 过期时间 30秒默认由配置lockWatchdogTimeout决定其中field是UUID:线程IDUUID区分了不同Redis客户端实例线程ID区分了同一JVM里的不同线程二者组合起来才是一个全局唯一的“持有者身份”。2.2 lock.lua逐行拆解获取锁与重入只在一段脚本里Redisson加锁的核心Lua脚本不同版本略有变化以下结构是3.x的稳定版大致长这样-- KEYS[1] : 锁名例如 myLock -- ARGV[1] : 锁的过期时间默认30000毫秒 -- ARGV[2] : 持有者标识例如 UUID:1 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压根不存在说明当前没有其他人持锁。直接创建Hashfield放持有者标识value初始化为1并设置过期时间。返回nil表示加锁成功。第二个分支锁key存在而且当前线程标识已经在这个Hash里。说明这是同一个线程重入于是把计数加1同时刷新过期时间。返回nil也表示加锁成功。如果锁存在但持有者不是当前线程直接返回当前锁的剩余过期时间。客户端拿到这个TTL后就知道“锁还差多久才可能释放”后续等待逻辑会利用这个数字。注意第二个分支里的pexpire容易被忽略。可重入不仅仅是计数加一它同时会刷新锁的过期时间。也就是说一次重入操作本身就带“续约”的副作用。很多人以为只有看门狗会续约其实每次重入时Lua脚本已经把有效期往后推了。Redis执行Lua脚本的原子性保证了这段逻辑在并发下不会被穿插。客户端之间判断exists和hset永远不会被拆开执行这是分布式锁的最底层保障。2.3 unlock.lua的秘密先做减法再做销毁解锁脚本是加锁脚本的镜像对称-- KEYS[1] : 锁名 -- KEYS[2] : 用于发布释放消息的channel名 -- ARGV[1] : 释放消息内容 -- 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。如果不存在说明这个线程压根没持有锁此时Redisson客户端会在Java层面抛出IllegalMonitorStateException。这是很多人遇到过“unlock的时候报错”的直接原因要么跨线程操作了锁要么锁已经因为过期被别人释放了。hincrby value -1是先把重入次数减一再根据减完后的结果决定下一步。这里必须用hincrby完成“减法”因为计数更新必须原子进行。如果减完后计数还大于0说明外层还有重入没释放不能删key但需要继续刷新过期时间。返回0表示“锁还在只是重入次数少了1”。如果减完后计数等于0说明所有重入都退出了此时删除锁key并向channel发布一条释放消息。返回1告诉客户端“锁真正释放了”。这里我特别想提醒一句开发同学容易在finally里无条件unlock但Redisson的unlock实际会校验线程身份。如果你在A线程加锁却因为代码结构问题把解锁放到了B线程就等着收IllegalMonitorStateException吧。排查这个问题的时间往往比想象中长。2.4 可重入在分布式环境下的边界条件可重入并不是“同JVM内只要拿到引用就算重入”。Redisson的判定依据是UUID:threadIdUUID对应一个RedissonClient实例。如果你在同一个进程里创建了两个RedissonClient或者公司封装了一层导致每次getLock都走不同客户端那么即使业务上“同一个线程”也会被识别成两个持有者。跨进程的重入天然不可能因为不同进程的UUID不同。这个设计是合理的否则锁就失去排他性了。但要注意场景如果你的业务在锁内通过RPC调用了另一个服务而那个服务又尝试拿同一把锁它拿到的一定不是同一份锁状态因为线程ID和UUID都是自己的和调用方完全无关。也就是说分布式锁的重入只对“同一客户端实例内的同一线程”成立跨服务、跨线程、跨实例的重入都无从谈起。还有一点容易被忽略可重入次数没有上限也没有超时约束。如果你在递归场景里每层都lock不unlockvalue会一路加下去直到业务逻辑跑完才归零。这不会撑爆Redis hash但会让锁的有效期被不断刷新相当于隐式续约。反过来如果释放次数大于获取次数第二次unlock会触发IllegalMonitorStateException因为第一次已经删key了。3. 可重试tryLock的等待队列是利用Redis的PubSub搭出来的3.1 lock、tryLock(0)、tryLock(waitTime)三种入口的本质差别Redisson提供了多个获取锁的入口它们的区别本质上是“抢不到锁之后怎么办”方法等待行为超时行为适用场景lock()无限等待直到拿到锁或线程被中断不返回线程会一直阻塞定时任务、Job类任务错过本轮就等下一轮tryLock(0, TimeUnit.SECONDS)立即尝试不等待拿不到直接返回false防重复点击、幂等处理这类一锤子买卖tryLock(waitTime, TimeUnit.SECONDS)在waitTime内持续尝试超过waitTime仍拿不到返回false请求生命周期有限不能无限阻塞的场景注意lock()方法默认是不限等待时长的。如果你的接口里用了lock()而拿锁的线程正好排在一串长任务的后面这个线程会一直阻塞请求超时之后线程继续占着池子最终可能拖垮服务。所以接口层我基本不用裸的lock()而是用tryLock(waitTime, TimeUnit.SECONDS)给等待设置一个上限。tryLock(waitTime, TimeUnit.SECONDS)实际上是tryLock(waitTime, -1, unit)的重载也就是说它不指定固定leaseTime会走看门狗续约机制。这一点不少人也弄反了以为tryLock一定是不续约的其实只有当你主动传了leaseTime大于0才会关闭看门狗。3.2 锁释放一方的publishunlock.lua里那行容易被忽略的代码可重试最核心的等待唤醒机制藏在前面unlock.lua脚本里那行容易被忽略的redis.call(publish, KEYS[2], ARGV[1])。当锁真正释放重入计数归零、删除了lockkey时Redis会向一个channel发布消息。这个channel是由锁名派生出来的通常形如redisson_lock__channel:{lockName}并不是随意的广播频道。消息的内容则是解锁线程的持有者标识。为什么要发布消息因为等待方需要被“叫醒”。如果没有这个channel等待中的线程就只能靠轮询锁状态来判断能否获取最粗暴的轮询是每秒查一次pttl锁一多Redis压力立刻上来。有了PubSub等待线程可以挂在一个计数信号量上收到消息后再做一次获取尝试。虽然最终还是要回到Lua脚本去抢锁但网络开销从“周期性轮询”降到了“事件驱动唤醒”效率完全不是一个量级。还有一个细节只有锁真正释放时才publish可重入次数从2变1时并不会发布消息。因为外层还没退出等待线程就算收到通知也抢不到锁发了反而制造无意义的竞争。3.3 锁等待一方的latch订阅、阻塞、再唤醒Redisson的等待方逻辑可以简化为以下伪代码// 伪代码解释tryLock的等待循环结构 long time unit.toMillis(waitTime); long start System.currentTimeMillis(); // 1. 先直接尝试一次获取 Long ttl tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl null) { return true; // 直接拿到了 } time - System.currentTimeMillis() - start; if (time 0) { return false; // 等待时间已经不够了 } // 2. 订阅锁释放的channel RFutureRedissonLockEntry subscribeFuture subscribe(threadId); // 等待订阅完成同时计入waitTime // 3. 循环尝试 while (true) { long begin System.currentTimeMillis(); ttl tryAcquire(waitTime, leaseTime, unit, threadId); if (ttl null) { return true; } time - System.currentTimeMillis() - begin; if (time 0) { return false; } // 4. 阻塞等待“锁释放”信号或者锁自动过期 if (ttl 0) { getEntry().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { getEntry().getLatch().acquire(); } time - System.currentTimeMillis() - begin; }核心状态全在getEntry().getLatch()这个信号量上。锁释放消息到达后PubSub监听器会让latch释放一个许可阻塞中的线程恢复执行再次进入Lua脚本抢锁。这里有一个非常重要的设计当tryAcquire返回的ttl 0时等待线程并不是收到一个通知就立刻抢而是先调latch.tryAcquire(ttl, MILLISECONDS)。它把“当前锁剩余过期时间”当作一次等待的预算。锁反正还有ttl毫秒才到期等待线程没必要在锁自然过期前反复去Redis做无意义的尝试。等到ttl毫秒后锁可能刚好过期此时再走一轮完整获取流程成功率反而更高。3.4 为什么收到释放通知依然可能抢不到锁这是很多人对可重试机制理解偏差最大的一点以为订阅channel就像排队取号锁释放后会按顺序发给自己。事实并非如此。Redisson普通锁的PubSub是“广播式唤醒”。线程T2、T3、T4都在等待同一把锁的channel当T1释放锁并publish消息时T2、T3、T4全都被唤醒然后一起冲进tryAcquire的Lua脚本。但只有一个线程能从exists0分支成功创建锁其余线程拿到的是剩余TTL只好回到latch上继续等。所以收到释放通知不代表你一定能拿到锁只代表“竞争窗口打开了”。这也是为什么等待循环里要反复tryAcquire因为每次被唤醒都只是新一轮竞争的开始。如果业务对锁的获取顺序有要求普通锁无法满足得用FairLock。3.5 FairLock排队和普通锁抢跑的区别Redisson提供了RedissonFairLock内部的等待结构不再是单纯的PubSub信号量而是维护了一个基于Redis有序集合和链表的FIFO等待队列。FairLock的逻辑粗略说是当锁被占用时后续线程会按到达顺序排队锁释放后只有队列头部节点有资格获取锁头部拿到锁后才依次向后推进。公平锁让等待方有了明确次序避免了“晚到先得”和“唤醒风暴”的问题但代价是每次加解锁的Lua脚本逻辑更重Redis端的计算更多且多了一个队列结构的维护成本。大多数业务其实不需要严格公平只要互斥成立就够了。如果你是拿锁做计数器扣减、幂等校验这类场景普通锁完全够用用FairLock反而会放大Redis压力。只有当你需要“谁先来谁先处理”的业务顺序时才值得为公平性买单。4. 超时续约看门狗那10秒一次的续约背后藏着什么4.1 默认30秒的锁有效期为什么要设成30秒Redisson的lockWatchdogTimeout配置默认是30000毫秒也就是锁最长存活30秒。这个值既不能太长也不能太短。太长意味着如果客户端进程真的崩溃了其他线程要干等一份基础TTL才能接管锁。比如你把watchdogTimeout调到10分钟某个节点突然宕机其他节点要等10分钟才能重新进来这在故障恢复场景里是灾难。太短则意味着业务代码稍微慢一点锁就可能在执行中失效。30秒是一个在大多数场景下都够用的折中值绝大多数数据库操作、缓存更新、接口调用的单次耗时都不会超过30秒即便超过看门狗也能在每10秒的续约节点把它续回来。配置是全局生效的Config config new Config(); config.setLockWatchdogTimeout(20000); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient redisson Redisson.create(config);改任何一个锁的看门狗时间都会影响整个客户端创建的所有锁。如果只是想单把锁的leaseTime更短不要改全局配置而是用lock(leaseTime, unit)重载或者在tryLock里显式传leaseTime。4.2 看门狗从启动到退休EXPIRATION_RENEWAL_MAP与TimeTask看门狗并没有一个独立的“无限循环线程”在跑它是由Redisson底层Netty的Timer一颗颗任务串起来的。加锁时如果当前获取锁的leaseTime是-1表示不手动指定过期时间Redisson会在锁拿到后调用scheduleExpirationRenewal(threadId)。核心数据结构是一个名为EXPIRATION_RENEWAL_MAP的ConcurrentHashMapkey是锁名value是一个ExpirationEntryExpirationEntry里记录了一组持有该锁的线程ID以及当前的定时任务。关键逻辑在于只有当某个锁的ExpirationEntry里加入的threadId是“第一个”时才真正启动定时续约任务后续线程重入时只是把threadId加进集合不会重复创建Timer。这样避免了重入N次就产生N个看门狗任务的资源浪费。定时任务本身是这样调度的// 伪代码看门狗定时任务的结构 Timeout task serviceManager.newTimeout(new TimerTask() { Override public void run(Timeout timeout) throws Exception { // 每隔internalLockLeaseTime / 3执行一次续约 renewExpiration(); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);默认internalLockLeaseTime是30000ms除以3就是10000ms所以每10秒续约一次。为什么是3等分而不是“每30秒直接续约30秒”因为网络传输、Redis执行、客户端处理都有延迟如果锁还剩10毫秒时才发起续约一个网络抖动就可能让锁过期。每10秒续一次约能让锁的剩余时间长期稳定在20到30秒之间留足了抖动空间。续约动作最终会请求一段Lua脚本if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(pexpire, KEYS[1], ARGV[1]); return 1; end; return 0;这个脚本只做一件事检查持有者标识还在不在在就把过期时间重置为30秒如果锁已经被删除或锁的field被清除则返回0上层收到0后会终止后续续约调度并清理EXPIRATION_RENEWAL_MAP里的记录。4.3 续约不是永动的STW、主从切换和自定义leaseTime看门狗看起来很聪明但绝不是无限续约。它的生命有以下几个终止条件第一个终止条件是解锁。线程调用unlock时Redisson在锁成功释放后会调用cancelExpirationRenewal(threadId)把定时任务清掉。《嗯这里要注意无论锁是由线程A正常释放还是因为过期被自动回收续约任务都要有一个出口。正常释放会取消定时任务如果锁因异常情况提前没了下一次续约脚本会因hexists返回0而终止调度这是双重兜底。第二个终止条件是客户端进程崩溃或与Redis连接断开。看门狗调度依赖Redis客户端的EventLoop。进程没了Timer当然不再执行锁本身会在30秒后过期释放不会造成永久死锁这是“默认有效期”最大的意义。第三个是执行环境发生严重的STW停顿。这是实际生产里最容易忽视的问题。一次Full GC引起的长停顿如果超过了30秒锁的过期时间早就到了而看门狗由于事件循环也停住了根本没机会续约。等GC恢复业务代码继续往下跑但它手里那把锁已经失效了另一个线程可能已经同时持锁执行。这个问题靠Redisson本身无法解决只能从业务角度缩短临界区、控制JVM堆大小和GC策略或者接受极小概率的双执行风险。第四个是手动指定了leaseTime的情况。只要调用方传了leaseTime大于0Redisson就认为“锁能活多久由你决定”不会启动看门狗。这在某些短租约场景下是合理的但前提是你对业务耗时上限有严格把握。许多人踩坑就是这个锁内业务平时只要200毫秒某次触发了一条慢SQL跑了8秒而leaseTime只给了5秒锁提前消失另一个请求趁虚而入数据就出了问题。5. 组合使用中的真实踩坑记录附排查过程5.1 手动leaseTime导致的“提前释放”排查链路先讲一个线上事故订单服务的库存扣减接口A和B两台机器同时处理同一个订单的退款和改价两股流程都会改库存快照。代码里明明加了锁还是出现了两次扣减重叠。排查的第一步是去看锁的入参。发现调用方式是tryLock(3, 8, TimeUnit.SECONDS)第一个参数是等待3秒第二个参数是锁租约8秒。开发同学当时认为“库存扣减最多2秒8秒足够”。但忽略了一个事实这个接口除了扣库存还会调一次会员系统的RPC查询RPC的超时时间设了10秒。当一次会员系统调用网络抖动到8秒时锁在第八秒到期而业务代码还在等待RPC返回。同一时刻另一个流程进来拿到锁也走了一遍“查询会员扣库存”两边基于不同的快照版本写数据自然产生了覆盖。修复方案不是把leaseTime拉到50秒而是先拆掉锁内无关的RPC调用让临界区只保留真正的“检查和更新库存”两步。锁的租约只有当业务必须整体串行时才有意义如果锁内塞了过多外部依赖再长的租约都只是概率性兜底。原则上我建议先用不传leaseTime的tryLock(waitTime, TimeUnit.SECONDS)让看门狗接管续约如果业务对“锁不能太晚自动释放”有强约束再显式传leaseTime但leaseTime必须大于历史业务耗时的P99。5.2 锁内跨越线程重入计数不认账第二个坑来自一个批处理任务主线程拿到锁后把一批数据交给线程池并行处理最后主线程统一unlock。结果一跑起来某个子线程在处理过程中也调用了lock.tryLock(1, TimeUnit.SECONDS)运气好它拿到了同一把锁的等待资格运气不好直接报IllegalMonitorStateException。日志里的异常栈指向Redisson的unlock方法但看代码逻辑怎么都找不到是谁乱unlock。问题的本质是锁的持有者标识绑定的是“UUID:主线程ID”子线程的ID完全不同。主线程加锁后子线程在锁内调用tryLockRedisson会认为子线程是另一个持有者。如果主线程在子线程没结束前unlock锁就没了如果子线程持锁完成后再unlock又会因为子线程的field不存在而报错。分布式锁不认线程池。可重入语义只在同一个线程的调用栈里有效。锁内若必须做异步操作最常见的安全做法是让子线程自己加锁、自己解锁锁key设计得更细或者干脆避免在锁内跨线程传任务把异步处理器整体挪到锁外面。用Spring的Async时更要小心方法级别的线程切换会直接切断锁的归属。5.3 waitTime被低估排队请求成批失败第三个问题不那么血腥但很影响业务成功率。某营销系统做活动积分发放用户请求进来后用tryLock(2, TimeUnit.SECONDS)做防重复锁key是activity:grant:{userId}。平时一切正常活动开始时大量用户同时领积分日志里不断出现“获取锁失败”的告警成功率的曲线直接掉下来。计算一下就能发现问题单个用户积分发放的锁内操作平均耗时在500毫秒左右锁key按用户维度拆分但活动放量时同一用户的并发请求并不高不同用户的锁互相不影响。问题出在别的地方——他们用了一个公共组件组件里把锁key设计成了activity:grant这种不带userId的全局key结果所有用户都在抢同一把锁。这种情况从代码上根本看不出有锁粒度问题只有去Redis查锁key的分布才能发现锁的并发窗口里只有一个key在跳动。排查时可以用redis-cli --bigkeys或者SCAN扫一下当前锁key的分布看同一时段内活跃锁key数量是否符合预期。如果永远只有一个key就说明锁粒度被无意识地放大了。解决办法是把锁key精确到业务对象活动维度用activity:grant:{activityId}:{userId}资源维度用resource:lock:{resourceId}。锁不是越粗越安全过粗的锁等价于全局串行分布式锁的并发能力就被浪费了。5.4 集群主从切换下“双主锁”的现实困境第四个问题是架构层面的。一个业务用Redis Sentinel做高可用某次Sentinel触发主从切换旧主节点上的锁key还没同步到新主节点。此时客户端A认为它仍持锁因为A的看门狗还在续约但续约请求已经打到新主节点上发现根本没有这个key续约脚本返回0续约链路终止。更麻烦的是客户端B在新主节点上执行加锁脚本此时旧主节点上的锁状态已经“消失”了B会成功创建一把同名的锁。于是A和B以为自己拿到了同一把锁的互斥权实际上两边手里的锁已经被主从切换隔断成两份。这不是Redisson的实现问题而是任何基于单Redis副本的分布式锁在“主从异步复制丢状态”场景下都绕不开的困境。如果你的业务对“同一时刻只有一个人在写”有极其严格的要求同时Redis集群又存在主从切换窗口最简单直接的缓解手段是Redis集群层面开启min-replicas-to-write 1让写操作必须同步到至少一个从节点才算成功。但这也会牺牲可用性主从断连时写请求会直接失败需要业务侧权衡。真正严谨的跨节点容错方案是RedLock或其他多节点仲裁机制但这套方案成本高、实现细节多生产里很少见。大多数业务其实可以接受“极小概率窗口内的双写”只要把底层数据操作做成幂等双写之后靠补偿或者唯一索引兜底比为了锁去搭建复杂仲裁体系现实得多。5.5 调试可重入时的实用小技巧如果你怀疑某个锁的可重入状态不对Redisson在运行时提供了getHoldCount()方法可以返回当前锁持有的重入次数。在测试环境打印lock.getHoldCount()能快速确认锁是被嵌套加了几层还是哪次unlock多减了一次。另一个实用技巧是在解锁处给锁的key和threadId打日志特别是线上环境把lock.getName()和当前线程ID打到业务日志里。排查“谁在解锁谁”、”谁提前释放了锁“时这行日志比任何监控都直接。6. 我的默认参数习惯与最后一轮自查清单6.1 不同业务场景下我常用的锁参数每个人对锁参数都有自己的偏好我经过了多次事故之后现在基本形成了几个固定选择业务场景推荐写法理由定时任务、批处理lock()愿意等锁错过一批不亏尤其适合“整点只该跑一次”的Job普通接口防并发tryLock(1~3, TimeUnit.SECONDS)接口本身有超时时间等待锁不应该超过接口容忍延迟强一致且限定耗时的操作tryLock(waitTime, 10, unit)并让leaseTime显著大于业务P99耗时手动租约适合短临界区但务必经过压测热点资源并发很高细化锁key缩小粒度避免所有请求排队抢同一把锁对公平性有硬要求的排队任务redisson.getFairLock(key).lock()保证先来先服务一个重要参数关系是waitTime和锁内业务耗时的匹配。假设锁平均被持有500毫秒你在同一时刻有10个线程在等这把锁那么最后一个线程等待的时间粗略估计是(N-1) × 500ms 4.5秒。如果waitTime只给了2秒必然有多个线程抢不到锁返回false。选waitTime前先想清楚这个锁要承载多少并发竞争不要凭感觉填数字。6.2 上线前必自查的六个问题我总结了一个简单的自查清单每次新写一个分布式锁都会过一遍锁key是不是精确到业务对象而不是一个笼统的常量。写锁时先问自己“这把锁到底在保护哪一份数据”。锁内有没有调用外部RPC或远程服务。如果有关键依赖优先把它移出临界区移不出确认leaseTime或看门狗能覆盖。有没有使用线程池、异步线程、Async在锁内干活。有了要么统一到同一线程要么让子线程自己拿锁。能不能接受锁过期后另一个线程进入。如果数据层有唯一索引或状态机约束双执行的影响可控如果完全没有需要把锁粒度、租约时间、Redis高可用方案一起重新评估。是否经过等待时间估算。waitTime不是拍脑袋要结合锁的持有时间、等待队列长度、业务可容忍延迟综合计算。上线前是否打印过锁key、threadId、获取结果等关键日志。分布式锁的排错难度比普通代码高得多前期日志越完整后期排查越省事。最后补一个我个人的小习惯很少直接在生产代码里裸写lock()和unlock()而是包一层简单的AOP或模板方法统一处理tryLock结果判断、finally解锁和日志打印。这样即使团队里有人把tryLock返回值忽略编译/检查阶段也能通过注释和模板把手限制住。Redisson给了你很强的工具箱但选择权永远在写代码的人手里。