ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot整合Redisson分布式锁实战:配置、踩坑与调优

Spring Boot整合Redisson分布式锁实战:配置、踩坑与调优

线上业务出过一次很典型的事故:秒杀活动刚开始,某个热门券码的接口超时率直接飙到 30%,数据库连接池被打满,运维半夜把我叫起来排查。日志拉出来一看,同一个券码的库存余量被几十个请求同时读到,每个请求都以为还有最后一张,于是全部放行。那一刻我特别清醒——服务已经拆成多实例了,单机时代的synchronized和本地Lock彻底失效,分布式锁不是"要不要上"的问题,而是"必须马上上"的问题。

那次事故之后,我在 Spring Boot 项目里正式整合了 Redisson 来落地分布式锁。本文就把整个整合过程、核心 API 的用法、我在生产环境踩过的坑、以及压测和调优经验完整写出来。适合正在做多实例部署、需要在库存扣减、幂等控制、定时任务防重等场景里加分布式锁的 Java 开发同学,也适合那些在"手写 SETNX 锁"和"引入 Redisson"之间犹豫不决的团队做选型参考。

1. 为什么我放弃了 SETNX 裸方案,转向 Redisson

1.1 分布式锁要解决的三个核心问题

先想清楚一件事:分布式锁到底在锁什么?多实例部署之后,一个请求可能被负载均衡打到任意一台机器上,本地锁只能拦住本机内的并发,拦不住跨机器的竞争。分布式锁的实质是让多台机器"抢"同一个资源的操作权,抢到的人执行,没抢到的人等待或者放弃。

一个合格的分布式锁,至少要满足三个条件:互斥性——同一时刻只有一个客户端能持有锁;安全性——不会因为持有者宕机导致死锁;可用性——加锁和解锁的过程本身不能成为性能瓶颈或单点故障。

很多团队一开始会手写 Redis 锁,也就是常见的SET key value NX EX。但真正上线之后就会发现,裸方案只是"看起来能用",离"生产可用"还差得很远。

1.2 原生 SETNX 方案的三宗罪

第一宗罪是锁误删。A 线程加锁后业务执行超时,锁自动过期了,B 线程此时成功加锁。A 线程业务终于跑完,执行DEL key,直接把 B 线程的锁删掉了。更糟的是,如果删除前没有校验 value 是否是自己的,这种误删几乎是百分百会发生。

第二宗罪是锁续期缺失。设置EX 10,但业务可能跑 30 秒,锁到点就没了,后面的请求照样冲进来。有人会把过期时间设得很长,比如 5 分钟,但这又带来新问题:如果持有者真的宕机了,锁要等 5 分钟才能释放,这期间整个资源相当于被"钉死"了。

第三宗罪是可重入性。一个方法内部又调用了另一个加锁方法,同一个线程第二次加锁时会被自己挡住。手写方案要解决可重入,就得在 Redis 里存持有者信息和计数,代码复杂度立刻上来了。

我贴一段当时手写的简化版代码,大家可以直观感受一下边界条件有多难处理:

// 这是简化演示,真实场景还有更多边角情况 public boolean tryLock(String key, String requestId, long expireSeconds) { // 1. 加锁 Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(success)) { return false; } // 2. 记录当前线程持有,便于重入判断 localOwner.set(requestId); return true; } public void unlock(String key, String requestId) { // 必须用 Lua 保证"判断 value 再删除"的原子性 String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) else return 0 end"; // 这里已经是在手写 Lua 脚本了,后续还要处理续期、重入计数... }

问题很明显:每多考虑一个边界,代码就膨胀一截。而且这些逻辑一旦写错,都是在并发最高的时候才暴露,定位成本极高。

1.3 Redisson 凭什么比手写方案稳

Redisson 是一个基于 Redis 的 Java 客户端,但它不只是客户端,更像一个 Java 分布式工具集。它把上面说的锁误删、续期、可重入这些问题全部内置解决了。

最核心的是它的看门狗机制。拿到锁之后,只要没指定锁的过期时间,Redisson 会启动一个后台任务,默认每 10 秒检查一次锁是否还持有,如果业务没跑完,就把锁的过期时间重置为 30 秒。这就完美解决了"业务执行时间超过锁过期时间"的问题。

它内部用 Lua 脚本保证了加锁和解锁的原子性,锁的 value 里记录了持有线程的 ID,解锁时先校验再删除,不会误删别人的锁。可重入也天然支持,同一个线程可以多次加同一把锁,内部维护一个加锁计数,释放一次减一次,减到零才真正删除。

我自己做选型时的判断很简单:分布式锁这种基础组件,稳定性比"少引入一个依赖"重要得多。手写方案省掉的成本,会在线上事故里加倍还回来。

2. Spring Boot 整合 Redisson 的配置细节与连接参数

2.1 Maven 依赖引入与版本匹配的坑

Spring Boot 项目引入 Redisson 最常见的方式是用官方提供的 starter。版本匹配是关键,我见过太多人栽在这里:

  • Spring Boot 2.x 建议搭配redisson-spring-boot-starter3.17.x 或 3.20.x 系列
  • Spring Boot 3.x 需要redisson-spring-boot-starter3.23.x 以上的版本,因为 3.x 基于 Jakarta EE 做了变更
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.20.1</version> </dependency>

如果不确定版本兼容性,最简单的办法是去 Redisson 的 GitHub 仓库看 release notes,里面明确写了每个版本对应支持的 Spring Boot 版本。千万不要随便拿个最新版就往上冲,starter 版本和 Spring Boot 主版本不匹配时,RedissonAutoConfiguration 可能会因为类加载问题直接起不来。

2.2 YAML 配置与单机、主从、集群模式的选择

引入依赖后,在application.yml里配置连接信息。Redisson 支持单机、哨兵、主从、集群、云托管等多种部署模式,配置格式不太一样。

单机模式最省事:

spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 redis: redisson: config: | singleServerConfig: idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000 retryAttempts: 3 retryInterval: 1500 password: yourpassword subscriptionsPerConnection: 5 clientName: null database: 0 address: "redis://127.0.0.1:6379" threads: 16 nettyThreads: 32 transportMode: "NIO"

主从模式和哨兵模式类似,区别在于地址配置写的是主节点信息或哨兵节点列表:

spring: redis: redisson: config: | masterSlaveServersConfig: masterAddress: "redis://10.0.0.1:6379" slaveAddresses: - "redis://10.0.0.2:6379" - "redis://10.0.0.3:6379" database: 0

集群模式稍微复杂一点,需要把所有节点地址都列出来,同时要关注scanInterval参数,它控制 Redisson 更新集群拓扑的频率,默认 1000 毫秒,一般保持默认就行。

2.3 RedissonClient 的初始化与自定义配置

如果你不想全用 YAML,也可以在代码里手动构建RedissonClient。这种方式的灵活性更高,比如你需要在启动时根据环境动态切换配置,或者做一些自定义的序列化配置:

@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setPassword(password) .setDatabase(database) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(50) .setIdleConnectionTimeout(10000) .setConnectTimeout(10000) .setTimeout(3000); // 设置全局默认看门狗超时时间,单位毫秒 config.setLockWatchdogTimeout(30000); return Redisson.create(config); } }

destroyMethod = "shutdown"这个细节很关键,它保证 Spring 容器关闭时 Redisson 的连接池和线程池能被正确释放,否则在应用停机时会出现线程泄漏的告警。

我实际使用中还有个习惯:把RedissonClient单独封装成一个DistributedLockTemplate,而不是在业务代码里到处直接注入RedissonClient。这样后续如果要替换锁实现,或者加统一的监控埋点,只需要改一个类就够了。

3. RLock 核心 API 的完整用法:从加锁到看门狗

3.1 上锁代码的三种写法与选择

RedissonClient拿到手之后,最常用的锁对象是RLock。核心方法有三个:lock()、tryLock()、lock(long leaseTime, TimeUnit unit),它们的语义差别很大。

第一种是简单粗暴的lock(),拿不到锁就阻塞等待,直到拿到为止。适合必须串行执行的场景,但要注意,如果锁永远被别的线程持有,这里会一直阻塞下去,所以一般不会裸用。

第二种是带等待时间的tryLock(waitTime, leaseTime, unit),这是生产环境最常用的:

RLock lock = redissonClient.getLock("order:pay:" + orderId); boolean locked = false; try { locked = lock.tryLock(5, 30, TimeUnit.SECONDS); if (!locked) { // 拿不到锁,直接返回或走降级逻辑 return Result.busy(); } // 业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (locked) { lock.unlock(); } }

这个写法里有几个细节很多人会忽视。tryLock(5, 30, TimeUnit.SECONDS)里的 5 秒是"等待获取锁的时间",30 秒是"锁的过期时间"。如果 30 秒内业务没跑完,锁会自动释放,除非看门狗在续期——而这里一旦指定了 leaseTime,看门狗就不会启动。这是一个非常容易踩的坑,我在下一节详细说。

还有catch (InterruptedException)里必须恢复中断标志位。很多同学忽略这一点,导致线程池里的线程中断状态被吃掉,后续任务表现诡异。

3.2 看门狗机制:锁续期到底是怎么回事

看门狗(Watchdog)是 Redisson 分布式锁最亮眼的特性,也是误解最多的部分。

当你调用lock()或者tryLock(waitTime, unit)(注意,没有传 leaseTime 的那一版),Redisson 会给锁设置一个默认的超时时间,默认值 30 秒,然后启动一个后台定时任务,每隔lockWatchdogTimeout / 3,也就是大约 10 秒,去 Redis 检查这个锁是否还被当前线程持有。如果持有,就把过期时间重新刷回 30 秒。

这个机制带来的好处是:锁的有效期跟业务执行时间"绑定"了,业务没结束,锁就不会提前失效。

但关键在于,只要你显式传了 leaseTime,看门狗就立刻失效。原因是 Redisson 的设计逻辑是:你自己指定了锁的持有时间,说明你很清楚业务最长执行多久,框架就不再多管闲事。

我在项目里遇到过一个线上问题:某同事在tryLock里传了 leaseTime 为 10 秒,但一个批量处理任务在极端数据量下跑了 40 秒。锁在第 10 秒就过期了,另一个请求趁虚而入,直接造成了重复处理。排查到最后发现不是什么玄学,就是看门狗没启动。

所以说,如果业务执行时间无法预估,建议用不传 leaseTime 的tryLock(waitTime, unit);如果业务执行时间非常稳定,比如一次 Redis 操作最多几十毫秒,那传一个合理的 leaseTime 反而更保险,省得看门狗线程白白空转。

3.3 解锁的规范姿势与 finally 陷阱

解锁只有一条铁律:必须在 finally 里解锁,并且只有加锁成功才解锁。

这个"只有加锁成功才解锁"很多人会漏掉。如果tryLock返回 false(没拿到锁),代码直接走进了 finally 去执行lock.unlock(),Redisson 此时会抛IllegalMonitorStateException,因为当前线程压根不持有这个锁。虽然业务可能"看起来没出问题",但异常日志会被刷爆,并且掩盖真正的业务异常。

还有一点是关于解锁时机的。不要为了"尽早释放"而在非 finally 位置调用unlock(),因为业务代码可能在解锁之后抛出异常,锁就无法释放了。正确姿势就是上面示例里那样,用一个局部变量locked标记加锁结果,finally 里统一处理。

另外,Redisson 的RLock是支持可重入的。同一个线程可以在持有锁的情况下再次getLock同一个 key 并加锁,内部计数加一,对应的 unlock 次数也要匹配。使用时要注意:加锁几次就要解锁几次,否则计数永远减不到零,锁会一直被认为持有中,最终由看门狗反复续期,造成隐性的死锁。

4. 库存扣减与幂等场景:分布式锁落地到真实业务

4.1 库存超卖复现与分布式锁解法

分布式锁最常见的落地场景就是库存扣减。模拟一个超卖场景:商品库存 10 件,下单接口被并发打到多台机器上。如果没有锁,两个请求同时读到库存为 1,各自判断"库存大于 0",然后都去扣减,最终库存变成 -1,这就是超卖。

用分布式锁改造后,逻辑变成:先抢锁,抢到锁的请求才允许读取库存和扣减库存,执行完毕释放锁,后面的请求再进来读到的就是扣减后的新值。这里我特意强调"锁内要重新查库存"——有的同学把库存数量放在方法参数里缓存了,锁内直接用旧值判断,锁了等于没锁。

给出一个简化的实现思路:

public boolean deductStock(Long skuId, int count) { RLock lock = redissonClient.getLock("stock:deduct:" + skuId); boolean locked = false; try { locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return false; } // 锁内重新查询库存 Stock stock = stockMapper.selectBySkuId(skuId); if (stock.getAvailable() < count) { return false; } // 乐观扣减:UPDATE stock SET available = available - ? WHERE sku_id = ? int rows = stockMapper.deductStock(skuId, count); return rows > 0; } finally { if (locked) { lock.unlock(); } } }

这里还隐藏着一个更深的问题:即使加锁了,库存更新本身尽量用数据库的条件更新来自带原子性。分布式锁解决的是"多实例并发进入临界区"的问题,数据库层面再叠加一层乐观扣减,相当于双保险。不要把锁当作唯一防线,锁只是把并发问题缩小到了可控范围。

4.2 锁粒度设计:别把所有商品锁在一把锁上

锁的粒度直接决定并发能力,这是我在实际项目中反复调整才想明白的。

一开始图省事,把 key 设置成"stock:deduct:all",意图是"所有扣库存操作串行化"。结果大促时所有商品的扣库存请求都在抢一把锁,吞吐量直接变成原来的十分之一。后来改成按商品维度 key,也就是"stock:deduct:" + skuId,不同商品之间不再互相阻塞,并发能力大幅提升。

再往后发现,即使是同一个 SKU,操作类型还能再拆。比如扣库存和查库存详情其实没有共享的可变状态,没必要共用一把锁。锁的粒度原则是:只锁真正需要串行化的共享资源变更,能用小粒度就别用大粒度。

不过要注意,锁粒度太小也会有代价。比如一个订单包含多个商品,要同时扣减多个 SKU 的库存,如果分别锁每个 SKU,就可能出现 A 订单先锁了 SKU1 等待 SKU2,B 订单先锁了 SKU2 等待 SKU1,形成死锁等待。这种场景要么按固定顺序加锁,要么用 Redisson 提供的一次性批量加锁工具,后面我会讲到。

4.3 幂等控制中的锁使用模式

另一个高频场景是幂等控制。典型的例子是支付回调:支付平台通知"订单已支付",如果回调处理没有幂等保护,重复通知会造成重复发货、重复入账。

常用的模式是"防重 key + 分布式锁":

// 生成全局唯一的幂等 key String idempotentKey = "pay:callback:" + orderNo + ":" + bizScene; RLock lock = redissonClient.getLock(idempotentKey); if (!lock.tryLock(0, 5, TimeUnit.SECONDS)) { // 拿不到锁说明已经有线程在处理,直接忽略本次回调 return; } try { // 业务处理... } finally { lock.unlock(); }

这里tryLock(0, ...)表示"拿不到锁立即返回",适合幂等控制场景,因为重复回调本来就不需要等待,直接返回即可。需要注意,锁释放之后如果又来一次重复回调,还是会被处理一次,所以"幂等标记落库"这个动作必须配合使用,锁只是为了控制同一时刻只有一个人在写标记,真正防重复的是数据库里的唯一索引或者状态字段。

5. 踩坑实录:依次排查锁失效与死锁问题的完整链路

5.1 锁加了但没生效,最后发现 key 设计错了

有一次代码 review,我看到一个同事的锁 key 是"lock:" + user.getId(),第一眼感觉没问题,但仔细一想:user 对象是从登录态里拿的,而登录态里面存的 userId 可能在不同子系统间格式不统一,有的带前缀,有的不带。结果同一个用户的操作,在订单服务和库存服务里生成的 key 完全不一样,锁自然形同虚设。

这个坑的排查过程很有代表性。现象是用户重复下单问题复现,日志里明显能看到两个请求都进入了临界区,但锁代码明明加了。当时的排查链路是这样的:先确认 RedissonClient 都指向同一个 Redis 实例,确认无问题;然后在加锁和解锁处分别打印 key,发现两个服务打印出来的 key 不一致;最后追踪 userId 的取值链路,发现是登录态解析逻辑在不同服务里返回了不同格式的 ID。

提示:锁 key 必须用统一的标准生成,最好是独立的LockKeyBuilder类统一拼接,禁止在业务代码里手工拼字符串。同时建议在 key 里带上业务场景前缀,避免两个毫无关系的业务模块因为恰好用了同一个 ID 而互相阻塞。

5.2 锁超时自动释放导致的并发问题与看门狗参数调整

另一个典型问题是锁的过期时间设置太短。那次业务是生成报表,正常情况 2 秒能跑完,但数据量大的时候要 8 秒。代码里写死了tryLock(5, 3, TimeUnit.SECONDS),意思是等待 5 秒、锁持有 3 秒。结果数据量一大,锁在第 3 秒就自动过期,第二个请求又进来自动跑了同样的报表任务,两份数据互相覆盖。

排查过程比较曲折,因为问题不是必现的,只在大数据量任务触发时出现。我先怀疑是 Redis 连接问题导致锁异常,但看监控没有连接错误;然后把锁的 key 打印到日志里,发现两个请求的 key 完全相同;最后看 Redisson 源码里 Watchdog 的触发逻辑,才发现只要传了 leaseTime,看门狗根本不会启动。

修复方案是去掉tryLock里的 leaseTime,让看门狗接管续期:

// waitTime 5秒,不传 leaseTime,启用看门狗续期 locked = lock.tryLock(5, TimeUnit.SECONDS);

同时把全局lockWatchdogTimeout从默认的 30 秒调到 60 秒,因为报表任务偶尔会超过 30 秒,续期频率从每 10 秒一次变成每 20 秒一次,对 Redis 的压力也更小。

5.3 主从切换导致的锁丢失与 RedLock 的权衡

这是分布式锁领域争议最多的一个话题:Redis 主从架构下,锁写入主节点后,在主节点把数据同步到从节点之前,主节点挂了,从节点被提升为新主节点,此时新主节点里没有这条锁记录,另一个请求就能加锁成功,锁的安全性被破坏。

网上对 RedLock 的争论很多,我自己的实践心得是:绝大多数业务场景,普通 Redisson 锁足够了,RedLock 的强一致代价不值得付。RedLock 需要向多个独立的 Redis 实例依次申请锁,过半成功才算加锁成功,这会导致加锁耗时变长,并且在极端情况下会出现"加锁部分成功但客户端响应超时"的尴尬状态,反而更复杂。

如果业务对锁的安全性要求极高,比如金融级别的转账操作,正确做法不是依赖分布式锁,而是把最终一致性放到数据库事务里,用唯一约束、版本号等手段兜底。分布式锁解决的是"并发控制"问题,不是"数据一致性"问题,这个边界一定要清醒。普通服务实例的部署架构,把 Redis 主从的高可用做好、避免选举期间写入,比引入 RedLock 的复杂度更实际。

6. 读写锁、信号量与批量工具:应对更复杂的并发场景

6.1 读多写少场景用 ReadWriteLock 优化

所有请求都加同一把互斥锁,在读多写少的场景里是一种浪费。比如一个配置中心,配置项读取极频繁,但修改极少。这时候互斥锁会让所有读请求排队,吞吐量上不去。

Redisson 提供了RedissonReadWriteLock,它把锁分成读锁和写锁:多个线程可以同时持有读锁;写锁是排他的,写锁持有期间,读锁和其他写锁都会被阻塞;读锁持有期间,写锁会被阻塞。当一个线程持有了任意一种锁,它再次进入该锁的读写操作是安全的。

RReadWriteLock rwLock = redissonClient.getReadWriteLock("config:cache:xxx"); RLock readLock = rwLock.readLock(); RLock writeLock = rwLock.writeLock(); // 读配置 readLock.lock(); try { return configCache.get(key); } finally { readLock.unlock(); } // 改配置 writeLock.lock(); try { configCache.put(key, value); // 刷新本地缓存等操作 } finally { writeLock.unlock(); }

用这个工具要注意一个很微妙的点:读锁和读锁并发、写锁和其他锁互斥,这个语义大家容易理解,但很多人会忽略"锁升级"问题。Redisson 和很多实现一样,不直接支持从读锁升级到写锁,一旦获取了读锁再尝试获取写锁,可能会造成死锁等待,使用时要在设计层面规避,比如修改操作直接走写锁,不要用"先读后写"的模式。

6.2 信号量与分布式限流:Semaphore 的用法

RedissonSemaphore是分布式信号量,它可以控制同时访问某个资源的并发数量,适合做简单分布式限流。

假设某个下游接口限流,最多允许 3 个并发调用,初始化信号量:

RSemaphore semaphore = redissonClient.getSemaphore("downstream:limit"); semaphore.trySetPermits(3);

每次调用前获取许可,调用后释放:

try { boolean acquired = semaphore.tryAcquire(2, TimeUnit.SECONDS); if (!acquired) { return Result.busy(); } // 调用下游接口 } finally { semaphore.release(); }

注意跟 JUC 的 Semaphore 一样,release 操作会无条件增加一个许可,所以必须保证"acquire 成功了才 release",“释放”的次数和“获取”的次数要匹配,否则许可总数会逐渐漂移,最后形同虚设。我建议每次用过之后都顺手用getAvailablePermits()打一条 debug 日志,方便观察许可数是否异常。

RedissonCountDownLatch则是分布式版的倒计时闩,适合多任务并行,等所有任务完成后统一汇总的场景。跨机器的任务协作如果靠自己用 Redis 计数,很容易出现并发问题,用现成的工具更省心。

6.3 批量加锁的 MultiLock 与分布式锁使用边界

回到前面提到的死锁场景:一个操作要同时锁多个 key。比如转账把 A 账户和 B 账户同时锁住:

RLock lockA = redissonClient.getLock("account:lock:1001"); RLock lockB = redissonClient.getLock("account:lock:1002"); // 同时加锁,避免逐个加锁导致互相等待 RMultiLock multiLock = redissonClient.getMultiLock(lockA, lockB); multiLock.lock(); try { // 处理转账逻辑 } finally { multiLock.unlock(); }

getMultiLock会按顺序对多个锁挨个加锁,但它的价值在于释放时统一处理,避免"锁了 A 还没锁 B 就被打断"导致的手动释放遗漏。不过要注意,MultiLock 内部仍然是逐个加锁的,如果锁数量很多,加锁耗时会线性增长,设计上尽量控制在个位数。

在使用分布式锁的边界上,我的经验是:不要在锁里面做耗时操作。网络请求、远程调用、大量数据库写操作都不应该在锁内执行,因为锁内时间越长,系统吞吐量越低,死锁和超时的风险越高。尽量做到"锁内只做必须的、最小范围的资源检查和修改"。如果一定要调用外部服务,给锁的等待时间和业务执行时间都设置明确的阈值,并保证业务级别的快速失败。

7. 性能调优与生产环境经验沉淀

7.1 压测时的响应时间与吞吐对比

我在整合完成后做过一轮简单的压测,用来验证分布式锁对性能的影响。测试场景是模拟 1000 个并发请求抢一个商品的库存扣减接口。在未加锁的情况下,接口平均响应时间约 15ms,但正确性无法保证,库存频繁超卖;加上 Redisson 锁之后,平均响应时间大约 25ms,P99 大约 60ms,库存数据完全正确。

如果把锁粒度细化到 SKU 维度,而不是全局锁,吞吐量可以再翻几倍。这个结论很朴素:分布式锁的性能损耗主要在 Redis 的额外 RTT 上,与其纠结如何减少这 1ms 的 RTT,不如把锁粒度设计得更合理。

7.2 锁时间参数如何设定,结合业务判断

很多同学会问:tryLock的 waitTime 和 leaseTime 到底怎么设置?这个问题真的没有统一答案,但有个通用的思考框架:

waitTime(等待获取锁的时间)取决于业务能容忍多长时间的等待。比如用户请求同步等待拿锁,最多等 3 秒;后台任务可以等 10 秒甚至更长。如果 waitTime 设置得太大,锁竞争激烈时请求会大量堆积在线程池里,会导致线程池队列膨胀。一般同步接口场景,我习惯设置在 2~5 秒之间。

leaseTime(锁持有时间)的设置逻辑分两种。如果启用了看门狗,leaseTime 不用传,交给看门狗自动续期;如果关闭看门狗,leaseTime 必须大于业务最坏情况下的执行时间,留出 20%~50% 的余量。宁可让锁多持有一点时间,也不能让它提前过期,这是我在实战中得到的最直接教训。

7.3 可观测性:锁监控、日志与告警

分布式锁一旦投入使用,必须有配套的监控,否则出问题的时候只能靠猜。我建议在锁的封装层做三件事:

第一,记录每次加锁和解锁的耗时。加锁耗时超过 100ms 就要注意了,说明 Redis 连接或锁竞争出现了问题。第二,在抢锁失败时打 WARN 日志。日志里带上锁的 key 和当前等待时间,便于事后复盘为什么竞争这么激烈。第三,统计锁的等待时间分布和持有时间分布,接入监控平台。

我封装锁模板时的一个关键记录点如下:

public <T> T executeWithLock(String lockKey, long waitTime, TimeUnit unit, Supplier<T> action) { RLock lock = redissonClient.getLock(lockKey); long start = System.currentTimeMillis(); boolean locked = false; try { locked = lock.tryLock(waitTime, unit); if (!locked) { LockMetrics.recordFail(lockKey); return fallback(lockKey); } LockMetrics.recordWaitTime(lockKey, System.currentTimeMillis() - start); return action.get(); } catch (Exception e) { LockMetrics.recordException(lockKey, e); throw e; } finally { if (locked) { long holdMillis = System.currentTimeMillis() - start; LockMetrics.recordHoldTime(lockKey, holdMillis); lock.unlock(); } } }

通过这套封装,某次线上排查"某个商户的订单接口变慢",直接通过监控发现"order:create:" 开头的锁等待时间异常偏高,最终定位到是一个外部系统耗时导致某个请求长时间持有锁,后面的订单全部排队。如果没有锁监控,这个问题很可能要等到用户投诉才暴露。

7.4 Redisson 与 Spring Cache 等其他组件的协作问题

最后提一个很容易被忽略的坑:Spring Boot 项目里如果同时使用了spring-boot-starter-data-redis和 Redisson,要注意它们管理的是不同的连接资源。Redisson 的RedissonClient和 Spring 的RedisTemplate是各自独立的,它们可以共用同一个 Redis 实例,但不要在业务代码里混用两套 API 去操作同一个 key 的锁数据,实际的锁数据结构是经过 Redisson 编码的,直接用RedisTemplate去读取会得到一串无法解析的 byte 数组。

整合多个 Redis 客户端时,建议明确分工:锁相关的操作全部走RedissonClient,普通的缓存读写走RedisTemplate,两者互不交叉。如果遇到需要同时使用锁和缓存的场景,比如"先抢锁,再更新缓存然后把锁释放",保持一个请求内所有缓存操作都走同一个客户端,代码可维护性会高很多。

用分布式锁这一年多,我最深的感受是:Redisson 这款组件本身的可靠性已经很高,真正出问题的往往是我们理解不深导致的错误用法。写本文的目的不是说"你照着敲就一定能跑通",而是希望大家在决定引入分布式锁时,能想清楚它帮你解决了什么、引入了什么新约束,在最关键的业务链路里经得起并发考验。如果你正准备在项目里落地分布式锁,先把我提到的 case 对照着过一遍,能帮你少走不少弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进