用ZooKeeper实现分布式锁的完整案例与深度解析
📚 文章目录导读
- 为什么需要分布式锁?——从一个并发事故说起
- ZooKeeper实现分布式锁的核心原理
- 手写一个ZooKeeper分布式锁(Java完整代码)
- 关键细节:临时顺序节点、Watch机制与羊群效应
- 基准测试:ZooKeeper锁 vs Redis锁性能对比
- 常见问题与避坑指南(问答环节)
- 总结与最佳实践建议
为什么需要分布式锁?——从一个并发事故说起
假设你正在运营一个电商平台的秒杀系统,当1000个用户同时抢购仅剩10件的商品时,如果单机环境,使用synchronized或ReentrantLock就能轻松解决,但当你将应用扩展到3台服务器,情况就变了——每台服务器上的本地锁只能锁住本机的线程,无法限制其他服务器同时扣减库存。

这就是典型的分布式场景下的并发问题:多个进程(不同机器)需要对同一共享资源(如数据库中的库存记录)进行互斥访问。
传统方案的痛点:
- 数据库悲观锁(
SELECT ... FOR UPDATE):性能差,容易死锁 - Redis分布式锁(
SETNX):依赖过期时间,可能误删锁,主从切换时存在锁丢失风险 - 基于ZooKeeper的分布式锁:强一致性、可靠性高、无需担心锁超时误删
ZooKeeper实现分布式锁的核心原理
ZooKeeper实现分布式锁依赖两个关键特性:
1 临时顺序节点(EPHEMERAL_SEQUENTIAL)
- 临时性:当客户端会话断开后,节点自动删除,避免死锁
- 顺序性:每个节点自动获得一个递增的序号,如
lock-00000001
2 Watch机制
客户端可以监听某个节点的变化,一旦节点状态变更(如被删除),ZooKeeper会通知所有监听的客户端。
经典算法:独享锁(排他锁)
- 在ZooKeeper的指定路径下创建一个临时顺序节点(如
/locks/lock-00000001) - 获取当前路径下所有子节点,按序号排序
- 如果自己的节点序号最小,则获得锁
- 否则,监听自己的前一个节点(序号比自己小一的节点)的删除事件
- 当监听到前一个节点被删除,重复步骤2-3
优点:避免了“羊群效应”——每个客户端只监听前一个节点,而不是监听所有节点。
手写一个ZooKeeper分布式锁(Java完整代码)
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public class ZKDistributedLock implements AutoCloseable {
private static final String LOCK_ROOT = "/locks";
private final ZooKeeper zooKeeper;
private final String lockName;
private String currentPath;
private String waitPath;
private CountDownLatch latch;
public ZKDistributedLock(String connectString, String lockName) throws Exception {
this.lockName = lockName;
this.zooKeeper = new ZooKeeper(connectString, 3000, event -> {
if (event.getType() == Watcher.Event.EventType.None && event.getState() == Watcher.Event.KeeperState.SyncConnected) {
latch.countDown();
}
});
this.latch = new CountDownLatch(1);
latch.await(); // 等待连接建立
ensureRootPath();
}
private void ensureRootPath() throws KeeperException, InterruptedException {
Stat stat = zooKeeper.exists(LOCK_ROOT, false);
if (stat == null) {
zooKeeper.create(LOCK_ROOT, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
}
public boolean tryLock(long timeout, TimeUnit unit) throws Exception {
// 1. 创建临时顺序节点
currentPath = zooKeeper.create(LOCK_ROOT + "/" + lockName + "-", new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
long start = System.currentTimeMillis();
long waitMs = unit.toMillis(timeout);
// 2. 尝试获取锁
if (attemptLock()) {
return true;
}
// 3. 超时等待
long remaining = waitMs - (System.currentTimeMillis() - start);
while (remaining > 0) {
if (latch != null) {
latch.await(remaining, TimeUnit.MILLISECONDS);
}
if (attemptLock()) {
return true;
}
remaining = waitMs - (System.currentTimeMillis() - start);
}
return false;
}
private boolean attemptLock() throws KeeperException, InterruptedException {
List<String> children = zooKeeper.getChildren(LOCK_ROOT, false);
Collections.sort(children);
String currentShortPath = currentPath.substring(currentPath.lastIndexOf("/") + 1);
int index = children.indexOf(currentShortPath);
if (index == 0) {
// 当前节点是最小节点,获得锁
return true;
}
// 监听前一个节点
waitPath = LOCK_ROOT + "/" + children.get(index - 1);
Stat stat = zooKeeper.exists(waitPath, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted && latch != null) {
latch.countDown();
}
});
if (stat == null) {
// 前一个节点已不存在,重新尝试获取锁
return attemptLock();
}
return false;
}
@Override
public void close() throws Exception {
if (currentPath != null) {
zooKeeper.delete(currentPath, -1);
}
zooKeeper.close();
}
}
使用示例:
public class OrderService {
public void createOrder(String orderId) {
try (ZKDistributedLock lock = new ZKDistributedLock("localhost:2181", "order-lock")) {
boolean locked = lock.tryLock(5, TimeUnit.SECONDS);
if (locked) {
// 执行业务逻辑
System.out.println("获得锁,处理订单:" + orderId);
Thread.sleep(100);
} else {
System.out.println("获取锁超时,订单处理失败");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
关键细节:临时顺序节点、Watch机制与羊群效应
1 为什么使用临时节点而非持久节点?
- 如果客户端崩溃,临时节点自动删除,锁自动释放
- 避免因客户端宕机导致锁永远无法释放的死锁问题
2 Watch机制的正确使用
- 一次性触发:每个Watcher只能触发一次,触发后需要重新注册
- 监听前一个节点:只监听比自己的节点,避免“羊群效应”
- 节点删除事件:只有前一个节点被删除时,当前节点才可能获得锁
3 什么是羊群效应?
如果所有等待锁的客户端都监听根节点/locks的子节点变化,那么当锁释放时,ZooKeeper需要同时通知所有客户端,造成大量不必要的通知和竞争,而我们的算法中,每个客户端只监听前一个节点,锁释放时只通知下一个等待的客户端,复杂度从O(n)降到O(1)。
基准测试:ZooKeeper锁 vs Redis锁性能对比
| 对比项 | ZooKeeper分布式锁 | Redis分布式锁(Redlock) |
|---|---|---|
| 一致性模型 | 强一致性(ZAB协议) | 最终一致性(可能因主从切换丢失锁) |
| 锁超时处理 | 会话超时自动释放 | 必须设置合理过期时间 |
| 平均获取锁延迟 | 约5-15ms(ZooKeeper自身延迟) | 约1-5ms(内存操作) |
| 高并发吞吐量 | 约2000 QPS(单机ZooKeeper) | 约10000 QPS(单机Redis) |
| 可靠性 | 极高(3节点集群保证) | 中等(依赖Redis集群状态) |
选择建议:
- 对一致性要求极高的场景(如金融交易、库存扣减)→ 选择ZooKeeper
- 对性能要求极高且允许短暂不一致的场景(如社交点赞、缓存刷新)→ 选择Redis
常见问题与避坑指南(问答环节)
❓ Q1:ZooKeeper集群挂掉怎么办?
分布式锁本身依赖ZooKeeper的可用性,建议部署3或5节点集群,容忍少数节点故障,如果所有ZooKeeper节点不可用,业务方需要有降级策略(如:暂停写入、等待恢复)。
❓ Q2:客户端会话超时导致锁自动释放,但业务还没执行完怎么办?
这是ZooKeeper分布式锁的根本缺陷,解决方案:
- 合理设置会话超时时间(通常2-10秒)
- 为业务代码添加超时保护,确保业务在锁有效期内完成
- 使用“锁续约”机制(类似Redisson的Watch Dog)
❓ Q3:锁被其他客户端删除了怎么办?
我们的实现中只监听前一个节点,且只有自己持有锁时才执行删除,但需注意:如果客户端A的会话断开后锁自动删除,客户端B获取锁后,客户端A的代码还在继续执行——这时需要业务方做好幂等性校验。
❓ Q4:大量客户端同时争抢锁,ZooKeeper性能会下降吗?
由于每个客户端只监听前一个节点,ZooKeeper不需要广播通知,性能相对可控,但在极端情况下(如10万个客户端排队),ZooKeeper的节点创建和Watch注册仍会造成压力,建议通过“分段锁”降低竞争粒度。
总结与最佳实践建议
适用场景最佳实践
- 使用Curator框架:Apache Curator对ZooKeeper分布式锁进行了封装,提供了
InterProcessMutex等现成实现,无需重复造轮子 - 设定合理的会话超时:通常设为业务最大执行时间的2倍
- 配合业务重试机制:获取锁失败时,使用指数退避策略重试
- 警惕网络抖动:在网络不稳定的环境下,建议使用“可重入锁”或“读写锁”
何时考虑替代方案
- 如果业务对性能要求极高(>5000 QPS),考虑使用Redis分布式锁
- 如果业务允许短暂不一致(如缓存更新),直接使用数据库乐观锁即可
- 如果业务涉及跨数据中心部署,考虑使用Etcd(基于Raft协议)实现分布式锁
最后一点提醒:没有任何一种分布式锁方案是银弹,在选择技术方案时,请务必结合你的业务场景、团队技术栈、运维能力综合评估,ZooKeeper实现分布式锁虽然复杂但稳定可靠,适合对数据一致性有严苛要求的核心业务系统。