用 ZooKeeper 写一个可靠分布式锁:临时顺序节点、会话超时与故障恢复

简介: ZooKeeper分布式锁利用临时顺序节点与Watcher机制,实现高可靠、公平、自动释放的跨进程互斥控制,适用于调度、选主等低频协调场景,但不适用于高频数据面加锁。

在单机程序里,synchronized、ReentrantLock 或数据库事务通常足够解决并发互斥问题。但一旦服务被部署成多个实例,锁就不能只存在于某个 JVM 进程内。例如:

  • 定时任务被 3 个实例同时触发,但只能有一个实例真正执行;
  • 多个消费者竞争处理同一批资源,必须保证同一资源不会被重复处理;
  • 灰度发布或主备切换时,需要临时选出一个“当前负责人”。

这类问题的关键不是“写一个锁对象”,而是让多个进程都能看到同一个互斥状态,并且在持锁进程宕机、网络抖动、重启后能够自动恢复。ZooKeeper 正适合这类协调问题:它提供层级节点、临时节点、顺序节点、Watcher 通知和会话机制,可以用较小的工程复杂度实现可靠的分布式锁。

需要先限定边界:ZooKeeper 分布式锁适合协调控制面任务,例如调度、选主、配置切换、低频资源互斥。它不适合高频、低延迟的数据面加锁;如果每个业务请求都要抢锁,吞吐和延迟通常会成为问题。

核心原理

ZooKeeper 的数据结构类似文件系统,每个路径都是一个 znode。实现分布式锁时,常用方案是“临时顺序节点”:

  1. 所有客户端在同一个锁目录下创建临时顺序节点,例如 /locks/job-lock/lock-0000000012。
  2. 客户端读取该目录下所有子节点,并按序号排序。
  3. 如果自己创建的节点序号最小,就获得锁。
  4. 如果不是最小节点,就监听自己前一个节点的删除事件。
  5. 前一个节点被删除后,再次检查自己是否变成最小节点。
  6. 释放锁时删除自己的节点;如果客户端崩溃,会话过期后临时节点也会被 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 才能从“能跑的锁示例”变成真正可靠的分布式协调组件。

相关文章
|
3月前
|
机器学习/深度学习 人工智能 缓存
DeepSeek V4 Flash 对标 Gemini 3.6,AI 大跑毒时代
DeepSeek V4 Flash 0731 上线公测,智能指数追平 Gemini 3.6 Flash,价格仅其零头,拆解「大跑毒时代」谁先出局。
652 0
DeepSeek V4 Flash 对标 Gemini 3.6,AI 大跑毒时代
|
27天前
|
安全 Linux 芯片
【万字长文】Ollama下载、安装、配置、使用保姆级教程(2026最新图解)
Ollama 是一款免费开源的本地大模型运行工具,支持 DeepSeek、Qwen 等主流模型,一条命令即可下载运行。2026 最新版 v0.35.1 官方安装包(1.47GB)已提供国内直连下载,纯本地、离线可用、无广告、无隐私泄露风险。
2739 6
|
5月前
|
数据采集 人工智能 物联网
从入门到精通:提示工程、RAG、微调——大语言模型LLM应用开发的三层技术栈详解
2026年,大语言模型(LLM)已成为AI应用开发的核心基础设施,但很多开发者仍陷入“只会调用API”的困境:要么用提示词解决所有问题导致效果不稳定,要么盲目微调浪费算力,要么误用RAG增加系统复杂度。本质上,LLM应用开发并非单一技术的选择,而是**提示工程、RAG、微调**三层能力的分层构建与组合应用——提示工程解决“怎么问”,RAG解决“问什么”,微调解决“模型本身的认知边界”。三者分别对应交互层、知识层、能力层,从低成本快速迭代到深度定制优化,形成完整的LLM应用开发技术栈。本文从核心原理、适用场景、实战代码、选型决策四大维度,全面解析这三种技术的差异与协同,帮助开发者精准选型、高效落
528 0
|
8月前
|
NoSQL 关系型数据库 MySQL
分布式锁:底层原理全拆解 + 高可用方案深度对比,生产落地全指南
本文系统解析分布式锁的必要性、八大核心设计准则,并对比MySQL、Redis原生、Redisson、ZooKeeper、Etcd五种主流方案,涵盖原理、代码实现、优缺点及生产选型指南,助你规避超时、误释放、脑裂等典型坑点。
627 1
|
11月前
|
消息中间件 存储 Java
消息中间件RabbitMQ(高级)
本文深入探讨RabbitMQ在生产环境中的高级应用,涵盖消息可靠性、延迟消息、消息堆积及集群高可用等核心问题。通过生产者确认、持久化、消费者确认机制确保消息不丢失;利用TTL与死信交换机实现延迟队列;借助惰性队列提升堆积能力;最后通过普通集群、镜像集群及仲裁队列实现高可用架构。
 消息中间件RabbitMQ(高级)
|
9月前
|
机器学习/深度学习 调度
大模型微调参数设置 —— 新手必看的核心参数与优化技巧
本文系统解析大模型微调核心参数(Epochs、Learning Rate、Batch Size等),涵盖SFT/PPO/DPO场景,提供新手友好默认值、作用解读与实用调优技巧,强调“先跑通默认值、再针对性优化”原则,助你避开过拟合/欠拟合陷阱,零代码快速提升微调效果。
|
消息中间件 缓存 监控
MQ消息积压 / Rocketmq 积压 最全的处理方案。 (秒懂+图解+史上最全)
MQ消息积压 / Rocketmq 积压 最全的处理方案。 (秒懂+图解+史上最全)
MQ消息积压 / Rocketmq 积压 最全的处理方案。 (秒懂+图解+史上最全)
|
11月前
|
安全 Java 测试技术
Groovy 脚本语法全解析:从入门到精通的干货指南
本文全面介绍基于JVM的动态脚本语言Groovy,涵盖从基础语法到高级特性的完整知识体系。主要内容包括:Groovy环境搭建与Maven集成;基础语法(变量、数据类型、运算符、流程控制);核心特性(集合操作、方法定义、类与对象、闭包);高级特性(元编程、异常处理、文件操作);与Java的差异对比;以及自动化测试、数据迁移、Jenkins Pipeline等实战场景。文章通过大量可直接运行的代码示例,帮助开发者快速掌握Groovy在提高开发效率、简化代码方面的优势,同时提供性能优化建议和学习资源。
1315 2
|
安全 Linux PHP
Web渗透-命令执行漏洞-及常见靶场检测实战
命令执行漏洞(RCE)指应用程序调用系统命令时,用户可控制输入参数,导致恶意命令被拼接执行,从而危害系统安全。常见于PHP的system、exec等函数。攻击者可通过命令连接符在目标系统上执行任意命令,造成数据泄露或服务瘫痪。漏洞成因包括代码层过滤不严、第三方组件缺陷等。可通过参数过滤、最小权限运行等方式防御。本文还介绍了绕过方式、靶场测试及复现过程。
2272 0
|
缓存 监控 负载均衡
将近2万字的Dubbo原理解析,彻底搞懂dubbo
市面上有很多基于RPC思想实现的框架,比如有Dubbo。今天就从Dubbo的SPI机制、服务注册与发现源码及网络通信过程去深入剖析下Dubbo。
31172 9

热门文章

最新文章