Redis 如何保证分布式锁的续期和可重入

在分布式系统中,Redis 凭借其高性能和原子性操作,成为实现分布式锁的主流方案之一。然而,一个健壮的分布式锁不仅要解决互斥问题,还必须处理两个核心难题:锁的续期(自动延长过期时间)可重入(同一线程可多次获取同一把锁)。这两点也是面试中高频出现的深度问题。本文将围绕这两个主题,从原理到实现逐一剖析。

一、为什么需要锁续期?

使用 Redis 做分布式锁时,通常会通过 SET key value NX PX milliseconds 命令设置一个过期时间,防止客户端宕机后锁无法释放。但过期时间是一把双刃剑:

  • 如果业务执行时间 小于 过期时间,锁会提前释放,没问题。
  • 如果业务执行时间 大于 过期时间,锁会在业务完成前自动过期,其他客户端就能获取到同一把锁,导致临界区代码被并发执行,锁的互斥性被破坏。

因此,我们需要一种机制,在业务尚未完成时,自动延长锁的过期时间。这就是 锁续期,也常被称为“看门狗”(Watch Dog)机制。

二、锁续期的实现原理

2.1 核心思路

客户端在成功获取锁之后,启动一个后台定时任务(守护线程),每隔一段时间(例如锁过期时间的 1/3)检查锁是否仍被当前客户端持有,如果是,则执行 EXPIRE 命令重置过期时间。当客户端主动释放锁或宕机时,定时任务停止,锁最终会自动过期。

2.2 关键细节

  • 续期间隔:通常设置为锁过期时间的 1/3。例如锁过期 30 秒,则每 10 秒续期一次。这样即使某次续期失败,仍有两次重试机会。
  • 持有者校验:续期前必须确认锁的 value 与当前客户端标识一致,避免误续期其他客户端持有的锁。通常使用 Lua 脚本保证“校验 + 续期”的原子性。
  • 停止条件:当业务执行完毕调用解锁,或客户端进程退出时,必须停止续期线程,否则会无意义地延长锁的寿命。

2.3 Redisson 的看门狗机制

Redisson 是 Java 生态中最常用的 Redis 客户端,它内置了看门狗实现:

  1. 调用 lock() 时,若未指定 leaseTime,则默认使用 30 秒过期时间。
  2. 加锁成功后,Redisson 会注册一个定时任务,每 10 秒(internalLockLeaseTime / 3)执行一次续期。
  3. 续期通过 Lua 脚本完成:先判断锁是否存在且持有者为当前线程,若是则调用 pexpire 重置过期时间。
  4. 当调用 unlock() 时,会取消续期任务并删除锁。

需要注意的是,如果显式指定了 leaseTime,Redisson 不会启动看门狗,锁会在指定时间后自动过期。因此,在不确定业务执行时长时,应避免手动设置过期时间,而依赖看门狗自动续期

三、为什么需要可重入?

可重入锁是指同一个线程在持有锁的情况下,可以再次获取同一把锁而不会被阻塞。这在递归调用或嵌套方法中非常常见。

如果分布式锁不可重入,那么当线程 A 已经持有锁,再次尝试获取时,会因锁已被占用而失败(甚至自己阻塞自己),导致死锁或业务逻辑错误。

四、可重入的实现原理

4.1 数据结构设计

可重入的核心是记录 哪个客户端(或线程)持有锁 以及 重入次数。通常使用 Redis 的 Hash 结构:

HSET lock_key client_id 1
  • lock_key:锁的键。
  • field:客户端唯一标识(如 UUID + 线程 ID)。
  • value:重入次数。

4.2 加锁逻辑(Lua 脚本保证原子性)

-- KEYS[1]: 锁键
-- ARGV[1]: 客户端标识
-- ARGV[2]: 过期时间(毫秒)

if redis.call('exists', KEYS[1]) == 0 then
    redis.call('hset', KEYS[1], ARGV[1], 1)
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 1
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 1
end

return 0

逻辑说明:

  1. 若锁不存在,创建 Hash 并设置重入次数为 1,同时设置过期时间。
  2. 若锁存在且当前客户端已持有,则重入次数加 1,并刷新过期时间。
  3. 否则返回 0,表示获取锁失败。

4.3 解锁逻辑(Lua 脚本)

-- KEYS[1]: 锁键
-- ARGV[1]: 客户端标识

if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then
    return nil
end

local count = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if count > 0 then
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 0
else
    redis.call('del', KEYS[1])
    return 1
end

逻辑说明:

  1. 若当前客户端不持有锁,直接返回 nil(防止误删他人锁)。
  2. 重入次数减 1。
  3. 若减后仍大于 0,说明还有外层锁未释放,仅刷新过期时间,不删除键。
  4. 若减后等于 0,说明完全释放,删除锁键。

4.4 Redisson 的可重入实现

Redisson 的 RLock 正是基于上述 Hash 结构实现的。它使用 UUID:threadId 作为客户端标识,确保同一线程可重入,不同线程互斥。每次 lock() 重入次数加 1,每次 unlock() 减 1,减到 0 时才真正删除锁并取消看门狗。

五、续期与可重入的协同

在实际实现中,续期和可重入并非孤立,而是协同工作:

  • 加锁时:若为重入,除了增加计数,还会刷新过期时间(相当于一次续期)。
  • 看门狗续期时:仅对当前持有者续期,不改变重入计数。
  • 解锁时:只有重入计数归零,才删除锁并停止看门狗。

这种设计保证了:

  1. 同一线程多次加锁不会导致锁提前释放。
  2. 业务未完成时锁不会过期。
  3. 业务完成后锁能及时释放。

六、常见面试追问

Q1:看门狗续期失败怎么办?
如果 Redis 节点宕机或网络分区,续期会失败。此时锁最终会过期,其他客户端可获取锁,可能导致并发。因此,Redisson 提供了 lockWatchdogTimeout 配置,并建议结合 RedLock 或高可用集群降低风险。

Q2:可重入锁的客户端标识为什么用 UUID + 线程 ID?
UUID 保证不同客户端实例的唯一性,线程 ID 保证同一客户端内不同线程的区分。若只用线程 ID,不同 JVM 的线程 ID 可能重复,导致误判重入。

Q3:为什么用 Lua 脚本?
Redis 执行 Lua 脚本时是原子性的,中间不会被其他命令打断。这保证了“判断 + 设置 + 续期”等复合操作的线程安全。

Q4:Redisson 看门狗默认续期时间是多少?
默认锁过期时间 30 秒,续期间隔 10 秒(即 lockWatchdogTimeout / 3)。可通过 Config.lockWatchdogTimeout 调整。

七、总结

Redis 分布式锁的续期与可重入是生产级实现的必备能力:

  • 续期 通过后台定时任务 + Lua 原子脚本实现,解决业务执行时间超过锁过期时间的问题。
  • 可重入 通过 Hash 结构记录持有者与重入次数,配合 Lua 脚本保证加锁、解锁的原子性。
  • 二者结合,才能构建出既安全又灵活的分布式锁。

理解这些原理,不仅能应对面试,更能在实际架构中避免锁失效、死锁等严重问题。Redisson 作为成熟方案,其源码值得深入研读,但掌握底层机制才是以不变应万变的关键。

未经允许不得转载:任鹏个人博客 » Redis 如何保证分布式锁的续期和可重入

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏