🦓 Zookeeper实现分布式锁的原理:排队叫号的智慧!

59 阅读12分钟

📖 开场:银行排队叫号

想象你去银行办业务 🏦:

没有叫号系统(抢锁)

大家挤在柜台前 👥👥👥
谁抢到谁办业务
    ↓
秩序混乱!😱
不公平(后来的可能先办)❌

有叫号系统(Zookeeper)

1. 小明进门 → 取号001 🎫
2. 小红进门 → 取号002 🎫
3. 小张进门 → 取号003 🎫
    ↓
叫号:001号请到1号窗口 📢
    ↓
小明办业务 ✅
    ↓
小明办完 → 叫号:002号请到1号窗口 📢
    ↓
小红办业务 ✅

这就是Zookeeper分布式锁的原理:排队叫号!

特点

  • 顺序号:按顺序分配
  • 公平:先来先得
  • 自动删除:离开自动取消号码

🤔 Zookeeper基础

Zookeeper是什么?

Zookeeper = 分布式协调服务

核心功能

  1. 配置管理:统一配置中心
  2. 命名服务:分布式命名服务
  3. 分布式锁:分布式同步 ⭐
  4. 集群管理:选举、监控

Zookeeper的数据模型

类似文件系统的树形结构

/
├── app
│   ├── config
│   └── locks  ← ⭐ 分布式锁节点
│       ├── stock-lock
│       │   ├── _c_001  (临时顺序节点)
│       │   ├── _c_002  (临时顺序节点)
│       │   └── _c_003  (临时顺序节点)
│       └── order-lock
│           ├── _c_001
│           └── _c_002
└── service
    ├── server1
    └── server2

Zookeeper的节点类型

节点类型说明特点
持久节点(PERSISTENT)创建后一直存在需要手动删除
临时节点(EPHEMERAL)会话结束后自动删除⭐ 分布式锁的关键
持久顺序节点(PERSISTENT_SEQUENTIAL)持久 + 自动编号编号递增
临时顺序节点(EPHEMERAL_SEQUENTIAL)临时 + 自动编号⭐⭐⭐ 分布式锁使用

Watch机制

Watch = 监听机制

Client注册Watch → 监听节点变化
    ↓
节点发生变化(删除、修改)
    ↓
Zookeeper通知Client 📢
    ↓
Client收到通知,采取行动

特点

  • 一次性触发(触发后失效)
  • 异步通知
  • 分布式锁的核心机制

🎯 Zookeeper分布式锁原理

基本原理

锁目录:/locks/stock-lock

步骤1:Client1创建临时顺序节点
    /locks/stock-lock/_c_0000000001

步骤2:Client2创建临时顺序节点
    /locks/stock-lock/_c_0000000002

步骤3:Client3创建临时顺序节点
    /locks/stock-lock/_c_0000000003

步骤4:各Client获取/locks/stock-lock下的所有子节点
    [_c_0000000001, _c_0000000002, _c_0000000003]

步骤5:判断自己是否是序号最小的节点
    - Client1:我是0000000001(最小)→ 获取锁成功 ✅
    - Client2:我是0000000002(不是最小)→ 监听0000000001
    - Client3:我是0000000003(不是最小)→ 监听0000000002

步骤6:Client1处理完业务,删除节点
    /locks/stock-lock/_c_0000000001(删除)

步骤7:Client2收到通知(0000000001被删除)
    重新判断 → 我是最小的了 → 获取锁成功 ✅

步骤8:Client2处理完业务,删除节点
    /locks/stock-lock/_c_0000000002(删除)

步骤9:Client3收到通知
    重新判断 → 我是最小的了 → 获取锁成功 ✅

为什么监听前一个节点?

错误方案:所有Client监听锁目录

/locks/stock-lock
    ├── _c_001 (Client1)
    ├── _c_002 (Client2) → 监听/locks/stock-lock
    ├── _c_003 (Client3) → 监听/locks/stock-lock
    ├── _c_004 (Client4) → 监听/locks/stock-lock
    └── ... (100个Client都监听)

问题:
Client1删除节点 → 通知所有100个Client 📢📢📢
    ↓
100个Client同时醒来 → 竞争锁
    ↓
只有Client2获取成功,其他99个继续等待
    ↓
惊群效应(Herd Effect)!😱

正确方案:每个Client监听前一个节点

/locks/stock-lock
    ├── _c_001 (Client1)
    ├── _c_002 (Client2) → 监听_c_001
    ├── _c_003 (Client3) → 监听_c_002
    ├── _c_004 (Client4) → 监听_c_003
    └── ...

优点:
Client1删除节点 → 只通知Client2 📢
    ↓
Client2获取锁
    ↓
Client2删除节点 → 只通知Client3 📢
    ↓
避免惊群效应!✅

🔧 Curator实现

Curator简介

Curator = Apache开源的Zookeeper客户端框架

优势

  • 封装了Zookeeper的复杂API
  • 提供了分布式锁的实现
  • 久经考验,生产级别

依赖配置

<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.2.1</version>
</dependency>

配置Curator

@Configuration
public class ZookeeperConfig {
    
    @Bean
    public CuratorFramework curatorFramework() {
        // ⭐ 重试策略:最多重试3次,每次间隔1秒
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        
        // ⭐ 创建Curator客户端
        CuratorFramework client = CuratorFrameworkFactory.builder()
            .connectString("localhost:2181")  // Zookeeper地址
            .sessionTimeoutMs(5000)           // 会话超时时间
            .connectionTimeoutMs(5000)        // 连接超时时间
            .retryPolicy(retryPolicy)
            .namespace("app")                 // 根节点(可选)
            .build();
        
        // ⭐ 启动客户端
        client.start();
        
        return client;
    }
}

使用InterProcessMutex(可重入锁)

@Service
@Slf4j
public class OrderService {
    
    @Autowired
    private CuratorFramework curatorFramework;
    
    /**
     * 扣减库存
     */
    public boolean deductStock(Long productId, int quantity) {
        String lockPath = "/locks/stock/" + productId;
        
        // ⭐ 创建可重入锁
        InterProcessMutex lock = new InterProcessMutex(curatorFramework, lockPath);
        
        try {
            // ⭐ 获取锁(阻塞等待)
            lock.acquire();
            
            log.info("获取锁成功,开始扣减库存");
            
            // ⭐ 业务逻辑
            int stock = getStock(productId);
            if (stock < quantity) {
                log.warn("库存不足");
                return false;
            }
            
            setStock(productId, stock - quantity);
            log.info("扣减库存成功");
            
            return true;
            
        } catch (Exception e) {
            log.error("处理失败", e);
            return false;
        } finally {
            // ⭐ 释放锁
            try {
                lock.release();
                log.info("释放锁成功");
            } catch (Exception e) {
                log.error("释放锁失败", e);
            }
        }
    }
    
    /**
     * 扣减库存(带超时)
     */
    public boolean deductStockWithTimeout(Long productId, int quantity) {
        String lockPath = "/locks/stock/" + productId;
        InterProcessMutex lock = new InterProcessMutex(curatorFramework, lockPath);
        
        try {
            // ⭐ 尝试获取锁(最多等待3秒)
            if (!lock.acquire(3, TimeUnit.SECONDS)) {
                log.warn("获取锁超时");
                return false;
            }
            
            // 业务逻辑...
            
            return true;
            
        } catch (Exception e) {
            log.error("处理失败", e);
            return false;
        } finally {
            try {
                lock.release();
            } catch (Exception e) {
                log.error("释放锁失败", e);
            }
        }
    }
    
    private int getStock(Long productId) {
        // 从数据库读取库存
        return 100;
    }
    
    private void setStock(Long productId, int stock) {
        // 写入数据库
    }
}

InterProcessMutex源码分析

public class InterProcessMutex implements InterProcessLock {
    
    private final CuratorFramework client;
    private final String basePath;  // 锁的根路径,如 /locks/stock/1
    
    /**
     * ⭐ 获取锁
     */
    @Override
    public void acquire() throws Exception {
        // 调用internalLock,阻塞等待
        internalLock(-1, null);
    }
    
    /**
     * ⭐ 尝试获取锁(带超时)
     */
    @Override
    public boolean acquire(long time, TimeUnit unit) throws Exception {
        return internalLock(time, unit);
    }
    
    /**
     * ⭐ 核心方法:获取锁
     */
    private boolean internalLock(long time, TimeUnit unit) throws Exception {
        Thread currentThread = Thread.currentThread();
        
        // ⭐ 1. 检查是否可重入(同一线程)
        LockData lockData = threadData.get(currentThread);
        if (lockData != null) {
            // 可重入,计数器+1
            lockData.lockCount.incrementAndGet();
            return true;
        }
        
        // ⭐ 2. 创建临时顺序节点
        String ourPath = client.create()
            .creatingParentsIfNeeded()  // 创建父节点(如果不存在)
            .withProtection()           // 添加GUID前缀(防止网络问题导致重复创建)
            .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)  // 临时顺序节点
            .forPath(basePath + "/lock-");
        
        // ⭐ 3. 尝试获取锁
        boolean hasTheLock = false;
        boolean isDone = false;
        
        while (!isDone) {
            isDone = true;
            
            try {
                // ⭐ 4. 获取所有子节点并排序
                List<String> children = getSortedChildren();
                
                // ⭐ 5. 判断是否是序号最小的节点
                String sequenceNodeName = ourPath.substring(basePath.length() + 1);
                int ourIndex = children.indexOf(sequenceNodeName);
                
                if (ourIndex == 0) {
                    // ⭐ 我是最小的,获取锁成功 ✅
                    hasTheLock = true;
                } else {
                    // ⭐ 不是最小的,监听前一个节点
                    String previousNodeName = children.get(ourIndex - 1);
                    String previousNodePath = basePath + "/" + previousNodeName;
                    
                    // ⭐ 6. Watch前一个节点
                    Stat stat = client.checkExists()
                        .usingWatcher(watcher)  // 设置监听器
                        .forPath(previousNodePath);
                    
                    if (stat != null) {
                        // 前一个节点还在,等待通知
                        if (time < 0) {
                            // 无限等待
                            wait();
                        } else {
                            // 超时等待
                            wait(unit.toMillis(time));
                        }
                        
                        // 被唤醒,重新检查
                        isDone = false;
                    }
                    // 如果stat == null,说明前一个节点已删除,重新循环
                }
                
            } catch (KeeperException.NoNodeException e) {
                // 节点不存在,重新尝试
                isDone = false;
            }
        }
        
        if (hasTheLock) {
            // ⭐ 获取锁成功,记录线程信息
            LockData newLockData = new LockData(currentThread, ourPath);
            threadData.put(currentThread, newLockData);
        }
        
        return hasTheLock;
    }
    
    /**
     * ⭐ 释放锁
     */
    @Override
    public void release() throws Exception {
        Thread currentThread = Thread.currentThread();
        LockData lockData = threadData.get(currentThread);
        
        if (lockData == null) {
            throw new IllegalMonitorStateException("当前线程未持有锁");
        }
        
        // ⭐ 可重入计数器-1
        int newLockCount = lockData.lockCount.decrementAndGet();
        
        if (newLockCount > 0) {
            // 还有重入,不释放锁
            return;
        }
        
        if (newLockCount < 0) {
            throw new IllegalMonitorStateException("锁计数器异常");
        }
        
        // ⭐ 删除临时顺序节点(释放锁)
        try {
            client.delete().guaranteed().forPath(lockData.lockPath);
        } finally {
            threadData.remove(currentThread);
        }
    }
}

临时节点的自动删除

关键特性:客户端断开连接,临时节点自动删除 ⭐⭐⭐

场景:服务器宕机

Client1获取锁 → 创建临时节点 /locks/stock/1/_c_001
    ↓
Client1处理业务中...
    ↓
【Client1所在服务器宕机】💀
    ↓
Zookeeper检测到会话断开
    ↓
自动删除 /locks/stock/1/_c_001 ✅
    ↓
Client2收到通知 → 获取锁成功 ✅

优势

  • 不会出现死锁(Redis需要设置过期时间)
  • 自动故障恢复

📊 Zookeeper vs Redis 分布式锁

特性ZookeeperRedis
可靠性⭐⭐⭐ 非常高(CP)⭐⭐ 较高(AP)
性能⭐⭐ 中等(磁盘IO)⭐⭐⭐ 高(内存)
实现复杂度⭐⭐⭐ 较复杂⭐⭐ 中等
公平性✅ 公平锁(先来先得)❌ 非公平锁
自动删除✅ 临时节点自动删除⚠️ 需要设置过期时间
死锁风险❌ 不会死锁⚠️ 可能死锁(过期时间设置不当)
主从切换✅ 强一致性,不会丢锁⚠️ 可能丢锁
阻塞等待✅ Watch机制,不浪费CPU⚠️ 轮询或Pub/Sub
运维成本⭐⭐⭐ 高(需要Zookeeper集群)⭐⭐ 中(Redis常用)

🎯 Zookeeper分布式锁的优势

1️⃣ 强一致性(CP)

Zookeeper的CAP选择

  • C:一致性(Consistency)
  • P:分区容错性(Partition tolerance)
  • 牺牲:A(可用性)

优势

Client写入锁节点 → 必须写入大多数节点(Quorum)
    ↓
写入成功,返回 ✅
    ↓
保证所有Client看到的都是最新数据 ✅

对比Redis

Redis主从架构:
Client写入Master → 异步复制到Slave
    ↓
Master宕机 → Slave升级为Master
    ↓
可能丢失还没复制的数据 ❌

2️⃣ 公平锁

Zookeeper

Client按顺序获取锁

Client1(10:00:00)→ 001号 → 先获取 ✅
Client2(10:00:01)→ 002号 → 后获取 ✅
Client3(10:00:02)→ 003号 → 最后获取 ✅

公平:先来先得 ✅

Redis

Client同时竞争锁

Client1(10:00:00)→ 竞争
Client2(10:00:01)→ 竞争
Client3(10:00:02)→ 竞争
    ↓
谁先SETNX成功谁获取锁
    ↓
可能Client3先获取 ⚠️(不公平)

3️⃣ 自动故障恢复

临时节点自动删除

Client1获取锁 → 临时节点
    ↓
Client1宕机 💀
    ↓
会话断开 → 临时节点自动删除 ✅
    ↓
Client2自动获取锁 ✅

对比Redis

Client1获取锁 → 设置过期时间10秒
    ↓
Client1宕机(没有释放锁)💀
    ↓
等待10秒 → 锁自动过期 ⏰
    ↓
Client2才能获取锁 ⚠️(延迟10秒)

4️⃣ Watch机制(不浪费CPU)

Zookeeper

Client2监听前一个节点
    ↓
Client1删除节点 → Zookeeper通知Client2 📢
    ↓
Client2立即获取锁 ✅

不需要轮询,不浪费CPU ✅

Redis(自己实现)

while (true) {
    if (tryLock()) {
        break;  // 获取成功
    }
    Thread.sleep(100);  // 轮询,浪费CPU ❌
}

🎓 面试题速答

Q1: Zookeeper分布式锁的原理是什么?

A: 核心原理:临时顺序节点 + Watch机制

1. Client创建临时顺序节点(如 _c_001, _c_002)
2. 获取所有子节点并排序
3. 判断自己是否是序号最小的:
   - 是 → 获取锁成功 ✅
   - 否 → 监听前一个节点(Watch)
4. 前一个节点删除 → 收到通知 → 重新判断
5. 业务完成 → 删除自己的节点(释放锁)

比喻:银行排队叫号,先来先得,公平有序


Q2: 为什么要监听前一个节点而不是监听锁目录?

A: 避免惊群效应(Herd Effect)!

监听锁目录

100Client监听锁目录
    ↓
锁释放 → 通知100Client 📢📢📢
    ↓
100Client同时醒来竞争
    ↓
只有1个成功,99个继续等待
    ↓
浪费资源!❌

监听前一个节点

Client1(001)→ Client2(002)→ Client3(003)
         ↓监听        ↓监听        ↓监听
    
001删除 → 只通知002 📢
    ↓
002获取锁 ✅

Q3: Zookeeper分布式锁如何避免死锁?

A: 临时节点自动删除!

Client获取锁 → 创建临时节点
    ↓
Client宕机/断开连接 💀
    ↓
Zookeeper检测到会话断开
    ↓
自动删除临时节点 ✅
    ↓
下一个Client自动获取锁 ✅

对比Redis:需要设置过期时间,如果时间设置不当可能死锁


Q4: Zookeeper分布式锁是公平锁还是非公平锁?

A: 公平锁!

Client按照创建节点的顺序获取锁

Client1(10:00:00)→ _c_001 → 先获取 ✅
Client2(10:00:01)→ _c_002 → 后获取 ✅
Client3(10:00:02)→ _c_003 → 最后获取 ✅

先来先得,绝对公平 ✅

对比Redis:非公平锁,谁先SETNX成功谁获取


Q5: Zookeeper分布式锁有什么缺点?

A: 三个主要缺点

  1. 性能较差

    • 磁盘IO(Redis是内存)
    • 吞吐量较低
  2. 实现复杂

    • 需要理解Zookeeper机制
    • 代码比Redis复杂
  3. 运维成本高

    • 需要部署和维护Zookeeper集群
    • 如果没有Zookeeper,需要额外引入

适用场景

  • 对可靠性要求极高(金融、交易)
  • 需要公平锁
  • 已有Zookeeper集群

Q6: 什么时候选择Zookeeper而不是Redis?

A: 三种场景

  1. 对可靠性要求极高

    • 金融交易、支付
    • 绝对不能容忍丢锁
  2. 需要公平锁

    • 按顺序处理任务
    • 先来先得的场景
  3. 已有Zookeeper集群

    • 不需要额外引入中间件
    • 充分利用现有资源

其他场景推荐Redis

  • 高并发、高性能场景(秒杀、抢购)
  • 对可靠性要求不是极高
  • 没有Zookeeper集群

🎬 总结

     Zookeeper分布式锁完整流程

┌─────────────────────────────────────────┐
 Client1: 创建 /locks/stock/1/_c_001     
 Client2: 创建 /locks/stock/1/_c_002     
 Client3: 创建 /locks/stock/1/_c_003     
└─────────────┬───────────────────────────┘
              
               判断序号
┌─────────────────────────────────────────┐
 Client1: 001最小  获取锁             
 Client2: 002不是最小  监听001          
 Client3: 003不是最小  监听002          
└─────────────┬───────────────────────────┘
              
               Client1业务完成
┌─────────────────────────────────────────┐
 Client1: 删除001  通知Client2 📢       
└─────────────┬───────────────────────────┘
              
               Client2醒来
┌─────────────────────────────────────────┐
 Client2: 002最小  获取锁             
 Client3: 继续监听002                    
└─────────────────────────────────────────┘

    公平 + 可靠 + 自动故障恢复 

🎉 恭喜你!

你已经完全掌握了Zookeeper分布式锁的原理!🎊

核心要点

  1. 临时顺序节点:自动编号,自动删除
  2. 监听前一个节点:避免惊群效应
  3. 公平锁:先来先得
  4. 强一致性:不会丢锁
  5. 自动故障恢复:临时节点自动删除

下次面试,这样回答

"Zookeeper分布式锁基于临时顺序节点和Watch机制实现。

Client创建临时顺序节点,判断自己是否是序号最小的,是则获取锁,否则监听前一个节点。前一个节点删除时收到通知,重新判断。

它有三个主要优势:一是强一致性,不会像Redis主从切换时丢锁;二是公平锁,严格按照先来先得的顺序;三是自动故障恢复,Client宕机时临时节点自动删除,下一个Client自动获取锁。

但Zookeeper性能较Redis差,因为是磁盘IO。我们项目中秒杀系统使用Redis锁,金融交易系统使用Zookeeper锁,根据场景选择。"

面试官:👍 "很好!你对Zookeeper分布式锁理解很透彻!"


本文完 🎬

上一篇: 196-Redis实现分布式锁的细节和问题.md
下一篇: 198-分布式事务的解决方案.md

作者注:写完这篇,我都想去银行当叫号员了!🎫
如果这篇文章对你有帮助,请给我一个Star⭐!