☰
Redisson分布式锁实战:从原理到避坑指南
2026/10/1 10:55:42 网站建设 项目流程

我平时不太写框架的快速入门,但 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 用得更顺手,如果有什么你自己踩过的坑,欢迎在评论区补全,互相省点时间。

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

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

立即咨询