深入理解分布式锁:ZooKeeper vs Redis

深入理解分布式锁:ZooKeeper vs Redis

本文从实现原理、可靠性、适用场景等多个维度,深入对比 ZooKeeper 与 Redis 两种主流分布式锁方案,并剖析红锁(RedLock)为何没有成为主流。


一、核心原理对比

Redis 分布式锁

Redis 利用 SET key value NX PX timeout 原子命令实现分布式锁,通过 TTL(过期时间)保证锁不会永久占用。

lua 复制代码
-- 释放锁需用 Lua 脚本保证原子性,防止误删他人的锁
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
end

ZooKeeper 分布式锁

ZooKeeper 的数据结构

ZooKeeper 的数据结构像一棵树,每个节点叫 ZNode

复制代码
/
├── locks/
│   ├── order_0000000001
│   ├── order_0000000002
│   └── order_0000000003
└── config/
什么是临时顺序节点

ZNode 有两个维度的属性可以组合:

维度一:生命周期

复制代码
持久节点(Persistent)--- 客户端断开后,节点依然存在
临时节点(Ephemeral) --- 客户端断开后,节点自动删除  ← 锁用这个

维度二:是否有序

复制代码
普通节点   --- 名字就是你指定的,重复创建会报错
顺序节点(Sequential)--- ZK 自动在名字后面追加递增编号  ← 锁用这个

两者结合就是临时顺序节点 ,比如你创建 /locks/order,ZK 会自动变成:

复制代码
/locks/order0000000001
/locks/order0000000002
/locks/order0000000003

断开连接后自动删除,且编号全局递增,天然有序。

什么是 Watch 机制

Watch 就是一次性监听,你可以对某个节点注册"它删除时通知我":

复制代码
客户端 B 对 order0000000001 注册 Watch
         ↓
order0000000001 被删除
         ↓
ZK 主动推送事件给客户端 B
         ↓
B 收到通知,重新竞争锁

注意:Watch 只触发一次,触发后需要重新注册。

加锁完整流程

用 3 个客户端竞争同一把锁举例:

第一步:各自创建临时顺序节点

复制代码
客户端A 创建 → /locks/order0000000001
客户端B 创建 → /locks/order0000000002
客户端C 创建 → /locks/order0000000003

第二步:各自查看所有子节点,判断自己编号是否最小

复制代码
A 看到 [001, 002, 003],自己是 001,最小 → 直接获得锁 ✅
B 看到 [001, 002, 003],自己是 002,不是最小 → 监听 001
C 看到 [001, 002, 003],自己是 003,不是最小 → 监听 002

第三步:A 完成业务,释放锁(删除节点)

复制代码
A 删除 /locks/order0000000001
         ↓
ZK 通知 B:"001 被删了"
         ↓
B 重新检查,自己 002 现在是最小 → B 获得锁 ✅
C 继续等待,监听 002

第四步:B 完成,C 获得锁,以此类推

复制代码
B 删除 /locks/order0000000002
         ↓
ZK 通知 C → C 获得锁 ✅
为什么监听"前一个"而不是监听"最小节点"?

这是一个关键设计决策。假设所有客户端都监听最小节点 001:

复制代码
001 删除
   ↓
B、C、D、E ... 100 个客户端同时收到通知
同时发起请求查询、竞争
→ 羊群效应(Herd Effect),ZK 被瞬间打爆 💥

改成每个节点只监听前一个:

复制代码
001 删除 → 只通知 002
002 删除 → 只通知 003

每次只唤醒一个客户端,优雅且高效,天然实现公平锁

关键维度对比

维度 ZooKeeper Redis
一致性模型 CP(强一致) AP(最终一致)
锁释放机制 临时节点,会话断开自动释放 依赖 TTL 过期,需手动删除
死锁风险 极低 存在(TTL 设置不当)
脑裂问题 ZAB 协议保证,不易发生 主从切换时可能出现
性能 较低(写需 ZAB 广播) 高(单机内存操作)
公平性 天然公平锁(顺序节点) 默认非公平

二、ZooKeeper 集群与 ZAB 协议

集群架构

ZooKeeper 集群称为 Ensemble ,通常部署奇数个节点(3、5、7),需要过半数节点存活才能正常工作。

复制代码
Client        Client        Client
  |             |             |
  └─────────────┼─────────────┘
                |
    ┌───────────┼───────────┐
    │           │           │
 Leader      Follower   Follower
 (主节点)    (从节点)    (从节点)
角色 职责
Leader 唯一处理写请求,发起投票广播
Follower 处理读请求,参与写投票,可被选为 Leader
Observer 只处理读,不参与投票(用于扩展读性能)

ZAB 协议

ZooKeeper 使用 ZAB(ZooKeeper Atomic Broadcast) 保证集群一致性:

复制代码
客户端写请求
    ↓
Leader 收到,生成事务 Proposal
    ↓
广播给所有 Follower
    ↓
超过半数 Follower ACK
    ↓
Leader Commit,通知所有节点应用
    ↓
返回客户端成功

正因如此,ZooKeeper 集群不会出现 Redis 主从切换丢锁的问题,任何节点看到的数据都是一致的。


三、Spring Boot 集成 ZooKeeper 分布式锁

方案一:Spring Integration(官方支持)

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-integration</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-zookeeper</artifactId>
</dependency>
java 复制代码
@Configuration
public class ZookeeperLockConfig {

    @Bean
    public CuratorFramework curatorFramework() {
        CuratorFramework client = CuratorFrameworkFactory.newClient(
            "localhost:2181", new ExponentialBackoffRetry(1000, 3)
        );
        client.start();
        return client;
    }

    @Bean
    public ZookeeperLockRegistry zookeeperLockRegistry(CuratorFramework client) {
        return new ZookeeperLockRegistry(client, "/locks");
    }
}

@Service
public class OrderService {

    @Autowired
    private ZookeeperLockRegistry lockRegistry;

    public void createOrder(String orderId) {
        Lock lock = lockRegistry.obtain(orderId);
        try {
            lock.lock();
            // 业务逻辑
        } finally {
            lock.unlock();
        }
    }
}

方案二:Curator(更主流 ⭐)

xml 复制代码
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.5.0</version>
</dependency>

Curator 是 Netflix 开源的 ZooKeeper 客户端,提供多种锁类型:

java 复制代码
// 可重入互斥锁(最常用)
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order");

// 读写锁
InterProcessReadWriteLock rwLock = new InterProcessReadWriteLock(client, "/locks/rw");

// 联锁(同时锁多个资源)
InterProcessMultiLock multiLock = new InterProcessMultiLock(Arrays.asList(lock1, lock2));

完整使用示例:

java 复制代码
@Service
public class OrderService {

    @Autowired
    private CuratorFramework client;

    public void createOrder(String orderId) throws Exception {
        InterProcessMutex lock = new InterProcessMutex(client, "/locks/" + orderId);

        if (lock.acquire(10, TimeUnit.SECONDS)) {
            try {
                // 业务逻辑
            } finally {
                lock.release();
            }
        }
    }
}
Spring Integration Curator
抽象层次 高,统一 Lock 接口 低,直接操作 ZK
灵活性 一般 强,支持多种锁类型
生产常用度 一般 ⭐ 更主流

四、红锁(RedLock)为何没有成为主流?

红锁的思路

RedLock 与 ZAB 表面上都采用"过半数"原则:

复制代码
ZAB:     写入 → 广播 → 过半数 ACK → Commit
RedLock: 加锁 → 广播 → 过半数成功 → 认为加锁成功

但两者本质差异巨大:

ZAB RedLock
设计层次 系统级一致性协议 应用层投票技巧
节点通信 节点间持续心跳同步 节点完全独立,互不通信
一致性保证 有 epoch 机制,旧命令会被拒绝 无协调机制

致命问题一:时钟跳跃导致 TTL 提前过期

TTL 是什么

Redis 加锁本质是:

复制代码
SET lock_key "clientA" NX PX 30000

PX 30000 表示这个 key 30秒后自动删除 ,即 TTL(Time To Live)。Redis 判断"30秒到了",靠的是服务器自己的系统时钟

NTP 的工作机制

服务器时钟并不是完全准确的,需要通过 NTP(网络时间协议) 定期同步校准。NTP 有两种同步方式:

方式一:slew(渐进调整)------ 正常情况

时钟偏差较小时,NTP 会慢慢加速或减速时钟来追上正确时间,平滑无感知:

复制代码
真实时间: 0s ──→ 1s   ──→ 2s   ──→ 3s
服务器:   0s ──→ 1.1s ──→ 2.05s──→ 3s  (慢慢追上)

方式二:step(强制跳跃)------ 问题所在

时钟偏差超过阈值(默认 128ms) 时,NTP 认为渐进调整太慢,会直接把时钟拨到正确时间

复制代码
服务器时钟: 12:00:00 ──→ 【直接跳到】──→ 12:00:45
情况 NTP 行为 是否跳跃
偏差 < 128ms slew 渐进调整 ❌ 不跳
偏差 > 128ms step 强制拨正 ✅ 跳跃
人工改时间 立即生效 ✅ 跳跃
VM 暂停恢复 step ✅ 跳跃
触发时钟跳跃的常见场景
  • 虚拟机被暂停后恢复(如备份快照),恢复后时钟还停在暂停前
  • 云服务器热迁移,Guest 时钟短暂冻结
  • NTP 上游时间源波动
  • 运维手动执行 date 命令调整时间
真实时间线案例
复制代码
12:00:00  Redis 节点3 收到锁请求,TTL = 30秒,预计 12:00:30 过期
12:00:10  VM 被宿主机暂停(触发备份快照)
12:00:50  VM 恢复,系统时钟还显示 12:00:10
12:00:51  NTP 发现偏差 40 秒,执行 step,时钟直接跳到 12:00:51
12:00:51  Redis 检查 TTL:当前时间 12:00:51 > 过期时间 12:00:30
           → 锁立即过期删除!
           → 实际上才过了 10 秒,客户端 A 还以为锁有效
导致的问题
复制代码
1. 客户端 A 在节点1、2、3 加锁成功,TTL = 30秒
2. 节点3 发生时钟跳跃 → TTL 提前过期,锁被自动删除
3. 节点3 变为空闲,客户端 B 在节点3、4、5 加锁成功
4. 结果:
     节点1: A的锁 ✅
     节点2: A的锁 ✅
     节点3: B的锁 ✅  (A 的锁已过期)
     节点4: B的锁 ✅
     节点5: B的锁 ✅
   A 持有 2 个节点,不足过半,但 A 不知道节点3 已失效
   B 持有 3 个节点,过半,B 认为自己拿到了锁
   两个客户端同时进入临界区 💥

致命问题二:GC STW 导致锁失效

复制代码
1. 客户端 A 获得红锁
2. A 发生 GC Stop-the-World,暂停 30 秒
3. 红锁 TTL 过期,客户端 B 获取锁
4. A 的 GC 结束,A 以为自己还持有锁
5. A 和 B 同时操作临界资源 💥

ZooKeeper 同样存在 GC 问题,但可配合 fencing token(每次加锁返回递增 token,写入资源时验证 token)来解决。

没成主流的现实原因

原因 说明
部署成本高 需要 5 个完全独立的 Redis 实例,不能是主从
性能差 要串行请求 5 个节点,延迟叠加
正确性存疑 理论上无法完全避免时钟和 GC 问题
替代方案成熟 强一致用 ZooKeeper/etcd,普通场景用单机 Redis

红锁处于一个尴尬位置:比单机 Redis 锁复杂很多,却达不到 ZooKeeper 的可靠性,两头不讨好。


五、选型总结

复制代码
追求性能、业务允许小概率锁失效  →  Redis 单机锁(+ Watchdog 续期)
追求正确性、金融/强一致场景      →  ZooKeeper / etcd
需要多 Redis 节点但又不想用 ZK   →  慎用 RedLock,需充分评估风险

核心结论

  • Redis 锁是"乐观的",靠 TTL 兜底,适合高并发但能容忍极小概率异常的场景
  • ZooKeeper 锁是"悲观的",靠 CP 强一致保证正确,但性能代价更高
  • RedLock 的本质缺陷在于:各 Redis 节点相互独立,无法在时钟漂移和进程暂停面前提供真正的安全保证
  • 分布式系统中有一条重要原则:不要依赖时钟做正确性保证
相关推荐
Knight_AL2 小时前
从 0 到 1:PG WAL → Debezium → Kafka → Spring Boot → Redis
spring boot·redis·kafka
冷小鱼2 小时前
Redis 技术全景解析:从缓存基石到 AI 时代的数据引擎
数据库·redis·缓存
iwS2o90XT2 小时前
仿写一个简化版Redis,理解内存数据库
数据库·redis·缓存
无籽西瓜a2 小时前
【西瓜带你学Kafka | 第六期】Kafka 生产确认、消费 API 与分区分配策略(文含图解)
java·分布式·后端·kafka·消息队列·mq
苏渡苇2 小时前
Redis 核心数据结构(三)——Hash,把一堆字段塞进一个 Key
数据结构·redis·redis hash·redis hset
紧固视界2 小时前
分布式光伏系统中紧固件选型与应用解析_2026上海紧固件专业展
分布式·上海紧固件展·紧固件展·上海紧固件专业展
Maiko Star2 小时前
Spring AI 多轮对话记忆(ChatMemory)保姆级教程:从内存版到 Redis 持久化
java·redis·spring
Thanks_ks2 小时前
穿透海量数据的迷雾:深入理解布隆过滤器的架构哲学与工程权衡
redis·高并发·缓存穿透·架构设计·布隆过滤器·分布式系统·海量数据
无籽西瓜a2 小时前
【西瓜带你学Kafka | 第七期】Kafka 日志存储体系:保留清理、消息格式与分段刷新策略(文含图解)
java·分布式·后端·kafka·消息队列·mq