尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

架构设计之Redisson分布式锁-自旋锁回退(四)

架构设计之Redisson分布式锁-自旋锁回退(四) 1. 引言与系列回顾在分布式系统的架构设计中分布式锁是解决资源竞争的核心手段之一。Redisson 作为 Java 生态中最流行的 Redis 客户端之一其分布式锁实现以功能全面、性能优异、可扩展性强而著称。本系列文章深入剖析 Redisson 分布式锁的架构设计从基础原理到高级特性逐步揭开其内部实现的面纱。在前三篇文章中我们已经详细讨论了以下内容第一篇——《Redisson分布式锁基础与原理》从 Redis 单机锁到分布式锁的演进过程介绍了 SETNX 命令的底层原理以及 Redisson 如何通过 Lua 脚本保证加锁操作的原子性。同时深入分析了锁的 Key 设计、Hash 结构存储、以及线程标识的生成策略。第二篇——《Redisson分布式锁-看门狗机制》重点剖析了 Redisson 的 Watch Dog 自动续期机制包括 Netty 时间轮的调度原理、续期锁的 Lua 脚本实现、以及如何通过看门狗解决业务执行时间超过锁过期时间的问题。我们还讨论了锁续期的性能开销和优化策略。第三篇——《Redisson分布式锁-可重入锁实现》深入讲解了 Redisson 可重入锁的设计思想包括锁重入计数的 Redis Hash 存储结构、同一线程多次获取锁的判断逻辑、以及释放锁时的重入计数递减机制。同时分析了可重入锁与 Java 中 ReentrantLock 的设计异同。本篇作为系列的第四篇文章我们将聚焦于一个容易被忽视但至关重要的设计细节——自旋锁回退机制。在分布式锁的获取过程中如果锁被其他线程持有当前线程通常有两种选择一是阻塞等待订阅 Redis 的 Pub/Sub 通知二是自旋重试不断轮询尝试获取锁。Redisson 采用了自旋锁与阻塞等待相结合的策略并且在自旋一定次数后会自动回退到阻塞等待模式。这种设计背后蕴含着深刻的架构权衡和性能考量。在接下来的内容中我们将从以下维度完整展开自旋锁回退机制自旋锁的底层原理与 CPU 开销分析Redisson 中自旋锁的源码实现细节回退策略的设计理念与参数调优自旋等待与阻塞等待的性能对比真实业务场景下的架构实践与避坑指南本文篇幅约 2 万字建议读者在阅读前已经对 Redis 基础命令和 Java 并发编程有一定了解同时建议配合前三篇文章一起阅读以建立完整的知识体系。2. 自旋锁基础原理2.1 什么是自旋锁自旋锁Spin Lock是一种非阻塞的锁获取方式。当线程尝试获取锁失败时它不会立即进入阻塞状态如调用Thread.sleep()或LockSupport.park()而是在一个循环中不断检查锁的状态直到成功获取锁为止。这种不断循环检查的过程被称为自旋Spinning。从操作系统层面来看自旋锁的本质是让线程在用户态持续运行避免陷入内核态进行上下文切换。在单机环境下Java 中的java.util.concurrent.locks.ReentrantLock底层依赖的 AQSAbstractQueuedSynchronizer就使用了自旋机制来优化锁的获取性能。而在分布式环境下Redisson 将这一思想引入到基于 Redis 的分布式锁实现中。下面通过一个简单的伪代码来理解自旋锁的基本逻辑// 自旋锁的基本逻辑伪代码 public void lock() { while (true) { // 尝试通过 CAS 操作获取锁 if (tryAcquire()) { // 获取锁成功退出循环 return; } // 获取锁失败继续自旋不释放 CPU // 自旋过程中 CPU 持续处于忙碌状态 } }在分布式场景下自旋锁的实现会有所不同。因为锁的状态存储在远程的 Redis 服务器上每次检查锁状态都需要发起一次网络请求。这就带来了一个新的问题网络 I/O 的开销远大于本地内存访问。因此分布式自旋锁的设计必须在自旋频率和网络开销之间取得平衡。2.2 自旋锁的 CPU 开销分析理解自旋锁的 CPU 开销是设计回退机制的关键前提。在单机环境下自旋锁的 CPU 开销主要体现在以下几个方面1. 忙等待消耗 CPU 周期自旋线程在等待锁的过程中CPU 始终处于忙碌状态执行无意义的循环指令。如果锁的持有时间很短例如只保护几行代码的执行自旋等待的开销通常小于线程阻塞和唤醒的开销。但如果锁的持有时间较长自旋等待会严重浪费 CPU 资源。2. 缓存一致性开销在多核处理器中每个 CPU 核心都有自己的本地缓存L1/L2 Cache。自旋线程不断读取锁的状态变量会导致缓存行Cache Line在多个核心之间频繁传输引发缓存一致性协议如 MESI的开销。这种开销在核心数较多时尤为明显。3. 内存屏障与指令重排为了保证锁状态的可见性自旋锁通常需要使用volatile变量或内存屏障指令。这些操作会阻止 CPU 的指令重排优化进一步降低执行效率。在分布式环境下自旋锁的 CPU 开销还叠加了网络 I/O 的延迟。每次 Redis 请求的往返时间RTT通常在 0.1ms 到 1ms 之间如果自旋频率过高会迅速耗尽客户端的网络连接资源和 CPU 时间片。下表对比了不同场景下的开销差异场景等待方式CPU 开销响应延迟适用场景单机自旋锁循环 CAS高消耗 CPU 周期极低纳秒级锁持有时间极短微秒级分布式自旋锁循环 Redis 请求高CPU 网络中等毫秒级锁持有时间短毫秒级分布式阻塞锁Pub/Sub 通知低线程挂起较高等待通知锁持有时间长秒级混合策略自旋回退先自旋后阻塞可控平衡可控平衡通用场景从上表可以看出单纯的分布式自旋锁在锁持有时间较长时会带来严重的资源浪费。这正是 Redisson 引入自旋锁回退机制的根本原因——在自旋的快速响应和阻塞的资源节约之间找到最优平衡点。2.3 自旋锁与阻塞锁的对比在深入 Redisson 的实现之前我们有必要从理论层面理解自旋锁和阻塞锁各自的适用场景和设计权衡。以下从多个维度进行对比响应时间自旋锁的响应时间极短因为线程一直在运行一旦锁释放就能立即感知并获取。阻塞锁的响应时间则取决于操作系统的线程调度策略通常需要经历唤醒→就绪→调度→运行的完整流程延迟在微秒到毫秒级别。资源消耗自旋锁在等待期间持续消耗 CPU 资源但不占用操作系统的线程管理开销。阻塞锁在等待期间不消耗 CPU但线程的挂起和唤醒涉及内核态切换内存上下文保存和恢复等操作每个线程的阻塞和唤醒大约消耗 5-10 微秒的时间。公平性自旋锁是非公平的因为多个自旋线程同时竞争锁谁先抢到取决于 CPU 的调度时机。阻塞锁可以通过队列机制实现公平性Redisson 的阻塞锁正是通过 Redis 的 Pub/Sub 和队列机制来保证锁的获取顺序。适用场景自旋锁适合锁持有时间极短的场景微秒级此时上下文切换的开销远大于自旋等待的开销。阻塞锁适合锁持有时间较长的场景毫秒级到秒级此时自旋等待会严重浪费 CPU 资源。Redisson 的混合策略则试图覆盖更广泛的场景在锁持有时间不确定时提供最优的性能表现。下面的架构图展示了自旋锁和阻塞锁在等待阶段的不同行为模式flowchart TD A[线程尝试获取锁] -- B{锁是否可用} B --|是| C[获取锁成功] B --|否| D{选择等待策略} D --|自旋锁策略| E[自旋等待] E -- F[不断重试获取锁] F -- G{获取成功} G --|是| C G --|否| E D --|阻塞锁策略| H[线程挂起] H -- I[订阅锁释放通知] I -- J[收到通知被唤醒] J -- K[重新尝试获取锁] K -- L{获取成功} L --|是| C L --|否| M[重新排队等待] M -- H D --|混合策略| N[先自旋N次] N -- O{自旋期间获取成功} O --|是| C O --|否| P[回退到阻塞等待] P -- H3. Redisson分布式锁架构回顾3.1 Redisson 锁的层次结构在深入自旋锁回退机制之前我们需要先回顾 Redisson 分布式锁的整体架构设计。Redisson 的锁实现采用了经典的模板方法模式Template Method Pattern通过抽象基类定义锁的核心流程再由具体子类实现差异化的锁获取策略。Redisson 锁的类层次结构如下// Redisson 锁的类层次结构简化版 public interface RLock extends Lock, RLockAsync { // 基础锁接口定义了 tryLock、lock、unlock 等方法 } public abstract class RedissonBaseLock extends RedissonExpirable implements RLock { // 抽象基类封装了锁的通用逻辑 // 包括锁 Key 生成、线程 ID 获取、过期时间管理等 protected abstract T RFutureLong tryAcquireAsync( long waitTime, long leaseTime, TimeUnit unit, long threadId ); } // 具体实现类 public class RedissonLock extends RedissonBaseLock { // 普通分布式锁实现 // 包含自旋锁回退逻辑 } public class RedissonFairLock extends RedissonBaseLock { // 公平锁实现 // 通过 Redis 队列保证锁获取顺序 } public class RedissonSpinLock extends RedissonBaseLock { // 纯自旋锁实现不推荐直接使用 // 适用于锁持有时间极短的场景 }在 Redisson 的架构设计中RedissonBaseLock抽象类承担了核心的协调职责。它定义了tryAcquireAsync()抽象方法由子类实现具体的锁获取逻辑。同时RedissonBaseLock还封装了锁的通用处理流程包括锁超时管理、看门狗续期、以及本篇文章的核心——自旋锁回退控制。下面通过架构图来展示 Redisson 锁的整体设计flowchart TD subgraph 接口层 RLock[RLock 接口] RLockAsync[RLockAsync 异步接口] end subgraph 抽象层 BaseLock[RedissonBaseLock 抽象基类] BaseLock --|通用逻辑| LockKey[锁 Key 生成] BaseLock --|通用逻辑| ThreadId[线程 ID 管理] BaseLock --|通用逻辑| WatchDog[看门狗续期] BaseLock --|通用逻辑| SpinBackoff[自旋回退控制] end subgraph 实现层 RedissonLock[RedissonLock 普通锁] FairLock[RedissonFairLock 公平锁] SpinLock[RedissonSpinLock 自旋锁] end RLock -- BaseLock RLockAsync -- BaseLock BaseLock -- RedissonLock BaseLock -- FairLock BaseLock -- SpinLock3.2 锁获取的核心流程Redisson 分布式锁的获取流程可以概括为以下几个关键步骤第一步构造锁 KeyRedisson 使用用户指定的锁名称作为 Redis Key 的前缀加上固定的命名空间构成最终的锁 Key。例如用户指定锁名称为order:lock:123Redisson 内部会将其映射为redisson_lock:order:lock:123的格式。这种设计确保了不同业务模块的锁 Key 不会冲突。第二步执行 Lua 脚本获取锁Redisson 通过 Lua 脚本在 Redis 服务端原子性地执行锁获取操作。Lua 脚本的核心逻辑包括检查锁是否已被持有、如果锁可用则设置 Hash 结构Key 为锁名称Field 为线程标识Value 为重入计数、设置锁的过期时间。第三步判断获取结果Lua 脚本执行完成后Redisson 根据返回值判断锁获取是否成功。如果返回null即 Redis 的 nil表示锁获取成功同时返回锁的剩余过期时间TTL。如果返回非 null表示锁已被其他线程持有返回值即为锁的剩余过期时间。第四步自旋重试或阻塞等待如果锁获取失败Redisson 不会立即放弃而是进入自旋重试阶段。在自旋一定次数后如果仍然无法获取锁则回退到阻塞等待模式通过订阅 Redis 的 Pub/Sub 频道等待锁释放通知。这一步正是本文要深入分析的核心机制。第五步启动看门狗可选如果锁获取成功且未指定锁的持有时间leaseTimeRedisson 会启动看门狗机制定期续期锁的过期时间防止业务执行时间过长导致锁自动释放。看门狗的续期间隔默认为锁过期时间的 1/3即 10 秒续期一次默认锁过期时间为 30 秒。下面的流程图展示了完整的锁获取流程flowchart TD Start[开始获取锁] -- BuildKey[构造锁 Key] BuildKey -- LuaScript[执行 Lua 脚本] LuaScript -- CheckResult{锁是否获取成功} CheckResult --|成功| StartWatchDog{是否指定 leaseTime} StartWatchDog --|否| EnableWD[启动看门狗续期] StartWatchDog --|是| SkipWD[跳过看门狗] EnableWD -- Done[锁获取完成] SkipWD -- Done CheckResult --|失败| SpinRetry[进入自旋重试] SpinRetry -- SpinCount{自旋次数是否超限} SpinCount --|未超限| WaitSpin[等待自旋间隔] WaitSpin -- LuaScript SpinCount --|已超限| BlockWait[回退到阻塞等待] BlockWait -- Subscribe[订阅 Pub/Sub 频道] Subscribe -- WaitNotify[等待锁释放通知] WaitNotify -- Notified[收到通知] Notified -- LuaScript4. 自旋锁回退的架构设计4.1 设计理念Redisson 自旋锁回退机制的设计理念可以概括为快速试错渐进收敛。这一设计理念源自对分布式锁使用场景的深刻洞察洞察一锁持有时间通常很短在大多数业务场景中分布式锁的持有时间通常只有几十毫秒到几百毫秒。例如更新缓存、扣减库存、生成订单号等操作在正常情况下执行速度很快。如果锁的持有时间很短那么自旋等待的成功率很高可以避免线程阻塞和唤醒的开销。洞察二锁持有时间不可预测尽管大多数情况下锁持有时间很短但业务中不可避免地会出现异常情况如数据库慢查询、网络抖动、GC 停顿等导致锁持有时间显著延长。如果在这种情况下仍然坚持自旋等待会造成严重的 CPU 和网络资源浪费。洞察三自旋的边际收益递减随着自旋次数的增加成功获取锁的概率逐渐降低。如果自旋了数十次仍然无法获取锁说明锁的持有者可能遇到了较长的事务处理或异常情况继续自旋的收益很低。此时切换到阻塞等待模式更为合理。基于以上洞察Redisson 设计了自旋锁回退机制其核心思想是先自旋后阻塞在锁获取失败后先进行有限次数的自旋重试利用自旋的快速响应优势。超限回退当自旋次数超过预设阈值后自动回退到阻塞等待模式避免资源浪费。可配置参数自旋次数、自旋间隔等参数均可配置允许开发者根据业务场景进行调优。异步非阻塞整个回退过程基于 Netty 的异步事件驱动模型不会阻塞 I/O 线程。4.2 核心参数设计Redisson 自旋锁回退机制涉及两个核心参数它们共同决定了自旋策略的行为1. 自旋次数internalLockLeaseTime / retryAttempts在 Redisson 的默认配置中自旋次数并不是一个显式的参数而是通过锁的默认过期时间30 秒和自旋间隔时间来间接决定。具体来说Redisson 在自旋阶段会以固定的时间间隔如 100 毫秒发送 Redis 请求尝试获取锁直到锁的剩余过期时间耗尽或达到最大自旋等待时间。实际上Redisson 3.x 版本中引入了更明确的自旋控制参数// Redisson 配置中的自旋相关参数 Config config new Config(); config.setLockWatchdogTimeout(30000) // 看门狗超时时间默认 30 秒 .setRetryAttempts(3) // 命令重试次数默认 3 次 .setRetryInterval(1500); // 命令重试间隔默认 1500 毫秒 // 注意retryAttempts 和 retryInterval 是命令级别的重试参数 // 自旋锁回退的参数在源码层面控制2. 自旋超时时间spinWaitTimeout在 Redisson 的RedissonLock实现中自旋等待的总时间由以下逻辑决定// RedissonLock 中的自旋超时计算逻辑简化版 long time unit.toMillis(waitTime); long current System.currentTimeMillis(); long remainTime time; // 剩余的等待时间 while (true) { // 如果剩余等待时间小于等于 0不再自旋 if (remainTime 0) { // 尝试最后一次获取锁 Long ttl tryAcquire(leaseTime, unit, threadId); if (ttl null) { return true; // 获取成功 } return false; // 获取失败超时返回 } // 尝试获取锁 Long ttl tryAcquire(leaseTime, unit, threadId); if (ttl null) { return true; // 获取成功 } // 计算自旋等待时间取剩余时间和锁 TTL 的较小值 long waitingTime Math.min(remainTime, ttl); // 通过信号量等待自旋等待的核心实现 if (subscribeFuture ! null) { // 已经订阅了 Pub/Sub阻塞等待通知 commandExecutor.getNow(subscribeFuture).getLatch() .tryAcquire(waitingTime, TimeUnit.MILLISECONDS); } else { // 未订阅进入自旋等待 // 通过 Semaphore 实现短时间的自旋等待 Thread.sleep(Math.min(waitingTime, spinTimeout)); } remainTime - (System.currentTimeMillis() - current); current System.currentTimeMillis(); }从上面的代码可以看出Redisson 的自旋锁回退机制并不是一个简单的自旋 N 次后阻塞的硬切换而是一个更加灵活的渐进式策略。在自旋阶段等待时间会参考锁的剩余过期时间TTL动态调整每次自旋的等待间隔。4.3 回退策略的三种模式根据锁的获取方式和配置参数的不同Redisson 的自旋锁回退机制可以表现为三种不同的模式模式一纯自旋模式不推荐当用户调用lock()方法且未指定waitTime时Redisson 会进入无限等待模式。在这种模式下如果锁获取失败线程会不断地自旋重试直到获取锁为止。这种模式虽然简单但在锁持有时间较长时会严重浪费资源。// 纯自旋模式无限等待 RLock lock redisson.getLock(myLock); lock.lock(); // 如果锁被占用会一直自旋等待 try { // 业务逻辑 } finally { lock.unlock(); }模式二自旋超时回退模式推荐当用户调用tryLock(waitTime, leaseTime, timeUnit)方法时Redisson 会在指定的waitTime内进行自旋重试。如果超过waitTime仍无法获取锁则返回false。这种模式结合了自旋的快速响应和超时的资源保护是推荐的使用方式。// 自旋超时回退模式 RLock lock redisson.getLock(myLock); try { // 最多等待 10 秒锁持有时间 30 秒 boolean acquired lock.tryLock(10, 30, TimeUnit.SECONDS); if (acquired) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 获取锁超时执行降级逻辑 handleLockTimeout(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); }模式三自旋阻塞回退模式内部实现这是 Redisson 内部实现的核心模式。当自旋一定次数后无法获取锁Redisson 会自动订阅 Redis 的 Pub/Sub 频道进入阻塞等待状态。当锁被释放时Redis 会发布通知唤醒等待的线程。这种模式在保证响应速度的同时最大程度地节约了系统资源。// Redisson 内部的混合模式简化版 private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) { long threadId Thread.currentThread().getId(); Long ttl tryAcquire(leaseTime, unit, threadId); if (ttl null) { return; // 获取锁成功 } // 获取锁失败进入自旋阻塞混合模式 CompletableFutureRedissonLockEntry future subscribe(threadId); while (true) { ttl tryAcquire(leaseTime, unit, threadId); if (ttl null) { break; // 获取锁成功 } if (ttl 0) { // 锁的剩余时间大于 0通过信号量等待 // 这里既包含自旋等待也包含阻塞等待 getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } } unsubscribe(future, threadId); }5. 核心源码深度解析5.1 RedissonLock.lock() 方法剖析让我们从RedissonLock.lock()方法开始逐步深入自旋锁回退机制的核心实现。以下是lock()方法的完整源码分析// RedissonLock.java - lock() 方法 Override public void lock() { try { lock(-1, null, false); } catch (InterruptedException e) { throw new IllegalStateException(); } } // 核心的 lock 方法 private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException { // 第一步获取当前线程 ID long threadId Thread.currentThread().getId(); // 第二步尝试获取锁第一次尝试 Long ttl tryAcquire(-1, leaseTime, unit, threadId); // 第三步如果获取成功ttl null直接返回 if (ttl null) { return; } // 第四步获取锁失败订阅 Redis Pub/Sub 频道 // 这是进入阻塞等待模式的前置步骤 CompletableFutureRedissonLockEntry future subscribe(threadId); // 第五步进入自旋阻塞混合等待循环 try { while (true) { // 再次尝试获取锁 ttl tryAcquire(-1, leaseTime, unit, threadId); // 获取成功退出循环 if (ttl null) { break; } // 第六步根据锁的剩余时间决定等待策略 if (ttl 0) { try { // 通过信号量等待这是自旋回退的核心 // 如果 ttl 较小这里表现为自旋等待 // 如果 ttl 较大这里表现为阻塞等待 getEntry(threadId).getLatch().tryAcquire( ttl, TimeUnit.MILLISECONDS ); } catch (InterruptedException e) { if (interruptibly) { throw e; } // 非中断模式下减少等待时间后继续尝试 getEntry(threadId).getLatch().tryAcquire( ttl, TimeUnit.MILLISECONDS ); } } else { // ttl 0 的情况锁已过期但尚未清理 if (interruptibly) { getEntry(threadId).getLatch().acquire(); } else { getEntry(threadId).getLatch().acquireUninterruptibly(); } } } } finally { // 第七步取消订阅 unsubscribe(future, threadId); } }上面的代码揭示了 Redisson 自旋锁回退机制的核心实现。关键点在于getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS)这一行。这里的latch是一个Semaphore信号量初始许可数为 0。当调用tryAcquire(ttl, TimeUnit.MILLISECONDS)时如果ttl的值较小如几百毫秒信号量会在短暂的超时后返回false线程继续下一轮循环尝试获取锁。这种短时间的等待本质上就是自旋等待。如果ttl的值较大如几十秒线程会在信号量上阻塞较长时间直到超时或被 Pub/Sub 通知唤醒。这种长时间的等待就是阻塞等待。这种设计巧妙地将自旋和阻塞统一到了同一个信号量机制中通过ttl的值动态决定等待策略。5.2 tryAcquire() 方法的 Lua 脚本分析tryAcquire()方法是锁获取
返回列表