架构设计之Redisson分布式锁-公平锁FairLock(七)

架构设计之Redisson分布式锁-公平锁FairLock(七)
一、引言在分布式系统中并发控制是保证数据一致性的核心难题。Redisson 作为 Redis 生态中最强大的 Java 客户端提供了丰富的分布式锁实现。在前几篇文章中我们已经深入探讨了可重入锁RLock、读写锁RReadWriteLock等基础锁的源码与原理。然而在实际业务场景中我们常常遇到这样的需求多个线程竞争同一把锁时希望按照请求的先后顺序来获取锁即先到先得。这就是本文要剖析的主角——Redisson 公平锁FairLock。公平锁的核心思想源自 AQSAbstractQueuedSynchronizer的设计理念但 Redisson 将其巧妙地移植到了 Redis 分布式环境中。本文将带你从数据结构设计、加锁流程、解锁流程、可重入机制、WatchDog 续期机制、源码深度解析等多个维度全面剖析 Redisson 公平锁的实现原理全文超过 2 万字建议收藏后慢慢阅读。在开始之前我们先回顾一下公平锁与非公平锁的核心区别非公平锁新的竞争者可以直接尝试抢占锁即使等待队列中已有线程在排队可能导致饥饿问题。公平锁新的竞争者必须首先检查是否有线程在排队如果有则加入队列末尾严格按顺序获取锁。Redisson 的公平锁通过 Redis 的 List 和 Hash 数据结构配合 Lua 脚本实现了一套完整的分布式公平锁机制。下面让我们正式进入 Redisson 公平锁的世界。二、公平锁的核心数据结构Redisson 公平锁的底层数据结构由多个 Redis Key 组成每个 Key 承担不同的职责。理解这些数据结构是掌握公平锁原理的基础。2.1 数据结构总览假设我们定义的锁名为myFairLock那么 Redisson 公平锁会在 Redis 中维护以下关键数据结构Key 名称Redis 数据类型作用说明myFairLockHash存储锁持有者的信息和重入次数redisson_lock_queue:{myFairLock}List公平锁的等待队列存储等待线程的 IDredisson_lock_timeout:{myFairLock}ZSet存储等待队列中每个线程的过期时间超时清理用这三个 Key 共同构成了公平锁的完整数据结构。其中Hash 负责记录锁的持有状态List 负责维护等待线程的先后顺序ZSet 负责处理超时清理。接下来我们将逐一深入剖析每个数据结构的具体内容。2.2 锁持有状态 Hash锁的 Hash 结构Key 名即为锁名用于记录当前锁的持有者线程以及重入次数。其结构如下FieldUUID:ThreadId即 Redisson 客户端 ID 线程 ID 的组合用于唯一标识一个线程。Value重入次数即该线程获取锁的次数重入一次则加 1。例如当客户端c1的线程t1获取了公平锁Redis 中会存储# myFairLock 锁的 Hash 结构 Field: c1:t1 Value: 1当线程t1再次重入该锁时Value 会变为2。当线程t1完全释放锁后该 Field 会被删除。如果 Hash 中没有任何 Field说明锁未被任何线程持有。2.3 等待队列 List等待队列是公平锁实现公平的核心数据结构。Key 为redisson_lock_queue:{锁名}数据类型为 Redis List。这个 List 中存储的是在等待锁的线程 ID即UUID:ThreadId格式。队列遵循FIFO先进先出原则新来的竞争者通过RPUSH命令将自身线程 ID 加入队列尾部。释放锁或锁空闲时从队列头部LPOP取出第一个等待线程将锁分配给它。示例假设有三个线程t1、t2、t3先后请求公平锁队列结构如下# redisson_lock_queue:{myFairLock} 的 List 结构 [0] : c1:t1 [1] : c2:t2 [2] : c3:t3当线程t1获取锁后它会被从队列头部移除此时队列变为[0] : c2:t2 [1] : c3:t32.4 超时清理 ZSetZSet 的 Key 是redisson_lock_timeout:{锁名}用于存储等待队列中每个线程的超时时间戳。它的作用是当某个等待线程因为网络原因或客户端崩溃而无法正常移除时通过超时机制自动清理防止队列堆积。MemberUUID:ThreadId即等待线程的 ID。Score线程的超时时间戳Unix 时间戳毫秒。例如线程t1的超时时间为 30 秒后那么 ZSet 中存储# redisson_lock_timeout:{myFairLock} 的 ZSet 结构 Member: c1:t1 Score: 1690000000000 # 超时时间戳Redisson 会在加锁流程中定期检查 ZSet 中已过期的线程并将其从等待队列和超时 ZSet 中同时移除保证队列的整洁性。三、公平锁的加锁流程深度解析公平锁的加锁流程是 Redisson 中最复杂的部分之一涉及多个 Lua 脚本的协同工作。整个流程可以概括为以下步骤尝试直接获取锁检查锁是否空闲且等待队列为空或队列头部就是当前线程。如果满足条件直接获取锁。加入等待队列如果无法直接获取锁将当前线程 ID 加入等待队列的尾部并设置超时时间。阻塞等待通过 Redis 的 Pub/Sub 机制或轮询方式等待被唤醒。被唤醒后再次尝试获取锁检查队列头部是否是自己且锁是否空闲如果是则获取锁否则继续等待。下面我们将通过源码和 Lua 脚本来详细拆解每个步骤。3.1 入口方法RedissonFairLock.tryLockInnerAsync()Redisson 公平锁的核心加锁逻辑在RedissonFairLock类中。加锁的入口最终会调用tryLockInnerAsync()方法该方法会根据不同的条件返回不同的 Lua 脚本。我们来看最核心的加锁 Lua 脚本简化版-- KEYS[1]: 锁的 Hash Key如 myFairLock -- KEYS[2]: 等待队列 List Key如 redisson_lock_queue:{myFairLock} -- KEYS[3]: 超时清理 ZSet Key如 redisson_lock_timeout:{myFairLock} -- ARGV[1]: 锁的租约时间leaseTime单位毫秒 -- ARGV[2]: 当前线程的唯一标识 UUID:ThreadId -- ARGV[3]: 当前线程的等待超时时间戳 -- ARGV[4]: 当前时间戳 -- 1. 清理等待队列中已超时的线程 local expiredThreads redis.call(zrangebyscore, KEYS[3], 0, ARGV[4], limit, 0, 100) for i 1, #expiredThreads, 1 do redis.call(lrem, KEYS[2], 0, expiredThreads[i]) redis.call(zrem, KEYS[3], expiredThreads[i]) end -- 2. 检查锁是否空闲 local lockExists redis.call(exists, KEYS[1]) -- 3. 获取等待队列的第一个线程 local firstThread redis.call(lindex, KEYS[2], 0) -- 4. 判断当前线程是否可以获取锁 if (lockExists 0) and (firstThread false or firstThread ARGV[2]) then -- 锁空闲且队列为空或队列头部是当前线程直接获取锁 redis.call(lrem, KEYS[2], 0, ARGV[2]) -- 从队列中移除 redis.call(zrem, KEYS[3], ARGV[2]) -- 从超时 ZSet 中移除 redis.call(hset, KEYS[1], ARGV[2], 1) -- 设置锁持有者重入次数为 1 redis.call(pexpire, KEYS[1], ARGV[1]) -- 设置锁的过期时间 return nil -- 返回 nil 表示成功获取锁 end -- 5. 如果锁已被当前线程持有可重入场景 if redis.call(hexists, KEYS[1], ARGV[2]) 1 then redis.call(hincrby, KEYS[1], ARGV[2], 1) -- 重入次数 1 redis.call(pexpire, KEYS[1], ARGV[1]) -- 刷新过期时间 return nil -- 返回 nil 表示成功重入 end -- 6. 无法获取锁检查是否需要加入等待队列 local queueIndex redis.call(lpos, KEYS[2], ARGV[2]) if queueIndex false then -- 当前线程不在队列中加入队列尾部 redis.call(rpush, KEYS[2], ARGV[2]) redis.call(zadd, KEYS[3], ARGV[3], ARGV[2]) end -- 返回当前线程在队列中的剩余等待时间供客户端阻塞等待 return ARGV[3] - ARGV[4]这个 Lua 脚本是整个公平锁加锁的核心。它的执行是原子性的保证了在分布式环境下数据的一致性。下面我们逐步分析这个脚本的每个关键步骤。3.2 步骤一清理超时线程脚本的第一步是清理等待队列中已超时的线程。这是通过 ZSet 的ZRANGEBYSCORE命令实现的local expiredThreads redis.call(zrangebyscore, KEYS[3], 0, ARGV[4], limit, 0, 100) for i 1, #expiredThreads, 1 do redis.call(lrem, KEYS[2], 0, expiredThreads[i]) redis.call(zrem, KEYS[3], expiredThreads[i]) end这段代码的逻辑是从超时 ZSet 中查找 Score 小于当前时间戳即已过期的线程每次最多清理 100 个。对每个已过期的线程从等待队列 List 中移除并从超时 ZSet 中删除。这个清理机制确保了即使某个客户端崩溃它的等待记录也不会永久占用队列空间。每次有新的线程尝试加锁时都会触发一次清理操作保持队列的整洁。3.3 步骤二判断锁是否空闲并尝试获取清理完超时线程后脚本会检查锁是否空闲local lockExists redis.call(exists, KEYS[1]) local firstThread redis.call(lindex, KEYS[2], 0) if (lockExists 0) and (firstThread false or firstThread ARGV[2]) then -- 获取锁 redis.call(lrem, KEYS[2], 0, ARGV[2]) redis.call(zrem, KEYS[3], ARGV[2]) redis.call(hset, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end这里有三个关键判断条件lockExists 0锁当前没有被任何线程持有。firstThread false等待队列为空没有任何线程在等待。firstThread ARGV[2]等待队列不为空但队列头部的线程就是当前线程说明当前线程排在第一位轮到它了。只要满足条件 1 且条件 2 或条件 3当前线程就可以获取锁。获取锁后脚本会从等待队列中移除当前线程。从超时 ZSet 中移除当前线程。在锁的 Hash 中设置当前线程为重入次数 1。设置锁的过期时间租约时间。这确保了只有队列头部的线程才有资格获取锁体现了公平性。3.4 步骤三可重入处理如果锁已经被持有脚本会检查持有者是否是当前线程if redis.call(hexists, KEYS[1], ARGV[2]) 1 then redis.call(hincrby, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end如果当前线程已经是锁的持有者则重入次数加 1并刷新过期时间。注意可重入获取锁时不需要检查等待队列因为持有锁的线程可以直接重入。3.5 步骤四加入等待队列如果以上条件都不满足说明当前线程无法立即获取锁需要进入等待队列local queueIndex redis.call(lpos, KEYS[2], ARGV[2]) if queueIndex false then redis.call(rpush, KEYS[2], ARGV[2]) redis.call(zadd, KEYS[3], ARGV[3], ARGV[2]) end return ARGV[3] - ARGV[4]脚本首先检查当前线程是否已经在队列中通过LPOS命令查找如果不在则通过RPUSH加入队列尾部并使用ZADD记录超时时间。最后返回剩余等待时间超时时间 - 当前时间供客户端决定阻塞等待多久。3.6 订阅与唤醒机制当 Lua 脚本返回剩余等待时间后客户端会进入阻塞等待状态。Redisson 使用 Redis 的发布/订阅Pub/Sub机制来实现唤醒。具体流程如下订阅频道客户端订阅一个特定的频道频道名格式为redisson_lock__channel:{锁名}。阻塞等待客户端调用Semaphore或CountDownLatch进行阻塞等待被唤醒或超时。释放锁时发布消息当持有锁的线程释放锁时会向频道发布一条消息通知等待队列中的第一个线程。收到消息后重试被唤醒的线程重新调用加锁 Lua 脚本尝试获取锁。这种机制实现了高效的线程间通信避免了轮询带来的 CPU 浪费。同时为了防止消息丢失Redisson 还设置了超时重试机制如果等待超时时间到了但仍未收到消息客户端会主动重新执行 Lua 脚本检查状态。3.7 加锁流程完整时序图为了更直观地理解整个加锁流程我们来看下面的 Mermaid 时序图sequenceDiagram participant Client as 客户端线程 participant Redis as Redis 服务器 participant PubSub as Redis Pub/Sub Client-Redis: 执行加锁 Lua 脚本 Redis-Redis: 清理超时线程 Redis-Redis: 检查锁是否空闲 alt 锁空闲 且 (队列为空 或 队列头部是当前线程) Redis-Redis: 从队列中移除当前线程 Redis-Redis: 设置锁的 Hash Redis--Client: 返回 nil获取锁成功 else 锁已被当前线程持有可重入 Redis-Redis: 重入次数 1 Redis--Client: 返回 nil重入成功 else 无法获取锁 Redis-Redis: 将当前线程加入队列尾部 Redis-Redis: 记录超时时间 Redis--Client: 返回剩余等待时间 Client-PubSub: 订阅锁频道 Client-Client: 阻塞等待Semaphore Note over Client: 等待被唤醒或超时 end/code/pre 这个时序图清晰地展示了加锁的三个分支直接获取锁、可重入获取锁、加入队列等待。理解了这个流程就掌握了公平锁加锁的核心逻辑。 四、公平锁的解锁流程深度解析 解锁流程相对加锁来说简单一些但同样涉及重要的 Lua 脚本逻辑。解锁的核心任务是减少重入次数当重入次数降为 0 时释放锁并通知等待队列中的下一个线程。 4.1 解锁 Lua 脚本 Redisson 公平锁的解锁 Lua 脚本如下 -- KEYS[1]: 锁的 Hash Key -- KEYS[2]: 等待队列 List Key -- KEYS[3]: 超时清理 ZSet Key -- KEYS[4]: 发布/订阅频道 Key -- ARGV[1]: 解锁消息0 表示解锁 -- ARGV[2]: 锁的租约时间 -- ARGV[3]: 当前线程的唯一标识 UUID:ThreadId -- 1. 检查锁是否被当前线程持有 if redis.call(hexists, KEYS[1], ARGV[3]) 0 then return nil -- 锁不是当前线程持有的返回 nil表示解锁失败 end -- 2. 减少重入次数 local counter redis.call(hincrby, KEYS[1], ARGV[3], -1) -- 3. 如果重入次数降为 0释放锁 if counter 0 then redis.call(del, KEYS[1]) -- 删除锁的 Hash Key redis.call(publish, KEYS[4], ARGV[1]) -- 发布解锁消息通知等待队列 return 1 -- 返回 1 表示锁已完全释放 else redis.call(pexpire, KEYS[1], ARGV[2]) -- 刷新过期时间 return 0 -- 返回 0 表示重入次数减少但锁未释放 end 这个脚本的核心逻辑是 首先检查当前线程是否是锁的持有者如果不是则直接返回 nil防止非法解锁。 将重入次数减 1如果减 1 后仍大于 0说明还存在重入只需刷新过期时间即可。 如果重入次数降为 0则删除锁的 Hash Key并向频道发布解锁消息。 4.2 唤醒等待队列的下一个线程 当锁被完全释放后Lua 脚本会向频道 redisson_lock__channel:{锁名} 发布一条消息。那么等待队列中的线程是如何被唤醒的呢 实际上发布消息只是通知锁已释放并不会直接指定哪个线程获取锁。被唤醒的线程包括所有订阅了该频道的线程会重新执行加锁 Lua 脚本。在加锁脚本中只有队列头部的线程才有资格获取锁因此其他线程即使被唤醒也会因为不满足条件而继续等待。 这种设计保证了 通知机制简单只需发一条消息无需指定目标线程。 竞争安全最终的锁分配由原子的 Lua 脚本决定避免了竞态条件。 4.3 解锁流程时序图 sequenceDiagram participant Client as 持有锁的客户端线程 participant Redis as Redis 服务器 participant PubSub as Redis Pub/Sub participant Waiter as 等待队列中的线程 Client-Redis: 执行解锁 Lua 脚本 Redis-Redis: 检查当前线程是否持有锁 Redis-Redis: 重入次数 -1 alt 重入次数 0 Redis-Redis: 刷新过期时间 Redis--Client: 返回 0锁未完全释放 else 重入次数 0 Redis-Redis: 删除锁的 Hash Key Redis-PubSub: 发布解锁消息 Redis--Client: 返回 1锁已完全释放 PubSub--Waiter: 推送解锁消息 Waiter-Redis: 重新执行加锁 Lua 脚本 Note over Waiter: 队列头部线程获取锁 end/code/pre 这个时序图展示了解锁的两个分支减少重入次数和完全释放锁。当锁完全释放时等待队列中的线程会收到消息并重新竞争锁。 五、可重入机制的深度剖析 可重入性是分布式锁的一个重要特性。Redisson 公平锁通过 Hash 结构的 Value 字段来记录重入次数实现了完整的可重入机制。 5.1 重入计数的存储 在锁的 Hash 结构中每个线程的重入次数是独立存储的 # 锁 myFairLock 的 Hash 结构 Field: c1:t1 Value: 3 # 线程 t1 重入了 3 次 Field: c2:t2 Value: 1 # 线程 t2 持有锁理论上不会同时存在多个 Field 在公平锁中同一时刻只会有一个线程持有锁因此 Hash 中通常只有一个 Field。但重入机制允许同一个线程多次获取锁每次重入都会将 Value 加 1。 5.2 重入获取的 Lua 逻辑 回顾加锁 Lua 脚本中的重入处理部分 if redis.call(hexists, KEYS[1], ARGV[2]) 1 then redis.call(hincrby, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end 当检测到当前线程已经是锁的持有者时直接执行 HINCRBY 将重入次数加 1并刷新过期时间。注意这里不会检查等待队列因为持有锁的线程拥有最高优先级。 5.3 重入释放的 Lua 逻辑 解锁时重入次数减 1 local counter redis.call(hincrby, KEYS[1], ARGV[3], -1) if counter 0 then -- 完全释放锁 else -- 还有重入只刷新过期时间 redis.call(pexpire, KEYS[1], ARGV[2]) end 只有当重入次数降为 0 时锁才会被真正释放。这种设计确保了 同一个线程可以多次获取锁而不会死锁。 外层方法释放锁时内层方法仍然持有锁保证业务逻辑的正确性。 5.4 可重入的典型使用场景 考虑以下代码场景 public class OrderService { Autowired private RedissonClient redissonClient; public void processOrder(String orderId) { RLock fairLock redissonClient.getFairLock(order:lock: orderId); fairLock.lock(); try { // 外层次获取锁 updateOrderStatus(orderId); } finally { fairLock.unlock(); } } private void updateOrderStatus(String orderId) { RLock fairLock redissonClient.getFairLock(order:lock: orderId); fairLock.lock(); try { // 内层次获取锁重入 // 执行订单状态更新逻辑 } finally { fairLock.unlock(); } } } 在 processOrder 方法中外层已经获取了锁当调用 updateOrderStatus 时内部再次获取同一把锁。如果没有可重入机制内部方法会因无法获取锁而阻塞导致死锁。Redisson 公平锁的可重入机制完美解决了这个问题。 六、WatchDog 自动续期机制 在分布式锁的使用中一个常见的问题是如果业务执行时间超过了锁的租约时间锁会自动释放导致其他线程提前获取锁引发并发安全问题。Redisson 通过 WatchDog看门狗机制来解决这个问题。 6.1 WatchDog 的工作原理 WatchDog 是一个后台定时任务默认每 10 秒执行一次即锁租约时间的 1/3。它的核心逻辑是如果锁的持有者线程仍然存活则自动延长锁的过期时间。 具体流程如下 当客户端获取锁时如果没有指定 leaseTime租约时间Redisson 会使用默认的 30 秒作为租约时间。 同时启动一个 WatchDog 定时任务每 10 秒30 秒 / 3执行一次。 WatchDog 执行时会向 Redis 发送一个 Lua 脚本检查当前线程是否仍然持有锁如果是则将锁的过期时间重置为 30 秒。 当客户端主动释放锁或客户端崩溃时WatchDog 也会随之停止。 6.2 WatchDog 续期 Lua 脚本 -- KEYS[1]: 锁的 Hash Key -- ARGV[1]: 新的过期时间30 秒 -- ARGV[2]: 当前线程的唯一标识 UUID:ThreadId if redis.call(hexists, KEYS[1], ARGV[2]) 1 then redis.call(pexpire, KEYS[1], ARGV[1]) return 1 -- 续期成功 end return 0 -- 续期失败锁已被释放或不属于当前线程 这个脚本非常简洁检查当前线程是否仍然持有锁如果是则刷新过期时间。WatchDog 的续期操作是幂等的多次续期不会产生副作用。 6.3 WatchDog 的启动与停止 在 Redisson 源码中WatchDog 的启动和停止由 RedissonFairLock 内部的 tryLockInnerAsync 方法和 unlockInnerAsync 方法控制 启动当加锁成功且没有指定 leaseTime 时调用 scheduleExpirationRenewal 启动 WatchDog。 停止当解锁成功重入次数降为 0时调用 cancelExpirationRenewal 停止 WatchDog。 这种设计确保了 长期运行的业务不会因为锁过期而出现问题。 客户端崩溃时WatchDog 也会停止锁最终会过期释放避免死锁。 6.4 自定义租约时间 如果业务场景中锁的持有时间是可以预测的也可以通过 tryLock(long waitTime, long leaseTime, TimeUnit unit) 方法手动指定租约时间 RLock fairLock redissonClient.getFairLock(myFairLock); // 手动指定租约时间为 10 秒此时不会启动 WatchDog boolean locked fairLock.tryLock(5, 10, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑必须在 10 秒内完成 } finally { fairLock.unlock(); } } 当手动指定 leaseTime 时Redisson 不会启动 WatchDog锁会在指定的时间后自动过期。这种方式适合执行时间可预测的短任务。 七、公平锁 vs 非公平锁的对比 Redisson 同时提供了公平锁和非公平锁普通可重入锁的实现。理解它们之间的区别有助于在合适的场景下选择合适的锁类型。 7.1 核心差异对比表 对比维度 非公平锁RLock 公平锁FairLock 获取锁的顺序 不保证顺序新来者可能抢占锁 严格按请求顺序先到先得 等待队列 无显式等待队列 有基于 Redis List 的等待队列 数据结构 Hash Pub/Sub Hash List ZSet Pub/Sub 性能 较高少一次队列操作 稍低多一次队列操作 饥饿问题 可能存在 不存在 适用场景 对吞吐量要求高可接受饥饿 对公平性要求高需保证顺序 7.2 非公平锁的加锁逻辑 作为对比我们来看一下非公平锁的加锁 Lua 脚本简化版 -- KEYS[1]: 锁的 Hash Key -- ARGV[1]: 租约时间 -- ARGV[2]: 当前线程的唯一标识 if redis.call(exists, KEYS[1]) 0 then -- 锁空闲直接获取 redis.call(hset, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end if redis.call(hexists, KEYS[1], ARGV[2]) 1 then -- 可重入 redis.call(hincrby, KEYS[1], ARGV[2], 1) redis.call(pexpire, KEYS[1], ARGV[1]) return nil end -- 获取失败返回剩余过期时间 return redis.call(pttl, KEYS[1]) 可以看到非公平锁没有等待队列当锁空闲时任何线程都可以直接获取。如果获取失败客户端会通过 Pub/Sub 订阅锁频道等待锁释放后再次尝试抢占。 7.3 公平锁的额外开销 公平锁为了实现公平引入了额外的开销 队列维护每次加锁和解锁都需要操作 List 和 ZSet增加了 Redis 的命令执行次数。 超时清理每次加锁时都需要扫描并清理超时线程在高并发场景下可能成为性能瓶颈。 顺序保证只有队列头部的线程才能获取锁即使锁空闲其他线程也必须等待降低了并发度。 因此在选择锁类型时需要根据业务场景权衡 如果业务对吞吐量要求高且可以接受偶尔的饥饿使用非公平锁。 如果业务对公平性要求高例如订单处理、秒杀排队等使用公平锁。 八、公平锁的源码深度解析 接下来我们将深入 Redisson 的源码从类的层次结构、核心方法实现、异步回调机制等角度全面剖析公平锁的实现细节。 8.1 类层次结构 Redisson 公平锁的类继承关系如下 // 简化后的类层次结构 public class RedissonFairLock extends RedissonBaseLock { // 公平锁的核心实现 } public abstract class RedissonBaseLock extends RedissonExpirable implements RLock { // 锁的基础功能实现 } public abstract class RedissonExpirable extends RedissonObject implements RExpirable { // 过期时间管理 } public interface RLock extends RExpirable, RLockAsync { // 锁的接口定义 } RedissonFairLock 是公平锁的核心实现类它继承自 RedissonBaseLock后者提供了锁的基础功能如 Pub/Sub 订阅、线程 ID 管理、WatchDog 等。 8.2 核心方法tryLockInnerAsync 这是公平锁加锁的核心方法我们来看完整的源码有删减 public class RedissonFairLock extends RedissonBaseLock { // 等待队列的 Key 前缀 static final String LOCK_QUEUE_KEY redisson_lock_queue:{; // 超时 ZSet 的 Key 前缀 static final String LOCK_TIMEOUT_KEY redisson_lock_timeout:{; Override lt;Tgt; RFuturelt;Tgt; tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommandlt;Tgt; command) { long wait unit.toMillis(waitTime); long currentTime System.currentTimeMillis(); long timeoutTime currentTime wait; // 获取线程的唯一标识 String threadName getLockName(threadId); // 执行加锁 Lua 脚本 return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command, // Lua 脚本内容即前面分析的加锁脚本 local expiredThreads redis.call(zrangebyscore, KEYS[3], 0, ARGV[4], limit, 0, 100); for i 1, #expiredThreads, 1 do redis.call(lrem, KEYS[2], 0, expiredThreads[i]); redis.call(zrem, KEYS[3], expiredThreads[i]); end; local lockExists redis.call(exists, KEYS[1]); local firstThread redis.call(lindex, KEYS[2], 0); if (lockExists 0) and (firstThread false or firstThread ARGV[2]) then redis.call(lrem, KEYS[2], 0, ARGV[2]); redis.call(zrem, KEYS[3], ARGV[2]); redis.call(hset, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; if redis.call(hexists, KEYS[1], ARGV[2]) 1 then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; local queueIndex redis.call(lpos, KEYS[2], ARGV[2]); if queueIndex false then redis.call(rpush, KEYS[2], ARGV[2]); redis.call(zadd, KEYS[3], ARGV[3], ARGV[2]); end; return ARGV[3] - ARGV[4];, // KEYS 和 ARGV 参数 Arrays.asList( getRawName(), // KEYS[1]: 锁的 Hash Key getQueueName(), // KEYS[2]: 等待队列 List Key getTimeoutSetName() // KEYS[3]: 超时 ZSet Key ), unit.toMillis(leaseTime), // ARGV[1]: 租约时间 threadName, // ARGV[2]: 线程唯一标识 timeoutTime, // ARGV[3]: 超时时间戳 currentTime // ARGV[4]: 当前时间戳 ); } // 获取等待队列的 Key private String getQueueName() { return LOCK_QUEUE_KEY getRawName() }; } // 获取超时 ZSet 的 Key private String getTimeoutSetName() { return LOCK_TIMEOUT_KEY getRawName() }; } } 注意这里的所有操作都是通过 evalWriteAsync 异步执行的返回值是 RFuture。Redisson 的异步回调机制会在 Lua 脚本执行完成后处理结果。 8.3 异步回调与重试机制 当 tryLockInnerAsync 返回 RFuture 后Redisson 会注册一个回调监听器来处理结果 // 简化后的回调处理逻辑 RFutureLong ttlFuture tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); ttlFuture.onComplete((ttl, exception) - { if (exception ! null) { // 异常处理返回失败 promise.tryFailure(exception); return; } if (ttl null) { // ttl 为 null 表示获取锁成功 // 启动 WatchDog如果需要 if (leaseTime -1) { scheduleExpirationRenewal(threadId); } promise.trySuccess(); } else { // ttl 不为 null表示获取锁失败需要等待 // 订阅锁频道等待被唤醒 long remainTime ttl; if (remainTime 0) { // 还有剩余等待时间订阅频道并阻塞等待 subscribeLockChannel(threadId, remainTime, promise); } else { // 等待超时返回失败 promise.tryFailure(new TimeoutException()); } } }); 这个回调处理逻辑是 Redisson 异步编程思想的体现。当获取锁失败时不是立即重试而是通过订阅频道等待通知避免了 CPU 空转。 8.4 订阅频道与等待唤醒 当线程需要等待时会调用 subscribeLockChannel 方法 private void subscribeLockChannel(long threadId, long remainTime, RPromiseT promise) { // 获取频道名称 String channelName getChannelName(); // 订阅频道 RFuturelt;PubSubConnectionEntrygt; subscribeFuture pubSub.subscribe(channelName, getRawName()); subscribeFuture.onComplete((entry, exception) - { if (exception ! null) { promise.tryFailure(exception); return; } // 注册消息监听器 int listenerId entry.addListener(channelName, (channel, message) -gt; { // 收到消息后重新尝试获取锁 if (message.equals(UNLOCK_MESSAGE)) { // 取消超时定时器 // 重新执行 tryLockInnerAsync retryLock(threadId, promise); } }); // 设置超时定时器防止消息丢失 timeoutScheduler.schedule(() -gt; { // 超时后取消订阅并重新尝试获取锁 entry.removeListener(channelName, listenerId); retryLock(threadId, promise); }, remainTime, TimeUnit.MILLISECONDS); }); } 这个方法的逻辑是 订阅锁的频道。 注册消息监听器当收到解锁消息时重新尝试获取锁。 设置超时定时器如果等待超时仍未收到消息主动重试获取锁。 这种订阅 超时兜底的双重机制确保了消息不会丢失同时避免了无限等待。 九、公平锁的高可用与故障处理 在生产环境中Redis 集群可能面临节点故障、网络分区等问题。Redisson 公平锁针对这些场景设计了相应的处理机制。 9.1 Redis 主从切换场景 当 Redis 主节点宕机从节点被提升为新的主节点时可能会出现锁数据丢失的问题。这是因为 Redis 主从复制是异步的主节点的锁数据可能尚未同步到从节点。 Redisson 通过 RedLock红锁 算法来解决这个问题但需要注意公平锁本身是基于单节点 Redis 实现的如果对数据一致性要求极高建议使用 RedLock 或考虑使用 Redisson 的 RedissonMultiLock 组合多个公平锁。 9.2 客户端崩溃处理 当客户端崩溃时可能出现以下情况 锁的 Hash 中仍有该线程的记录由于 WatchDog 停止续期锁会在租约时间到期后自动释放。 等待队列中仍有该线程的记录超时清理机制会定期清理过期的等待线程防止队列堆积。 这两种情况都不会导致死锁体现了 Redisson 公平锁的健壮性。 9.3 网络分区处理 当客户端与 Redis 之间的网络出现分区时 持有锁的客户端WatchDog 无法续期锁会在租约到期后自动释放。 等待中的客户端订阅频道可能收不到消息但超时兜底机制会触发重试。 这些机制确保即使在网络不稳定的情况下锁系统也能正常工作。 十、实践案例公平锁在秒杀系统中的应用 下面通过一个具体的秒杀系统案例展示公平锁的实际应用。 10.1 场景描述 在一个秒杀活动中100 个用户同时抢购 10 件商品。为了保证公平性使用 Redisson 公平锁来控制并发让用户按照请求的先后顺序获取购买资格。 10.2 代码实现 Service public class SeckillService { Autowired private RedissonClient redissonClient; Autowired private StringRedisTemplate redisTemplate; private static final String PRODUCT_KEY seckill:product:stock:; private static final String LOCK_KEY seckill:lock:; /** 秒杀下单 param userId 用户 ID param productId 商品 ID return 是否秒杀成功 */ public boolean seckill(String userId, String productId) { String lockKey LOCK_KEY productId; RLock fairLock redissonClient.getFairLock(lockKey); try { // 尝试获取公平锁最多等待 5 秒锁的租约时间为 10 秒 boolean locked fairLock.tryLock(5, 10, TimeUnit.SECONDS); if (!locked) { System.out.println(用户 userId 获取锁超时秒杀失败); return false; } // 检查库存 String stockKey PRODUCT_KEY productId; String stockStr redisTemplate.opsForValue().get(stockKey); int stock stockStr null ? 0 : Integer.parseInt(stockStr); if (stock lt; 0) { System.out.println(用户 userId 秒杀失败库存不足); return false; } // 扣减库存 redisTemplate.opsForValue().set(stockKey, String.valueOf(stock - 1)); // 创建订单省略订单创建逻辑 System.out.println(用户 userId 秒杀成功剩余库存 (stock - 1)); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { // 释放锁 fairLock.unlock(); } } } 10.3 并发测试 使用 JMeter 或 JUnit 进行并发测试 Test public void testSeckill() throws InterruptedException { ExecutorService executor Executors.newFixedThreadPool(100); CountDownLatch latch new CountDownLatch(100); AtomicInteger successCount new AtomicInteger(0); // 初始化库存 redisTemplate.opsForValue().set(seckill:product:stock:1, 10); for (int i 0; i 100; i) { final int userId i; executor.submit(() - { try { boolean result seckillService.seckill(user_ userId, 1); if (result) { successCount.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); executor.shutdown(); System.out.println(秒杀成功人数 successCount.get()); // 预期输出秒杀成功人数10 } 这个测试用例模拟了 100 个用户同时抢购 10 件商品最终只有 10 个用户秒杀成功且抢购顺序严格按照请求的先后顺序体现了公平锁的特性。 十一、性能优化与最佳实践 在实际使用 Redisson 公平锁时有一些性能优化建议和最佳实践可以参考。 11.1 合理设置租约时间 租约时间leaseTime的设置需要根据业务执行时间来权衡 太短业务未执行完锁就释放了可能导致并发问题。 太长客户端崩溃后锁需要很长时间才能自动释放影响其他线程。 建议 对于可预测执行时间的短任务手动指定一个合理的 leaseTime。 对于执行时间不确定的长任务使用默认的 WatchDog 机制。 11.2 避免锁粒度过大 锁的粒度直接影响并发性能。建议 将锁的粒度细化到最小必要范围例如按商品 ID 加锁而不是对整个秒杀活动加锁。 在锁内部只执行必要的临界区代码非关键逻辑放在锁外执行。 11.3 使用 tryLock 替代 lock 在可能发生长时间等待的场景中优先使用 tryLock(timeout, timeUnit) 而不是 lock()避免线程无限阻塞 // 推荐带超时的 tryLock if (fairLock.tryLock(5, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { fairLock.unlock(); } } else { // 获取锁超时执行降级逻辑 } 11.4 确保 unlock 在 finally 中执行 这是一个常见的坑如果 unlock() 没有放在 finally 块中当业务代码抛出异常时锁可能不会被释放导致死锁。 // 正确做法 fairLock.lock(); try { // 业务逻辑 } finally { fairLock.unlock(); } // 错误做法可能导致死锁 fairLock.lock(); // 业务逻辑 fairLock.unlock(); 11.5 监控锁的等待时间 在生产环境中建议监控锁的等待时间和持有时间及时发现性能瓶颈 long startTime System.currentTimeMillis(); boolean locked fairLock.tryLock(5, TimeUnit.SECONDS); long waitTime System.currentTimeMillis() - startTime; if (locked) { try { // 业务逻辑 long holdTime System.currentTimeMillis() - startTime; // 上报监控指标 metricsCollector.recordLockHoldTime(lockKey, holdTime); } finally { fairLock.unlock(); } } else { // 上报等待超时指标 metricsCollector.recordLockTimeout(lockKey, waitTime); } 十二、常见问题与排查指南 在使用 Redisson 公平锁的过程中可能会遇到一些常见问题。下面列出典型问题及排查思路。 12.1 锁一直获取不到 现象线程长时间阻塞无法获取锁。 排查思路 检查 Redis 中的等待队列是否堆积了大量线程LLEN redisson_lock_queue:{锁名}。 检查是否有线程持有锁但未释放可能因为业务逻辑死循环或异常未捕获。 检查 WatchDog 是否正常工作锁是否因为过期而被其他线程抢占。 12.2 锁提前释放 现象业务逻辑还在执行但锁已经被其他线程获取。 排查思路 检查是否手动指定了 leaseTime 且时间过短。 检查 Redis 服务器的时间是否发生了跳变。 检查网络延迟是否导致 WatchDog 续期失败。 12.3 等待队列不清理 现象等待队列中堆积了大量已过期的线程导致新线程无法获取锁。 排查思路 检查超时清理机制是否正常执行。 手动清理过期的等待线程ZREMRANGEBYSCORE redisson_lock_timeout:{锁名} 0 当前时间戳。 12.4 Redis 内存占用过高 现象Redis 内存使用率持续上升分析发现锁相关的 Key 占用大量内存。 排查思路 检查是否有大量锁 Key 未被删除因为客户端未正常释放锁。 检查锁的租约时间是否设置过长导致 Key 长期存在。 设置合理的 Key 过期时间或使用 Redis 的内存淘汰策略。 十三、总结与展望 本文从数据结构、加锁流程、解锁流程、可重入机制、WatchDog 续期机制、源码解析、实践案例等多个维度全面深入地剖析了 Redisson 公平锁的实现原理。总结如下 数据结构公平锁使用 Hash锁持有状态、List等待队列、ZSet超时清理三组 Redis 数据结构协同工作。 加锁流程通过 Lua 脚本保证原子性包含清理超时线程、检查锁空闲、可重入判断、加入等待队列四个步骤。 解锁流程减少重入次数当重入次数降为 0 时释放锁并通过 Pub/Sub 通知等待队列中的下一个线程。 可重入机制通过 Hash 的 Value 字段记录重入次数支持同一线程多次获取锁。 WatchDog 机制后台定时任务自动续期防止业务执行时间过长导致锁提前释放。 公平性保证等待队列按 FIFO 顺序管理只有队列头部的线程才能获取锁严格保证先到先得。 Redisson 公平锁的设计充分体现了分布式系统设计的精髓以 Redis 为底层存储以 Lua 脚本保证原子性以 Pub/Sub 机制实现高效通信以 WatchDog 保证可用性。掌握这些设计思想不仅有助于用好 Redisson也能为自研分布式组件提供宝贵的参考。 在未来的文章中我们将继续深入探讨 Redisson 的其他高级特性包括联锁MultiLock、红锁RedLock、信号量Semaphore等敬请期待。 十四、参考资料 Redisson 官方 GitHub 仓库 Redis 官方文档分布式锁 Redis 官方文档Lua 脚本 Martin Kleppmann如何实现分布式锁

最新新闻

日新闻

周新闻

月新闻