【后端开发|Redis进阶01】—— Redis分布式锁全解:从SETNX到Redisson看门狗,再到RedLock

Redis 分布式锁全解:从 SETNX 到 Redisson 看门狗,再到 RedLock

单机环境下加把 synchronized 锁就能搞定并发,为什么上了微服务就不灵了?因为 synchronized 的锁是"JVM 进程内的锁",管得了单个进程,管不了多个进程。订单服务一扩容到 3 个实例,三个进程同时扣库存,超卖照样发生------这就是分布式锁要解决的问题。

本文按一条演进链讲透:单机锁为什么不够 → 分布式锁的评判标准 → Redis 实现从 SETNX 到 Redisson 看门狗再到 RedLock 的五代演进 → 常见坑与实战选型。看完你能说清"手写 Redis 锁和 Redisson 到底差在哪""RedLock 为什么吵了十年还没定论"。


一、先厘清:单机锁为什么不够

1.1 并发问题的本质

并发问题的根源只有一个:多个执行单元同时读写了共享资源,且读写不是原子的。典型场景:

  • 电商扣库存:两个请求同时读到库存 1,各自减一,都写回 0------实际卖出 2 件,库存变 0,超卖。
  • 重复提交:两个请求同时处理同一笔订单,各执行一遍发货逻辑。

解决思路也只有一个:把"读-改-写"变成互斥的临界区,同一时刻只允许一个执行单元进入。

1.2 synchronized 的边界:进程内锁

synchronized / ReentrantLock 是基于 JVM 内存模型实现的锁,它的作用域是单个 JVM 进程。判断依据很简单:

  • 锁对象存在堆内存里,属于某个进程;
  • 别的进程既看不到这把锁,也不受它约束。

所以当应用是单实例 部署时,进程内锁完全够用;一旦变成多实例 / 微服务部署,同样的业务代码在 N 个 JVM 里各跑各的,进程内锁形同虚设。

🔴 重点:分布式锁的本质,是把"互斥的判定点"从进程内 搬到所有进程都能访问的公共组件上(Redis、ZooKeeper、etcd、数据库都行)。

1.3 分布式锁的定义与核心要求

分布式锁:让多个进程在访问共享资源时,通过一个外部协调组件实现互斥的机制。一把合格的分布式锁至少要满足:

要求 含义 违反的后果
互斥性 任意时刻只有一个客户端持有锁 并发进入临界区,数据错乱
防死锁 持有者崩溃后锁能自动释放(过期机制) 锁永远不释放,业务永久卡死
防误删 只能释放自己持有的锁(唯一标识校验) A 释放掉 B 的锁,互斥被破坏
原子性 加锁、解锁的关键步骤不可分割 中间步骤失败留下脏状态
可重入/可续期 同线程可重入;长任务锁不过期 死锁或锁提前失效

✅ 一句话:互斥是底线,防死锁是保底,防误删是礼貌,原子性是技术前提。后面每一代 Redis 实现,都是在补齐这几条。


二、基于 Redis 的实现演进(核心章节)

🔴 本章是全文核心。记住一个总览:Redis 分布式锁的每一代演进,都是在补上一代的漏洞------1.0 不防死锁,2.0 防死锁但不防误删,3.0 防误删但不防"业务没跑完锁先过期",4.0(Redisson)解决续期与可重入,5.0(RedLock)试图解决主从切换丢锁。

2.1 1.0 版本:SETNX + 手动 DEL ------ 会死锁

Redis 的 SETNX(Set if Not eXists)天然适合做互斥:key 不存在才设置成功,存在就失败。于是第一版锁长这样:

bash 复制代码
# 加锁:成功返回 1,失败返回 0
> SETNX lock:order 1
(integer) 1
# ... 业务逻辑 ...
> DEL lock:order   # 释放锁

问题 :如果业务代码在 DEL 之前抛异常、进程崩溃或机器宕机,锁永远不会被删除,其他进程永远拿不到锁------直接死锁。

⚠️ 避坑:单独调 EXPIRE 也不行。SETNX 和 EXPIRE 是两条独立命令,中间挂了同样死锁。两条命令的"非原子性"本身就是漏洞。

2.2 2.0 版本:SET NX EX ------ 防死锁,但不防误删

Redis 2.6.12+ 给 SET 命令加了扩展参数,把"加锁"和"设过期时间"合并成一条原子命令。Redis 官方文档明确给出的锁写法就是:

bash 复制代码
> SET lock:order 1 NX EX 30
OK     # 成功拿到锁,30 秒后自动过期
# ... 业务逻辑(若超过 30 秒,锁自动释放)...
> DEL lock:order
  • NX:key 不存在才设置(互斥);
  • EX 30:30 秒后过期(防死锁,进程崩了锁也会自动释放)。

问题 :解锁时直接 DEL,没有校验"这把锁是不是我的"。典型事故场景------进程 A 持锁,业务执行超过 30 秒,锁自动过期;进程 B 拿到锁开始干活;此时 A 终于执行完,一个 DEL 把 B 的锁删了,进程 C 又趁机拿到锁,三个进程同时进临界区。

⚠️ 避坑:锁过期时间设置是门学问。设短了业务没跑完锁就没了,设长了进程崩溃后其他进程要干等。2.0 的解法很粗暴:让业务"必须在锁过期前跑完",做不到就出问题。

2.3 3.0 版本:value 唯一标识 + Lua 脚本解锁 ------ 防误删

核心改进两处:

  1. 加锁时给 value 放一个唯一标识(UUID 或雪花 ID),锁是谁的一目了然;
  2. 解锁用 Lua 脚本:先比对 value 再删,比对和删除在一个脚本里原子执行,杜绝"先查后删"中间被别人插队。
bash 复制代码
# 加锁:value 放本客户端唯一标识
> SET lock:order 9f8a2b3c-xxxx NX EX 30
OK

# 解锁:Lua 脚本,校验 value 一致才 DEL(原子操作)
lua 复制代码
-- unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
java 复制代码
// Java 侧调用示意(jedis)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, 1, "lock:order", lockValue);

✅ 成功标志:A 现在删不掉 B 的锁了(value 不匹配,Lua 返回 0 不删)。

仍存在的问题:业务执行超过 30 秒时锁照常过期------value 校验再严格,也拦不住"锁提前失效、B 提前进入"。这要等 Redisson 的看门狗来解决。

2.4 4.0 版本:Redisson ------ 看门狗自动续期 + 可重入

Redisson 是 Java 生态最主流的 Redis 分布式锁客户端库,它把上面所有补丁做成了开箱即用的封装。两个核心能力:

① 看门狗(Watchdog)自动续期

用 lock() 且不指定 leaseTime 时,Redisson 启动一个后台定时任务:

  • 默认锁有效期 30 秒 (lockWatchdogTimeout,可配置);
  • 后台线程每 10 秒 (30 秒的 1/3)检查一次:若当前线程仍持有锁,就把过期时间刷新回 30 秒;
  • 只要持有锁的 JVM 还活着,锁就永远不过期;JVM 一旦挂掉,看门狗停止续期,锁在最多 30 秒内自动释放------既不会提前失效,也不会永久死锁。
java 复制代码
// Redisson 使用示例
RLock lock = redisson.getLock("lock:order");
lock.lock();                // 不传 leaseTime → 启用看门狗,默认 30s 自动续期
try {
    // 业务逻辑,执行多久锁都在
} finally {
    lock.unlock();          // 释放锁
}

⚠️ 注意:lock(leaseTime) 指定了过期时间就不会启动看门狗。传了 leaseTime 等于放弃自动续期,回到 3.0 的"必须在期限内跑完"模式。

② 可重入

同一线程可重复获取同一把锁(内部用哈希结构记录持有线程 + 重入次数),解锁时计数减一,为 0 才真正删除------行为和 ReentrantLock 一致,防止自己锁自己。

仍未解决的问题 :主从切换丢锁 。若 Redis 用主从架构,客户端 A 在 master 上加锁成功,master 还没来得及同步到 slave 就宕机,slave 提升为 master------新的 master 上没有这把锁,客户端 B 就能加锁成功,互斥被打破。这是 RedLock 诞生的直接动因。

2.5 5.0 版本:RedLock ------ 多节点半数加锁,以及那场著名论战

RedLock 思路 :不再信任单个 Redis 实例,而是向 N 个互相独立的 Redis 节点(官方建议 5 个)同时尝试加锁:

  1. 客户端依次向每个节点执行 SET key value NX EX ttl,每个节点设一个很短的获取超时(如 50ms),避免在不可达节点上干等;
  2. 只要在 ttl 内成功加锁的节点数 ≥ N/2 + 1(如 5 个中 ≥ 3 个),且总耗时小于锁有效期,就认为加锁成功;
  3. 锁的有效期 = 初始 ttl − 获取锁耗时;失败则向所有节点释放。

意图:个别节点挂掉、主从切换丢锁都不影响,因为只要多数节点还承认这把锁,互斥就成立。

这场著名的论战(2016 年 2 月,分布式领域名场面):

  • Martin Kleppmann (《数据密集型应用系统设计》作者)发表《How to do distributed locking》批评 RedLock,核心两点:
    1. 时序假设不成立:RedLock 隐含"网络延迟有界、进程暂停有界、时钟同步"三个假设。一次长 GC 暂停(比如 35 秒)就能让客户端以为还持有锁,实际锁早已过期被别人拿走------GC 暂停没法用过期时间防御;
    2. 没有 fencing token :锁应该携带一个每次获取都递增的令牌,让资源侧校验"我这个操作是不是最新的"。RedLock 只有随机值,资源侧无法拒绝旧客户端的迟到写入。
  • antirez (Redis 作者)随即发文《Is Redlock safe?》辩护:论战不是"安全/不安全"的二元问题,而是锁的安全模型与使用场景的匹配问题;对多数应用,RedLock 提供的是"合理范围内的实用安全性"。

✅ 双方罕见的一致:如果资源侧支持 fencing token(每次操作带递增序号,资源侧拒绝旧序号),即使锁失效也能兜底------这才是终极解。Redis 锁提供不了这个,数据库版本号、ZooKeeper 的递增 zxid 可以。

RedLock 的实践结论 (务实版):RedLock 成本高、收益有争议,生产环境绝大多数场景不需要上 RedLock;更值得做的是"锁 + 业务幂等 + 资源侧校验"的组合。


三、常见坑与实战建议

坑 原因 解决
主从切换丢锁 master 未同步锁就宕机,新 master 无锁 RedLock;或用读写分离下的一致性策略;最务实的是接受小概率并用幂等兜底
GC 停顿致锁过期 业务线程 STW 35s,锁 30s 过期,别的进程进入 看门狗续期只能缓解不能根治(GC 停顿期间续期线程也停了);关键操作配 fencing token 或幂等
时钟跳跃 依赖 TTL 过期,服务器时钟回拨导致锁提前/延后失效 避免依赖系统时钟的强一致场景;Redis 侧开启保护,监控时钟偏差
续期失败 网络抖动导致看门狗续期命令失败 Redisson 看门狗有重试;监控续期失败率
忘了 finally 解锁 异常路径不释放锁 一律 try/finally;用 Redisson 的 lock() 时优先 tryLock 带超时

实战建议(可照抄):

  1. 能上 Redisson 就别手写。手写锁需要你自己处理 Lua 脚本、续期、可重入,Redisson 全封装好了,踩坑成本远低于省下的依赖。
  2. 加锁务必原子,解锁务必校验 :SET key value NX EX ttl + Lua 校验删除,缺一不可。
  3. 锁的粒度越小越好 :用 lock:order:{orderId} 而不是 lock:order 全局锁,避免无关请求互相阻塞。
  4. 设置合理的 waitTime :tryLock(waitTime, leaseTime, TimeUnit) 拿不到锁时快速失败/降级,别无限阻塞。
  5. 锁不是银弹 :真正确性要求高的场景(转账、库存强一致),用数据库行锁 + 版本号 或 ZooKeeper/etcd,别把正确性押在 Redis 锁上。
  6. 业务幂等是最后防线:即使锁意外失效,接口幂等(唯一订单号防重)能兜住重复执行。

四、对比与选型

方案 互斥实现 防死锁 可靠性 复杂度 适用场景
手写 SETNX(1.0~3.0) Redis SET/Lua 过期时间 低(主从切换丢锁) 低 学习、临时脚本、非关键路径
Redisson 单实例 Redis + Lua + 看门狗 过期 + 自动续期 中(仍受主从切换影响) 低 绝大多数业务首选
RedLock 多节点半数加锁 过期 + 多数派 中(有时序假设争议) 高 要求较高的场景,需评估成本
ZooKeeper 临时顺序节点 + Watch 会话超时自动删 高(CP 一致性) 中 正确性敏感、对性能不极致敏感
etcd Lease + Revision 前缀 租约过期 高(Raft) 中 云原生/K8s 生态,配置与锁并存

选型规则(结论前置):

  • 默认选 Redisson 单实例(配好主从/哨兵/集群),覆盖 90% 场景;
  • 正确性敏感、能接受更重依赖 → ZooKeeper / etcd;
  • 追求极致简单、业务可容忍偶发并发 → 手写 3.0 版(原子加锁 + Lua 解锁);
  • RedLock:能不上就不上,上了也别把它当"绝对安全"------它解决的是主从丢锁,不是 GC 停顿。

五、总结:你真正需要记住的 N 件事

  1. 分布式锁解决的痛点是进程间互斥 :单机的 synchronized 管不到其他 JVM。
  2. 一把合格的锁 = 互斥 + 防死锁 + 防误删 + 原子性,缺一条就出事故。
  3. 手写锁的正确姿势 :加锁用 SET key value NX EX ttl(原子),解锁用 Lua 脚本校验 value(原子),两条缺一不可。
  4. 演进逻辑是补漏洞:SETNX 不防死锁 → SET NX EX 防死锁 → 唯一标识 + Lua 防误删 → Redisson 看门狗解决续期 → RedLock 试图解决主从丢锁。
  5. 看门狗的本质:JVM 活着锁就续期,JVM 挂了锁最多 30 秒自动释放;指定 leaseTime 就没有看门狗。
  6. RedLock 有先天争议 :Martin Kleppmann 批它依赖时序假设、没有 fencing token;antirez 辩它是"实用安全"。两者唯一共识是 fencing token / 资源侧校验才是终极兜底。
  7. 生产选型:Redisson 单实例是默认答案,正确性敏感上 ZooKeeper/etcd,RedLock 谨慎使用。
  8. 锁不是万能的:业务幂等 + 数据库版本号 + 资源侧校验,比任何锁都可靠。

验证清单

  • 能解释"为什么微服务多实例下 synchronized 失效"
  • 能手写 3.0 版锁:原子加锁 + Lua 校验解锁
  • 能说清看门狗的触发条件(不指定 leaseTime 才启用)和默认参数(30s 锁、10s 续期)
  • 能说清主从切换丢锁的原理和 RedLock 的应对思路
  • 能复述 Kleppmann 批评 RedLock 的两点核心论据(时序假设、fencing token)
  • 能在面试中答出"生产环境你用哪个方案、为什么"

参考资源

  • Redis 官方文档:Distributed Locks with Redis(含 RedLock 分析与实现清单)
  • Redis 官方文档:SET 命令(SET key value NX EX 官方锁写法)
  • Redisson 官方文档:Locks and Synchronizers(看门狗机制与 lockWatchdogTimeout 配置)
  • Martin Kleppmann:How to do distributed locking(2016,对 RedLock 的批评)
  • antirez:Is Redlock safe?(2016,对批评的回应)
  • 《Redis 设计与实现》《深入理解分布式系统》------分布式锁与一致性基础参考
相关推荐
llqbzllll1 小时前
A2A 1.0 到底和 MCP 差在哪:从 Agent Card、Task 到 Java 最小互操作
后端
花间相见1 小时前
【后端开发|JVM基础01】—— Java垃圾回收全解:从分代内存模型到各收集器的分代职责
后端
geovindu1 小时前
rust: Abstract Factory pattern
开发语言·后端·设计模式·rust·抽象工厂模式
pe7er1 小时前
Spring Boot 日志最佳实践:接入、分级、异常记录与滚动拆分
后端
程序员老赵2 小时前
Docker 部署 Dolibarr:轻松搭建开源 ERP/CRM 平台
运维·前端·后端
量化分析码农3 小时前
【Python量化系统工程实战 #06】从本地脚本到云端部署:量化系统的最小可运行架构
后端
136096757233 小时前
页面打不开不是 Nginx 的错
前端·后端
ALONE阿龙太原微码3 小时前
RBAC 以及主流权限模型
后端