分布式锁深度解构:从 Redis 到 ZooKeeper,一场正确性与性能的博弈

一、引言

分布式锁怎么选?这个问题没有标准答案。

但有一条铁律:场景决定技术

你的系统如果只是防止重复执行、避免资源浪费,Redis 足够了。如果你的系统一旦锁失效会造成数据损坏、金钱损失,那 Redis 就不够,你需要 ZooKeeper 或者 etcd。

为什么?往下看。

二、Redis 分布式锁:甜蜜的陷阱

2.1 最简单的锁,最要命的坑

用 Redis 做分布式锁,99% 的开发者都会这么写:

bash 复制代码
SET lock_key 1 NX PX 10000

一条命令,原子操作,10 秒过期。看起来完美。

但它有三个致命的坑。

第一坑:锁过期,业务还没完。

你设了 10 秒过期。但业务因为 GC 停顿、网络抖动、磁盘 IO 阻塞,跑了 15 秒。锁在第 10 秒自动释放了。此时另一个线程抢到锁,你的业务还没跑完,两个线程同时操作共享资源------数据一致性直接崩盘。

这就是经典的 Zombie Worker 问题:客户端在锁过期后仍然以为自己持有锁,继续执行临界区代码。

第二坑:释放锁,删了别人的。

你跑完业务,执行 DEL lock_key。但此时锁已经过期了,被其他客户端抢走了。你这一删,删的是别人的锁。其他客户端还在"被保护"地跑业务呢,你又可以进来加锁了------互斥性荡然无存。

第三坑:SETNX + EXPIRE,原子性陷阱。

bash 复制代码
SETNX lock_key 1   # 执行完,服务器挂了
EXPIRE lock_key 10 # 这行没执行

锁永远不释放,系统死锁。

这三坑,每一个都是生产事故的高发区。

2.2 Redisson 的"看门狗":给锁续命

Redisson 的解决方案是 Watchdog(看门狗)机制

加锁成功后,Redisson 会启动一个后台线程,默认每 10 秒检查一次(lockWatchdogTimeout / 3)。如果业务还没完成,就自动把锁的过期时间重置为 30 秒。只要客户端活着,锁就永远不过期。业务执行完,主动 unlock,看门狗随之退出。

注意:看门狗只在你不主动指定锁过期时间(leaseTime = -1)时才生效。如果你手动指定了过期时间,Redisson 认为你清楚自己在干什么,不会启动看门狗。

2.3 解决"误删锁":Lua 脚本的原子操作

释放锁不能直接 DEL,要先校验这个锁是不是"我"加的。

Redisson 的做法是:加锁时,Value 存的是一个唯一 ID(UUID + 线程 ID)。释放锁时,用 Lua 脚本原子地校验并删除:

Lua 复制代码
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redis 保证 Lua 脚本执行期间不会插入其他命令,校验和删除是一体的。

2.4 主从切换:最隐蔽的雷

以上所有方案,都假设 Redis 是单机。但生产环境你一定会用主从 + Sentinel 做高可用。

这里藏着一个致命的场景:

  1. Client A 在主节点上成功加了锁

  2. 主节点还没来得及把这条数据同步给从节点,就挂了

  3. Sentinel 把从节点提升为新主节点

  4. Client B 连接到新主节点,加锁------成功了

  5. 此时 A 和 B 都以为自己持有锁

主从异步复制导致了锁状态的丢失。主从切换的时间窗口内,互斥性被打破。

这是 Redis 分布式锁最大的结构性缺陷,无法在单实例模型下彻底解决。

2.5 Redlock:Redis 官方的高可用方案

为了解决主从切换导致的锁丢失,Redis 作者 Antirez 提出了 Redlock 算法

Redlock 的核心思想是:不依赖单个 Redis 实例,而是向 N 个独立的 Redis 主节点(通常 5 个)同时申请锁。客户端只有在超过半数(N/2 + 1)的节点上成功加锁,才算获取锁成功。

流程大致如下:

Redlock 的巧妙之处在于:只要半数以上节点正常工作,锁就不会丢失。即使少数节点宕机或网络分区,锁依然可用。

2.6 Redlock 的争议:一场轰动业界的辩论

2016 年,DDIA 的作者 Martin Kleppmann 发表了一篇博客,标题是《How to do distributed locking》,对 Redlock 提出了尖锐的质疑。

Martin 的核心论点是:Redlock 依赖系统时钟的准确性,而在分布式系统中,时钟是不可靠的。

Redlock 在两个关键环节使用了本地时间:

  1. 锁的过期时间基于 Redis 节点的本地时钟

  2. 客户端计算锁有效期时,依赖两次本地时间的差值

Martin 举了一个反例:客户端 A 获取锁后,在操作共享资源前发生了 GC Stop-The-World。GC 期间锁过期了。客户端 B 获取到锁。GC 结束后,客户端 A 继续执行------两个客户端同时操作共享资源。

GC 只是冰山一角。还有:

  • 网络延迟和重传

  • Page fault 导致的磁盘交换

  • 虚拟机热迁移时的暂停

  • NTP 时钟同步导致的时钟回退

  • 闰秒问题

其中时钟回退是最致命的------如果 NTP 同步把系统时间往回拨了几十毫秒,依赖时间比较的锁有效性判断会直接失效。

Martin 将分布式锁的使用场景分为两类:

  • Efficiency(效率型) :目标是避免重复工作、节省资源。锁失效的代价很小,比如多发了封邮件、多跑了一次报表。

  • Correctness(正确性型) :目标是防止并发操作破坏数据一致性。锁失效的代价是灾难性的,比如重复扣款、文件损坏。

Martin 的结论是:Redlock 对于 Efficiency 场景足够,但对于 Correctness 场景不安全。

2.7 Antirez 的回应

面对质疑,Antirez 也发表了长文回应。

他的核心观点是:

  1. 时钟问题被夸大了。合理的 NTP 配置可以将时钟漂移控制在毫秒级,远小于锁的过期时间(通常是几十秒)。

  2. GC 问题不是 Redlock 独有的。任何基于超时的锁机制都会受 GC 影响,ZooKeeper 的 session 超时同理。

  3. 可以通过 fencing token(栅栏令牌)来增强安全性:每次加锁成功时返回一个单调递增的 token,共享资源在写入时校验 token,拒绝过期的写入。

Antirez 强调,Redlock 是在"高性能 "和"足够安全"之间做的工程权衡。如果追求绝对安全,任何依赖时间的锁都不够,但这在工程上并不现实。

这场辩论没有绝对的赢家。但它揭示了一个核心事实:任何依赖超时机制的分布式锁,都无法在理论上做到绝对正确。

三、ZooKeeper 分布式锁:可靠性的代价

3.1 核心原理:临时顺序节点 + Watcher

ZooKeeper 的锁实现,用四个字概括:优雅且可靠

它的核心机制是:临时顺序节点 + Watcher 监听机制

流程如下:

  1. 所有客户端在 /locks 目录下创建一个临时顺序节点 (Ephemeral Sequential),ZK 自动分配递增序号,如 lock-0000000001lock-0000000002

  2. 客户端获取 /locks 下所有子节点并排序,判断自己是否是最小序号

  3. 如果是最小序号,获取锁成功;否则,对前一个节点(不是父节点)注册 Watcher,进入等待

  4. 前一个节点的客户端释放锁(删除节点)或宕机(临时节点自动删除),Watcher 触发,当前客户端重新检查自己是否是最小序号

  5. 重复步骤 3-4,直到获取锁

这个机制的精妙之处在于:

  • 临时节点 :客户端会话断开时,ZK 自动删除节点,锁自动释放,绝不死锁

  • 顺序节点:全局唯一、单调递增,天然保证公平性,不会饿死。

  • 监听前一个节点:避免"羊群效应"。如果所有等待者都监听父节点,一个节点删除会导致所有客户端同时唤醒,造成惊群。

3.2 底层支撑:ZAB 协议

ZooKeeper 之所以能保证锁的强一致性,底层依赖的是 ZAB(ZooKeeper Atomic Broadcast)协议

ZAB 是专为 ZooKeeper 设计的原子广播协议,有两个核心模式:

  • 消息广播模式:Leader 接收写请求,将事务 Proposal 广播给所有 Follower。收到半数以上 Ack 后,Leader 发送 Commit 指令,Follower 执行提交。

  • 崩溃恢复模式:Leader 宕机时,集群选举新 Leader,新 Leader 确保已提交的事务在所有节点上一致。

这意味着:任何一次锁的创建或删除,都必须经过过半节点的确认。不存在 Redis 主从切换时的那种"写入主节点但未同步到从节点"的信息丢失窗口。

3.3 ZK 锁的"坑":Session 超时

ZK 锁并非完美。它的核心"坑"在于 Session 超时

ZK 客户端与服务器之间维持一个 Session,有超时时间(通常是 30-60 秒)。如果客户端因为:

  • GC Stop-The-World 超过 SessionTimeout

  • 网络长时间抖动

  • 机器负载过高导致心跳线程无法及时发送

Session 就会过期,临时节点被 ZK 自动删除,锁被释放。

而此时,客户端的业务逻辑可能还在执行------它以为自己还持有锁,实际上已经"裸奔"了。

这和 Redis 的"锁过期业务没完"是同一个问题,只是触发条件从"主动设置的 TTL"变成了"被动检测的 Session 超时"。

解决思路有两个:

  1. 增大 SessionTimeout:比如设到 60 秒甚至更长,降低误判概率。但代价是,客户端真的宕机时,锁要等 60 秒才能释放。

  2. 业务层面做幂等:无论用什么锁,共享资源的操作都应该设计成幂等的。锁只是减少冲突,不能 100% 依赖。

3.4 ZK 锁的性能代价

强一致性是有成本的。

每一次加锁,ZK 需要:

  1. 客户端 → Leader(创建节点请求)

  2. Leader → 所有 Follower(Proposal 广播)

  3. 所有 Follower → Leader(Ack 确认)

  4. Leader → 所有 Follower(Commit 指令)

  5. Leader → 客户端(响应)

至少 2 个网络 RTT(往返时间),加上磁盘写入。实测 P99 加锁延迟约 8-10ms,而 Redis 单机锁只有 1-2ms。

这就是为什么说 ZK 是"宁可慢,不可错"的悲观锁。

四、Redis vs ZooKeeper:终极对比

4.1 架构对比图

4.2 维度对比表

维度 Redis(单机 / Redisson) Redis Redlock ZooKeeper
一致性模型 最终一致 最终一致(依赖时钟) 强一致(ZAB 协议)
加锁延迟(P99) 1-2ms 2-5ms 8-10ms
锁自动释放 TTL 过期 TTL 过期 临时节点(Session 过期)
主从切换安全性 ❌ 不安全 ⚠️ 部分改善 ✅ 安全
客户端崩溃恢复 TTL 内无法释放 TTL 内无法释放 Session 超时后自动释放
公平性 不支持(竞争式) 不支持 ✅ 支持(顺序节点)
实现复杂度 中高
运维复杂度

4.3 核心差异的本质

Redis 的设计哲学是 AP(可用性 + 分区容错),ZooKeeper 的设计哲学是 CP(一致性 + 分区容错)。

Redis 的主从复制是异步的,追求极致的写入性能。数据从主节点同步到从节点有时间窗口,这个窗口内的一致性无法保证。

ZooKeeper 的写入必须经过过半节点的确认,牺牲了部分写入性能,换来了强一致性保证。在网络分区时,ZK 宁可拒绝服务,也不会让两个客户端同时拿到锁。

Redis 锁的"高性能"建立在接受小概率不一致之上;ZooKeeper 的"高可靠"是以增加网络往返和协调开销为代价。

五、选型指南

没有银弹,只有场景。

选择 Redis 的场景

  • 秒杀扣库存(允许极小概率的超卖)

  • 定时任务去重(跑两次问题不大)

  • 缓存击穿保护(多重建一次缓存没大事)

  • 任何 Efficiency 型场景

选择 ZooKeeper 的场景

  • 金融交易(重复扣款不可接受)

  • 分布式文件写入(文件损坏不可逆)

  • 库存扣减(精确到个位数的库存)

  • 任何 Correctness 型场景

折中选择:etcd

如果你想要强一致性(像 ZK),又想要更简单的 API 和运维(像 Redis),可以考虑 etcd

etcd 基于 Raft 共识算法,提供线性一致性的分布式锁(Lease + Compare-And-Swap)。P99 延迟约 4-5ms,介于 Redis 和 ZK 之间,且避免了 ZK 的 Session 超时不可控问题。

Kubernetes 用 etcd 做配置中心,其可靠性经过了大规模生产验证。

最后的忠告

  1. 不要裸写 Redis 锁。用 Redisson 或 Redlock 库,它们帮你处理了续期、误删、可重入等细节。

  2. 锁的粒度要尽量小。锁住整个方法是性能杀手。

  3. 共享资源的操作要做幂等设计。锁只是减少冲突,不是解决冲突的唯一手段。

  4. 如果你用 Redis 做 Correctness 型锁,至少加上 fencing token。每次加锁返回递增 token,写共享资源时带上 token,资源方拒绝过期 token 的写入。

  5. 监控锁的持有时长。如果经常有锁持有超过 TTL 的情况,说明你的 TTL 设置不合理,或者业务逻辑有问题。

分布式锁这个话题,本质是在"性能"和"正确性"之间做权衡。理解了两种方案的核心原理和边界条件,你就能做出正确的技术决策。

相关推荐
摇滚侠2 小时前
Java Map 类型的数据可以存储到 Redis Hash 类型中
java·redis·哈希算法
ALex_zry2 小时前
go-zero Redis缓存封装与Model层设计
redis·缓存·golang·气象
WangJunXiang62 小时前
NoSQL之Redis配置与优化
数据库·redis·nosql
lThE ANDE8 小时前
最完整版Linux安装Redis(保姆教程)
linux·运维·redis
Meepo_haha8 小时前
配置 Redis
数据库·redis·缓存
不吃香菜学java10 小时前
Redis的java客户端
java·开发语言·spring boot·redis·缓存
2601_9498177217 小时前
基础篇:Linux安装redis教程(详细)
linux·运维·redis
indexsunny18 小时前
互联网大厂Java面试实战:核心技术与微服务架构在电商场景中的应用
java·spring boot·redis·kafka·maven·spring security·microservices
devilnumber21 小时前
Redis 使用过程中可能遇到的常见问题或 “坑”
数据库·redis·缓存