1. 引言
在分布式系统中,多个服务节点往往需要访问共享资源,例如数据库记录、缓存数据或定时任务。如果缺乏有效的互斥机制,多个节点同时操作同一份数据,很容易引发数据不一致、重复执行等问题。分布式锁正是为了解决这类跨进程、跨节点的资源竞争问题而诞生的。
ZooKeeper 作为一款成熟的分布式协调服务,天然具备创建临时顺序节点、监听节点变化等能力,非常适合用来实现分布式锁。本文将从 ZooKeeper 的数据模型讲起,逐步分析基于临时顺序节点实现分布式锁的原理,并给出完整的 Java 代码示例。
2. ZooKeeper 基础回顾
在深入分布式锁之前,先简单回顾 ZooKeeper 的几个核心概念,这些概念是理解锁实现的基础。
2.1 数据模型
ZooKeeper 的命名空间类似于一个文件系统,每个节点称为 ZNode。ZNode 既可以存储少量数据,也可以拥有子节点。常见的节点类型包括:
- 持久节点:创建后一直存在,除非显式删除。
- 临时节点:会话结束或超时后自动删除。
- 顺序节点:创建时自动追加一个单调递增的序号。
分布式锁主要利用临时节点和顺序节点的组合特性。
2.2 Watcher 机制
Watcher 是 ZooKeeper 提供的事件监听机制。客户端可以针对某个节点注册监听,当该节点发生变化(如子节点新增、删除、数据变更)时,ZooKeeper 会向客户端推送通知。这一机制是锁等待与唤醒的关键。
3. 分布式锁的实现原理
基于 ZooKeeper 实现分布式锁,最经典的方式是使用临时顺序节点,整体思路可以概括为以下几步:
- 客户端在锁目录下创建一个临时顺序节点,例如
/locks/lock-0000000001。 - 客户端获取锁目录下的所有子节点,并按照序号排序。
- 如果自己创建的节点序号最小,说明获取锁成功。
- 如果自己不是最小序号,则监听前一个节点的删除事件,并进入等待状态。
- 当前一个节点被删除(即前一个客户端释放锁或会话超时)时,客户端被唤醒,再次检查自己是否成为最小序号。
这种方案被称为「顺序临时节点 + 前驱监听」模式,它避免了惊群效应:每个客户端只监听自己前一个节点,而不是监听所有节点。
下面用一张流程图来直观展示加锁与解锁的完整过程:
4. Java 代码实现
下面使用 Apache Curator 框架来实现分布式锁。Curator 对 ZooKeeper 的底层 API 做了很好的封装,其中 InterProcessMutex 就是基于上述原理实现的互斥锁。
4.1 引入依赖
首先在 Maven 项目中引入 Curator 相关依赖:
xml
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.5.0</version>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
<version>5.5.0</version>
</dependency>
4.2 使用 InterProcessMutex 加锁
下面是一个完整的示例,演示如何创建 ZooKeeper 客户端并使用分布式锁保护临界区:
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 ZooKeeperLockDemo {
private static final String ZK_ADDRESS = "127.0.0.1:2181";
private static final String LOCK_PATH = "/locks/my-lock";
public static void main(String[] args) throws Exception {
// 1. 创建 ZooKeeper 客户端,带重试策略
CuratorFramework client = CuratorFrameworkFactory.newClient(
ZK_ADDRESS,
new ExponentialBackoffRetry(1000, 3));
client.start();
// 2. 创建分布式锁
InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
try {
// 3. 获取锁
if (lock.acquire(10, java.util.concurrent.TimeUnit.SECONDS)) {
System.out.println("获取锁成功,开始执行业务逻辑");
// 模拟业务处理
Thread.sleep(3000);
} else {
System.out.println("获取锁超时");
}
} finally {
// 4. 释放锁
lock.release();
client.close();
}
}
}
4.3 关键点说明
- 重试策略 :
ExponentialBackoffRetry会在连接失败时按指数退避重试,避免瞬时抖动导致会话中断。 - 锁路径 :
LOCK_PATH是锁在 ZooKeeper 中的根路径,Curator 会在该路径下自动创建临时顺序节点。 - 超时控制 :
acquire方法支持传入超时时间,避免因 ZooKeeper 不可用而无限阻塞。 - 释放锁 :
release方法会删除当前客户端创建的临时节点,从而唤醒等待中的下一个客户端。
5. 常见问题与注意事项
5.1 会话超时与锁自动释放
由于锁节点是临时节点,当客户端与 ZooKeeper 的会话超时或客户端崩溃时,临时节点会自动删除,锁会被自动释放。这避免了死锁问题,但也带来一个隐患:如果业务执行时间过长,超过会话超时时间,锁可能被提前释放,导致其他客户端同时进入临界区。因此,需要合理设置会话超时时间,并尽量缩短业务执行时间。
5.2 惊群效应
如果所有等待锁的客户端都监听锁目录下的全部子节点,那么一旦锁被释放,所有客户端都会被唤醒,但只有一个能抢到锁,其余客户端需要再次进入等待,这就是惊群效应。本文介绍的「监听前一个节点」方案,每个客户端只关注自己的前驱节点,能够有效避免惊群效应。
5.3 锁的可重入性
Curator 的 InterProcessMutex 支持可重入,即同一个客户端在持有锁的情况下可以再次获取同一把锁。这在递归调用或嵌套业务场景中非常有用。
6. 总结
ZooKeeper 分布式锁通过临时顺序节点和 Watcher 机制,实现了跨节点的互斥访问。相比 Redis 分布式锁,ZooKeeper 方案在锁的自动释放和一致性方面更加可靠,但部署和维护 ZooKeeper 集群的成本也更高。在实际项目中,应根据业务场景、可用性和性能要求,选择合适的分布式锁方案。
如果希望进一步了解 Curator 的更多锁类型,例如读写锁 InterProcessReadWriteLock 或信号量 InterProcessSemaphoreMutex,可以参考官方文档继续深入。