1. 项目概述从一次线上事故说起那天晚上系统监控突然告警核心交易链路出现大量超时。紧急排查日志满屏都是“Could not acquire lock(s)”的异常。问题定位到一个使用了分布式锁的商品库存扣减服务上。在流量高峰时线程在争抢同一个Redis锁时发生了阻塞最终像高速公路堵车一样整个服务线程池被占满引发了雪崩。事后复盘我们发现团队里有的同学习惯性地用lock()有的则用tryLock()但大家对这两者在高并发下的行为差异理解并不透彻埋下了隐患。lock()和tryLock()这两个来自Redisson客户端最常用的方法名字相似职责却天差地别。它们不仅仅是“阻塞”与“非阻塞”那么简单其背后关乎着系统在高并发下的吞吐量、响应时间、死锁风险乃至整个服务的可用性。理解它们的区别是设计一个健壮分布式系统的基石。本文将深入Redisson源码与实战场景为你彻底拆解这两个方法的核心机制、适用场景以及那些教科书上不会写的“踩坑”实录。2. 核心机制深度解析不只是阻塞与非阻塞很多人对lock()和tryLock()的第一印象是一个会死等一个尝试一下不行就撤。这个理解只对了一半更关键的区别在于它们所触发的Redisson内部不同的加锁流程、等待机制以及故障处理逻辑。2.1 lock()方法铁了心的等待者当你调用lock()方法时当前线程会进入一个近乎“偏执”的加锁循环。它的核心逻辑不是简单的sleep而是一个结合了Redis Lua脚本、看门狗Watchdog续期和订阅发布机制的复杂过程。加锁流程拆解尝试获取锁线程首先会执行一段Lua脚本尝试在Redis中设置一个特定的键即锁的Key。如果键不存在nil则设置成功并设置一个默认的生存时间TTL默认30秒同时将线程标识UUID 线程ID存入值中。这一步是原子性的。获取失败进入等待如果锁已被其他线程持有当前线程并不会立即进入忙等待。相反它会通过Redis的发布订阅Pub/Sub功能订阅一个与锁Key相关的特定频道。等待通知线程在Java端使用Semaphore信号量进行等待。当锁被释放时通过unlock()释放锁的线程会向之前提到的频道发布一条消息。所有订阅了这个频道的等待线程都会被唤醒。被唤醒后重试被唤醒的线程会再次跳转到步骤1尝试获取锁。由于可能同时有多个线程被唤醒因此仍然需要通过Lua脚本的原子性来确保只有一个线程能成功获取。看门狗续期对于成功获得锁的线程Redisson会启动一个后台定时任务“看门狗”每隔一段时间默认是锁TTL的1/3即10秒检查一次如果当前线程还持有锁就自动将锁的TTL重置为初始值默认30秒。这解决了业务执行时间超过锁超时时间导致的锁失效问题。注意lock()方法默认是不带超时参数的。这意味着如果永远获取不到锁比如持有锁的客户端崩溃且看门狗也失效了调用线程会永远等待下去。虽然可以通过lock(long leaseTime, TimeUnit unit)指定锁的自动释放时间但此时看门狗机制将不会启动。2.2 tryLock()方法精明的机会主义者与lock()的执着不同tryLock()体现的是一种“机会主义”策略。它有几个重载方法但核心思想是在给定的条件等待时间、锁持有时间下尝试获取锁成功则返回true失败则返回false绝不无谓等待。最常见用法的流程boolean tryLock(long waitTime, long leaseTime, TimeUnit unit)是这个家族中最灵活也最常用的方法。它的执行逻辑如下计算截止时间根据传入的waitTime等待时间计算出尝试获取锁的最终截止时间点。尝试获取锁同样先执行Lua脚本尝试加锁。成功或失败成功如果加锁成功方法直接返回true。如果指定了leaseTime锁持有时间则锁会在该时间后自动过期如果leaseTime为-1则会启用看门狗机制需注意在tryLock中启用看门狗的行为与lock()略有不同取决于版本和参数。失败计算剩余等待时间。如果剩余等待时间小于等于0则立即返回false。订阅与限时等待如果加锁失败且还有剩余等待时间则订阅锁释放频道。然后线程在Java端的Semaphore上进行限时等待等待时间就是剩余的waitTime。这里有两个唤醒条件收到锁释放通知和其他等待线程一样被唤醒后跳回步骤2重试。等待超时在指定的waitTime内没有收到通知则停止等待取消订阅并返回false。关键差异对比表特性维度lock()tryLock(waitTime, leaseTime, unit)阻塞行为无限期阻塞直到成功获取锁。最多阻塞waitTime时长超时则返回false。返回值void无返回值。boolean成功为true失败为false。默认超时锁默认看门狗续期30秒自动续调用线程无等待超时。必须显式指定waitTime等待超时和leaseTime锁持有超时。异常处理在等待过程中线程被中断会抛出InterruptedException。在等待过程中线程被中断会抛出InterruptedException超时则正常返回false。适用场景锁必须获取成功的场景且业务执行时间不确定或可能较长。锁获取可失败需控制等待时间或需要快速失败降级的场景。3. 场景化选型与实战要点理解了核心机制我们来看看在什么情况下该用谁。选型错误轻则性能受损重则引发故障。3.1 何时使用 lock()追求绝对可靠性的场景lock()适用于那些锁必须获取成功业务才能继续的强一致性场景。它的“死等”特性在分布式协调中有时是必要的。典型场景一分布式全局配置初始化假设你的系统在启动时需要从数据库加载一份全局配置到JVM缓存。多个实例同时启动这个加载动作只需要执行一次。RLock lock redissonClient.getLock(GLOBAL_CONFIG_INIT_LOCK); lock.lock(); // 必须拿到锁才能初始化 try { if (!configLoaded) { // 双重检查 // 从DB加载配置 loadConfigFromDB(); configLoaded true; } } finally { lock.unlock(); }这里使用lock()是合理的因为初始化动作必须且只能发生一次。任何一个实例都必须等待初始化完成使用tryLock()可能会导致多个实例都获取失败从而都跳过初始化导致配置为空。典型场景二后台定时任务的防重复执行多个服务实例部署了同一个定时任务如每天凌晨对账但任务本身在分布式环境下只能由一个实例执行。Scheduled(cron 0 0 2 * * ?) public void dailyReconciliation() { RLock lock redissonClient.getLock(JOB_DAILY_RECONCILIATION); if (lock.tryLock()) { // 【错误示范】这里用tryLock()可能有问题 try { // 执行对账逻辑 doReconciliation(); } finally { lock.unlock(); } } }上面代码使用tryLock()的无参版本立即返回在低并发竞争下可能没问题。但如果多个实例的定时器同时触发稍有偏差就可能出现都获取锁失败导致当天任务漏执行。更稳妥的做法是使用lock()或者使用带等待时间的tryLock(10, TimeUnit.SECONDS)给一个短暂的竞争窗口期。实操心得不要滥用无参的lock()。在无法预估业务执行时间时它配合看门狗是安全的。但如果能预估一个最大执行时间更推荐使用lock(long leaseTime, TimeUnit unit)并设置一个稍大于业务最大执行时间的值这样可以避免因客户端长时间GC或网络问题导致看门狗线程停止从而造成锁无法释放的“伪死锁”情况。3.2 何时使用 tryLock()兼顾性能与可用性的场景tryLock()的核心价值在于可控的等待和快速的失败它是构建高响应、高可用系统的利器。典型场景一秒杀库存扣减这是文章开头事故的场景。在高并发秒杀中使用lock()会导致所有请求串行化吞吐量极低且容易线程池打满。public boolean deductStock(String itemId, int quantity) { String lockKey LOCK_STOCK_ itemId; RLock lock redissonClient.getLock(lockKey); // 尝试在50毫秒内获取锁锁持有时间最多3秒 boolean isLocked false; try { isLocked lock.tryLock(50, 3000, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; // 中断视为失败快速返回 } if (!isLocked) { // 获取锁失败记录日志或返回“抢购失败请重试” log.warn(Failed to acquire lock for item: {}, thread: {}, itemId, Thread.currentThread().getName()); return false; } try { // 执行库存查询与扣减 return doDeductStock(itemId, quantity); } finally { if (isLocked) { lock.unlock(); } } }这里waitTime50ms非常关键。它意味着一个线程最多只愿意花50ms在等待锁上超时就立刻向用户返回“失败”。这保证了接口的响应时间上限避免了请求堆积。leaseTime3s则根据扣减库存这个业务操作的最大可能耗时来设定防止业务异常导致锁永不释放。典型场景二资源池的限流访问假设你有一个外部API调用配额每秒最多100次。你可以用一个Redis键作为计数器。当大量请求涌入时你需要控制获取“调用许可”的等待行为。public boolean tryAcquirePermission(String resource) { String lockKey RATE_LIMIT_ resource; RLock lock redissonClient.getLock(lockKey); // 只尝试一次绝不等待 if (lock.tryLock()) { try { // 检查并更新计数器 Long count redisTemplate.opsForValue().increment(lockKey); if (count ! null count 1) { redisTemplate.expire(lockKey, 1, TimeUnit.SECONDS); } return count 100; } finally { lock.unlock(); } } return false; // 没拿到锁说明计数器正被其他线程更新直接返回false稍后重试或拒绝 }这里使用了无参tryLock()实现了一种“非阻塞的互斥访问”。它确保了计数器更新的原子性同时又不会引起线程阻塞适合对延迟极其敏感的场景。参数设置经验谈waitTime等待时间这个值不是拍脑袋定的。你需要结合业务可接受的最大响应时间RT和系统平均处理时间来设定。例如用户操作可接受RT为200ms业务平均处理需50ms那么waitTime可以设为200ms - 50ms 150ms留出一些余量。在秒杀场景这个值会设得非常小如10-100ms以快速抛弃过多请求。leaseTime锁持有时间必须大于业务逻辑的最大可能执行时间并留出充足余量比如1.5-2倍。同时要绝对小于Redis的maxmemory-policy策略下可能导致键被淘汰的时间。设置过短业务没执行完锁就释放了会导致数据不一致设置过长万一客户端宕机锁需要更长时间才能自动释放影响可用性。4. 高级特性与底层原理探秘仅仅会用还不够理解一些高级特性和底层原理能让你在复杂场景下游刃有余。4.1 看门狗机制的生效条件这是一个极易混淆的点。很多人以为tryLock只要不传leaseTime就会自动续期其实不然。lock()默认启用看门狗。你也可以通过lock(10, TimeUnit.SECONDS)指定租期此时看门狗不启用锁10秒后自动释放。tryLock(long waitTime, long leaseTime, TimeUnit unit)如果leaseTime 0则使用你指定的租期看门狗不启用。如果leaseTime -1则使用默认的30秒租期并且启用看门狗这是Redisson的约定。如果调用的是无参tryLock()或tryLock(0, TimeUnit.SECONDS)它内部相当于调用tryLock(0, -1, TimeUnit.SECONDS)即不等待、租期-1会启用看门狗。踩坑记录我们曾遇到一个任务调度系统使用tryLock()获取锁后执行一个长达10分钟的任务。开发同学以为看门狗会自动续期但实际上他调用的是tryLock(5, TimeUnit.SECONDS)只有waitTime。这个方法内部租期默认是5秒5秒后锁自动释放其他节点抢到锁开始执行导致同一个任务被重复执行。务必清楚你调用方法的每一个参数含义。4.2 公平锁与非公平锁Redisson的lock()和tryLock()默认实现的是非公平锁。这意味着当锁释放时所有等待的线程被同时唤醒去竞争最早开始等待的线程不一定能拿到锁。这有利于提高吞吐量但可能导致“线程饥饿”。Redisson也提供了公平锁Fair Lock通过getFairLock获取。公平锁内部使用Redis的队列来维护等待顺序严格按照先来后到的顺序授予锁。公平锁能保证公平性但性能开销比非公平锁大因为需要维护队列秩序。如何选择默认用非公平锁在绝大多数业务场景下非公平锁的性能更好且线程饥饿问题在实际中并不明显。考虑公平锁的场景当锁的持有时间非常长且等待线程对等待时间极度敏感需要可预测的等待时间或者业务逻辑上必须保证绝对的先来后到时才考虑使用公平锁。4.3 Redis数据结构与Lua脚本原子性理解Redisson在Redis中存储的数据结构有助于调试和排查问题。当你用Redisson加锁时Redis中创建的是一个Hash结构。Key: “my_lock” Type: hash Value: 字段1: “mode” - “redisson_lock” 字段2: “UUID:threadId” - 1 (重入次数)加锁的Lua脚本会原子性地完成“判断是否存在、设置值、设置过期时间”等一系列操作。解锁脚本则会判断当前线程是否持有锁通过UUID和线程ID如果是则减少重入次数当次数为0时删除Key。这种原子性操作是分布式锁正确性的根本保障。5. 生产环境避坑指南与问题排查理论最终要服务于实践。下面是我在多年运维中总结的常见坑点和排查思路。5.1 常见问题速查表问题现象可能原因排查思路与解决方案“Could not acquire lock(s)” 频繁出现1. 业务处理时间超过锁租期(leaseTime)。2. 系统负载高waitTime设置过短。3. Redis性能瓶颈加锁操作变慢。1. 检查业务逻辑耗时调大leaseTime或确保看门狗启用。2. 监控系统负载适当增加waitTime或优化业务。3. 监控Redis CPU、内存、网络IO检查慢查询日志。锁永远无法释放死锁1. 获得锁的线程执行了lock()但业务异常或进程崩溃未执行unlock()。2. 看门狗线程因Full GC等原因挂起导致锁过期释放。3. 错误地在非持有锁的线程上调用unlock()。1.必须在finally块中释放锁。2. 优化JVM GC或使用带明确leaseTime的锁不依赖看门狗。3. 确保加锁和解锁在同一线程上下文。Redisson通过UUIDThreadId校验。锁被其他线程误释放使用了固定的锁Key且不同业务逻辑误用了相同的Key。锁Key的设计要具备唯一性通常包含业务前缀资源标识如ORDER_LOCK_123456。性能瓶颈吞吐量低1. 锁粒度太粗如整个库存库一把锁。2. 使用lock()导致大量线程串行。3. Redis单点压力大。1. 细化锁粒度如按商品ID加锁。2. 改用带短waitTime的tryLock()快速失败降级。3. 考虑Redis集群或使用Redisson的联锁MultiLock对集群模式进行优化。在tryLock失败后依然执行业务逻辑代码逻辑错误没有正确判断tryLock的返回值。牢记tryLock返回boolean必须用if判断成功后才进入临界区。5.2 锁监控与诊断技巧查看Redis中的锁数据直接使用redis-cli连接Redis通过hgetall your_lock_key查看锁的详细信息包括持有者UUID:ThreadId和重入次数。这能直接确认锁被谁持有。打印Redisson内部日志将org.redisson的日志级别设置为DEBUG或TRACE可以看到加锁、解锁、续期、订阅等详细的内部事件对排查复杂问题非常有帮助。监控看门狗线程看门狗线程名通常包含redisson-timeout。如果业务线程长时间停顿如Full GC看门狗线程也可能停止可以通过JVM监控工具观察这些线程的状态。模拟网络分区这是分布式锁的“终极考验”。可以手动断开应用与Redis的网络观察锁的行为。Redisson在默认配置下会在连接丢失时不断尝试重连期间看门狗失效锁最终会因超时而被释放。这符合CAP理论中的AP选择保证可用性最终一致性。5.3 我的最佳实践清单首选tryLock在大部分业务场景中优先考虑使用带超时参数的tryLock。明确指定waitTime和leaseTime给你的系统设置明确的超时边界。锁命名规范使用清晰的命名规则如业务:功能:资源IDtrade:order:pay:${orderId}避免冲突。finally中解锁这是铁律。使用tryLock时结合一个boolean locked变量在finally中有条件地解锁。避免在事务中加锁数据库事务和Redis锁的边界可能不一致容易引发复杂问题。尽量在事务外部加锁。不要依赖锁做精确限流分布式锁用于互斥对于限流场景更推荐使用Redis的令牌桶或漏桶算法。测试锁的释放单元测试和集成测试中必须覆盖“获取锁成功”、“获取锁失败”、“锁超时释放”等场景。分布式锁是分布式系统中最精妙的平衡艺术之一它需要在一致性、可用性、性能和复杂度之间做出取舍。lock()和tryLock()就是Redisson交给我们的两把不同性格的钥匙。理解它们的本质差异根据你的业务场景是追求绝对成功还是允许优雅降级和性能要求是接受等待还是要求快速响应来做出合适的选择才能构建出既稳健又高效的分布式应用。下次当你写下lock()或tryLock()的时候不妨多花一秒想想这个选择背后的代价是什么又守护了什么样的业务底线。