1. 项目概述
1.1 为什么需要分布式锁
先从一个最常见的场景说起:库存扣减。假设一个商品只剩最后一件,两个用户同时下单。单机部署时,我们直接用synchronized或者ReentrantLock就能保证同一时刻只有一个线程执行扣减逻辑。但一旦上了微服务,服务拆成多个实例部署在不同的机器上,本地锁就彻底失效了——A 机器上的锁,B 机器根本感知不到。
我做过一个实际的电商模拟项目,三个订单服务实例共享一个库存表,压测时并发 500 的秒杀请求,不加分布式锁的情况下,超卖率一度到了 30% 以上。后来引入分布式锁,超卖直接降为 0。这就是分布式锁存在的根本意义:在多个进程、多台机器之间同步访问共享资源。
解决这个问题的方案不少,主流的有三种:
- 基于数据库的悲观锁或乐观锁
- 基于 ZooKeeper 的临时顺序节点
- 基于 Redis 的分布式锁
而 Redisson 就是 Redis 分布式锁领域最成熟的客户端实现。
1.2 Redisson 是什么
Redisson 是一个基于 Redis 的 Java 客户端框架,但它的定位远不止一个客户端。它提供了分布式锁(包括可重入锁、公平锁、读写锁、联锁、红锁)、分布式集合、分布式对象、分布式消息队列(RQueue、RTopic)等一系列高阶功能。
一句话概括:Redis 原生的SETNX命令只是给你了一把"手动挡"的锁,Redisson 则是把这一整个过程封装成了"自动挡"。你只需要lock()和unlock()两个动作,什么加锁、续期、死锁检测、原子释放,全部由框架帮你处理。
这篇博客将围绕 Redisson 分布式锁的完整落地路径展开,包括核心原理、实战编码、参数调优以及我在真实项目中踩过的坑,希望能给正在为分布式并发问题头疼的朋友们一个直接可参考的方案。
2. 核心原理深度拆解
2.1 从 SETNX 到看门狗:锁是怎么一步步进化的
Redis 提供的SETNX命令,全称是SET if Not eXists,它本身就可以用来实现一把最原始的锁:
# 加锁 SET lock_key unique_value NX PX 30000 # 释放锁(需要用 Lua 脚本保证原子性) if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end第一版用这个方案做的事中排查,遇到了三个典型问题:
问题一:锁的持有者混淆。如果客户端 A 加的锁,因为业务逻辑执行超时(超过设定的 30 秒),锁自动释放了。这时候客户端 B 拿到锁开始执行业务,A 的代码终于跑完了,回头执行DEL lock_key,结果把 B 的锁删掉了。这就是锁误删。解决办法是加锁时放入一个唯一标识(比如 UUID 或线程 ID),释放时先校验值再删除。
问题二:锁的自动过期。分布式锁必须设置过期时间,否则客户端宕机会死锁。问题是过期时间短了,业务没执行完锁就没了;过期时间长了,客户端挂了之后锁长时间不释放。怎么让过期时间去自适应业务执行时间?这是 Redisson 看门狗机制解决的。
问题三:释放锁的操作不是原子的。先 GET 再 DEL,中间如果出现并发,状态就会错乱。必须用 Lua 脚本把"判断值 + 删除 key"放在同一段脚本中原子执行。
Redisson 在 SETNX 的基础上做了全面封装。加锁的 Redis 命令变成了这样:
EVAL "return redis.call('exists', KEYS[1]) == 0 and redis.call('hset', KEYS[1], ARGV[1], 1) and redis.call('pexpire', KEYS[1], ARGV[2]) or (redis.call('hexists', KEYS[1], ARGV[1]) and redis.call('hincrby', KEYS[1], ARGV[1], 1) and redis.call('pexpire', KEYS[1], ARGV[2]) or 0)"这里用的是 Hash 结构,key 是锁的名称,field 是客户端的唯一标识(UUID + 线程 ID),value 是重入计数。这个设计让 Redisson 天然支持可重入,同一个线程可以反复加同一把锁,每次hincrby加一,每次释放减一,减到 0 才真正删除 key。
2.2 看门狗机制:锁的隐形续期员
Redis 的锁必须有过期时间,但业务逻辑的执行时间往往不可预测。Redisson 的解决方案是"看门狗"(Watch Dog)。
当你调用lock()方法(不带过期时间参数)加锁时,默认情况下锁的超时时间是 30 秒。看门狗会启动一个后台定时任务,每 10 秒(即30 秒 / 3)检查一次当前线程是否仍然持有这把锁。如果持有,就把锁的过期时间重新刷新到 30 秒。
这个机制用代码来表达是这样的:
private void renewExpiration() { ExpirationEntry entry = EXPIRATION_RENEWAL_MAP.get(getEntryName()); if (entry == null) { return; } Timeout task = getServiceManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { if (entry == null) { return; } // 每 10 秒续期一次 renewExpirationAsync(); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); }这里有个容易误用的点。很多入门的文章说"看门狗会自动续期,所以不用设置过期时间",但实际是有条件的:看门狗默认只有在不显式传入过期时间的lock()调用下才会生效。如果你用了lock(10, TimeUnit.SECONDS)这种带超时时间的方法,Redisson 不会启动看门狗,因为框架默认你在业务上已经明确了锁的最大持有时间。
我在生产环境里见过一个线上问题,就是开发者用lock(5, TimeUnit.SECONDS)去锁一个可能要执行 10 秒的批量任务,结果锁在第 5 秒就提前释放了,另一个线程立刻进入,两个线程同时处理同一批数据,酿成数据不一致的线上事故。所以这里必须明确:当业务执行时间不可控时,使用无参lock()且配合看门狗;当业务时间有明确上限时,使用lock(long time, TimeUnit unit)但时间要留足够余量。
2.3 释放锁的原子性保障
Redisson 的释放锁不是简单的DEL,它同样用 Lua 脚本确保原子性。核心逻辑是:
- 先判断 Hash 中是否存在当前客户端的 field
- 不存在则直接返回(说明锁已经被过期释放了,不需要做任何操作)
- 存在则把重入计数减一
- 减计数后大于 0,刷新过期时间后返回
- 减计数后等于 0,删除整个 Hash key,并发布一条释放锁的消息
这段 Lua 脚本大致长这样:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1) if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]) return 0 else redis.call('del', KEYS[1]) redis.call('publish', KEYS[2], ARGV[1]) return 1 end这套机制的核心价值在于:任何一条命令的执行路径上都不会出现"判断完之后、删除之前"的间隙。Lua 脚本在 Redis 中是单线程原子执行的,Redis 不会在脚本中间穿插执行其他客户端的命令,天然杜绝了并发问题。
2.4 锁等待机制与 PubSub 唤醒
当一个线程尝试加锁但锁已被占有时,Redisson 并不是像setnx那样立即失败返回,而是会进入一个等待队列。客户端 A 持有锁,客户端 B 尝试加锁失败后,会订阅一个专门用于锁释放通知的 Channel(频道),然后阻塞等待。
当 A 释放锁时,Lua 脚本里最后一行publish就会向该 Channel 广播一条消息。B 收到消息后重新尝试加锁。这种方式比"固定间隔轮询重试"高效得多——B 不需要每隔 100 毫秒就去 Redis 请求一次,而是在锁释放的瞬间立即被唤醒。
这个机制的背后是 Redisson 对RBlockingQueue、RTopic等底层组件的组合使用。关键是它的等待不是无限期的,可以通过tryLock(long waitTime, long leaseTime, TimeUnit unit)控制最大等待时间。超过等待时间还没拿到锁,就直接放弃,返回false,避免客户端无限阻塞。
3. 环境准备与工程配置
3.1 依赖引入
使用 Redisson 的第一步是引入 Maven 依赖。以当前主流版本为例:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.27.2</version> </dependency>如果你并没有使用 Spring Boot,想单独使用,可以只引入核心依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.27.2</version> </dependency>这里给一个版本选择的建议:优先选用较新的稳定版本。但如果你用的是老项目,注意 Redisson 3.x 对 Spring Boot 的版本有要求,官方文档里有一张版本适配表。我实际踩过的一个坑是 Spring Boot 2.7 配 Redisson 3.27 一起用没问题,但换到 Spring Boot 3.0 之后,因为 Jakarta 命名空间的变化,必须升级到适配的 Redisson 版本,否则容器启动会直接报类加载错误。
3.2 客户端配置
最简单的方式是直接在application.yml里配置:
spring: data: redis: host: 127.0.0.1 port: 6379 password: your_password database: 0如果是 Spring Boot 项目且引入了redisson-spring-boot-starter,它会自动识别这些配置并创建 RedissonClient。但如果你需要更精细的控制,建议使用RedissonConfig类来配置:
@Configuration public class RedissonConfig { @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); // 单节点模式 config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setPassword("your_password") .setConnectionPoolSize(64) .setConnectionMinimumIdleSize(16) .setIdleConnectionTimeout(10000) .setConnectTimeout(3000) .setTimeout(3000) .setRetryAttempts(3) .setRetryInterval(1500); return Redisson.create(config); } }这些参数我建议不要用默认值直接上线。连接池大小需要根据你的并发量和 Redis 自身能力来调。setTimeout(3000)是 Redis 请求的响应超时,在实际高并发场景下,如果 Redis 出现短暂的 CPU 抖动,3 秒内可能响应不过来。我们之前把timeout设成 5 秒,给 Redis 多留了一些缓冲时间,锁服务异常率明显下降。
3.3 集群模式配置
如果生产环境用的是 Redis 集群,单机配置就不适用了。集群模式的配置方式:
Config config = new Config(); config.useClusterServers() .addNodeAddress( "redis://127.0.0.1:7001", "redis://127.0.0.1:7002", "redis://127.0.0.1:7003") .setScanInterval(2000); RedissonClient client = Redisson.create(config);集群模式下需要注意一个先决条件:Redisson 官方并不建议在 Redis 集群模式下直接使用RLock普通锁来实现严格意义上的全局互斥。原因在于 Redis 的主从架构存在数据同步延迟,如果主节点刚设置完锁还没来得及同步到从节点就宕机了,从节点被提升为主节点后,锁信息就丢了——这被称为"锁丢失问题"。
要解决这个问题,Redisson 提供了红锁(RedLock)方案:同时向集群中的多个独立 Redis 主节点请求加锁,如果超过 N/2+1 个节点成功加锁,则视为整体加锁成功。但是,红锁在工程界一直存在争议。各大云厂商的实现也对红锁方案有所调整,因为红锁在极端场景下依然不能保证绝对的线性安全,而且性能开销大。就我个人的实践经验,如果业务场景允许,推荐使用 ZooKeeper 的分布式锁(Curator)来获得更强的数据一致性;如果坚持用 Redis,我更倾向于在架构层面尽量避免需要"强互斥"的业务场景,或者在业务逻辑中增加必要的兜底校验(比如扣减库存前先检查库存数量)。
4. 核心场景实战
4.1 库存扣减场景
这是分布式锁用得最多的场景。来看一个完整的实现:
@Service public class StockService { private static final String STOCK_LOCK_PREFIX = "stock:lock:"; @Autowired private RedissonClient redissonClient; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void deductStock(Long skuId, int count) { String lockKey = STOCK_LOCK_PREFIX + skuId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // 尝试加锁,最多等待 3 秒,自动释放时间为 10 秒 locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("系统繁忙,请稍后重试"); } // 1. 事务内查询当前库存 int stock = stockMapper.selectStock(skuId); // 2. 校验库存是否充足 if (stock < count) { throw new RuntimeException("库存不足"); } // 3. 执行库存扣减 stockMapper.deductStock(skuId, count); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("操作被中断", e); } finally { // 在 finally 中释放锁,确保一定执行 if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这段代码有几个细节值得展开讲:
第一,锁粒度的选择。这里的锁 key 是stock:lock:加上 SKU ID,每个 SKU 一把锁。这意味着不同 SKU 的库存扣减完全不会相互阻塞,可以并行执行。如果图省事用一把全局锁stock:lock:all,那所有商品都互相阻塞,性能损耗非常大。锁的粒度越小,并发度越高,但这会带来锁数量的管理成本。理论上一个 SKU 一把锁是合理的选择。
第二,为什么用tryLock而不是lock。lock()是无条件阻塞获取,会一直等待直到拿到锁,这个过程中用户请求一直挂着。在 Web 场景里这很不友好,用户可能等了 5 秒都不知道系统在干什么,最后返回一个超时错误。tryLock(3, 10, SECONDS)的意思是:等锁最多 3 秒,拿不到就放弃,直接返回 false 走"系统繁忙"分支。这是一种"快速失败"的思想,适合大多数 Web 请求场景。
第三,isHeldByCurrentThread()的检查。防止了过期后去释放别人锁的情况。如果一个线程的锁因为业务执行时间太长被看门狗续期失败(客户端长时间 GC 或其他原因)而自动释放,另一个线程又重新拿到了锁,此时当前线程去unlock()就可能释放别人的锁。加上这个检查可以避免极端场景下的误释放。
4.2 可重入锁验证
Redisson 的锁是可重入的,这意味着同一个线程在锁内嵌套调用同一个锁方法时不会产生死锁。这在递归调用、类间调用时非常实用:
public void processOrder(Long orderId) { RLock lock = redissonClient.getLock("order:lock:" + orderId); lock.lock(); // 第一层加锁 try { validateOrder(orderId); // 内部还会加同一把锁 // ... } finally { lock.unlock(); } } public void validateOrder(Long orderId) { RLock lock = redissonClient.getLock("order:lock:" + orderId); lock.lock(); // 重入,不会死锁 try { // 校验订单状态 } finally { lock.unlock(); } }内部实现靠的是 Hash 结构中的重入计数。每次加锁时hincrby field 1,释放时hincrby field -1。计数器归零才真正删除锁 key。这正是 Lua 脚本里hincrby那一段的核心作用。如果没有可重入机制,第二个lock()会因为锁已被占用而等待,而等锁的线程正是持锁线程自己,就会出现经典的"自己锁自己"死锁。
4.3 公平锁与非公平锁的选择
Redisson 提供了两种语义上差异明显的锁:
- 非公平锁(默认的
RLock):线程加锁时直接尝试抢占,抢不到就进入等待队列。等待队列中的线程被唤醒后依然要和其他新来的线程竞争。极端情况下可能出现"线程饿死"——某个线程一直等不到锁。 - 公平锁(
FairLock):严格按照请求到达的顺序来分配锁。先请求的先获得锁,每个等待线程都有一个稳定的排队位置。
创建一个公平锁:
RLock fairLock = redissonClient.getFairLock("anyLock");实现上,公平锁使用了一个 Redis 列表来记录等待线程的顺序,每个线程加锁时先入队,然后根据队列顺序依次获取锁。公平锁带来了额外的 Redis 操作和性能损耗,除非业务上严格要求先到先得(比如抢购类场景里避免某些客户端总是抢不到),否则默认非公平锁已经足够。
我实际用下来有一个体会:非公平锁的吞吐量高于公平锁,在"允许少量饥饿"的场景下优先用非公平锁。公平锁适合那种对"顺序性"有严格要求的业务,例如银行叫号取款,A 先来的必须比 B 先处理,不允许插队。
4.4 读写锁在缓存一致性场景的应用
读多写少的场景里,普通的互斥锁会导致大量读线程互相阻塞。Redisson 提供了RReadWriteLock,读锁是共享的,写锁是排他的:
@Autowired private RedissonClient redissonClient; private static final String LOCK_KEY = "cache:rw:lock"; public Object getFromCache(String key) { RReadWriteLock rwLock = redissonClient.getReadWriteLock(LOCK_KEY); RLock readLock = rwLock.readLock(); readLock.lock(); try { Object val = redisTemplate.opsForValue().get(key); if (val == null) { // 缓存未命中的兜底逻辑 val = queryFromDatabase(key); } return val; } finally { readLock.unlock(); } } public void updateCache(String key, Object value) { RReadWriteLock rwLock = redissonClient.getReadWriteLock(LOCK_KEY); RLock writeLock = rwLock.writeLock(); writeLock.lock(); try { // 写缓存和同步数据库的操作 } finally { writeLock.unlock(); } }需要提醒的是,读写锁的粒度控制要非常小心。锁的 key 是全局的cache:rw:lock,也就是说任何不同业务 key 的读写操作都会互相阻塞。如果缓存数据量大、分片多,应该按业务维度拆锁,比如cache:rw:lock:" + key.hashCode() % 10这样的分片锁。我见过有人用全局读写锁给一个容量上千万的缓存做更新,结果写操作一来,所有读线程全部阻塞,造成服务大面积超时。锁的粒度思考不到位,方案再好也会出事故。
4.5 连续失败场景的快速失败策略
我在一个支付对账任务里遇到过这样的情况:某次对账,需要锁住某个商户的账户,防止重复对账。由于对账逻辑复杂,加上外部返回慢,一个商户的对账时长可能达到 20 秒以上。最初用lock()方法时,如果某个商户被上一个对账任务占用,后续任务会一直阻塞等待,长时间占用大量线程资源,最后线程池被打满了。
后来我统一改成tryLock(2, 30, TimeUnit.SECONDS)—— 等待锁 2 秒,拿不到就抛异常返回"对账进行中,请稍后再试"。这样做牺牲了极少数情况下的可重试性,但保全了整体服务的吞吐量和稳定性。
痛定思痛,我总结出一个规律:锁的等待时间需要根据业务的并发模型和容错能力来定,没有通用的标答。设置过短会导致大量业务直接失败,设置过长容易拖垮线程池。合理的方式是:先根据业务 P99 耗时推算一个初始值,然后压测,再根据压测结果和线上指标持续调整。
5. 参数选型与调优实践
5.1 加锁等待时间与锁持有时间
这两个参数是整个分布式锁使用中最容易理解错误、也最容易出问题的配置。
waitTime(等待时间):尝试获取锁的最大阻塞时间。超过这个时间还没拿到锁就返回失败。它与线程池利用率强相关。如果大量请求同时涌入,等待时间设得太长,线程会大量挂起,线程池瞬间被打满,新请求无法处理。我在一个订单系统中把waitTime设为 5 秒后,线程池活跃线程数轻易就到顶了,后来改成 1.5 秒,系统立刻恢复平稳。
leaseTime(锁持有时间/自动释放时间):锁的存活时间,超过这个时间锁自动释放。这里有个隐藏细节:如果显式设置了leaseTime,看门狗不会续期。所以这个值不能小于业务的最长执行时间,否则锁会在业务执行完之前就被释放,引发并发安全风险。
有一种比较稳妥的实践方式:业务时间确实不可控的情况下,使用lock()不显式传 leaseTime,让看门狗自动续期。这样做的缺点是每次续期只是一次 Redis 操作,选举或网络故障时可能出现续期失败的窗口,但这个概率通常极低,工程上可以接受。
我常用的一组合理配置是:tryLock(1, 30, TimeUnit.SECONDS),配合业务执行时间估算 5 到 10 秒。这样即便看门狗不工作,30 秒的兜底也足够。如果业务超过 30 秒还没结束,我会怀疑是死循环还是外部依赖卡死了,会主动排查而不是无限等待。
5.2 连接池大小对锁性能的影响
连接池的大小直接影响分布式锁在高并发场景下的吞吐表现。连接数太少,线程获取锁时需要等待可用连接,造成额外的等待延迟;连接数太大,Redis 本身的性能会被拖垮。
我实测过一组数据(模拟 300 并发,锁持有时间平均 50ms):
| 连接池大小 | QPS | 锁获取平均耗时 | 异常率 |
|---|---|---|---|
| 16 | 约 1200 | 42ms | 0.5% |
| 64 | 约 2300 | 18ms | 0.1% |
| 128 | 约 2600 | 15ms | 0.08% |
| 256 | 约 2400 | 20ms | 0.15% |
当连接池从 64 提升到 256 时,吞吐并没有继续提高,反而因为连接数太多、线程频繁争用连接,性能出现了轻微下降。64 的连接池大小在绝大多数业务场景下已经够用。Redis 本身是单线程模型,命令的执行顺序取决于到达顺序,连接池再大也不会提升 Redis 单条命令的执行速度,反而会带来不必要的调度开销。
5.3 锁粒度设计
锁粒度决定了并发度上限。我把锁的 key 设计归纳为三个层次:
- 粗粒度:全局锁(如
lock:all),并发度仅 1,适合重大分布式任务,比如数据库迁移任务。 - 中粒度:按业务对象 ID 分锁(如
stock:lock:{skuId}),并发度等于唯一 ID 数量,适合大部分业务场景。 - 细粒度:按字段或操作维度分锁(如
account:lock:{userId}:{operationType}),并发度最高,适合对性能有极致要求的场景,比如热点账户操作。
锁粒度越细,锁数量越大,Redis 内存占用也会增加(每个锁都是 Hash 结构)。同时锁的管理复杂度会上升,锁丢失、锁误删除的概率也随之增大。设计锁粒度时,建议从业务语义出发,而不是盲目追求并发度最大化。
6. 常见问题与避坑指南
6.1 锁提前失效与处置策略
虽然看门狗能解决大部分场景下的锁续期问题,但有些场景看门狗并不会生效,比如:
隐式条件一:主线程内的子线程。看门狗是基于当前线程的,如果业务逻辑通过线程池异步执行了后续耗时操作,主线程释放锁后,异步线程还在处理数据——此时锁已经释放了。
隐式条件二:长时间 Full GC。JVM 发生长时间 GC 时,应用线程会停顿,看门狗任务也可能处于暂停状态,无法及时续期。等 GC 恢复,锁可能已经过期。这是业内比较知名的分布式锁隐患之一。
针对 Full GC 导致的锁丢失,目前比较务实的方案是在业务侧做幂等处理。分布式锁解决的是"并发"问题,但不应该是唯一的守卫者。关键业务操作需要保证即使锁失效,重复执行也不会产生错误结果。用数据库的唯一约束、更新 SQL 中的乐观锁条件等作为兜底,这才是一个健壮的架构。
6.2 Redis 主从切换导致的锁丢失问题
这个问题在第 3 节已经提到过。Redis 集群中,主节点刚写入锁 key,还没同步到从节点,主节点宕机。从节点被提升为主节点后,新主节点上不存在锁 key。此时其他客户端尝试加锁会成功,导致同一把锁在两个客户端同时持有,互斥失效。
红锁(RedLock)在理论上能缓解这个问题,但实现复杂,且它本身也存在争议。在工程实践中,通常有几种思路:
- 锁服务改用 ZooKeeper/etcd 等具备强一致性的组件
- 通过数据库唯一索引、版本号做最终一致性兜底
- 在 Redis 侧开启
WAIT命令等待主从同步完成(仍有理论窗口,但工程上可接受)
我不推荐一上来就上红锁。先分析业务是否真的需要"绝对可靠"。如果业务可以接受极低概率的并发错误,那 Redis 普通锁配幂等就够用;如果完全不能接受,ZooKeeper 是更稳妥的选择。我在一个支付系统里,最终采用了"Redis 锁 + 数据库唯一索引"双层防线,线上运行一年多没有出现过一次并发违规。
6.3 tryLock 返回 false 是不是真的"没抢到"
有一次排查联调环境的问题,发现某个接口偶尔返回"系统繁忙"。我看日志,锁的tryLock返回了false。当时第一反应是并发太高、竞争激烈,但把日志调出来看,并发数并没有超过预期。
后来检查才发现,问题出在 Redis 连接池。当时的 Redisson 连接池配置connectionMinimumIdleSize设置得太小,大量连接在空闲后被回收。当高并发联调请求突然涌入时,连接池在短时间内创建连接的速度跟不上请求速度,很多请求在等待连接的过程中直接被超时中断,tryLock根本还没来得及去 Redis 就抛了异常。
排查方法:看tryLock抛出的异常是RedisConnectionException还是正常返回 false。日志里打印异常堆栈(加锁失败不应该只打false,还要打印失败原因),才能准确区分是业务竞争还是基础设施问题。现在我在所有加锁失败的日志里都会额外记录两条信息:当前等待时长和当前线程池活跃线程数。有了这两个指标,排查这类问题快得多。
6.4 unlock 时的常见异常处理
unlock()在以下情况会抛出IllegalMonitorStateException:
- 锁已经被自动释放(过期了),当前线程再去调用 unlock
- 加锁时用了
lock(),但执行unlock()的线程和加锁线程不是同一个线程
第二个情况经常出现在 AOP 代理加锁逻辑里。比如你定义了一个注解:
@Around("@annotation(distributedLock)") public Object handleDistributedLock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { String key = distributedLock.key(); RLock lock = redissonClient.getLock(key); lock.lock(); try { return joinPoint.proceed(); } finally { lock.unlock(); } }如果被切的方法内部又用自己的线程去释放这把锁,或者方法内部把锁对象传递给了其他对象来释放,都会报 IllegalMonitorStateException。这里的规则是:谁加锁,谁释放;加锁和释放必须在同一个线程的调用栈内完成。我的代码规范里明确要求:所有加锁操作后必须在同一方法的 finally 中释放,必须检查lock.isHeldByCurrentThread()后再释放。
6.5 锁 key 的命名规范
锁的 key 在设计上要避免两个问题:一是 key 太短或语义模糊导致锁的冲突范围过大;二是 key 太长导致 Redis 内存浪费。我比较推荐用业务前缀加业务 ID 的方式:
{业务域}:{子业务}:{业务ID}举例:
- 库存域:
stock:sku:{123456} - 订单域:
order:payment:{20241001001} - 用户域:
user:profile:{uid}
这种命名方式有几个好处:Redis 的 key 本身有按冒号分层的习惯,便于后期按前缀批量查询和排查问题;同时业务 ID 放在最后,当锁出问题时,从日志里能直接溯源到具体是哪条业务数据。
6.6 锁掩码与业务上下文传递
有一个坑值得单独拿出来说:在一个分布式锁的 while 循环中(比如等待锁的场景下使用 while 轮询),如果锁等待时间过长,线程上下文(比如追踪 ID、用户 ID)可能已经改变了。因为 Redisson 的锁等待是通过信号量和条件等待实现的,这个过程中线程不会阻塞到完全暂停,如果同一个锁对象被复用或者同一个线程在不同请求间复用,在续期或重入判断时可能会用到过期的上下文。
这个坑的典型表现是:日志中追踪 ID 还在,但业务代码里从 ThreadLocal 取到的用户 ID 变成了另外一个请求的。原因就是 Tomcat 线程池复用了线程,而锁等待期间 ThreadLocal 变量没有被清除。解决方案是在加锁前和加锁后都显式设置一次 ThreadLocal 上下文,并且在释放锁之后清理掉。只依赖父线程设置的上下文,在线程池场景下一定是不可靠的。
7. 工程化实践:从 Demo 到生产
7.1 封装分布式锁工具类
直接用RLock散落在业务代码里,代码会越来越乱。我建议在工程中封装一个分布式锁的基础工具类,把公共的配置和执行逻辑收拢起来:
@Component public class DistributedLockTemplate { @Autowired private RedissonClient redissonClient; public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier<T> supplier) { RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(waitTime, leaseTime, timeUnit); if (!locked) { throw new BizException("系统繁忙,请稍后重试"); } return supplier.get(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("加锁过程被中断", e); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } public void executeWithLock(String lockKey, long waitTime, long leaseTime, TimeUnit timeUnit, Runnable runnable) { executeWithLock(lockKey, waitTime, leaseTime, timeUnit, () -> { runnable.run(); return null; }); } }在业务代码中使用时就清爽多了:
public void deductStock(Long skuId, int count) { distributedLockTemplate.executeWithLock( "stock:sku:" + skuId, 3, 10, TimeUnit.SECONDS, () -> doDeductStock(skuId, count) ); }封装的另一个好处是可以在统一的地方打印加锁耗时、锁等待次数等监控日志,而不需要每个业务方法手写一遍。我第一次封装这些时低估了日志的价值,后来靠这些日志排查了几个偶发性异常,才真正意识到工具类里埋点的重要性。
7.2 注解方式落地
更进一步,可以用自定义注解 + Spring AOP 将分布式锁做成声明式使用:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedLock { String key(); long waitTime() default 3; long leaseTime() default 10; TimeUnit timeUnit() default TimeUnit.SECONDS; }切面实现:
@Aspect @Component public class DistributedLockAspect { @Autowired private RedissonClient redissonClient; @Around("@annotation(distributedLock)") public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { String lockKey = parseKey(distributedLock.key(), joinPoint); RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); if (!locked) { throw new BizException("服务繁忙,请稍后再试"); } return joinPoint.proceed(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("加锁过程被中断", e); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } private String parseKey(String key, ProceedingJoinPoint joinPoint) { // 支持 SpEL 表达式解析方法参数 // 具体实现略 } }注解方式有两个注意点:
- 切面方法内使用
joinPoint.proceed()调用目标方法,目标方法内的this调用同类方法时不会触发切面,需要把锁方法放在独立的 Service 类中,通过 Spring 代理调用。 parseKey需要支持从方法参数动态拼接 key,比如订单 ID。我这里用 SpEL 表达式解析,方式可靠且灵活。有一种偷懒的写法是拼接参数索引(如args[0]),但参数一旦调整很容易出错。
7.3 监控与告警
分布式锁是"隐性"的基础设施,它出问题时业务方往往感受不到直接报错,而是表现为偶发的响应变慢、数据不一致。所以我强烈建议:将分布式锁接入监控体系。
我自己的做法是记录了以下四类指标:
| 指标名称 | 作用 |
|---|---|
| 加锁等待耗时(wait time) | 反映竞争激烈程度 |
| 锁持有时间(hold time) | 反映业务执行耗时是否合理 |
| 加锁失败次数(lock fail count) | 反映系统是否过载 |
| 看门狗续期次数 / 异常次数 | 反映续期机制是否正常工作 |
这四个指标在手,锁相关的问题基本都能快速定位出一个方向。没有监控的分布式锁,就像没有仪表的飞机,飞着的时候一切未知,出了事才追悔莫及。
7.4 锁与事务的相互作用
这是一个极其容易被忽略的点:分布式锁加锁的范围和数据库事务的范围不一致时,锁的意义会被削弱,甚至产生反效果。
最常见的一个错误写法:
@Transactional public void deductStock(Long skuId, int count) { RLock lock = redissonClient.getLock("stock:sku:" + skuId); lock.lock(); try { // 业务操作 } finally { lock.unlock(); } }Spring 的事务默认是在方法执行完成后提交。以上代码的执行顺序是:
- 进入方法 -> 开启事务
- 加分布式锁
- 执行业务操作
- 解锁
- 方法返回 -> 事务提交
步骤 4 和步骤 5 之间有一个事务提交窗口。这个窗口内,锁已经释放了,但数据还没真正提交到数据库。另一个线程 B 在这个窗口拿到锁,去查询库存时,可能会看到旧数据(因为在事务提交之前,修改对其它事务不可见),从而执行错误的业务判断。
你可能会说,加锁后的查询不带锁场景很少吧?实际上非常常见。库存扣减操作大量使用先查后改的方式,需要保证整个"查询-判断-修改"过程的原子性。锁必须覆盖到事务提交完成之后,最简单的办法是:让加了分布式锁的 Service 方法不要自带@Transactional,把事务控制转移到锁外层,或者使用TransactionTemplate将提交动作控制在锁内部:
public void deductStock(Long skuId, int count) { RLock lock = redissonClient.getLock("stock:sku:" + skuId); lock.lock(); try { transactionTemplate.execute(status -> { // 查询、判断、修改 return null; }); } finally { lock.unlock(); } }这样做的顺序是:加锁 -> 开启事务 -> 执行业务 -> 提交事务 -> 释放锁。保证锁释放时数据已经对全局可见,BUG 的隐患被彻底消除。
7.5 锁服务的降级选择
不回避地说,Redis 分布式锁依赖 Redis 的高可用。如果 Redis 挂了,锁服务也挂了,这时候系统怎么响应?
我在一些系统里看到两种做法:一种是直接抛异常,让用户稍后重试;另一种是把 Redis 锁退化成本地锁(但这也只能保证单机内的互斥)。
实际上还有一种值得参考的思路:"熔断降级"。当锁服务连续失败达到一定阈值时,开启降级开关:对于核心链路(与资金相关的操作),宁可让请求失败也不能允许逻辑并发执行;对于非核心链路(比如一些轻量级标记操作),可以不再加锁,通过其它方式进行补偿。
这个方案的定位不是"正确的并发控制",而是"系统可用性保护"。降级的核心原则是区分业务的"安全性"和"可用性"优先级,触发降级时必须有直接的告警通知到值班人员。
我个人的实战体会是在分布式锁这个基础设施上,"宁可服务不可用,也不能出现数据错误"。在降级开关配置页面里,我把资金链路的安全防护全部保持最保守状态——不允许无锁降级,其它链路由业务负责人自行决策。
8. 性能测试与对比分析
8.1 分布式锁基准测试方法
在做性能对比前,先明确测什么:锁获取耗时、锁释放耗时、最大并发下的事务吞吐量、失败率。测试结构通常模拟"加锁-执行-释放"完整链路。
我用 JMeter 或者自写并发测试组件压测过三种方案:
| 方案 | 平均获取锁耗时(ms) | P99(ms) | 最大吞吐(QPS) |
|---|---|---|---|
| Redis SETNX 原生实现 | 较高,且波动大 | 不稳定 | 约 800 |
| Redisson RLock | 12 | 30 | 约 3000 |
| ZooKeeper Curator | 25 | 60 | 约 1800 |
Redisson 性能优于 ZooKeeper 方案的核心原因:Redisson 加锁只在 Redis 上执行一次 EVAL 脚本,后续释放消息基于 PubSub,没有多余的磁盘写入和会话管理开销。ZooKeeper 的强一致性保证是通过每次写操作都要过 ZAB 协议同步,代价在延迟上。
Redis 原生 SETNX 之所以表现最差,并不是因为命令慢,而是因为没有完善的重试、等待、释放机制。用 SETNX 高频轮询不仅占用 Redis 资源,还需要调用方自行控制重试策略,很容易写糟。
8.2 高并发场景下的性能表现
在 500 并发持续压测、业务执行耗时约 30ms 的场景下,Redisson 的锁获取 P99 保持在 30ms 以内。但如果单节点 Redis 的 CPU 已经逼近 100%,锁获取延迟会线性上升。所以分布式锁的性能瓶颈最终会落在 Redis 本身,而不是 Redisson 客户端。
如果压测发现锁获取 P99 超过 50ms,第一个要排查的就不是锁 API 的用法,而是 Redis 节点的 CPU、内存、网络 IO 指标。先确保 Redis 服务器没有成为瓶颈,再回头优化客户端参数。
这里有一个容易误导人的说法:"Redisson 锁的性能不好"。事实上,分布式锁本身就是一次额外的 RPC 往返,它的性能必然低于本地锁,这是分布式系统的物理约束。Redisson 能做的只是在单次往返尽量减少不必要的操作和网络开销,把每次加锁固定为一次 EVAL 命令完成。它已经是 Redis 分布式锁最优的实现方式之一了。
9. 总结与个人建议
分布式锁这个概念看着简单,真正落地时牵扯到的细节一点都不少。做一次完整的实践之后,我个人最大的体会是——分布式锁永远是"最终一致性"体系中的一环,不要指望它是万能的救世主。真正靠谱的生产级方案应该是:
| 防护层次 | 手段 | 目标 |
|---|---|---|
| 第一层 | Redisson 分布式锁 | 避免并发冲突 |
| 第二层 | 数据库唯一约束 / 版本号 | 兜底数据一致性 |
| 第三层 | 消息队列串行化处理 | 从架构上规避并发 |
| 第四层 | 日志 + 监控 | 快速定位问题 |
最后分享一个编码习惯:我在所有加了分布式锁的方法定义处,都会在方法注释里标明三件事——锁的 key、持有时间、业务执行预期耗时。等到三个月后,看到这段代码的人(可能就是我本人)还可以通过注释快速判断当前锁参数是否还匹配业务的真实情况。分布式锁的问题最容易发生在业务演化过程中,锁参数却一直没跟着变。它能在一开始就在注释层面把"锁的意图"固化下来,比写十页设计文档都有用。