Redisson分布式锁的实现原理与加锁机制(含可视化)

概述

Redisson 基于 Redis 提供了健壮的分布式锁实现,核心由原子化 Lua 脚本、可重入计数、看门狗自动续期、自旋重试、以及在集群环境下的路由与脚本缓存优化共同构成。本文在校正与扩展原有资料的基础上,系统性梳理其原理与工程实践,并附带配色优化的 Mermaid 图以提升可读性。

简介与项目背景

  • 业务痛点:多实例并发下的资源互斥、临界区保护、幂等控制。
  • 传统问题:仅依赖 setnx/expire 容易出现“非原子”与“误释放”问题。
  • Redisson 解法:以 Lua 保证原子性,以哈希结构记录重入,以看门狗解决长耗时,以自旋保证获得锁,以 EVALSHA 提升脚本执行性能。

名词解释

  • 客户端ID:通常为 UUID + “:” + threadId,用于标识“谁持有锁”。例如 UUID.randomUUID() 与线程ID拼接。
  • 可重入锁:同一线程在持有锁的情况下可以再次进入临界区,计数 +1;释放时计数 -1 直至 0 才真正释放。
  • 看门狗(watchdog):锁持有期间的后台续期任务,默认每隔 10 秒检查并延长过期。
  • EVAL/EVALSHA:Redis 执行 Lua 脚本的两种方式;EVALSHA 通过脚本摘要命中缓存,减少传输与编译开销。参见 EVALSHA
  • TTL/PTTL:键剩余存活时间(毫秒级为 PTTL);用于判定何时可以重试加锁。

架构原理图

Redis Cluster

自旋重试

自旋重试

客户端1 Redisson

Master

客户端2 Redisson

Slave

Master

Slave

看门狗 自动续期

看门狗 自动续期

锁键 K

为什么仅依赖 setnx/expire 容易出现“非原子”和“误释放”?

  • 非原子问题(两条命令间的故障窗口):如果加锁用 setnx 成功后再单独执行 expire,一旦在两条命令之间发生异常(网络抖动、客户端崩溃),锁就没有 TTL,会造成“永久占用”,形成死锁风险。Lua 将“检查→加锁→设置过期”合并为一次原子执行,杜绝该窗口。
  • 误释放问题(未校验持有者):锁到期后被客户端 B 重新获得,客户端 A 仍调用 del(K) 删除锁,若不校验“锁的持有者”,会误删 B 的锁,导致临界区暴露。应采用“比较持有者后再删除”的原子释放(通常用 Lua 脚本完成 compare-and-del)。
  • 重入与续期的原子性:可重入计数 + TTL 续期需要与持有者校验绑定在同一次原子操作内;否则会出现越权续期或计数错乱(例如非持有者把他人锁续期成“僵尸锁”)。

配套可视化(误释放示意)

客户端B Redis 客户端A 客户端B Redis 客户端A K当前已被B持有 del(K)(未校验持有者) 删除成功 加锁失效(被误删)

非原子窗口时序示意(setnx + expire)

  • 问题本质:将“加锁”和“设置过期时间”分成两条指令,中间存在故障窗口。一旦客户端在两条命令之间崩溃/超时,锁就没有 TTL,可能长期占用资源,形成“死锁”。
Redis 客户端 Redis 客户端 客户端崩溃/网络中断/进程被杀 K 无过期时间 → 潜在死锁 SETNX K OK (K 创建成功,但无过期) EXPIRE K (未执行)

补充:为什么不直接用 SET NX PX?

  • 单条 SET 命令支持“原子加锁 + TTL(PX毫秒)”,可以避免 setnx/expire 的非原子窗口:
    • 优点:一条指令原子完成“加锁 + 设置过期”,没有中间失败窗口。
    • 但局限明显:
      1. 不含“持有者标识与校验”逻辑,释放时仍需“比较-再删除”保护(通常依赖 Lua compare-and-del)。
      2. 不支持“可重入计数”,同一线程多次进入临界区会出问题。
      3. 不包含“看门狗自动续期”,长耗时任务容易在 TTL 到期前被释放。
      4. 无法直接表达“返回 PTTL 做精准退避”的语义与重试策略。
  • 因此工程上常采用:原子 SET NX PX 仅解决“初始加锁 + 过期”这一点,而 Redisson 的方案进一步用 Lua + Hash 结构为“重入、校验、安全释放、续期、自旋退避、集群脚本缓存”等提供系统化支持。

FAQ:通俗解释“原子性”和“误释放”

  • 原子性(像“银行扣款 + 记账”的单笔事务):要么全部成功,要么全部不做,期间不允许插入其他并发修改或半途失败。setnx/expire 分两步就不是原子事务,中间能出意外。
  • 误释放(把别人的锁给开了):释放时不校验“当前锁是否我自己加的”,就可能把其他线程后来获得的锁删掉,导致临界区暴露。工程上必须“校验持有者再删除”,并用 Lua 原子化执行以防竞态。

加锁机制(核心流程校正与拆解)

  1. 选路到 Master:在 Redis Cluster 中,客户端根据 key 的哈希槽选择目标 Master 节点,避免跨节点操作。
  2. 原子加锁 Lua 逻辑:使用 Lua 保证“检查 + 设置 + 过期 + 重入计数”的整体原子性:
    • 若 key 不存在:创建哈希结构,字段为客户端ID,值为重入计数 1;同时设置过期(默认 30s)。
    • 若 key 存在且字段为本客户端ID:执行可重入,计数 +1,并刷新过期。
    • 若 key 存在但持有者非本客户端:返回剩余 TTL(PTTL),用于指导下一次重试间隔。
  3. 自旋与退避:客户端循环尝试加锁,可根据 PTTL 实现“精准睡眠”,避免无意义忙等。
  4. 看门狗续期:锁成功持有后,后台任务每 10 秒续期一次,确保业务超过初始过期时间仍不丢锁。
  5. 释放锁:减计数;当减至 0 时删除 key;非持有者不可释放,避免“误删他人锁”。

Lua 逻辑语义解读(无代码版)

  • exists(KEYS[1]) == 0 → hset(KEYS[1], ARGV[2], 1); pexpire(KEYS[1], ARGV[1]); return nil
  • hexists(KEYS[1], ARGV[2]) == 1 → hincrby(KEYS[1], ARGV[2], 1); pexpire(KEYS[1], ARGV[1]); return nil

行级解析(逐行与逐参数解释)

  • 语境说明:
    • KEYS[1]:锁对应的 Redis 键(例如 “lock:order:123”)
    • ARGV[1]:锁的过期时间,单位毫秒(例如 30000,代表 30 秒)
    • ARGV[2]:持有锁的“客户端唯一标识”(通常为 UUID + “:” + threadId)
  1. exists(KEYS[1]) == 0

    • 含义:判断“锁键”是否不存在(0 表示不存在)。
    • 目的:只有在“没有任何持有者”时,才允许首次加锁。

    随后执行:

    • hset(KEYS[1], ARGV[2], 1)

      • 作用:以 Redis Hash 结构记录“持有者”,field=ARGV[2](客户端ID),value=1(重入计数为 1,表示首次持有)。
      • 为什么用 Hash:便于同一个持有者进行可重入(计数 +1),并能区分“谁持有锁”。
    • pexpire(KEYS[1], ARGV[1])

      • 作用:为锁键设置“毫秒级”过期时间(PTTL),避免永久占用。
      • 设计意图:锁初次创建后必须附带 TTL,配合看门狗续期可避免长事务导致的提前释放或永久占用。
    • return nil

      • 语义:返回 nil 表示“当前客户端已成功获得锁或完成首次加锁逻辑”,在 Redisson 约定中 nil 代表“无需等待、已成功”。
  2. hexists(KEYS[1], ARGV[2]) == 1

    • 含义:判断“锁键的 Hash”中是否存在“本客户端ID”这一字段(1 表示存在)。
    • 目的:如果锁已被“本客户端”持有,则允许“可重入”。

    随后执行:

    • hincrby(KEYS[1], ARGV[2], 1)

      • 作用:将本客户端的重入计数 +1,表示同一个线程/客户端再次进入临界区。
      • 价值:支持递归调用或多段业务在同一锁内执行,不会误认为是“抢他人锁”。
    • pexpire(KEYS[1], ARGV[1])

      • 作用:刷新锁的过期时间,保持锁在业务执行期间不被自动过期。
      • 结合看门狗:看门狗会周期性刷新 TTL;在发生可重入时也主动刷新 TTL,保证一致的生存时间策略。
    • return nil

      • 语义:返回 nil 表示“可重入成功”,调用方无需等待。
  3. 其他路径(既非首次持有,也非本客户端可重入)

    • return pttl(KEYS[1])
      • 作用:返回锁键的“剩余毫秒 TTL”。
      • 用途:客户端据此进行“精准退避/自旋”,例如 sleep(PTTL) 后再尝试加锁,减少无效忙等与 CPU 占用。
  • 其他情况 → return pttl(KEYS[1]) 以指导等待时长

EVALSHA 优化机制与伪代码示意

  • 首次执行:发送完整 Lua,Redis 缓存并返回 SHA1。
  • 后续执行:优先以 SHA1 执行;若未命中缓存,再回退完整 Lua 并重建缓存。
  • 客户端侧:预计算并本地缓存内置 Lua 的 SHA1 摘要,减少运行时计算。
  • 集群侧:每个 Master 独立缓存脚本;客户端需针对不同节点维护脚本摘要命中。
  • 伪代码引用:redis.evalsha() / redis.eval() / 异常 NoScriptException

续期机制(Watchdog)工作原理

  • 触发条件:当线程成功获得锁且仍在执行临界区。
  • 执行频率:默认每 10 秒(可配置),调用 pexpire 刷新 TTL 至设定值(如 30 秒)。
  • 结束条件:线程完成业务并释放锁,或线程中断/应用停止(需配合健壮的释放逻辑)。
  • 设计要点:续期必须与“持有者身份”绑定,防止非持有者续期导致锁僵死。

可重入锁实现要点

  • 结构选择:使用 Redis Hash,field = 客户端ID,value = 重入计数。
  • 加锁路径:本客户端持有时计数 +1;初次持有时设置过期。
  • 释放路径:本客户端计数 -1;当计数为 0 → del 整个 key。
  • 并发安全:所有操作在 Lua 中原子执行,避免并发条件竞争。

释放锁的安全性

  • 核验持有者:仅当 hexists(KEYS[1], ARGV[2]) == 1 才允许 hincrby -1 或 del。
  • 防误删:若不是持有者,直接返回,避免删除他人锁导致“临界区暴露”。
  • 最终删除:计数归零时 del key;否则继续保持锁与 TTL。

时序图(获取锁→续期→释放)

Watchdog Redis Master Redisson 客户端1 Watchdog Redis Master Redisson 客户端1 alt [K 不存在或可重入] [被他人持有] 请求加锁(K) 1 路由至 Master 并执行 Lua 2 成功(nil) 3 获得锁 4 每10秒 pexpire(K, TTL) 5 返回 PTTL 6 自旋等待(依据 PTTL) 7 释放锁 8 Lua 验证持有者并减计数/删除 9 成功 10

容错与边界场景

  • 时钟漂移与长 GC:续期线程被阻塞可能导致 TTL 过期,需合理设置过期与续期间隔,并监控 GC 暂停。
  • 网络分区:客户端误以为释放失败或续期失败,建议增加重试与幂等保护。
  • 主从复制延迟:锁写入在主库,读写一致性以主为准,避免从库读取造成误判。
  • 进程崩溃:看门狗停止,TTL 到期自动释放,保证锁不会永久占用。
  • 阻塞临界区:单点长事务应尽量分解,缩短持锁时间,避免对系统整体吞吐的影响。

性能与实践建议

  • 设置合理 TTL:初始 30s 仅为默认;结合业务耗时与续期频率动态评估。
  • 精准退避:根据返回的 PTTL 进行 sleep,降低无效重试。
  • 指标与日志:记录“加锁成功率、等待时长、持锁时长、续期次数、释放失败数”等关键指标。
  • 统一封装:对外暴露统一的加锁接口,内部包含重试、续期、释放、异常处理与监控。

高级话题与对比

  • 公平锁 vs 非公平锁:Redisson 默认非公平;公平锁需要排队队列语义,吞吐与延迟权衡。
  • 读写锁:读锁共享、写锁互斥;适用于读多写少场景,但实现复杂度更高。
  • RedLock(多主多副本):理论争议较大,工程实践需谨慎评估网络分区与延迟,参考官方讨论。

与 Java API 的对应关系(名称引用)

参考资料与权威文献

  • Redis 官方 Lua/EVAL/EVALSHA 文档:https://redis.io/docs/latest/develop/reference/scripting/
  • Redis Keyspace TTL/PTTL 文档:https://redis.io/commands/pttl/
  • Redisson 官方文档与源码(GitHub):https://github.com/redisson/redisson
  • Redis Cluster 介绍与槽位:https://redis.io/docs/latest/operate/cluster/
  • RedLock 原论文与讨论(antirez):https://redis.io/docs/latest/develop/use/patterns/distributed-locks/

速记口(便于复述与面试)

  • 一句概括:Lua 原子 + Hash 重入 + Watchdog 续期 + EVALSHA 缓存 + 自旋退避 + 主库路由。
  • 关键流程:exists → hset/hexists → hincrby → pexpire → 返回 pttl → 自旋 → 释放减计数/删除。
  • 风险点:长 GC/网络分区/主从延迟/错误释放;监控与退避不可少。
  • 配置要点:合理 TTL 与续期频率;精准自旋;健壮释放;统一封装与度量。

结语

Redisson 分布式锁以工程化手段解决了“原子性、持有者识别、长耗时续期、重入与释放安全”等关键问题。理解其在集群中的脚本缓存与路由细节、在边界场景下的容错策略,才能做到知其然并知其所以然,在真实生产环境中稳定发挥作用。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐