做后端开发的几乎没人没听过 Redisson 分布式锁。面试题里翻来覆去问的 Redis 分布式锁、锁过期、看门狗实战里天天见的秒杀扣库存、定时任务抢单背后都有它的影子。说白了分布式锁就是让多个服务实例之间互斥地操作共享资源而 Redisson 是 Java 生态里封装得最成熟、用起来最顺手的一把锁。这篇文章不给你背八股文我从一次线上库存超卖事故讲起把 Redisson 分布式锁的原理、快速上手、看门狗机制、事务顺序、高频面试题全串一遍适合刚接触分布式锁想快速落地或者面试前想把这块彻底吃透的同学。1. 为什么分布式场景下离不开分布式锁1.1 一次库存超卖事故让我彻底理解了互斥先讲个我早年踩过的真实事故。当时系统还是单机部署扣库存只要在方法上挂一个synchronized就能保证安全。后来服务拆成三个实例库存数据放在 Redis 里key 是seckill:stock:10086value 是剩余库存。上线第一天就出了超卖三个实例的请求同时打进来都从 Redis 里读到库存还剩 50然后各自在自己的内存里减 1再写回 49。三个请求都成功了库存却只减少了 1等于多卖了两单。这个问题很典型本质就是单机的synchronized和Lock锁的是 JVM 内部的对象监视器三个 JVM 之间根本感知不到彼此。它们各自持有一把“本地锁”保护的是三块不同的临界区互斥自然就失效了。要解决必须引入一把所有服务实例都能看到、都能遵守的公共锁也就是分布式锁。可以把分布式锁类比成火车站的售票窗口服务台。所有售票员服务实例在卖同一座位前必须先到服务台登记拿到一个“正在售卖”的标识其他售票员看到标识就得等。这个服务台就是 Redis标识就是锁。谁登记了、谁释放了、什么时候超时被清掉全部由这个公共服务台裁决。1.2 分布式锁最常用的几个业务场景理解了互斥再来看实际问题就知道该往哪用了。我实际项目里用得最多的场景有这么几类秒杀、抢购、下单扣库存多个用户同时操作同一个商品库存必须串行扣减。分布式定时任务多个服务节点都会触发任务调度但同一时刻只允许一个节点真正执行任务。防止缓存击穿后的并发重建热点 key 过期后几十个线程同时查数据库、同时写缓存没有锁会把数据库打爆。多个服务对同一个业务资源做状态流转比如订单状态机、账户余额变更需要保证同一时间只有一个服务在处理。接口幂等、重复提交控制用锁挡住并发请求或重复请求。这些场景有一个共同点不是单纯靠数据库事务就能解决的。数据库行锁确实能串行化 SQL但业务里往往有“先查后写”“远程调用后再写”“多个资源一起更新”这种组合操作锁需要覆盖整个操作过程而不是只包住一条 SQL。此时分布式锁就派上用场了。1.3 一把合格的分布式锁至少要满足四个条件用 Redis 实现分布式锁很容易但“能用”和“合格”差得很远。我在评估一个分布式锁方案时基本按下面四条来核对互斥性同一时刻只能有一个客户端持有锁这是最基本的要求。防死锁客户端在持锁期间宕机、异常退出锁也必须能自动释放不能一直卡住其他请求。可重入同一个线程在已经持锁的情况下再次获取同一把锁不能自己把自己锁死。高性能与高可用加锁、释放锁的耗时要低Redis 抖动或主从切换时不能出现大面积锁丢失。前两点决定方案安不安全第三点决定好不好用第四点决定能不能上生产。自己用SETNX写一个锁很容易但把这四条全做到你会发现代码量比业务代码还多。这也是我后来坚定选择 Redisson 的原因。2. 手写 Redis 锁实现简单坑却埋满2.1 最朴素的实现SETNX 加 Expire很多人第一次写分布式锁就是照着网上的教程来一套三连用SETNX key value尝试写入返回 1 表示拿到锁。执行业务代码。用DEL key释放锁。这套逻辑单看没有问题但一旦业务执行时间变长、客户端突然宕机锁永远不会被释放后面的请求全部卡死。所以第二步之前得加一个过期时间比较常见的姿势是SET lockKey uuid EX 30 NX含义是只有当 key 不存在时才设置成功同时设置 30 秒过期时间。这个命令是原子的解决了“设置 key 设置过期时间”两步操作之间宕机导致的死锁问题。到这一步锁已经能应对宕机了。但如果只做到这里就上生产还是会出事。我见过不少项目就是停在这个阶段然后线上出问题后用一堆临时补丁去填坑越填越痛苦。2.2 你以为加个 Expire 就稳了其实还有五个坑第一个坑是误删别人的锁。线程 A 拿到锁执行到一半锁过期自动释放了线程 B 立刻拿到同一把锁开始执行业务这时 A 终于执行完走到DEL lockKey把 B 刚拿到的锁删掉了。于是 B 和后续的 C 同时进入临界区互斥被打破。解决办法是把 value 设成当前线程的唯一标识删除前先比对 value 是不是自己的。第二个坑是“比对”和“删除”必须原子。先GET再DEL是两个命令中间哪怕隔了 0.1 毫秒都可能因为 GC 停顿、网络延迟出现误删。正确做法是用 Lua 脚本把两个操作合成一步if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end第三个坑是锁过期了但业务没跑完。30 秒锁超时是拍脑袋写的碰上慢 SQL、GC 停顿、第三方接口超时业务可能 50 秒才执行完锁在第 30 秒就没了其他线程趁机进来。这个坑 Redisson 用看门狗解决后面专门讲。第四个坑是不可重入。同一个线程在持锁后递归调用、或者方法 A 里调方法 B 且 B 也加同一把锁如果锁不支持重入第二次加锁永远失败直接死锁。自己实现可重入要做计数器复杂度上升一个台阶。第五个坑是 Redis 主从切换导致的锁丢失。Master 上刚 set 完锁还没同步给 SlaveMaster 就挂了哨兵把 Slave 提升为新 Master新 Master 上根本没有这把锁别人就能随便拿到锁。Redis 官方提出的 RedLock 算法就是为了缓解这个问题但它也有争议生产上怎么做要看你对安全性的容忍度。2.3 为什么最终选择了 Redisson 而不是自己造轮子上面五个坑随便哪一个都要花不少代码去填。填完之后还要考虑锁的等待通知机制总不能拿不到锁就死循环轮询吧还要考虑优雅释放、Redis 连接池、客户端版本兼容。把这些全部写完大约是一个小工具库的工作量而且你自己写的轮子缺少大规模线上验证遇到极端情况不知道会怎么表现。Redisson 把这些问题全部封装好了。它的核心锁接口RLock用起来和 JDK 的Lock几乎一样底层通过 Lua 脚本保证原子性通过 hash 结构实现可重入通过 Netty 定时任务实现看门狗续期通过 Redis 发布订阅实现锁释放通知。我们不需要重复造轮子只需要知道哪些 API 对应什么语义出了问题能快速定位就行。3. Redisson 快速入门五分钟跑通第一个分布式锁3.1 环境准备与依赖引入本地环境我默认是 JDK 8、Maven、Redis 6.0 以上。Spring Boot 项目直接在pom.xml里引入dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency提示版本号按需选择建议选一个社区最新且稳定的版本不要盲目追新。有些老版本和 Spring Boot 2.x / 3.x 的自动装配有兼容性问题。不接 Spring Boot 的项目用普通的redisson依赖然后手动创建客户端Configuration public class RedissonConfig { Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setPassword(123456) .setDatabase(0) .setConnectionPoolSize(32); return Redisson.create(config); } }这里要留意destroyMethod shutdown没有它的话 Spring 容器关闭时不会释放 Redisson 占用的线程池和连接很容易在重启时引起连接泄漏告警。3.2 第一个 DemoRLock 的基本用法拿到RedissonClient之后创建锁只需要一行代码。锁的 key 就是分布式锁的互斥范围比如按订单号、商品 ID、用户 ID 来拼RLock lock redissonClient.getLock(order:create:10086); try { // 等待 3 秒拿不到锁就放弃拿到锁后 10 秒自动释放 if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 这里写受保护的业务逻辑 System.out.println(拿到锁开始执行); } else { System.out.println(拿锁失败做降级处理); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }这个 demo 是生产上最常用的形态。tryLock(waitTime, leaseTime, timeUnit)三个参数我再说得直白一点waitTime最多等多久去抢锁超过这个时间还没有拿到就直接返回 false业务方可以走降级。leaseTime锁的自动释放时间。设置了这个参数锁到期后 Redis 会自动删除不管业务有没有跑完。锁释放finally里一定要判断isHeldByCurrentThread()防止没拿到锁或锁已过期还在执行 unlock。3.3 两种常用加锁模式怎么选我实际代码里用得最多的两种模式给你一个参考模式写法适用场景注意点阻塞式lock.lock()内部任务型、对响应时间不敏感的业务lock 可重入默认看门狗 30 秒续期快速失败型lock.tryLock(3, 10, SECONDS)秒杀、用户请求链路拿不到就要快速降级waitTime 别设太长否则线程都阻塞在等待上无期限型lock.lock(30, TimeUnit.SECONDS)明确知道业务最长耗时显式传 leaseTime看门狗不生效我刚入门 Redisson 时犯过一个错误把waitTime设成 30 秒高峰期几千个线程都在 Redisson 内部阻塞等待同一把锁线程池直接被拖垮。后来改成 3 到 5 秒拿不到就走降级系统反而稳得多。锁是互斥的等锁的资源消耗一点都不比业务执行少。4. 看门狗机制Redisson 分布式锁的核心设计4.1 看门狗到底解决了什么问题回到手写锁的经典难题业务执行时间超过了锁的过期时间锁被提前释放其他线程进入临界区。Redis 官方给不了现成答案Redisson 给出的方案就是看门狗Watchdog。默认情况下如果调用lock()或tryLock()时没有显式传 leaseTimeRedisson 会认为锁的默认释放时间是 30 秒同时启动一个后台定时任务每隔 10 秒检查一次如果锁还在当前线程名下就把锁的过期时间再延长 30 秒。整个过程对业务代码完全透明你不需要关心锁是不是快过期了。画个简单的类比你租了一个房间房东要求最多住 30 天。你每住 10 天就给房东打一次电话续租只要电话打得通就永远不会被赶出去。如果哪天你失联了进程宕机房东等到第 30 天就把房间清空让下一个租客进来。看门狗做的事就是不停打电话续租同时保证失联时锁最终会被释放不会死锁。4.2 看门狗是怎么实现续期的Redisson 内部维护了一个lockWatchdogTimeout默认 30000 毫秒。持锁时如果 leaseTime 是 -1也就是没设置Redisson 设置锁的过期时间为 30 秒并用 Netty 的Timeout调度一个延迟任务延迟时间为 10 秒internalLockLeaseTime 的三分之一。续期动作本质是执行一段 Lua 脚本核心逻辑是这样if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(pexpire, KEYS[1], ARGV[1]); return 1; end; return 0;ARGV[2]是当前线程的唯一标识UUID:threadIdARGV[1]是新的过期时间。脚本先确认锁还是当前线程持有的是就续期不是就直接返回 0不执行任何操作。之所以用 Lua就是为了把“判断持有者”和“续期”两个操作合在一起避免出现锁已经被其他线程拿到、自己却还在续期的错误。4.3 看门狗的两个经典误用第一个误用是显式传了 leaseTime却以为看门狗还在续期。看门狗只有在不传 leaseTime 的时候才启动你一旦调了tryLock(3, 10, TimeUnit.SECONDS)10 秒就是铁打的 10 秒。业务如果超过 10 秒锁照样被释放。所以显式设置 leaseTime 时一定要按业务耗时的 P99 甚至 P999 再留一倍余量来设。第二个误用是对看门狗过于信任。看门狗是客户端进程内的定时任务进程整体停止或发生长时间 Full GC 时调度一样会暂停。虽然没有精确的实验数字但理论上如果 Full GC 超过了 30 秒锁就会提前失效。对一致性要求极高的场景不能只靠看门狗兜底还得在业务层加幂等控制或版本号校验。提示生产环境里我更推荐把关键任务的锁时间按“业务预期最大耗时”显式设置并且同时开启看门狗无法覆盖的兜底逻辑比如数据库乐观锁、状态机校验而不是把所有希望都押在锁上。5. 实战项目秒杀扣库存的正确姿势5.1 先看场景与表结构秒杀扣库存是分布式锁最经典的应用。假设数据库有一张库存表CREATE TABLE seckill_stock ( id bigint(20) NOT NULL AUTO_INCREMENT, product_id bigint(20) NOT NULL COMMENT 商品ID, stock int(11) NOT NULL DEFAULT 0 COMMENT 剩余库存, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_product_id (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;扣减 SQL 要带条件防止数据库层面超卖UPDATE seckill_stock SET stock stock - 1 WHERE product_id #{productId} AND stock 1;影响行数为 1 说明扣减成功为 0 说明库存不足。这行 SQL 本身已经有了库存保护那还需要 Redis 分布式锁吗需要。因为真实业务里扣库存之前可能还有创建订单、校验用户资格、调用风控接口等一系列操作这些都是非原子的需要锁把整条链路串起来。5.2 完整的加锁扣库存代码Service public class SeckillService { Resource private RedissonClient redissonClient; Resource private StockMapper stockMapper; public boolean deductStock(Long productId, Integer quantity) { String lockKey seckill:stock: productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { log.warn(获取库存锁失败productId{}, productId); return false; } int rows stockMapper.deduct(productId, quantity); if (rows 0) { log.info(扣库存成功productId{}, quantity{}, productId, quantity); return true; } return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(扣库存被中断productId{}, productId, e); return false; } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这段代码有两个关键细节。一是在finally里必须同时判断locked和isHeldByCurrentThread()否则可能出现“根本没拿到锁但调用了 unlock”“锁已经因为超时被别人拿走但自己在 unlock”的情况这两种都会抛IllegalMonitorStateException。二是InterruptedException处理完要恢复中断标记Thread.currentThread().interrupt()这一行很多教程都不写但规范上是必须的。5.3 事务和锁的顺序很多人栽在这里如果你直接把上面的方法加上Transactional恭喜你埋了一个隐蔽的坑。Spring 事务默认在方法返回后才提交而finally里的unlock()是在方法返回前执行的。也就是说锁的释放发生在事务提交之前。这样有什么问题事务还没提交数据库行锁还握着但 Redis 锁已经放开了。第二个请求进来拿到 Redis 锁去执行同一条扣减 SQL会在数据库层面被第一个事务的行锁挡住。如果第一个事务执行得慢第二个事务就一直等接着第三个、第四个也都等在数据库行锁上很快数据库连接池就被打满。更麻烦的是两个事务如果彼此持有对方需要的行锁还可能形成死锁。我推荐的写法是不要用Transactional改成用TransactionTemplate手动控制事务边界把事务提交包在 Redis 锁的释放逻辑之前Resource private TransactionTemplate transactionTemplate; public boolean deductStock(Long productId, Integer quantity) { String lockKey seckill:stock: productId; RLock lock redissonClient.getLock(lockKey); boolean locked false; try { locked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!locked) { return false; } // 事务在这里执行提交完成后方法才结束锁才释放 return Boolean.TRUE.equals( transactionTemplate.execute(status - { int rows stockMapper.deduct(productId, quantity); return rows 0; }) ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } } }用TransactionTemplate时事务提交发生在execute方法返回前而unlock()在finally里晚于事务提交执行顺序就对了。这个点面试里也经常被拿来追问答上来了基本能证明你是真的写过生产代码。6. 面试与生产环境高频问题与实践排坑6.1 Redisson 分布式锁的底层实现原理这是面试必问的一道题我建议按这个顺序答Redisson 的锁数据结构用的是 Redis Hashkey 是锁名称field 是UUID:threadIdvalue 是重入计数。加锁时执行一段 Lua 脚本判断 Hash 中是否存在当前线程的字段不存在说明没人持有锁执行hset把 value 设成 1同时设置过期时间。存在说明当前线程重入执行hincrby计数加 1。获取失败客户端不会死等而是订阅对应锁的 Redis Channel收到释放消息后再尝试获取。释放锁同样用 Lua先判断当前线程字段是否存在存在就计数减 1计数归零就删除 key并发布解锁消息。整个过程没有任何非原子操作。6.2 面试官最爱追问的五个问题以我面试别人的经验分布式锁这块下面几个问题出现频率最高锁过期了但业务还没执行完怎么办答Redisson 的看门狗会定时续期。默认锁时间 30 秒每 10 秒续一次。但如果自己显式传了 leaseTime看门狗就不生效。Redis 主从切换锁丢了怎么办答Redisson 提供RedissonRedLock也就是 RedLock 多节点加锁向多个独立 Redis 节点加锁超过半数成功才算加锁成功。不过 RedLock 在业界有争议生产实践中更常见的做法是尽量保证 Redis 高可用同时对业务做幂等兜底。为什么锁用 Hash 结构而不是简单 SET 一个字符串答Hash 天然适合做可重入计数field 是线程唯一标识value 是锁重入次数释放时递减。如果拿锁的线程一直不退看门狗会不会把锁无限续期答不会。只要线程持有锁就续期一旦线程独占 flag 或锁被手动释放续期任务就取消。如果客户端宕机续期任务进程消失Redis 里的 key 会在默认 30 秒后自动过期。Redis 不可用的时候分布式锁要怎么做降级答可以在 Redisson 客户端层面配置连接失败后的处理策略也可以加一层本地synchronized作为兜底但本地锁只保护单 JVM跨实例还是要靠 Redis。最稳妥的还是评估业务能不能接受短暂不可用能接受就直接快速失败。6.3 生产环境踩过的坑整理成速查表现象原因处理建议业务没执行完锁就被别人拿走了显式传了 leaseTime看门狗没生效或 leaseTime 设太短能省则省别传 leaseTime必须传时按 P99 耗时留一倍余量服务重启时 Redis 连接告警RedissonClient 没配destroyMethod线程池和连接没释放Bean 定义加上destroyMethod shutdownIllegalMonitorStateException释放锁时锁已经不属于当前线程finally里先判断isHeldByCurrentThread()锁迟迟不释放忘了写 unlock或 unlock 没放在 finally加锁逻辑全部用 try/finally 包裹高峰期线程池被打爆waitTime设太长大量线程阻塞在等待锁tryLock的 waitTime 控制在 3 秒内拿不到就走降级扣完库存锁释放了但数据没提交用的Transactional锁比事务先释放改用TransactionTemplate把事务提交包在锁释放前面6.4 定位锁相关问题的排查思路线上如果出现锁相关问题我一般按三层思路去看。第一层先看 Redis用redis-cli查锁 key 的 TTL 和 Hash 结构确认锁到底是没释放、被续期了、还是提前过期了。第二层看业务日志里的加锁时间和释放时间算一下持锁时长如果持锁时长经常接近 leaseTime说明锁时间设得太紧。第三层看线程栈阻塞在tryLock上的线程多不多如果多说明 waitTime 和锁粒度都要优化。7. 最后再分享一点经验7.1 关于锁粒度我的选择标准分布式锁用起来最大的问题不是不会用而是粒度控制不好。我见过有人把整个订单创建流程全包进一把锁结果下单接口的吞吐量直接掉到个位数也见过有人用订单号做锁 key同一用户的多个订单还能互相干扰。我的标准很简单锁的粒度越小越好能锁资源 ID 就不锁用户 ID能锁商品 ID 就不锁整个商品分类。锁内只放必须互斥的操作把校验、日志、组装这种不冲突的逻辑尽量挪到锁外。7.2 个人体会锁是兜底不是银弹用 Redisson 这些年最深的体会是锁只解决互斥不解决数据正确性。库存哪怕加了锁也要在 SQL 里写stock 1这种条件订单哪怕加了锁也要在业务表上建唯一索引分布式任务哪怕加了锁也要保证任务本身是可重入的。把锁当成最后一道防线前面的业务校验和数据库约束都做好才是上生产最稳妥的做法。Redisson 把写锁这件事变得太简单了简单到容易让人忘记真正保护系统的是你对业务流程完整性的一整套设计。
阅读完成 · 觉得有帮助?