Redisson分布式锁全解:手写踩坑、看门狗续期与多样化锁实战

发布时间:2026/10/2 8:54:27
Redisson分布式锁全解:手写踩坑、看门狗续期与多样化锁实战 1. 从一把Redis锁说起先搞清楚为什么需要分布式锁1.1 哪些场景真的离不开分布式锁我最早接触分布式锁是在一个秒杀项目里。多台后端实例同时处理同一个商品的扣减请求刚开始用synchronized上线第二天就发现超卖老板脸上阴云密布。后来调出日志一看锁只锁住了单台机器的线程另外两台服务器该抢照抢。那一刻我才真正理解单机锁和分布式锁之间隔着的不是代码量而是对多节点共享资源互斥的理解。分布式锁解决的核心问题就是让分布在多个进程、多台机器上的业务逻辑在操作共享资源时保持互斥。典型场景包括库存扣减、防止缓存击穿、分布式定时任务调度多实例只允许一台执行、幂等控制防止用户重复提交、跨服务转账等。判断一个场景是否真的需要分布式锁我有个土办法加锁的代码同一时刻是否会被多个JVM实例执行是就需要只是单机并发那就别为了看起来高级引入分布式锁徒增运维负担。很多读者刚接触分布式锁时会不自觉把它和数据库唯一索引、消息队列顺序消费混为一谈。实际上它们解决的问题层次不同唯一索引解决的是写入不重复消息队列顺序消费解决的是同一个分片内有序而分布式锁解决的是临界区互斥访问。搞清楚这句话后续选型就不容易跑偏。1.2 单节点锁与分布式锁的本质区别单节点锁比如JVM的ReentrantLock依赖内存中的状态标记同一个进程内的线程可以通过共享内存感知锁的持有与释放。但一旦跨进程内存状态无法同步必须借助一个所有节点都能访问的第三方组件来备案这个组件就是锁的协调者。常见的有Redis、ZooKeeper、etcd、数据库各有各的脾气。Redisson选择Redis做协调者是因为Redis本身性能极高单线程模型天然串行化命令非常适合做互斥判断再加上Redis的过期时间机制能够兜底死锁让锁自动释放成为可能。相比ZooKeeper的临时节点需要长连接和心跳维持Redis的实现更轻量在绝大多数并发不超过万级的业务场景里性能表现已经绰绰有余。不过Redis分布式锁也有先天短板如果Redis发生主从切换锁信息可能丢失导致锁失效。这也是后面红锁RedLock被讨论的根源。我平时在线上项目中会根据业务容忍度决定是否要上多节点方案而不是一味追求最安全。1.3 一把合格的分布式锁必须满足的条件我面试别人或者被面试的时候经常被问到一个问题什么是一把好的分布式锁标准答案一般包含四点我用自己的话复述一遍。互斥性任意时刻只有一个客户端能持有锁这是底线。安全性锁必须设过期时间防止持锁者宕机导致死锁也就是锁不能永远不释放。可用性加锁和解锁要尽量快不能因为锁服务本身的瓶颈拖垮业务。容错性部分节点故障时锁服务仍然能正常工作。此外我还会补充两个容易被忽视的细节一是锁要支持可重入因为业务代码里经常出现方法嵌套调用如果每次重入都重新抢锁百分百死锁。Redisson的锁天然支持重入这也是它比裸写Redis SETNX更省心的原因之一。二是加锁和解锁必须有客户端标识校验防止A线程把B线程的锁误删这其实是手写实现时最容易踩的坑下文会细讲。2. 手写Redis分布式锁从踩坑到极限2.1 SETNX加锁的正确姿势很多人第一次实现分布式锁都是从SETNX开始的。SETNX的作用是如果key不存在才写入完美契合抢锁语义。但早期的实现有一个经典错误单独用SETNX加锁再用EXPIRE设置过期时间两步之间非原子。如果第二步失败Redis里就会留下一个永远不过期的锁直接击穿所有兜底机制。正确做法是使用一条命令完成加锁和过期设置SET lock_key unique_value NX EX 30其中NX表示只有key不存在时才能写入EX 30表示锁自动过期时间为30秒unique_value是客户端生成唯一标识用来在后面解锁时证明锁是我的。如果写成两条命令一旦客户端在两步之间宕机锁就变成了永久锁。这个优化只需要改一行命令却能避开最大的死锁隐患属于典型的用知识换事故。2.2 过期时间与误删锁的经典陷阱加锁解决了解锁又是个坑。我先展示一段错误代码读者可以自查有没有写过类似的// 错误示例 if (redis.call(exists, key) 1) { redis.call(del, key); }这个实现有个致命漏洞当锁到了过期时间自动释放后另一个线程B成功抢到了锁。此时线程A业务还没执行完它看到key存在直接删除等于把B的锁解了B的业务接着跑临界区瞬间变成两个线程同时进入。这就是误删锁问题。解决办法是先校验后删除而且两步必须原子执行。Redis中的Lua脚本是最佳载体if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end脚本先比较unique_value确认锁的持有者是自己才删除。我把这个脚本封装成一个工具方法工具类里还包含加锁和重试逻辑整体稳定跑了大半年没出过问题。手写锁不是不能用而是需要你足够细心把原子性、阻塞等待、重入、续期、容错全部自己处理一遍。2.3 Lua脚本与tryLock的重试机制如果业务抢不到锁我希望它等待而不是直接失败这就需要重试机制。手写实现时可以在循环里反复尝试加锁同时设置最大等待时间超时后返回失败。核心逻辑类似public boolean tryLock(String key, String value, long waitTime, long leaseTime) { long start System.currentTimeMillis(); while ((System.currentTimeMillis() - start) waitTime) { if (SET(key, value, NX, EX, leaseTime)) { return true; } Thread.sleep(50); } return false; }这个实现有几个隐蔽问题sleep间隔太短Redis压力大太长等待时间不可控锁过期时间没法动态续期业务处理超过leaseTime时锁已经自动释放后面的线程会提前进入临界区。想到这里我不禁感叹这才是很多人转向Redisson的真正原因——不是手写不了而是手写的锁在真实业务场景里要么重试策略粗糙要么生命周期管理不完善始终差一口气。3. Redisson正式入场一个库搞定锁的续航和重入3.1 Redisson到底是什么为什么够格当主角Redisson是一个基于Redis的Java客户端但它又不止是客户端。它把分布式锁、分布式集合、消息队列、布隆过滤器、限流器等常用的分布式组件全部封装成了开箱即用的Java API。你可以把它理解成Redis界的Spring Boot把原本需要手写的大量样板代码内化成一行API调用。在分布式锁领域Redisson最大的贡献是帮你解决了两个手写实现最容易翻车的问题。第一可重入锁的计数管理同一个线程重复获取锁时锁的计数器自动加一释放时减一直到归零才真正删除key。第二锁的自动续期通过后台定时任务为未释放的锁持续刷新过期时间避免业务没执行完、锁先到期的问题。另外Redisson还提供了多种锁类型可重入锁、公平锁、读写锁、联锁、红锁、信号量、闭锁。这些锁对应不同业务场景组合起来就是一个完整的分布式锁工具箱。我在下文会逐个拆解并给出可以拿去用的代码片段和配置说明。3.2 WatchDog续期机制锁为什么不会提前失效我刚用Redisson时对它的看门狗机制特别感兴趣。默认情况下调用lock()方法Redisson会为锁设置一个30秒的leaseTime但这不是写死30秒后必然释放。如果锁还没有释放Redisson会通过一个后台定时任务每过三分之一的锁有效期也就是默认每10秒就自动把锁的过期时间重置为30秒。这个后台任务就是看门狗WatchDog。为什么需要续期因为业务代码的执行时间是不可预测的。网购高峰期一次库存扣减可能因为下游接口慢从几百毫秒拖到几秒甚至几十秒。如果锁的过期时间写死长尾请求会拿着一个已经失效的锁继续操作互斥性就被破坏了。WatchDog解决的正是锁生命周期跟不上业务生命周期的问题。需要注意tryLock和lock的默认续期行为不同。lock()方法默认启用WatchDog续期只要锁没显式解锁就一直续而tryLock(long waitTime, long leaseTime, TimeUnit unit)如果传了leaseTime则不会续期到期自动释放。用tryLock传leaseTime的语义是业务方自己预估最大执行时间超时锁就释放这是一个精细但很容易忽略的设计。我的习惯是能够确认最大执行时间的场景显式传leaseTime并关闭续期避免后台线程空转无法确认执行时间的场景用默认的看门狗续期机制兜底。3.3 可重入锁的正确用法Redisson最常用的锁是可重入锁对应接口RLock它天然支持同一个线程的重复获取。下面是一个标准的用法我把它作为团队内部的基础模板Autowired private RedissonClient redissonClient; public void deductStock(Long productId, Integer count) { String lockKey stock:product: productId; RLock lock redissonClient.getLock(lockKey); try { // 等待最多2秒拿到锁后30秒自动过期不续期 boolean locked lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } // 业务操作检查库存、扣减库存 doDeduct(productId, count); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(抢锁过程被中断); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }这段代码有几个关键点tryLock会阻塞等待最多2秒获取锁失败时不至于直接报错isHeldByCurrentThread用来防止业务未抢到锁却执行了unlock避免解掉别人的锁finally里释放锁是铁的纪律无论业务成功还是异常都必须释放。可重入性还意味着同一个线程在A方法里拿到锁后调用B方法时可以再次获取同一个key的锁计数器加一不会死锁这让服务链路中的嵌套调用非常从容。4. 多样化锁逐个拆解不同场景用不同的锁4.1 公平锁排队比抢购重要Redisson的公平锁FairLock会保证多个客户端获取锁的顺序和请求顺序一致类似于先到先得。默认的可重入锁是非公平的多个线程抢锁时谁先抢到谁先得后来的线程可能出现饿死或长时间等待。需要严格控制顺序的场景比如给用户发放稀缺权益、按工单顺序处理任务使用公平锁更符合直觉。使用方式和普通锁几乎没有区别RLock fairLock redissonClient.getFairLock(queue:order: orderId); try { fairLock.lock(5, TimeUnit.SECONDS); processOrder(orderId); } finally { fairLock.unlock(); }公平锁的内部实现是基于Redis的ZSet有序集合请求线程会先写入一个等待队列然后按顺序从队首取出锁。代价是性能比非公平锁要低因为每次加锁解锁都要维护有序集合。我在实际项目里只在需要严格顺序的场景使用公平锁绝大多数秒杀、扣库存场景用不上它没必要为了看起来公平牺牲吞吐。4.2 读写锁读多写少场景的利器读写锁ReadWriteLock提供了两把锁读锁和写锁。读锁之间可以共享写锁和任何锁都互斥。这种多读单写的模型非常适合缓存刷新、配置中心、热点数据的低频更新高频读取场景。Redisson的RReadWriteLock用法和Java原生读写锁非常像RReadWriteLock rwLock redissonClient.getReadWriteLock(cache:product: productId); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock(); // 读操作 readLock.lock(); try { cache.get(productId); } finally { readLock.unlock(); } // 写操作 writeLock.lock(); try { cache.put(productId, data); } finally { writeLock.unlock(); }这里需要强调一个语义读写锁并不是无脑提升并发它的收益只体现在读多写少的场景。如果写操作频繁读锁和写锁互相排斥性能可能比单一可重入锁更差。我踩过的一个坑系统里配置了读写锁但是写操作占了一半比例结果热点数据的吞吐反而下降明显。后来把写频率高的数据改成可重入锁读多写少的配置缓存用读写锁效果立刻正常了。锁的选型不是越复杂越好永远从业务读写比例出发。4.3 联锁与红锁多节点协同的极限挑战如果业务需要同时获取多把锁才能进入临界区可以使用联锁MultiLock。Redisson的MultiLock允许一次锁定多个RLock只有所有这些锁都获取成功才算加锁成功。比如跨库存中心扣减多个仓库的库存如果每个仓库有独立的Redis联锁能保证操作的原子性。红锁RedLock则更进一步它要求客户端在多个独立的Redis节点上分别加锁只有在大多数节点比如五个节点中的至少三个加锁成功才认为锁已持有。这个方案能有效应对单个Redis节点宕机导致的锁丢失问题代价是部署成本高、性能下降而且实现复杂。Redisson提供了现成的RedissonMultiLock可以组合多个节点的锁。我用红锁的经验有限因为它引入的运维复杂度不是所有团队都能承受。大多数场景下单集群Redis加上合理的锁超时设置已经足够。如果真有极高的一致性要求我更倾向于直接用ZooKeeper或者etcd的分布式锁而不是在Redis上强行玩红锁——这不是技术选型偏好问题而是用合适工具解决合适问题的基本判断。联锁和红锁的编码方式类似构建多个锁对象后交给MultiLock统一管理即可重点在于节点的独立性和部署配置这里不展开裁开。4.4 信号量、闭锁与单机版的一个真实使用案例Redisson不只有互斥锁还有信号量Semaphore和闭锁CountDownLatch这两个特别适合做流量控制和多节点任务编排。信号量用来限制访问某个资源的并发数量比如限制某个接口的瞬时并发为100。它和线程池里的信号量的区别在于这里的计数是全局的多台机器共享同一个最大许可数。高并发场景下可以用来做平滑限流的兜底RSemaphore semaphore redissonClient.getSemaphore(gate:promotion); semaphore.trySetPermits(100); if (semaphore.tryAcquire()) { try { handleRequest(); } finally { semaphore.release(); } }闭锁RCountDownLatch则常用于等待多个分布式任务全部完成后再执行后续逻辑。典型场景是大数据量批量处理后各分片节点处理完自己的数据计数减一主节点直到计数归零才继续。它和Java的CountDownLatch思路一致只是把等待逻辑从单机内存扩展到了多个实例。我印象最深的真实案例是一个活动页多个服务分别负责生成不同的活动数据全部完成后才允许用户进入页面。用RCountDownLatch等待三个数据源就绪超时时间为5秒实测下来非常顺畅。分布式锁的价值不止互斥还体现在协调多节点完成整体任务上这也是多样化锁这个标题想强调的核心。5. 实战中踩过的坑整理成问题排查速查表5.1 锁和事务一起吃锁为何突然失效很多人在Spring项目里同时使用Transactional和Redis锁发现锁并不能有效阻止并发问题。原因在于事务的提交时机和锁的释放时机不一致。如果一个方法带有Transactional方法执行完不代表事务提交完成而是等事务代理提交后数据库操作才真正落盘。假如锁先释放另一个线程立刻进入它读到的可能是尚未提交的旧数据脏读、超卖随之而来。正确的做法是把锁的范围扩大到事务提交之后或者把数据库操作拆成两个方法让锁在事务外层加提交完再解锁。我在团队里定的规范是加锁的逻辑和事务逻辑分离锁放在Controller或者Service入口层事务放在内部独立方法保证锁的释放晚于事务提交。这个坑极其隐蔽线上排查时日志里锁都正常加、正常释放但数据就是错了新手往往无从下手最后发现是事务边界在搞鬼。5.2 锁的粒度、超时时间和WatchDog的取舍锁粒度过大是性能杀手。比如把同一个订单下的所有子商品都用一把锁保护互斥范围被过度放大本来没有竞争关系的操作也被串行化吞吐自然上不去。我的做法是尽量按业务维度拆分key比如商品库存按skuId加锁订单状态按orderId加锁用户资产按userId加锁让锁尽量贴近被保护的资源。超时时间的设置也要结合业务实际。锁的过期时间太长宕机后恢复时间长太短在高峰期业务没执行完锁就自动释放临界区被打开。我的经验值通常设置为基础业务耗时的3到5倍并在压测环境中验证。使用看门狗续期时则要小心后台任务本身的开销频繁刷新过期时间会产生大量Redis写命令尤其是锁数量大的时候需要关注Redis的CPU和网络指标。加锁失败的策略同样重要。直接抛异常虽然简单但用户体验差无限等待又会拖垮调用方。我一般设置等待时间waitTime为1到3秒抢锁失败返回系统繁忙同时记录WARN日志靠报警平台监测失败率。分布式锁不是不让你失败而是让失败快速、可控、可观测。5.3 Redis主从切换和锁丢失的争议这部分想聊一个绕不开的话题Redis发生主从切换时刚刚写入的锁key可能还没同步到从节点主节点故障后从节点顶上锁信息丢失另一个客户端趁虚而入成功加锁原有锁的互斥性被打破。严格意义上这是Redis分布式锁的先天缺陷。Redisson提供了RedLock来解决但代价巨大。我的建议是先把业务对锁丢失的容忍度量化。如果涉及资金安全或强一致场景比如支付、转账我的态度是宁可使用etcd或ZooKeeper也不装红锁自欺欺人如果是典型的互联网业务如秒杀、活动领券锁丢失造成的损失只是偶发超量发放通过事后对账和补偿可以兜住Redis锁完全够用还要什么自行车。这里送读者一句我认为最重要的判断标准分布式锁是以可用性换一致性还是以一致性换可用性要根据业务对数据错误的可修复程度来定。没有银弹只有取舍。5.4 高频面试题视角如何把锁讲清楚因为分布式锁面试题经常被搜我顺带整理几个高频问题的回答思路。面试官问Redis分布式锁为什么用Lua脚本本质是考你原子性意识。加锁的SET NX EX是原子命令解锁的校验删除必须依靠Lua脚本的原子执行否则就有误删锁和超时删除的问题。问Redisson的看门狗原理是什么重点是要说出默认leaseTime 30秒、每10秒续期一次的逻辑并引申到业务执行时间和锁生命周期的匹配问题。问你为什么不直接用SETNX。参考答案是手写SETNX要自行处理可重入、续期、重试、死锁恢复、公平排队等问题而Redisson把这些成熟方案都封装好经过大量生产环境验证。面试时能顺手补一个自己实际踩过的坑比如误删锁和事务边界导致锁失效会比背概念更有说服力。我见过不少候选人把分布式锁的实现细节背得滚瓜烂熟但问到他锁丢失怎么办如何看待主从切换时就开始含糊。其实面试官真正想听的不是结论而是你权衡方案的过程。能把利弊讲清楚比给出一个绝对答案重要得多。6. 我从这些实践中留下的个人心得回头再看这个标题多样化锁实现真正吸引人的不是API列表而是每一把锁背后的适用场景和代价。Redisson带来的不仅是几分钟上手的便捷更是一套经过设计的分布式协作思想可重入锁保护临界区公平锁维护顺序读写锁优化读多写少信号量控制并发额度联锁和红锁处理多节点复杂性。理解这些才算理解锁的本质。我现在接手一个新系统第一件事就是梳理哪些资源需要保护、竞争激烈程度如何、容忍容忍度多高再去选锁类型。分布式锁不是越多越好也不是越复杂越安全它在大多数时候是最后一道防线而不是第一件武器。真正的稳妥是把加锁范围做小、把超时做合理、把失败做优雅、把数据不一致的修复预案做扎实。好锁帮你不乱但救不了烂的业务设计。另外我还想多说一句无论技术选型多成熟都要保留足够的监控和可观测性。锁的等待时间、获取次数、释放是否及时、Redis内存是否异常增长这些都是分布式锁健康度的核心指标。我在线上会给每类锁加上独立的metrics一旦某个锁的等待耗时突然上升就能提前发现热点和死锁苗头。技术方案落地之后真正的功夫都藏在运维细节里。