在单机程序里,synchronized、ReentrantLock 或数据库事务通常足够解决并发互斥问题。但一旦服务被部署成多个实例,锁就不能只存在于某个 JVM 进程内。例如:
- 定时任务被 3 个实例同时触发,但只能有一个实例真正执行;
- 多个消费者竞争处理同一批资源,必须保证同一资源不会被重复处理;
- 灰度发布或主备切换时,需要临时选出一个“当前负责人”。
这类问题的关键不是“写一个锁对象”,而是让多个进程都能看到同一个互斥状态,并且在持锁进程宕机、网络抖动、重启后能够自动恢复。ZooKeeper 正适合这类协调问题:它提供层级节点、临时节点、顺序节点、Watcher 通知和会话机制,可以用较小的工程复杂度实现可靠的分布式锁。
需要先限定边界:ZooKeeper 分布式锁适合协调控制面任务,例如调度、选主、配置切换、低频资源互斥。它不适合高频、低延迟的数据面加锁;如果每个业务请求都要抢锁,吞吐和延迟通常会成为问题。
核心原理
ZooKeeper 的数据结构类似文件系统,每个路径都是一个 znode。实现分布式锁时,常用方案是“临时顺序节点”:
- 所有客户端在同一个锁目录下创建临时顺序节点,例如
/locks/job-lock/lock-0000000012。 - 客户端读取该目录下所有子节点,并按序号排序。
- 如果自己创建的节点序号最小,就获得锁。
- 如果不是最小节点,就监听自己前一个节点的删除事件。
- 前一个节点被删除后,再次检查自己是否变成最小节点。
- 释放锁时删除自己的节点;如果客户端崩溃,会话过期后临时节点也会被 ZooKeeper 删除。
这里有几个设计点很重要。
第一,使用“顺序节点”可以天然形成排队顺序,避免所有客户端争抢同一个固定节点。
第二,只监听前一个节点,而不是监听整个锁目录,可以降低通知风暴。如果所有等待者都监听父目录,任何一个节点变化都会唤醒大量客户端,这就是常说的“羊群效应”。
第三,使用“临时节点”可以处理进程异常退出。只要客户端会话最终过期,节点会被清理,后续等待者就能继续推进。但这也意味着:如果持锁客户端发生长时间 GC、网络分区或阻塞,业务层不能假设“只要代码没调用 unlock 就一定还持有锁”。锁的正确性依赖会话有效性,关键操作还应配合幂等、状态校验或 fencing token。
本地环境准备
下面用 Docker 启动一个本地 ZooKeeper。生产环境应使用独立集群、持久化数据目录、监控和合适的会话超时配置;本地示例只用于开发验证。
docker run --rm --name zk-dev \
-p 2181:2181 \
-e ZOO_TICK_TIME=2000 \
zookeeper:3.9
验证端口是否可连接:
printf ruok | nc 127.0.0.1 2181
如果返回 imok,说明服务已响应四字命令。部分环境可能禁用了四字命令,此时可直接运行后面的 Java 示例验证连接。
Maven 依赖
Java 项目中可以使用 Apache Curator,它封装了 ZooKeeper 客户端连接、重试、锁和选主等常见模式。下面示例使用 curator-recipes 中的 InterProcessMutex,避免手写 Watcher 细节。
<dependencies>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>5.6.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.13</version>
</dependency>
</dependencies>
版本号不是唯一选择;实际项目应与现有依赖树、JDK 版本和安全扫描结果统一管理。
可执行示例:只允许一个实例执行任务
下面的程序从环境变量读取 ZooKeeper 地址,启动后尝试获取锁。你可以开两个终端同时运行,观察只有一个进程进入临界区。
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;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
public class DistributedJobLockDemo {
private static final String LOCK_PATH = "/locks/report-job";
public static void main(String[] args) throws Exception {
String zkAddress = System.getenv().getOrDefault("ZK_ADDRESS", "127.0.0.1:2181");
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(zkAddress)
.sessionTimeoutMs(15_000)
.connectionTimeoutMs(5_000)
.retryPolicy(new ExponentialBackoffRetry(1_000, 3))
.build();
client.start();
InterProcessMutex lock = new InterProcessMutex(client, LOCK_PATH);
boolean acquired = false;
try {
acquired = lock.acquire(10, TimeUnit.SECONDS);
if (!acquired) {
System.out.println("No lock acquired, skip this round: " + LocalDateTime.now());
return;
}
System.out.println("Lock acquired, running job: " + LocalDateTime.now());
runCriticalJob();
System.out.println("Job finished: " + LocalDateTime.now());
} finally {
if (acquired) {
lock.release();
}
client.close();
}
}
private static void runCriticalJob() throws InterruptedException {
TimeUnit.SECONDS.sleep(8);
}
}
运行方式:
export ZK_ADDRESS=127.0.0.1:2181
mvn -q compile exec:java -Dexec.mainClass=DistributedJobLockDemo
再开一个终端执行同样命令。第一个进程持锁期间,第二个进程会等待;如果 10 秒内拿不到锁,就跳过本轮。对于定时任务,这通常比无限等待更安全,因为无限等待可能导致任务堆积。
工程化改造建议
上面的示例能跑通,但生产中还需要补齐几个边界。
1. 临界区必须幂等
分布式锁不是事务。持锁进程可能在完成外部副作用后崩溃,例如已经写入数据库、发送消息,但还没更新任务状态。因此临界区里的操作要具备幂等能力。
一种常见做法是在业务表里记录任务批次号和状态:
CREATE TABLE job_execution (
job_name VARCHAR(128) NOT NULL,
batch_id VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP NULL,
PRIMARY KEY (job_name, batch_id)
);
执行前先插入唯一批次记录。如果插入失败,说明同一批次已经被处理或正在处理;如果进程中断,可以由补偿任务根据状态和时间窗口决定是否重试。
2. 设置合理的等待时间
lock.acquire(10, TimeUnit.SECONDS) 中的 10 秒不是固定标准。它应根据任务频率、可接受延迟和实例数量决定:
- 高频任务:等待时间应短,拿不到就快速跳过;
- 低频关键任务:可以等待更久,但要避免任务堆积;
- 用户请求链路:通常不建议依赖 ZooKeeper 锁阻塞请求。
3. 区分连接丢失和业务失败
当 ZooKeeper 连接异常时,业务代码不应简单地继续执行临界区。更稳妥的策略是:获取锁失败就不执行;执行中如果发现会话状态异常,则尽快停止可中断工作,并依赖幂等机制重新调度。
Curator 可以监听连接状态:
client.getConnectionStateListenable().addListener((curator, newState) -> {
System.out.println("ZooKeeper connection state changed: " + newState);
});
日志只是第一步。对关键任务,应将状态变化接入监控告警,并在业务循环中检查停止标记。
4. 不要把锁路径设计得过粗
如果所有任务都抢 /locks/global,系统会出现不必要的串行化。锁路径应贴近资源粒度,例如:
/locks/report/daily-sales
/locks/report/monthly-billing
/locks/import/customer-2026-08-01
粒度越细,并发越好;粒度越粗,逻辑越简单。选择时要以资源冲突边界为准,而不是为了省路径。
常见问题
ZooKeeper 锁能保证绝对不会重复执行吗?
不能这样理解。它能提供协调互斥能力,但真实系统还会受到网络分区、会话过期、进程暂停、外部系统超时等因素影响。关键业务必须结合幂等键、状态机、唯一约束或 fencing token,不能只依赖锁本身。
为什么不用 Redis set nx?
Redis 也能实现分布式锁,部署和性能上有不同取舍。ZooKeeper 的优势在于顺序节点、Watcher 和会话语义更适合协调类场景,例如选主、队列式等待和配置协调。Redis 更常见于缓存体系内的轻量互斥。选择哪一个取决于团队已有基础设施、可用性模型和业务风险。
临时节点会不会立刻删除?
客户端主动释放锁时会删除节点;客户端异常退出时,ZooKeeper 需要等会话超时后才会删除临时节点。因此会话超时时间过长会拖慢故障恢复,过短又可能在短暂抖动时误判客户端失联。生产配置需要结合网络质量、GC 行为和任务耗时压测后确定。
Watcher 通知丢了怎么办?
ZooKeeper 的典型用法不是“收到通知就直接相信状态”,而是“收到通知后重新读取状态”。即使发生连接变化,客户端恢复后也应重新检查节点列表和自身位置。使用成熟客户端库可以减少手写 Watcher 的错误,但仍要理解这个状态重读模型。
锁释放失败会怎样?
如果释放时连接异常,客户端可能无法立即删除节点。只要会话最终过期,临时节点会被清理。业务上要避免把释放失败当成任务失败的唯一依据,真正的任务状态应由业务存储记录。
总结
ZooKeeper 分布式锁的核心不是“远程版互斥锁”,而是基于临时顺序节点构建一套可恢复的协调机制。顺序节点负责排队,临时节点负责故障清理,Watcher 负责减少轮询,客户端库负责封装重复细节。
在工程落地时,最容易出问题的地方往往不在锁 API,而在临界区设计:任务是否幂等、锁粒度是否合理、会话异常时是否停止、业务状态能否恢复。把这些边界补齐后,ZooKeeper 才能从“能跑的锁示例”变成真正可靠的分布式协调组件。