☰
分布式锁实战指南:Redisson原理、看门狗与秒杀扣库存
2026/10/1 10:55:49 网站建设 项目流程

做后端开发的,几乎没人没听过 Redisson 分布式锁。面试题里翻来覆去问的 Redis 分布式锁、锁过期、看门狗,实战里天天见的秒杀扣库存、定时任务抢单,背后都有它的影子。说白了,分布式锁就是让多个服务实例之间互斥地操作共享资源,而 Redisson 是 Java 生态里封装得最成熟、用起来最顺手的一把锁。这篇文章不给你背八股文,我从一次线上库存超卖事故讲起,把 Redisson 分布式锁的原理、快速上手、看门狗机制、事务顺序、高频面试题全串一遍,适合刚接触分布式锁想快速落地,或者面试前想把这块彻底吃透的同学。

1. 为什么分布式场景下离不开分布式锁

1.1 一次库存超卖事故,让我彻底理解了互斥

先讲个我早年踩过的真实事故。当时系统还是单机部署,扣库存只要在方法上挂一个synchronized就能保证安全。后来服务拆成三个实例,库存数据放在 Redis 里,key 是seckill:stock:10086,value 是剩余库存。上线第一天就出了超卖:三个实例的请求同时打进来,都从 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

很多人第一次写分布式锁,就是照着网上的教程来一套三连:

  1. 用SETNX key value尝试写入,返回 1 表示拿到锁。
  2. 执行业务代码。
  3. 用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 完锁,还没同步给 Slave,Master 就挂了,哨兵把 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> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.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 第一个 Demo:RLock 的基本用法

拿到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()时没有显式传 leaseTime,Redisson 会认为锁的默认释放时间是 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:threadId,ARGV[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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

扣减 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 Hash,key 是锁名称,field 是UUID:threadId,value 是重入计数。加锁时执行一段 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 = "shutdown"
IllegalMonitorStateException释放锁时锁已经不属于当前线程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 把写锁这件事变得太简单了,简单到容易让人忘记,真正保护系统的是你对业务流程完整性的一整套设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询