分布式专题17-ZooKeeper经典应用场景实战下
分布式专题——17 ZooKeeper经典应用场景实战(下)
[
『Java分布式系统开发:从理论到实践』征文活动
10w+人浏览
305人参与
](
)
1 ZooKeeper 分布式锁实战
1.1 什么是分布式锁
在单体应用开发场景中,涉及并发同步时,通常使用
Synchronized
(同步)或其他同一 JVM 内的Lock
机制来解决多线程间的同步问题;在分布式集群工作的开发场景下,需要一种更高级的锁机制来处理跨机器的进程之间的数据同步问题,这种跨机器的锁就是分布式锁;
目前比较成熟、主流的分布式锁方案有以下几种:
- 基于数据库的分布式锁:利用数据库的事务和锁机制来实现分布式锁。虽然在某些场景下能实现简单的分布式锁,但由于数据库操作性能相对较低,还可能面临锁表的风险,所以一般不是首选方案;
- 基于 Redis 的分布式锁:Redis 分布式锁是一种常见且成熟的方案,适用于高并发、性能要求高且可靠性问题可通过其他方式弥补的场景。Redis 提供了高效的内存存储和原子操作,能快速获取和释放锁,在大规模分布式系统中得到广泛应用;
- 基于 ZooKeeper 的分布式锁:这种方案适用于对高可靠性和一致性要求较高,而并发量不是太高的场景。因为 ZooKeeper 具有强一致性保证,所以能较好地满足对可靠性和一致性要求高的分布式锁需求。
1.2 基于数据库实现分布式锁的设计思路
利用数据库的唯一索引来实现分布式锁,因为唯一索引天然具有排他性。具体流程如下:
开始;
插入一条数据,其中
method name
字段具有唯一约束;判断数据是否插入成功:
- 如果插入成功,说明获取到了锁;
- 如果插入失败,说明锁已被其他进程获取,此时需要等待,当对应的数据被删除后,再去尝试获取锁;
当获取到锁并完成相关操作后,释放锁,即删除之前插入的数据;
结束;
基于数据库实现分布式锁存在一些问题,比如:
- 性能问题:数据库操作相对较慢,尤其是在高并发场景下,频繁的数据库插入、查询、删除操作会导致性能瓶颈;
- 可靠性问题:如果获取锁的进程在释放锁之前出现异常(如宕机),可能导致数据无法被删除,从而造成锁无法释放,出现死锁情况;
- 扩展性问题:对于分布式系统来说,数据库本身的扩展性可能有限,当系统规模扩大时,可能无法很好地支撑大量的锁操作请求。
1.3 基于 ZooKeeper 实现分布式锁设计思路一
核心逻辑:使用临时 ZNode(
/lock
)来表示获取锁的请求,创建 ZNode 成功的用户拿到锁;流程:
- 开始尝试创建临时节点
/lock
; - 判断是否创建成功:
- 如果创建成功,获取锁;之后删除
/lock
节点释放锁,流程结束; - 如果创建失败,监听
/lock
节点,并等待。当/lock
节点被删除时,会收到通知,再次尝试创建/lock
节点以获取锁;
- 如果创建成功,获取锁;之后删除
- 开始尝试创建临时节点
存在的问题:如果所有的锁请求者都监听锁持有者(即代表锁持有者的 ZNode),当该 ZNode 被删除后,所有锁请求者都会收到通知,但只有一个锁请求者能拿到锁。这会导致羊群效应,即多个客户端同时被唤醒,但只有一个能获取锁,其他客户端又会继续等待,造成不必要的资源消耗。
1.4 基于 ZooKeeper 实现分布式锁设计思路二
核心逻辑:使用临时有序 ZNode 来表示获取锁的请求,创建最小后缀数字 ZNode 的用户成功拿到锁,实现了公平锁(按照请求顺序获取锁);
流程:
- 开始在
/locks
下创建临时有序节点; - 获取
/locks
下的所有子节点,并将子节点按序号由小到大排序; - 判断当前节点序号是否最小:
- 如果是,获取锁;之后删除该节点释放锁,流程结束;
- 如果不是,只需要监听比自己序号小 1 的节点的删除事件。当比自己序号小 1 的节点被删除时,会收到通知,再次检查自己是否是当前序号最小的节点,若是则获取锁;
- 开始在
开发建议:在实际开发中,如果需要使用分布式锁,不建议自己“重复造轮子”,建议直接使用 Curator 客户端中的各种官方实现的分布式锁,例如其中的
InterProcessMutex
可重入锁,这样能避免自己实现过程中可能出现的各种问题,提高开发效率和可靠性。
1.5 Curator 可重入分布式锁工作流程
acquire
(获取锁)流程判断当前线程是否已获取锁:
- 若已获取锁:重入次数加 1,直接获取成功。同时,会将当前线程和锁信息存入
ConcurrentHashMap
,标记重入次数为 1(首次重入时); - 若未获取锁:进入后续创建节点的流程;
- 若已获取锁:重入次数加 1,直接获取成功。同时,会将当前线程和锁信息存入
创建临时有序子节点:在锁节点下创建一个临时有序子节点;
判断节点创建是否成功:
- 若成功:
- 检查是否是第一个子节点:
- 若是:获取锁成功,将当前线程和锁信息存入
ConcurrentHashMap
,重入次数记为 1; - 若不是:获取前一个节点的路径,并为该前序节点添加
watcher
(监听器)。之后当前线程进入阻塞状态(wait()
),等待被唤醒(notifyAll()
);
- 若是:获取锁成功,将当前线程和锁信息存入
- 检查是否是第一个子节点:
- 若失败:判断是否超过重试次数
- 未超过:再次尝试创建临时有序子节点;
- 超过:抛出异常,获取锁失败;
- 若成功:
release
(释放锁)流程获取当前线程锁信息,重入次数减 1;
判断重入次数:
- 若重入次数仍大于 0:说明未完全释放重入,直接返回(锁仍由当前线程持有,只是重入层级减少);
- 若重入次数小于 0:抛出异常(逻辑上不可能出现,属于异常情况);
- 若重入次数等于 0:
- 删除锁对应的节点;
- 删除
ConcurrentHashMap
中该线程和锁的信息; - 触发
watcher
,唤醒等待该锁的线程(notifyAll()
),完成释放,标记释放完成;
关键知识点
- 可重入性:通过
ConcurrentHashMap
记录线程的重入次数,支持同一线程多次获取锁,每次重入次数递增;释放时次数递减,直到次数为 0 才真正释放锁; - 临时有序节点:利用 ZooKeeper 临时有序节点的特性,保证锁的公平性(按节点创建顺序排队),且节点随会话结束自动删除,避免死锁;
- 监听器(Watcher):为前序节点添加监听器,当前序节点被删除(锁释放)时,触发通知,唤醒当前阻塞的线程重新尝试获取锁;
- 重试机制:创建节点失败时,支持重试,避免因网络波动等临时问题导致获取锁失败;
- 线程同步:通过
wait()
和notifyAll()
实现线程间的等待与唤醒,保证锁的有序获取。
- 可重入性:通过
1.6 小结
优点:ZooKeeper 分布式锁具备高可用、可重入、阻塞锁的特性
- 高可用:ZooKeeper 本身是分布式协调服务,具有良好的集群特性,能保证在集群环境下锁服务的可用性;
- 可重入:支持同一线程多次获取同一把锁,就像本地的可重入锁(如
ReentrantLock
)一样,避免了同一线程因重复获取锁而出现死锁的情况; - 阻塞锁特性:当线程获取锁失败时,会进入阻塞状态,等待锁释放后再尝试获取,保证了线程获取锁的有序性;
- 解决失效死锁问题:由于 ZooKeeper 临时节点的特性,当持有锁的客户端会话失效(如宕机)时,临时节点会被自动删除,锁也会被释放,从而避免了因客户端异常导致的死锁问题;
- 使用简单:Curator 等客户端封装了 ZooKeeper 分布式锁的实现,开发者可以方便地调用相关 API 来使用分布式锁;
缺点:因为在使用过程中需要频繁地创建和删除 ZooKeeper 节点,而 ZooKeeper 节点的操作相对 Redis 等基于内存操作的组件来说,开销更大,所以性能上不如 Redis 分布式锁;
适用场景建议:
- 在高性能、高并发的应用场景下,不建议使用 ZooKeeper 的分布式锁,因为其性能可能无法满足高并发下的性能要求,此时 Redis 分布式锁可能是更优选择;
- 由于 ZooKeeper 具有高可靠性,所以在并发量不是太高的应用场景中,推荐使用 ZooKeeper 的分布式锁,能在保证可靠性的同时,满足锁的使用需求。
2 基于 ZooKeeper 实现服务的注册与发现
2.1 设计思路
- 服务注册:服务(如
Server1
、Server2
、Server3
)启动时,会在 ZooKeeper 集群的/services
节点下创建临时节点(如/services/server1
、/services/server2
、/services/server3
),并将自身的ip:port
等信息存储在节点中; - 服务发现(客户端初始操作):客户端(如
Client1
、Client2
、Client3
)会获取当前/services
节点下的所有子节点,从而得到当前在线的服务列表,并且会注册对这些子节点的监听(Watcher); - 服务下线:当某一服务节点(如
Server2
)下线时,其在 ZooKeeper 上对应的临时节点会被删除; - 下线通知:ZooKeeper 会将服务节点下线的事件通知给监听该节点的客户端;
- 客户端更新服务列表:客户端收到通知后,会重新获取
/services
节点下的子节点,更新服务列表,并且再次注册监听,以感知后续服务节点的变化。
2.2 优缺点
优点:
- 高可用性:ZooKeeper 是高可用的分布式系统,可通过配置多个服务器实例提供容错能力。若其中一个实例出现故障,其他实例仍能继续提供服务,保障服务注册与发现功能的持续可用;
- 强一致性:ZooKeeper 能保证数据的强一致性,当有更新操作完成时,所有服务器都会有相同的数据视图。这使得 ZooKeeper 作为服务注册中心时,可确保所有客户端看到的服务状态一致,避免因数据不一致导致的服务调用问题;
- 实时性:ZooKeeper 的监听器(Watcher)机制允许客户端监听节点变化。当服务提供者状态(上线或下线)改变时,客户端会实时收到通知,使服务消费者能快速响应服务变化,实现动态服务发现;
缺点:
- 性能限制:ZooKeeper 的性能可能不如一些专为服务注册中心设计的解决方案(如 Nacos、Consul)。特别是在大量读写操作或大规模集群场景下,ZooKeeper 可能会遇到性能瓶颈,难以高效处理高并发的服务注册与发现请求。
2.3 整合SpringCloud ZooKeeper实现微服务注册中心
;
在父
pom
文件中指定 SpringCloud 版本:<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.3.2.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <java.version>1.8</java.version> <spring-cloud.version>Hoxton.SR8</spring-cloud.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>
微服务
pom
文件中引入 SpringCloud ZooKeeper 注册中心依赖:<!-- ZooKeeper 服务注册与发现 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-zookeeper-discovery</artifactId> <exclusions> <exclusion> <groupId>org.apache.zookeeper</groupId> <artifactId>zookeeper</artifactId> </exclusion> </exclusions> </dependency> <!-- ZooKeeper client --> <dependency> <groupId>org.apache.zookeeper</groupId> <artifactId>zookeeper</artifactId> <version>3.8.0</version> </dependency>
- 注意: ZooKeeper 客户端依赖和 ZooKeeper sever 的版本兼容问题;
- SpringCloud整合 ZooKeeper 注册中心核心源码入口:
ZookeeperDiscoveryClientConfiguration
;
微服务配置文件
application.yml
中配置 ZooKeeper 注册中心地址:spring: cloud: zookeeper: connect-string: localhost:2181 discovery: instance-host: 127.0.0.1
整合 feign 进行服务调用:
@RequestMapping(value = "/findOrderByUserId/{id}") public R findOrderByUserId(@PathVariable("id") Integer id) { log.info("根据userId:"+id+"查询订单信息"); // feign调用 R result = orderFeignService.findOrderByUserId(id); return result; }
测试:
http://localhost:8040/user/findOrderByUserId/1
。