Redisson分布式锁实战:原理、看门狗与生产故障排查

发布时间:2026/10/6 16:20:13
Redisson分布式锁实战:原理、看门狗与生产故障排查 写这篇之前我先说个题外话标题里的“Redission”其实是指Redisson这个红色开头的Java客户端当年做分布式锁时很多人会拼错。不过这不重要重要的是它确实是把Redis分布式锁做到极致的一个库。市面上讲分布式锁的文章很多但我翻了半天几十篇发现大多停在“用SETNX加锁”的阶段甚至还有人拿setIfAbsent简单包一下就敢说实现了分布式锁。真要到了线上多实例并发锁偷偷过期、锁续期导致死锁、Redis主从切换锁直接丢这些才是真正能把人坑到加班的问题。所以这篇文章我就按自己从零开始接入Redisson到上线的实战过程来写把快速入门的每一步、背后的原理、以及我踩过的坑都过一次。如果你是刚开始接触分布式锁或者已经写了不少SETNX代码但总感觉不踏实这篇应该能帮上忙。整体分五块讲先搞清楚分布式锁到底在锁什么然后跑通Redisson的最小可用代码再深入看门狗和Lua脚本的核心机制接着分享几个真实生产故障的排查链路最后把面试题和选型对比一并收掉。1. 先说清楚分布式锁到底在锁什么以及为什么不是一把MySQL锁很多人一听到分布式锁就觉得是个很高端的东西其实它就解决一个问题多个进程之间互斥访问共享资源。1.1 单机锁失效的场景在单体应用里synchronized、ReentrantLock都挺好用它们锁定的是JVM进程内的线程。但一旦应用水平扩展成两个节点比如A服务和B服务都连着同一个数据库用户下单扣库存的接口同时被打到这两台机器上单机的Thread锁就完全失效了——A节点的线程锁不住B节点的线程两个请求会同时去改数据库里的库存字段。我自己第一次做秒杀活动时就是这么栽的。上线前压测一切正常因为压测工具默认只打一台实例。后来把流量调度到双节点后商品超卖订单直接冒了出来。当时组里大佬问了一句你用的是什么锁我说synchronized啊他才点了一句你锁的是本机不是资源。从那以后我才真正理解锁的本质是对资源的互斥约定在分布式环境里这个约定必须放在所有节点都能访问到的公共组件上比如数据库、ZooKeeper、Redis。1.2 数据库锁和Redis锁之间的取舍市面上实现分布式锁的方案就那么几类。用数据库唯一约束或者for update能实现但性能差压个几千并发数据库就扛不住锁还容易滞留。ZooKeeper用临时顺序节点做锁很可靠缺点是引入一套ZK集群的成本不低而且会话心跳本身也有几十毫秒的延迟。Redis方案最大的优势是快单线程模型下指令执行微秒级加上原子操作和过期时间机制做锁特别顺手。但Redis做锁有一个经典缺陷锁的释放和过期时间需要精细设计否则容易出现死锁或者误删。后来Netty社区的大佬们搞了Redisson把所有麻烦都封装掉了。1.3 Redisson在众多客户端里为什么脱颖而出我用过Jedis、Lettuce也手动写过十几行Lua脚本包好锁逻辑但Redisson给我的第一感觉是它把锁做成了一个Java对象。RLock lock redissonClient.getLock(inventory_lock); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }就这么三段代码锁的获取、自动续期、可重入、释放全都处理好了。没有Lua脚本没有过期时间计算没有难听的我到底要不要删key判断。这也是这篇文章要重点带大家上手的东西——Redis做锁不难难在锁的生命周期管理而Redisson把这块封装干净了。2. 五分钟跑通第一个Redisson分布式锁依赖、客户端、最小代码这一节直接进入实操。我的环境是Spring Boot 2.x Redis 6.xJava 8下面的代码都在这套环境验证过。2.1 Maven依赖和客户端初始化Redisson在Maven中央仓库的groupId是org.redisson我习惯直接引入最新稳定版dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.4/version /dependency如果你不想用Spring Boot的自动配置只想在普通Java项目里用可以引redisson依赖然后手动建客户端。我个人更喜欢手写配置类因为能直观看到连接池、超时时间这些参数。Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(50) .setIdleConnectionTimeout(60000) .setConnectTimeout(10000) .setTimeout(3000); return Redisson.create(config); } }destroyMethod shutdown这个细节值得注意Spring容器关闭时会顺手把Redisson的连接池关掉不然测试环境里执行完main方法或停掉应用后Netty线程还挂在那很容易留一堆未释放的连接。2.2 最基础的锁代码lock/unlock与tryLock有了客户端锁的获取和释放就非常直白了Autowired private RedissonClient redissonClient; public void deductStock(String productId, int count) { RLock lock redissonClient.getLock(product: productId); lock.lock(); try { // 检查库存、扣减库存的数据库操作 } finally { lock.unlock(); } }如果你看过Redis分布式锁的经典写法应该知道有个大坑手动加锁后必须手动续期手动释放前还要判断value是不是自己的否则会误删别人的锁。Redisson把这些全部封装进RLock了lock()方法默认就会开启看门狗自动续期unlock()方法也只会释放当前线程持有的锁所以误删问题也不存在。但真线上环境我更推荐用tryLock它能明确控制等待锁的时间和锁的持有时间避免线程无限期等下去boolean locked lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { throw new BizException(系统繁忙请稍后重试); } try { // 业务逻辑 } finally { if (locked) { lock.unlock(); } }这个重载的意思是最多等待5秒获取锁获取成功后锁自动在30秒后释放到期不续期。第三个参数传了值就相当于把自动续期的控制权交给调用方明确告诉Redisson最多锁30秒这适合那些业务耗时可控的场景。2.3 加锁报错和值判断的细节正常来说lock.lock()不会抛异常但它内部会在Redis不可用时抛出RedisConnectionException。还有一个很常见的细节不少人第一次用Redisson时会对同一把锁在方法A中lock()在方法B中unlock()结果发现报attempt to unlock lock, not locked by current thread。这是Redisson在并发场景下主动提示你“当前线程没有持有锁”本质上是你的编码结构破坏了可重入锁的配对规则。记住一个口诀哪个线程加锁哪个线程释放锁谁加锁谁释放尽量在同一个try-finally里完成。3. Redisson锁的核心机制拆解看门狗为什么值20行封装Lua脚本为什么必要很多人用Redisson就停留在调API的层面一旦面试官问看门狗实现原理是什么Redisson为什么不用Java代码判断锁而是用Lua脚本就答不上来。这块恰恰是入门之后必须吃透的内容。3.1 看门狗自动续期到底续了什么先回忆手动实现Redis锁时最痛苦的问题SET key value EX 30设置了过期时间但业务如果跑了40秒还没结束锁就自动消失了另一个请求就能抢到锁。这时候两个业务同时在跑锁形同虚设。Redisson的方案是引入看门狗watchdog机制。在说Redisson之前有个容易混淆的点Redisson本身并没有一个叫“Dog”的类它是通过ScheduleService配合Netty的时间轮HashedWheelTimer来做定时续期的。流程是这样的当你不传leaseTime调用lock()时默认加锁的过期时间lockWatchdogTimeout是30秒。接着Netty时间轮会每隔10秒检查一下这把锁如果还在持锁状态就执行一段续期Lua脚本把key的过期时间重新设置成30秒。业务执行多久锁就续期多久直到业务完成并显式unlock()。这里需要区分两个时间概念术语值作用lockWatchdogTimeout默认30秒加锁时的初始TTLwatchdog续期间隔默认10秒每10秒续期一次重置TTL为30秒续期是靠Lua脚本完成的if redis.call(hexists, KEYS[1], ARGV[1]) 1 then return redis.call(pexpire, KEYS[1], ARGV[2]) end return 0这段脚本的意思是检查这把锁Hash里的field还是不是当前线程的标识是的话就续期不是则不动。为什么要多这一步判断防止误续期。比如锁已经因为某些原因被删了或者已经被别人重新获取了你不能还傻乎乎给别人的锁续期。3.2 Lua脚本为什么原子性是分布式锁的命根子我自己最早写分布式锁是网上抄的模板Boolean flag redisTemplate.opsForValue().setIfAbsent(key, value, 30, TimeUnit.SECONDS);看起来没啥问题加锁、过期时间、原子性都有了。但解锁时怎么写很多人会写成先get再判断再del这三步在并发下就不是原子的。我见过一个真实故障线程A持有锁业务卡住超过了过期时间锁到期自动释放线程B拿到锁开始执行这时候A终于恢复过来直接del删掉key把B的锁误删了。经典删了别人的锁事故。正确做法是用Lua把“判断删除”合并成一步if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedisson就是把类似这样的Lua逻辑全部内置了。加锁的时候一条Lua脚本完成“检查锁是否存在 — 不存在则创建Hash并记录重入次数 — 存在则判断是不是当前线程并递增重入次数”。这保证了整个获取锁的过程不会被人从中间打断。3.3 可重入锁和公平锁Redisson是怎么做的RLock接口直接继承了JDK的Lock接口所以天然支持lockInterruptibly也支持用Condition做等待/通知。看实现可重入的信息存在一个Hash结构里product:123 - { xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:thread-1: 1 }Hash的field是UUID加线程ID拼出来的唯一标识value是重入次数。每次重入lock()value就加1每次unlock()value就减1减到0才真正删除key。这个设计跟JDK的ReentrantLock思路一脉相承所以你在一个事务里多次lock()同一把锁不会把自己锁死。如果想要公平锁Redisson里也很容易换实现RLock fairLock redissonClient.getFairLock(anyLock);这个是基于Redis ZSet加发布订阅实现的排队锁。它按线程请求顺序发令牌先到先得。代价是性能明显比普通互斥锁低。我一般只在用了消息队列还要保证顺序消费的场景里用它普通秒杀场景用不上。3.4 顺带聊聊红锁RedLock的争议Redisson也实现了RedissonRedLock即多节点加锁的RedLock算法。但这东西在业内争议不小Martin Kleppmann写过一篇文章质疑它的安全性说分布式系统里网络停顿、GC暂停会让分布式锁的假设不成立。Redis官方的态度也变化过后来更多建议是要么接受Redis单节点锁在极端情形下的不完美要么直接上ZooKeeper这类带强一致语义的中间件。我的个人看法是绝大多数公司里Redis单实例或Cluster模式配合Redisson已经足够用了别再引入RedLock给自己增加复杂性。真遇到Redis整个集群挂掉这种极端情况应当优先做业务兜底而不是赌一把分布式锁的强一致。这点后面讲选型时再展开。4. 真实生产环境下的翻车现场三个分布式锁故障的完整排查链路快速入门是一回事生产环境把锁用好是另一回事。几个印象深刻的线上故障值得写出来。4.1 锁一直拿不到一个排队订单把所有线程卡住背景是做了个采购单的幂等控制同一个采购单号要保证只能处理一次所以我直接用Redisson的lock.lock()在finally里unlock。上线第一天就出问题有个采购单特别大人工干预时反复触发处理逻辑导致这把锁一直被同一个线程持有其他线程调用时在lock()处无限等待最终线程池全部占满HTTP请求排队饿死。排查链路是这样的先看监控发现应用线程数飙升大量线程阻塞在Redisson的awaitAcquire上再看Redis某个key的TTL一直被刷新说明看门狗还在续期最后翻业务日志发现是有人把一个大订单重复提交了十多次每次的前一个请求都还没结束。修复方案不直接无限等待而是所有入口都改成tryLock(3, TimeUnit.SECONDS)拿不到锁就快速返回系统处理中。这就涉及一个重要原则分布式锁不是处理队列别让业务无限排队。该降级降级该返回提示返回提示把锁当作一个快速互斥的屏障而不是一个无底洞。4.2 锁明明加了为什么两个节点还能同时进临界区有段时间我们做一个跑批任务两台应用机器都配置了定时任务按道理加Redisson锁之后只会有一台机器真正跑。结果有一次数据却出现了重复处理看日志两台机器居然都进了临界区。排查过程很有代表性。先怀疑锁的key是不是不同结果两台机器的getLock(job:xxx)完全一致。再看Redis客户端配置都是指向Cluster模式。最后翻Redisson的配置文档才发现Config.useClusterServers()里我漏配了MasterSlaveConnectionMinimumIdleSize之类的问题都在其次真正的问题是Cluster模式下默认读写分离配置我把readFrom设置成了MASTER以外的值导致某些场景下读请求被路由到从节点。而从节点的数据在主从异步复制下是有延迟的两台机器各自连到了不同的从节点各自认为自己拿到了锁。看起来是“锁失效”其实是底层数据一致性没保证。修复方案很直接在Redisson的Cluster配置里显式设置锁相关操作的读写路由全部走主节点同时把checkSlavesSynced这类参数调大确保加锁操作同步到足够多的从节点后再返回。那次修复之后类似问题再没出现过。4.3 锁的超时时间和业务执行时间不对齐谁都怕的死锁与活锁还有一个很经典的坑发生在把lock和unlock写在了两个不同的方法里public void process() { boolean locked lock.tryLock(1, 10, TimeUnit.SECONDS); businessA(); // 每个业务内部都会自行unlock businessB(); }表面上看锁在10秒后自动释放了不会死锁但这和synchronized的语义完全不一样。synchronized是持锁执行而这里锁可能早在businessA阶段就过期了B线程拿到锁开始执行A线程还在做后续操作两个线程同时在写同一份数据锁形同虚设。这类问题排查起来很隐蔽因为代码能跑、监控不报警、数据偶尔错但频率不高。我的建议是需要锁保护的业务必须收敛在同一个方法内lock()和unlock()之间只放受保护的关键代码段。如果业务天然横跨多个方法可以将锁对象作为入参传递或者把业务逻辑拆成一个单独的服务方法整个方法加锁。Redisson的普通互斥锁没有“自动续期到业务结束”的神话——看门狗只在还没被显式指定leaseTime时工作一旦指定了leaseTime到点就释放。5. 分布式锁的场景边界、选型对比和面试题的底层答案5.1 到底哪些业务该上分布式锁不是所有多实例业务都需要分布式锁。我归纳了下比较典型的场景是下面这几种防重复提交同一用户对同一订单重复发起支付用锁保证同一时刻只有一个处理链路。秒杀扣库存多个请求并发扣减同一个商品的库存必须在Redis锁内做库存判断和扣减。定时任务调度集群模式下多个节点都配置了同一套定时任务用锁保证任务只在主节点执行。幂等控制异步消息重复投递或者平台回调重复触发用锁保证只处理一次。分布式事务补偿多个微服务之间状态同步时避免并发写导致状态混乱。反过来不需要分布式锁的场景也明显只是本地内存数据修改或数据量小且允许偶尔并发覆盖就直接上单机锁或者无锁设计。锁本身是有代价的它增加了一次Redis往返、引入了排队等待、还会增加运维心智负担。设计系统时能用幂等去重解决问题的地方别上来就整分布式锁。5.2 三分钟选型对比Redis、ZooKeeper、数据库锁到底怎么选为了让你面试或者做技术方案时不用翻文档我把三者做了一张对比表对比项Redis/RedissonZooKeeper数据库锁性能很高微秒级中等毫秒级低秒级甚至更低可用性高但主从切换有锁丢失窗口高ZK保证了强一致高但数据库本身是瓶颈可靠性依赖过期时间和看门狗极端情况锁可能丢失靠临时节点和会话超时可靠性高事务保证强但锁粒度粗实现复杂度低Redisson封得好中需要管理会话和节点低但容易影响业务表典型应用缓存服务、高并发扣减强一致性要求高的分布式协调低频后台任务、不追求性能的互斥我的倾向大部分互联网业务选Redis/Redisson因为性能和实现难度的平衡最好。如果业务是强一致性的分布式协调需求比如命名服务、leader选举、分布式队列元数据管理ZK依然是更稳的选择。数据库锁适合以数据库为中心的老系统临时加个互斥需求不想引入新组件。5.3 面试官钟爱的几个分布式锁问题平时帮同事模拟面试分布式锁几乎是必考内容。梳理下最高频的几道1. 为什么不用SETNX实现分布式锁不是不能而是裸的SETNX有三座大山过期时间不准导致锁提前消失、释放时容易误删别人的锁、可重入语义无法支持。这些Redisson都通过Lua脚本、看门狗和Hash结构解决掉了。答到这三层基本就能从初级候选人里区分出来。2. 看门狗延时多久一次锁的默认过期时间是30秒看门狗每10秒检查一次。为什么是30秒和10秒因为30秒的三分之一正好是10秒留足时间余量。业务执行如果超过30秒锁会在到期前被续期不会提前消失。3. 为什么Redis锁在极端情况下会失效最经典的场景是主从切换主节点持有某个锁但还没同步到从节点主节点挂掉了从节点晋升为新主节点此时锁信息丢失其他客户端可以再次获取同一把锁。这就是RedLock算法要解决的场景而RedLock本身也存在争议所以在面试里聊清楚它解决的问题和存在的质疑是一个加分项。4. 锁粒度怎么设计别用一把大锁锁住全部商品键设计要尽量细。比如锁商品就用product:{id}锁用户就用user:{id}。粒度越细并发度越高。但是也别走到另一个极端锁太细会导致维护的key数量暴增Redis内存和连接压力上升核心思想是“锁的粒度与实际共享资源的粒度相匹配”。5. 如果Redis挂了呢答案是区分处理Redis短时不可用tryLock拿不到锁就快速失败并返回友好提示Redis整体挂掉就需要本地降级策略比如暂时关闭秒杀入口或者切换到一个备用的锁实现。不要设计成Redis挂了系统就崩了这种单点强依赖。5.4 从入门到上手的几个建议文章的最后一部分我不喜欢写总结就分享几条实战建议。依赖版本要锁定。Redisson版本迭代很快不同小版本之间的配置类和Spring Boot版本适配都有差异。我见过同事因为升级Redisson版本后Config类属性名变化导致配置没生效锁整个变成无限等待的案例。引入前建议看看官方GitHub仓库的Release Notes。监控不能缺。分布式锁的监控建议至少盯三件事锁的等待时间分布、锁的持有时间、以及Redis连接池的使用率。Redisson自己带了Metrics能力可以接Prometheus。在一个大流量的秒杀里如果锁等待时间突然从几十毫秒涨到几秒基本可以判断是后端业务变慢把锁堵住了尽早预警比事后排查香太多。加锁代码要短。锁里面不要做远程IO、不要调别的微服务、不要执行慢SQL。我在实际开发中见过一个事故程序员把整个报表生成过程都塞进锁里报表要跑5分钟锁被续期了将近10次期间所有请求全部排队系统响应时间从50毫秒暴涨到10秒以上。锁应该只保护共享资源的读改写那几行代码。不要总想着发明锁。我见过有人从零手写分布式锁写了各种自旋排队、心跳续期、边缘处理最后稳定性反而不如Redisson的标准实现。我的体会是在分布式锁这个领域里把被人验证过的成熟方案吃透比重复造轮子更省时间。Redisson的源码值得花一个下午细读尤其是RedissonLock和RedissonBaseLock两个类学到的绝对不只是锁还有Netty、Lua脚本、发布订阅这些配合在一起的设计思路。最后如果你真决定在生产环境里用Redisson花点时间通读一遍官方文档再结合自己的业务设计一套压测和监控方案。这套东西一旦跑顺了就是那种“平时感觉不到存在、但再也不用半夜爬起来处理超卖”的组件。