在分布式系统中,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 客户端,它内置了看门狗实现:
- 调用
lock()时,若未指定leaseTime,则默认使用 30 秒过期时间。 - 加锁成功后,Redisson 会注册一个定时任务,每 10 秒(
internalLockLeaseTime / 3)执行一次续期。 - 续期通过 Lua 脚本完成:先判断锁是否存在且持有者为当前线程,若是则调用
pexpire重置过期时间。 - 当调用
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
逻辑说明:
- 若锁不存在,创建 Hash 并设置重入次数为 1,同时设置过期时间。
- 若锁存在且当前客户端已持有,则重入次数加 1,并刷新过期时间。
- 否则返回 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
逻辑说明:
- 若当前客户端不持有锁,直接返回 nil(防止误删他人锁)。
- 重入次数减 1。
- 若减后仍大于 0,说明还有外层锁未释放,仅刷新过期时间,不删除键。
- 若减后等于 0,说明完全释放,删除锁键。
4.4 Redisson 的可重入实现
Redisson 的 RLock 正是基于上述 Hash 结构实现的。它使用 UUID:threadId 作为客户端标识,确保同一线程可重入,不同线程互斥。每次 lock() 重入次数加 1,每次 unlock() 减 1,减到 0 时才真正删除锁并取消看门狗。
五、续期与可重入的协同
在实际实现中,续期和可重入并非孤立,而是协同工作:
- 加锁时:若为重入,除了增加计数,还会刷新过期时间(相当于一次续期)。
- 看门狗续期时:仅对当前持有者续期,不改变重入计数。
- 解锁时:只有重入计数归零,才删除锁并停止看门狗。
这种设计保证了:
- 同一线程多次加锁不会导致锁提前释放。
- 业务未完成时锁不会过期。
- 业务完成后锁能及时释放。
六、常见面试追问
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 如何保证分布式锁的续期和可重入

