基于 ZooKeeper 原生 API 实现分布式锁
大纲
前言
本文将基于 ZooKeeper 原生 API 实现分布式锁,包括公平锁和非公平锁两种类型。值得注意的是,文中给出的代码缺乏健壮性和可用性(尤其是对网络抖动、可重入、会话过期的处理),仅供参考学习,不适用于生产环境。在生产环境中,强烈建议通过 Apache Curator 提供的 API 来实现基于 ZooKeeper 的分布式锁。
Apache Curator 介绍
Apache Curator 是 ZooKeeper 的客户端工具包,封装了很多常见的分布式功能(Recipes),比如:分布式锁、分布式队列、分布式计数器、领导者选举(Leader Election)、单向栅栏(Barrier)、双向栅栏(DoubleBarrier)、组成员管理(Group Membership)等。
核心概念
ZK 实现分布式锁的几种方式
ZooKeeper 实现分布式锁有两种常见的方式:
(1) 基于临时节点实现分布式锁(非公平锁)
- 客户端在指定的父节点下创建一个临时节点,如果成功创建,则获得锁;如果抛出异常,说明锁已经被占用,那么就监听已创建的节点,并等待接收监听事件通知。
- 当锁的持有者删除自己创建的节点,从而释放锁。ZooKeeper 会通知在该节点上监听的客户端,该客户端会重新尝试创建一个同名的临时节点,若创建成功,则获得锁。
- 该方式可以理解为基于异常的分布式锁实现,缺点是存在惊群效应的问题,也就是多个客户端可能同时尝试获取锁,导致频繁地创建节点和设置监视器。
(2) 基于临时顺序节点实现分布式锁(公平锁)
- 客户端在指定的父节点下创建一个有序的临时节点,然后获取该父节点下所有的子节点,并检查自己创建的节点是否是最小的序号节点。
- 如果是,则该客户端获得锁。如果不是,则客户端给序号最接近且小于自己的那个节点设置监视器(Watcher),然后进入等待状态。
- 持有锁的客户端执行完临界区代码(即操作共享数据的代码)后,删除自己创建的节点,从而释放锁。
- 当持有锁的客户端删除节点后,ZooKeeper 会通知在该节点上监听的下一个客户端,该客户端会重新检查自己是否是最小的序号节点,若是,则获得锁。
ZK 可以实现哪些类型的分布式锁
ZooKeeper 提供了顺序节点和 Watch 机制,使其成为实现分布式锁的理想选择。通过创建临时节点(Ephemeral Node)或临时顺序节点(Ephemeral Sequential Node),可以在分布式系统中实现对共享资源的访问控制。ZooKeeper 可以实现的分布式锁类型不限于:
(1) 非公平锁(NonFairLock)
- 实现步骤:
- 通过临时节点(没有编号)实现。
- 每个客户端分别在同一父节点下创建临时节点。
- 第一个成功创建临时节点的客户端将获得锁,可以执行操作。
- 其他客户端创建临时节点失败后,需要对该锁节点注册一个删除事件监听器。
- 一旦当前持锁客户端删除该锁节点(即释放锁)时,监听器会触发,其他客户端就会被通知可以重新尝试创建临时节点以获取锁。
- 实现特点:
- 实现简单,但无法保证请求锁的顺序公平性。
- 后来的客户端有可能比先前失败的客户端先获取到锁(抢锁问题)。
- 如果有高频率的加锁请求,某些客户端可能一直失败,始终没机会获得锁(饥饿问题)。
- 适用场景:
- 对锁获取顺序不敏感、只需要简单互斥的情况。
- 实现步骤:
(2) 公平锁(FairLock)
- 实现步骤:
- 通过临时顺序节点(有编号)实现。
- 每个客户端分别在同一父节点下创建临时顺序节点。
- ZooKeeper 会自动分配递增编号,编号最小的客户端将获得锁,可以执行操作。
- 其他客户端不会监听父节点的所有变化,只需要监听编号最接近且小于自己的那个节点(即前一个节点)的删除事件。
- 当锁节点被删除后,负责监听该节点的客户端会收到通知,然后重新判断当前是否满足获取锁的条件,如果满足则立即获得锁,否则继续监听下一个编号最接近且小于自己的节点。
- 实现特点:
- 先到先得:保证请求锁的顺序按照创建节点的顺序分配锁,公平性高。
- 避免抢锁和饥饿问题:长期等待的客户端不会被后来加入的客户端 “插队”,而且每个客户端都有机会获得锁。
- 避免惊群效应:每个客户端只需要监听前一个节点,而不是监听父节点的所有变化,减少了 ZooKeeper 的事件通知量。
- 适用场景:
- 适合对锁获取顺序有严格要求的业务场景,例如:分布式任务队列处理、分布式写操作顺序控制、对公平性要求高的集群资源分配。
- 不适合锁竞争非常激烈且对延迟敏感的场景,因为顺序锁每次释放都需要重新判断前驱节点,可能会引入一些延迟。
- 实现步骤:
(3) 读写锁(ReadWriteLock)
- 基于临时顺序节点(有编号)并通过节点命名区分读写类型(如
read-0000003/write-0000005)。 - 实现读锁:
- 客户端创建
read-前缀的临时顺序节点。 - 检查比自己编号小的节点中是否存在写锁节点(
write-前缀):- 如果没有写锁节点,则立即获得读锁(多个读锁可并行)。
- 如果有写锁节点,则监听编号最接近且小于自己的那个写锁节点的删除事件。当该节点被删除后,重新判断当前是否满足获取锁的条件,如果满足则立即获得锁,否则继续监听下一个编号最接近且小于自己的写锁节点。
- 客户端创建
- 实现写锁:
- 客户端创建
write-前缀的临时顺序节点。 - 检查比自己编号小的所有节点(包括读锁和写锁)是否为空:
- 如果为空,则获得写锁。
- 如果不为空,则监听编号最接近且小于自己的那个节点(即前一个节点)的删除事件。当该节点被删除后,重新判断当前是否满足获取锁的条件,如果满足则立即获得锁,否则继续监听下一个编号最接近且小于自己的节点。
- 客户端创建
- 实现特点:
- 读锁之间不互斥,允许多个客户端并发读。
- 写锁与读锁、写锁之间互斥,保证数据一致性。
- 顺序编号和 Watch 机制可以保证公平性和低惊群效应(每个客户端只监听前一个节点)。
- 适用场景:
- 读多写少的分布式系统:允许多个客户端同时读取共享资源,提高读吞吐量。
- 分布式缓存:多个节点可并行读取缓存,但写操作需独占缓存。
- 配置或状态管理:读操作频繁,写操作较少,但需要保证写入时的数据一致性。
- 分布式数据库:需要对读写操作进行协调,保证顺序性和互斥性。
- 基于临时顺序节点(有编号)并通过节点命名区分读写类型(如
代码案例
ZK 实现非公平锁
ZooKeeper 实现分布式公平锁时需要注意的地方
- 死锁问题:锁不能因为意外就变成死锁,所以要用临时节点,客户端连接断开了,锁就自动释放。
- 锁等待问题:锁有排队的需求(公平锁),所以要用顺序节点。
- 锁管理问题:一旦锁的持有者释放了锁,需要通知其他的锁竞争者,所以需要用到监听器(Watcher)。
- 容错处理问题:在实际应用中,需要处理网络中断、会话过期等异常情况,确保锁的机制不会因为这些问题而失效。
- 性能问题:ZooKeeper 的性能在一定程度上依赖于节点数量和操作频率,因此需要合理设计锁的粒度和使用频率。
- 避免惊群效应:多个客户端可能同时尝试获取锁,导致频繁地获取子节点列表和设置监视器。比如,有 1000 个锁竞争者,一旦锁释放了,1000 个竞争者就会得到通知,然后判断自己创建的节点是否是最小的序号节点,若是就获取到锁。其它 999 个竞争者会重新注册监听,这就是惊群效应,出点事就会惊动整个羊群。解决方案是每个竞争者只监听序号最接近且小于自己的那个节点(即前一个节点),比如 2 号释放了锁,那么只有 3 号会得到通知。
- 引入 Maven 依赖
1 | <dependency> |
- 非公平锁的实现
1 | import org.apache.zookeeper.CreateMode; |
- 非公平锁的测试代码
1 | public class DistributeLockTest { |
- 程序运行的结果
1 | ZooKeeper server connect success. |
ZK 实现公平锁
- 引入 Maven 依赖
1 | <dependency> |
- 公平锁的实现
1 | import org.apache.zookeeper.CreateMode; |
- 公平锁的测试代码
1 | public class DistributeLockTest { |
- 程序运行的结果
1 | ZooKeeper server connect success. |
代码下载
本文所需的完整案例代码,可以直接从 GitHub 下载对应章节 distributed-locks-05。
