如何使用 ZooKeeper 实现分布式锁?
使用 ZooKeeper 实现分布式锁是一个非常经典和可靠的应用场景。下面我将从核心原理、具体实现步骤、代码示例以及优缺点分析四个方面来详细讲解。
一、核心原理:利用临时顺序节点
ZooKeeper 实现分布式锁的核心是利用其 临时顺序节点 的特性。
- 临时节点:客户端与 ZooKeeper 的会话断开后,该节点会自动被删除。这可以防止客户端崩溃后锁无法释放(死锁)。
- 顺序节点:在创建节点时,ZooKeeper 会自动在路径后面追加一个单调递增的序号(如
/lock/lock-0000000001,/lock/lock-0000000002)。
基于以上两点,我们可以设计如下的锁获取逻辑:
- 锁目录:在 ZooKeeper 中创建一个持久节点作为锁的根目录,例如
/locks/my_lock。 - 获取锁:所有客户端尝试在
/locks/my_lock下创建一个临时顺序节点。 - 判断序号:客户端创建节点后,获取
/locks/my_lock下的所有子节点,并判断自己创建的节点是否是序号最小的那个。- 如果是最小的:则成功获取锁,执行业务逻辑。
- 如果不是最小的:说明有其他客户端持有锁。此时,当前客户端需要监听它前一个(序号比它小的最大节点)节点的删除事件。
- 等待与唤醒:如果前一个节点被删除了(意味着持有锁的客户端释放了锁或会话超时),ZooKeeper 会通知监听它的客户端。客户端收到通知后,重新执行“判断序号”的逻辑,检查自己是否成为了序号最小的节点。
- 释放锁:业务逻辑执行完毕后,主动删除自己创建的临时顺序节点。或者,如果客户端崩溃,会话超时,ZooKeeper 也会自动删除该节点。
这种机制被称为 “羊群效应”优化 或 “公平锁” 的实现,因为每个客户端只监听它前面的一个节点,避免了大量客户端同时监听同一个节点而导致的惊群问题。
二、具体实现步骤(Curator 框架)
虽然可以原生 ZooKeeper API 实现,但过程较为繁琐。实际生产环境中,强烈推荐使用 Apache Curator 这个开源客户端,它提供了高层次的分布式锁封装。
以下是使用 Curator 实现分布式锁的步骤:
1. 添加 Maven 依赖
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version> <!-- 请使用最新版本 -->
</dependency>
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.8.0</version> <!-- 版本需与你的ZK服务器兼容 -->
</dependency>
2. 编写代码
java
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
public class ZkDistributedLockExample {
// ZooKeeper 连接字符串
private static final String ZK_ADDRESS = "localhost:2181";
// 锁的根路径
private static final String LOCK_PATH = "/my_app/locks/my_distributed_lock";
public static void main(String[] args) {
// 1. 创建 Curator 客户端
CuratorFramework client = CuratorFrameworkFactory.newClient(
ZK_ADDRESS,
new ExponentialBackoffRetry(1000, 3) // 重试策略:初始休眠1s,最多重试3次
);
client.start();
// 2. 创建分布式可重入互斥锁
InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
try {
// 3. 尝试获取锁
if (lock.acquire(10, TimeUnit.SECONDS)) { // 最多等待10秒获取锁
try {
// 4. 成功获取锁,执行业务逻辑
System.out.println(Thread.currentThread().getName() + " 成功获取锁,开始工作...");
// 模拟业务操作耗时
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " 工作完成,释放锁。");
} finally {
// 5. 务必在 finally 块中释放锁!
lock.release();
System.out.println(Thread.currentThread().getName() + " 锁已释放。");
}
} else {
System.out.println(Thread.currentThread().getName() + " 获取锁失败!");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 关闭客户端
client.close();
}
}
}
关键点解释:
InterProcessMutex:Curator 提供的分布式可重入互斥锁,就是我们上面描述的基于临时顺序节点实现的公平锁。acquire(timeout, unit):尝试获取锁。可以设置超时时间,避免无限期等待。release():必须在finally块中调用,以确保锁一定会被释放,防止死锁。- 可重入性:同一个线程可以多次
acquire而不会阻塞自己,但需要对应次数的release。
三、原生 ZooKeeper API 简要思路(了解即可)
如果不使用 Curator,使用原生 API,你需要手动处理以下步骤:
- 创建客户端:
ZooKeeper zk = new ZooKeeper(...); - 创建锁节点:
zk.create("/locks/my_lock/lock-", ... , CreateMode.EPHEMERAL_SEQUENTIAL),注意模式是EPHEMERAL_SEQUENTIAL。 - 获取所有子节点并排序:
List<String> children = zk.getChildren("/locks/my_lock", false);,然后对列表按序号排序。 - 判断自己是否是最小节点:
- 如果是,加锁成功。
- 如果不是,找到前一个节点(序号最大的且小于自己的节点)。
- 注册监听:对前一个节点注册
Watcher,监听NodeDeleted事件。 - 等待:在 Watcher 的
process方法中被唤醒后,回到第 3 步重新检查。 - 释放锁:
zk.delete(自己创建的节点路径, -1);
这个过程非常复杂且容易出错,因此强烈不推荐在生产环境使用原生 API 实现。
四、优缺点分析
优点:
- 高可靠性:基于 ZooKeeper 的集群特性,只要大多数节点存活,服务就可用。临时节点避免了死锁。
- 公平锁:通过顺序节点实现了先到先得的公平性,避免了“饥饿”现象。
- 高性能:Curator 等客户端做了大量优化,性能表现良好。
- 可重入性:Curator 的
InterProcessMutex支持可重入。
缺点:
- 复杂性:部署和维护 ZooKeeper 集群本身有一定成本。
- 性能开销:每次获取锁都需要在 ZooKeeper 中创建和删除节点,网络交互较多,性能通常低于基于 Redis 的 RedLock 算法(但在一致性要求极高的场景下,ZK 更可靠)。
- 会话超时问题:如果网络抖动导致客户端会话超时,锁会被意外释放,可能导致临界区代码执行到一半时被打断。需要业务层有幂等性或补偿机制来处理这种情况。
总结
使用 Apache Curator 的 InterProcessMutex 是在 ZooKeeper 上实现分布式锁的最佳实践。它隐藏了底层复杂的节点操作和 Watcher 管理,提供了简洁、健壮且功能丰富的 API。在选择分布式锁方案时,如果对一致性和可靠性要求极高,ZooKeeper 是一个绝佳选择;如果追求极致性能且能接受一定的数据不一致风险,可以考虑基于 Redis 的方案。