我平时不太写框架的快速入门,但 Redisson 是少有的值得单独聊的例外。原因很简单:分布式锁这个概念,网上搜出来的内容九成都是教你用 SETNX 去 set 一个 key、设个过期时间,先不说实现对不对,光是各种边界情况就能让线上事故频发。而 Redisson 把锁做成了开箱即用的 Java API,你只需要引入依赖、构造一个 client,然后调用 lock() 和 unlock(),剩下的可重入、自动续期、阻塞等待这些事,底层全都帮你处理了。这篇文章我会从一个真实业务的角度,把 Redisson 处理分布式锁的底层机制、关键配置、典型用法、常见坑全部串起来,并且补充几张我在实际项目中打磨出来的“避坑清单”。如果你正准备把分布式锁落地到生产环境,或者马上要去面试被问到 Redisson 源码细节,这篇应该能帮你省下不少折腾的时间。
1. 为什么单机锁在分布式环境里不好使
先想一个问题:一个普通的 synchronized 锁,锁住的是当前 JVM 进程内的一个对象。单机部署时,多个线程抢同一个锁,JVM 自己就能保证互斥。可一旦服务部署成多节点,请求被负载均衡分发到不同的 Tomcat 进程,情况立刻不同:节点 A 的 synchronized 根本不知道节点 B 里发生了什么。同样的“扣减库存”或“领取优惠券”操作,在两个节点上同时执行,两个线程都认为自己拿到了锁,数据就乱了。
更实际一点的场景:多个服务实例同时消费同一个 MQ 消息,如果不做幂等,消息可能被重复处理;多个定时任务节点同时触发同一个 job,如果没有锁,任务就会执行两遍;秒杀场景下,同一件商品被上万个请求打过来,数据库里的库存字段极有可能被并发扣成负数。这时候,我们就需要一个“所有节点都认账”的互斥机制,也就是分布式锁。
分布式锁的核心诉求只有三条:第一,互斥性。任何时刻只能有一个客户端持有锁。第二,安全性。持有锁的客户端崩溃了,锁必须能自动释放,不能变成永久死锁。第三,性能。获取锁和释放锁的开销必须足够小,否则本来为了防并发,结果把接口拖慢,得不偿失。
Redis 实现分布式锁是最主流的方案,因为 Redis 本身是单线程执行命令,天然具备原子性,而且性能极高。但你直接基于 Redis 手写一套锁,很快就会撞上四个麻烦:锁过期了但业务没执行完,导致并发临界区被突破;持有锁的线程把自己的锁删了,结果把别人刚获取的锁误删掉;锁没有可重入能力,同一个线程再次获取就会死锁;Redis 主从切换时锁丢失,整个互斥就失效了。Redisson 存在的意义,就是把这四个坑全部堵上,并且包装成一个连子类都能看懂的标准 Java 锁接口。
2. Redisson 的核心设计:锁是怎么变成一门“数学”的
Redisson 的锁不是用简单的 SET key value EX 来实现的,这一点非常重要。你去看它获取锁的 Lua 脚本,会发现底层用的是 Redis 的 hash 数据结构。
用一句话描述它做的事:把一个锁对象存储为 Redis 里的一个 hash,hash 的 key 是业务指定的锁名字,hash 的 field 是持有锁的客户端唯一标识(通常是 UUID + 线程ID),hash 的 value 是获取锁的次数。每次同一个线程重复获取锁,就把这个 value 加一;每次释放锁,就把 value 减一。value 归零,才真正删除这个 hash key。
这个设计解决了两个核心问题。一个是可重入:同一个线程可以反复 lock() 同一把锁而不会自我阻塞,因为每次加锁只是在计数。另一个是误删问题:释放锁之前,Redisson 会先校验 hash 里的 field 是不是当前客户端的标识。若不是,直接返回不删除;若是,才执行删除。也就是说,锁的“归属”在数据结构层面就锁死了,而不是靠“先 get 判断、再 del”这种非原子操作碰运气。
还有一个很多人忽略的关键机制:等待机制。你在用 Redisson 的 lock(long leaseTime, TimeUnit unit) 或者 tryLock 时,其他线程在锁被占用时不会直接失败返回。它们会通过 Redis 的 pub/sub 订阅一个释放锁的 channel。当持有者释放锁后,所有排队等待的线程都会收到通知,然后重新尝试获取锁。这个设计比“轮询 + sleep”要优雅得多,避免了大量无效的 Redis 请求堆积。
下面这张表是我自己在选型时整理的,对比各方案处理分布式锁关键问题的能力:
| 关键能力 | 普通SETNX+过期 | Redisson |
|---|---|---|
| 锁自动过期 | 支持,但需自行管理 | 支持,watch dog 自动续期 |
| 可重入 | 不支持,需自研 | 原生支持,基于 hash 计数 |
| 误删他人锁 | 极容易发生 | 通过 clientId 校验避免 |
| 等待锁 | 不支持需自旋 | pub/sub 通知式等待 |
| 主从切换锁丢失 | 无法避免 | 可搭配 RedLock,但仍有限制 |
如果你去面试,被问到“Redis 分布式锁的实现原理”,直接把这套 hash + Lua + pub/sub 的机制讲清楚,基本算过关了。
3. Redisson 快速接入:三步把这把锁用起来
讲理论讲太多容易飘,直接上实践。接下来我会带你把一个最小可运行的 Redisson 锁跑通,然后逐步添加到业务里的最佳实践。
3.1 引入依赖与构建客户端
假设你的项目是 Spring Boot,先用 Maven 引入 Redisson 的 starter。Redisson 的版本更新比较快,我习惯直接用最新的稳定版,以下面的坐标为准:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>如果你不是 Spring Boot,也可以直接用核心依赖redisson,然后手动创建 RedissonClient。我这里还是用最常见的 Spring Boot 方式演示。
最简单的构建配置:
spring: redis: redisson: config: | singleServerConfig: address: "redis://127.0.0.1:6379" database: 0这是单机 Redis 的接法。生产环境一般会用主从或哨兵,甚至 Redis Cluster,配置会稍微复杂点。不过无论哪种方式,Redisson 最终都会给你一个 RedissonClient 对象,这个对象就是所有锁操作的入口。
@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(32); return Redisson.create(config); } }有两点我在实践里体会很深。一是连接池大小要按并发量预留,默认值偏保守,高并发下会出现获取连接等待。二是 Redis address 那个前缀redis://不能丢,如果开了 TLS 则要写rediss://,这是很容易踩的小坑。
3.2 第一个可重入锁实例
下面这个例子,几乎可以原样搬到你的业务代码里:
@Resource private RedissonClient redissonClient; public void deductInventory(String productId, int count) { RLock lock = redissonClient.getLock("lock:product:" + productId); try { lock.lock(10, TimeUnit.SECONDS); // 这里写你的核心业务逻辑 inventoryService.deduct(productId, count); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }注意我用的写法是lock.lock(10, TimeUnit.SECONDS),不是默认的无参 lock()。为什么?因为这里直接指定了锁的过期时间是 10 秒。这个写法会关闭 watchdog 自动续期机制,锁到期后 Redis 不管业务有没有结束,都会强制释放。
那什么时候用无参的 lock() 呢?当你无法预估业务执行时长,同时又不想因为锁过期导致并发数据错乱的时候。比如一单跨境订单的完整处理,中间可能涉及多个 RPC 调用,耗时浮动非常大。这时候无参 lock() 更安全,因为 Redisson 会在锁持有期间每 10 秒自动续期一次,把锁的有效期续到 30 秒,业务只要没结束,锁就不会过期。
3.3 解锁时为什么要判断 isHeldByCurrentThread
这段代码里我加了一个isHeldByCurrentThread()的判断,很多人会问这不多此一举吗?其实这个判断在非常规流程里能救命。想象一个场景:你在 lock() 之后执行了一段耗时的 RPC,锁已经因为时间耗尽自动释放了;另一个线程 B 抢到了锁开始执行。此时你的线程终于走到了 finally,如果直接调用 unlock(),会发生什么?Redisson 的 Lua 脚本会校验 lockValue 不是你的客户端标识,从而拒绝删除锁。这个校验本身就是安全的兜底。但更微妙的是,如果业务异常抛出,线程 A 想释放一把已经不属于它的锁,Redisson 内部的看门狗任务可能都没有正常退出,下次再获取同一把锁时,状态就变得很奇怪。加一个 heldByCurrentThread 判断,是确保你只释放自己持有的锁,从流程上更干净。
再说得直白一点:正确姿势是拿到锁之后把它放在 try-finally,最后不解锁成功绝不罢休。我在生产代码里还见过把 unlock() 写在 catch 里的,一旦核心业务没有抛异常,锁就永远不会释放,直接死锁。这是分布式锁使用中排名第一的惨案。
4. 深入 lock() 与 tryLock():这 5 个参数值得抠一下
Redisson 的锁方法表面看就两个:lock() 和 unlock(),但如果你只停留在这一层,生产环境迟早给你颜色看。我挑几个实际用得上的方法和参数展开说说。
4.1 lock() 的三种写法
第一种,lock()。无参,默认以 30 秒为过期时间,后续每 10 秒自动续期 30 秒。适合你拿不准业务耗时的场景,就是防止业务没跑完锁先没了。
第二种,lock(long leaseTime, TimeUnit unit)。手动指定过期时间,关闭续期。适合你明确知道这段业务最多 1 秒或 2 秒就结束,比如简单的库存扣减、优惠券核销,一次性把锁的时长给定死。这样如果服务进程直接宕机,Redis 也会在指定时间之后自动释放锁,不会死锁。
第三种,lock(long waitTime, long leaseTime, TimeUnit unit)。既设定等待锁的时间,又设定锁的过期时间。这个在真实业务里是相当实用的。它允许:如果锁被其他线程占用,当前线程最多等待 waitTime 这么久,等不到就直接放弃返回。配合返回值判断,你可以做到“抢不到锁就快速失败”,比如秒杀场景直接返回“您手速慢了”。
4.2 tryLock() 与 waitTime 的区别
tryLock()是我们的好朋友。无参 tryLock() 表示“尝试获取锁,拿不到就立刻返回 false,不等待”。带参 tryLock(long waitTime, long leaseTime, TimeUnit unit) 表示“尝试等待 waitTime 这么久,期间锁释放了我就获取,如果超时还没拿到就返回 false”。哪种场景用哪个,应该很清楚了。
但是这里有个性能陷阱我要提醒大家。如果你盲目地把 waitTime 设得很大,比如 60 秒,那么当锁被一个慢业务占住时,你的线程会一直阻塞等待。在微服务体系里,这会快速耗尽 Tomcat 线程池,导致整个服务雪崩。我自己常用的做法是:waitTime 不超过 3 秒;业务可以失败重试的,waitTime 调到 1 秒即可;秒杀这种必须快速响应的场景,直接用无参 tryLock()。
4.3 看门狗机制(watch dog)到底怎么工作
很多面试者提到 Redisson 的看门狗都会说“自动续期”,但具体怎么续的、续多久,说得清楚的人很少。看门狗其实是一个后台定时任务。当你通过无参lock()获取锁时,锁的默认 leaseTime 是 30 秒,同时 Redisson 会启动一个 netty 定时任务,这个任务每 10 秒执行一次。每次执行时,它会检查当前线程是否还持有这把锁,如果还持有,就将锁的过期时间重新设置为 30 秒。一旦你主动 unlock(),看门狗任务立刻取消。这个机制保证了一个线程只要没主动释放锁,并且进程本身还活着,锁就永远不会过期。
注意,看门狗只在“未指定 leaseTime”时生效。一旦你使用lock(10, TimeUnit.SECONDS)这种指定过期时间的写法,看门狗就关门了。原因很简单:你都定了死期,就没必要再续命。这两个模式不要混用,面试里也经常拿这个当考点。
4.4 锁的值与 Lua 脚本长什么样
最后补充一下底层的 Lua 脚本。Redisson 加锁的脚本核心是这样的(这是它早期版本的简化思路,新版更复杂但核心一致):
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[1], 1); redis.call('pexpire', KEYS[1], ARGV[2]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('hincrby', KEYS[1], ARGV[1], 1); redis.call('pexpire', KEYS[1], ARGV[2]); return nil; end; return redis.call('pttl', KEYS[1]);这段脚本的逻辑非常好理解:锁不存在就直接创建并计数 1;锁存在且当前线程标识一致就加 1;否则说明被别人持有,返回剩余过期时间,用于阻塞等待。整个检查与设置是原子执行的,因此不会出现竞态条件。这就是 Redisson 在没有分布式事务的情况下,仍能把锁的获取做到线程安全的根本原因。
解锁脚本则是先校验持有者、再递减计数、计数归零才删除。这套原子操作避免了“误解锁”和“多释放”的问题。如果面试官让你手写一个分布式锁,你可以用这段脚本作为底层依据,把“为什么不用 setnx+del”这个问题彻底讲明白。 ## 5. 常用锁类型盘点:不只是 RLock,还有读写锁和信号量 很多人以为 Redisson 只能做互斥锁,其实它提供了好几类锁,适用场景完全不同。我只挑生产环境里用过且值得推荐的几种讲。 ### 5.1 读写锁:ReadWriteLock 场景是“读多写少”。比如商品详情页,大部分请求在读取缓存或数据库,少部分请求在修改商品价格。如果全部加互斥锁,读操作之间都会被串行化,性能损失非常大。Redisson 的 `RReadWriteLock` 实现了读读并发、读写互斥、写写互斥。代码示例: ```java RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:price:" + productId); RLock readLock = rwLock.readLock(); RLock writeLock = rwLock.writeLock();读锁可以被多个线程同时持有,写锁是独占的。这个思路和 JUC 里的 ReentrantReadWriteLock 几乎一致,只是从进程内扩展到了分布式环境。另一个常见用法是“缓存刷新”场景:多个节点同时回源数据库刷新缓存,如果你只是先查缓存再回源,会产生缓存击穿。用读锁处理查询、用写锁处理回源,可以显著降低数据库压力。
5.2 公平锁:FairLock
Redisson 的RLock默认是非公平锁。什么意思?就是线程 A 释放锁之后,其他阻塞等待的线程不按先来后到的顺序抢锁,谁运气好谁先抢到。大多数业务无所谓,但某些对顺序敏感的场景(比如用户排队领取资源)需要公平。Redisson 提供了getFairLock(),它的实现是让等待的线程按 FIFO 队列排队。用起来和普通锁没有区别,只是底层维护了一个队列,代价是每次抢锁都会多点 Redis 操作,性能比非公平锁低一些。
5.3 信号量和计数锁:RSemaphore
信号量本质上不是锁,是“限流器”。比如某个资源最多允许 3 个线程同时访问,剩下的人必须等待。Redisson 提供getSemaphore("name"),初始化时指定许可证数量,通过acquire()和release()控制占用和释放。这个在某些老系统接口限流、或者特殊资源池化管理的场景里很好用。实现上限流,我一般优先用 RSemaphore,而不是自己在业务里维护一个 Redis 计数器,后者在并发扣减时容易产生负数。
5.4 RedLock:多节点锁的争议与用法
RedLock 是 Redis 作者提出的一个多节点分布式锁算法,要求锁同时写入多个独立 Redis 节点,只有大多数节点写入成功才算获取锁。Redisson 提供了RedissonRedLock支持这个方案。但说实话,我对 RedLock 的评价比较中性:它能解决单点故障问题,但由于需要多个节点响应,性能比单机锁低很多,而且业界对 RedLock 本身是否足够安全一直有争论。如果你不做严格的金融级场景,单机 Redis + 主从哨兵已经能满足绝大多数业务。用 RedLock 前一定问自己一句:我的 Redis 节点真的会同时宕机吗?如果不会,就不要为低概率事件牺牲大量性能。
6. 分布式锁的经典使用场景:从秒杀到定时任务
我把项目里用到的几个典型场景列出来,你可以直接对照业务场景去套用。
6.1 秒杀扣库存:防超卖
在 Redis 里先预扣库存,或者直接在数据库更新库存。最核心的步骤是在“校验库存 -> 扣减库存 -> 生成订单”这整段流程上加锁。锁的 key 最好带商品 ID,粒度要细到商品级别,而不是全局一把锁。否则 A 商品的秒杀会把 B 商品也堵住,并发能力极差。
RLock lock = redissonClient.getLock("lock:seckill:stock:" + skuId); if (lock.tryLock(0, 5, TimeUnit.SECONDS)) { try { int stock = stockService.getStock(skuId); if (stock <= 0) { return "已售罄"; } stockService.deductStock(skuId); orderService.createOrder(userId, skuId); } finally { lock.unlock(); } } return "系统繁忙,请稍后再试";这里 waitTime 设成 0,是因为秒杀场景抢不到锁就应该立刻返回,而不是傻等。leaseTime 设成 5 秒,是因为一段纯数据库操作通常不会超过这个时间。如果数据库存在慢查询,那问题应该在 SQL 层面解决,而不是靠无限续期锁。
6.2 定时任务分布式调度:防止重复执行
定时任务框架(如 xxl-job、quartz)在高可用部署时通常会配多个执行器。如果不加锁,每个节点到点都会触发同一个 job。解决方法是:在 job 开始执行时尝试获取一把全区唯一的锁,抢到锁的节点执行,没抢到的直接退出。
RLock lock = redissonClient.getLock("lock:job:daily-settle"); if (!lock.tryLock(0, 60, TimeUnit.SECONDS)) { return; } try { // 执行每日结算 settlementService.executeDailySettlement(); } finally { lock.unlock(); }leaseTime 要大于 job 的最长执行时间。我见过一个线上事故:job 正常执行只需要 10 秒,但某天数据量突增,跑了 40 秒,结果锁在第 30 秒过期,另一个节点又进来重复执行,导致账务重复入账。这类问题加一个看门狗就能解决:改用无参 lock(),让锁持续续期到业务真正结束。
6.3 MQ 消息幂等
MQ 在极端情况下会重复投递消息,消费者如果不做幂等,就可能重复记账。用 Redisson 实现“消费中”标记非常自然。消费者收到消息后,尝试获取锁lock:msg:idempotent:{messageId}。抢到锁说明这个消息第一次被处理,执行业务;抢不到说明另一个节点正在处理或已处理过,直接丢弃或进入待确认队列。
6.4 分布式应用中的库存预占
下单前先锁定库存,支付成功再真正扣减,支付失败或超时则释放库存。这个业务里,锁的持有时间跨度往往很长(几分钟到几十分钟)。这种场景千万不要用固定 leaseTime,必须用无参 lock() 加看门狗续期。否则用户还没付完钱,锁就过期了,结果同一件库存又被卖给别人。你可以把这个“预占”理解成一种分布式事务的轻量替代方案,虽然不等价,但在很多场景下已经足够把并发控制住。
7. 实战踩坑与排查技巧:这些坑我替你踩过了
这部分我会写得比较碎,但每条都是真实生产环境里能救命的经验。
7.1 锁粒度与性能优化
这是新手最容易犯的错:所有业务共用一把锁。比如lock:user,所有用户的所有操作都串行化,并发直接残废。正确的粒度原则是“按业务资源类型拆分”:商品锁按 SKU ID 分,用户锁按用户 ID 分,订单锁按订单号分。粒度越细,并发度越高,但也不能细到每个字段都加锁,那样锁的数量太多,Redis 内存也会炸。经验值是:让锁代表一个可独立争用的业务实体,而不是保护所有共享资源。
7.2 锁超时对业务的影响
固定 leaseTime 的场景里,如果业务执行时间超过锁的过期时间,其他线程就会提前拿到锁,此时原来的线程还没执行完,就会出现两个线程同时进入临界区。别以为我在制造焦虑,我见过真实案例:一个“发放优惠券”的接口,因为慢 SQL 导致执行时间超过锁的 3 秒过期时间,同一用户的同一优惠券被发了两次。解决的思路有两层:第一层,优先检查业务执行时间是否可控,尽量对耗时的 SQL 和 RPC 做监控;第二层,如果确实不可控,直接使用无参 lock(),让看门狗兜底。不要为了追求“锁必须过期”而强行设一个太短的 leaseTime。
7.3 Redis 连接与服务不可用
如果 Redis 本身挂了,Redisson 的锁就用不了了。此时所有获取锁的请求都会抛异常。防这种问题,我建议在业务代码里做一个降级策略。核心金融类操作宁可失败,也不要继续执行;非核心的操作可以降级为“本地乐观锁”或直接放行,但要明确接受并发可能带来的少量脏数据。说到底,分布式锁只是“尽最大努力”保证互斥,如果 Redis 宕了,你不可能拿分布式事务来替代它,很多时候业务需要在“可用性”和“一致性”之间做个取舍。
7.4 锁没有释放
最典型的问题是 unlock() 放在 try 里而不是 finally 里。业务流程正常的时,锁能释放;一旦业务抛异常,直接跳过了 unlock,锁就永久占着。另一个常见问题是在使用 tryLock 时忘了处理返回值,拿到锁和没拿到锁执行的是同一段业务,等于锁白加。我的习惯是:所有锁操作都在 finally 中释放,且开头必须先判断是否持锁成功。如果你觉得这个 template 写起来太啰嗦,可以用一个简易的 AOP 注解或环绕通知去封装,把 tryLock、finally-unlock 统一处理,业务代码只暴露真正的业务方法。
7.5 主从切换与锁丢失
使用 Redis 主从复制时,如果主节点宕机,锁的数据还没来得及同步到从节点,这时从节点被提升为新的主节点,锁就丢了。Redisson 对这个问题的解决办法是提供 RedLock 多节点方案。但 RedLock 本身也有争议,并且在大多数场景下,主从切换造成的锁丢失概率极低。如果业务对一致性要求非常高,我会把重点放在“数据校验”而不是“锁”上:例如在数据库扣减库存时加乐观锁进行二次校验。分布式锁是第一道防线,数据库乐观锁是最后一道防线,两者结合比单纯依赖任何一方都靠谱得多。
7.6 连接池耗尽
Redisson 的连接池如果配置过小,在高并发获取锁时会导致连接获取超时。现象是线上大量请求报RedisConnectionException,但 Redis 本身负载并不高。排查时优先看连接池的配置。我在压测里的经验是:单机 Redis 场景下,连接池 size 设为 CPU 核心数的两倍,能支撑大部分业务;如果连接池调大后仍然报错,基本可以判断是 Redis 端到客户端的网络链路或 Redis 自身的吞吐到了瓶颈,就要考虑加从节点或换集群了。
8. 附:Redisson 分布式锁速查表
| 问题 | 原因 | 解法 |
|---|---|---|
| 死锁 | unlock() 未放在 finally | 统一用 finally 释放,或用 AOP 封装 |
| 重复执行 | 固定 leaseTime 过短 | 改用无参 lock(),开启看门狗续期 |
| 性能低下 | 锁粒度太大 | 按业务实体拆分锁 key |
| 误删锁 | 未校验锁归属 | Redisson 已内置,但不要手写 del |
| 并发等待阻塞 | waitTime 设太长 | 减小等待时间,快速失败 |
| 主从切换丢锁 | Redis 同步延迟 | 用 RedLock,或在数据库侧二次校验 |
| 连接池耗尽 | pool size 偏小 | 调大连接池,并监控 Redis 负载 |
| 锁永久占用 | 业务异常未释放或看门狗异常 | 检查异常处理路径,并设置合理 timeout |
9. 最后再分享一个小技巧
我在做压测和线上问题复盘时,会专门为 Redisson 的锁加一套日志切面,记录每次加锁的 key、等待时间和持锁时间。这套切面最大的价值不是监控性能,而是发现“锁竞争热点”。哪把锁等待时间特别长,说明对应的资源争抢太激烈,要么是并发量过高,要么是锁粒度没有拆细致。沿着这条线去优化,比盲目加 Redisson 的配置参数有效得多。另外,获取锁时最好给每个业务 key 加上统一前缀(比如lock:order:),后续排查问题时,在 Redis 里用SCAN lock:*就能快速定位那一堆顽固的锁。这些小习惯看起来不起眼,但面对几百个微服务同时跑的环境,你就能体会到标准化命名的价值了。希望这篇能帮你把 Redisson 用得更顺手,如果有什么你自己踩过的坑,欢迎在评论区补全,互相省点时间。