Zookeeper应用场景之分布式锁
本文章先概述一下zk的主要应用场景,然后重点讲述其分布式锁的实现。
1.应用场景概述
1.1.数据发布/订阅
数据发布/订阅系统,即所谓的配置中心。意思就是发布者将数据发布到zk的n个节点上,供订阅者进行数据订阅,进而达到动态获取数据的目的,实现配置信息的集中式管理和数据的动态更新。
1.2.负载均衡
zk作为路由,client从zk那里拿到可用的server地址(最好做个缓存),通过自己的负载均衡算法得到要连接的地址,client去连接指定server。server要做的是,当server启动后去zk注册自己(创建临时文件),子节点变化时(比如当某台server宕机),server与zk的链接会断掉,zk会自动删除该临时文件。
1.3.命名服务
命名服务是分布式系统中最基本的公共服务之一。要命名的对象包括集群中的机器列表,服务名称等。zk实现命名服务的方式:通过调用zk节点创建的API接口创建一个顺序节点,并且在API返回值中返回这个节点的完整名字。
1.4.分布式协调/通知
zk中特有的Watcher注册与异步通知机制,能够很好的实现分布式环境下不同机器,甚至是不同系统之间的协调与通知,从而实现对数据变更的实时处理。基于zk实现分布式协调与通知功能,通常的做法是:客户端都对zk上同一个数据节点进行Watcher注册,监听数据节点的变化,如果数据发生变化,所有订阅的客户端都能收到相应的Watcher通知,并做出相应处理。
1.5.集群管理
随着分布式系统的日益扩大,集群中的机器规模也随之变大,我们经常会遇到如下需求:
- 希望知道当前集群中究竟有多少机器在工作
- 对集群中没台机器的运行时状态进行数据收集
- 对集群中机器进行上下线操作
可以基于zk的两大特性来实现:
- 客户端如果对zk的一个数据节点进行注册Watcher监听,那么当该数据节点的内容或子节点发生变更时,zk的服务器就会向订阅的客户端发送变更通知
- 对于在zk上创建的临时节点,一旦客户端与服务器之间的会话失效,那么该临时节点也就被自动清除。
可以基于这个实现集群机器存活监控的系统。监控系统在/clusterServers节点上注册一个Watcher监听,那么凡是进行动态添加机器的操作,都会在/clusterServers节点下创建一个临时节点:/clusterServers/[HostName],这样一来,监控系统就能实时监测到机器的变动情况,后续处理就是监控系统内部的事了。
1.6.Master选举
Master选举是分布式系统中非常常见的应用场景。分布式最核心的特性就是能够将具有独立计算能力的系统单元部署在不同机器上,构成一个完整的分布式系统。同时,实际场景中也需要在这些不同机器上的独立系统单元选出一个所谓的“老大”,称之为Master
.
分布式系统中,Master往往用来协调集群中的其他系统单元,拥有对分布式系统状态变更的决定权。例如,读写分离的场景,客户端的写请求都是由Master来处理的;另一些场景中,Master则常常处理一些复杂的逻辑,并将处理结果同步给集群中其他系统单元。Master选举是zk最典型的应用场景了。
1.7.分布式锁
zk中获取锁是通过创建临时节点实现的。zk会保证所有客户端最终只有一个能成功创建节点。后面详细介绍
1.8.分布式队列
分布式队列,简单来说分为两类,一种是先入先出队列,另一种是等所有元素都集聚之后统一安排的Barrier模型。
在另一篇文章中会详细讲到。
2.分布式锁
2.1. 排他锁
排他锁(Exclusive Locks,简称X锁),又称为写锁或独占锁,是一种基本的锁类型。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取和更新操作,其他任何事务都不能再对这个数据对象进行任何类型的操作---直到T1释放了排他锁。
下面看看zk的实现:
2.1.1. 定义锁
通过zk上的数据节点来表示一个锁,例如/exclusive_lock/lock
节点就可以被定义为一个锁
-/exclusive_lock
-lock
2.1.2. 获取锁
需要获取排他锁时,所有的客户端都会试图通过调用create()
接口,在/exclusive_lock
节点下创建临时子节点/exclusive_lock/lock
。zk会保证在所有的客户端中,最终只有一个客户端能够创建成果,那么就认为该客户端获取了锁。同时,所有没有获取到所的客户端就需要到/exclusive_lock
节点上注册一个子节点变更的Watcher监听,以便实时监听lock节点的变更情况。
2.1.3. 释放锁
/exclusive_lock
是一个临时节点,因此在以下两种情况下,都有可能释放锁。
- 当前获取锁的客户端发生了宕机,那么zk上的这个临时节点就会被移除
- 正常执行完业务逻辑后,客户端就会主动将自己创建的临时节点删除。
无论哪种情况下移除lock节点,zk都会通知所有在/exclusive_lock
节点上注册了子节点变更Watcher监听的客户端,这些客户端接收到通知后,再次重新发起分布式锁获取,重复了 获取锁过程 。示意图如下:
2.2. 共享锁
共享锁(Shared Locks,简称S锁),又称为读锁。也是一种基本的锁类型。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁---直到该数据对象上的所有共享锁都被释放。
2.2.1. 定义锁
同样,zk上也是通过一个数据节点来表示一个锁。类似于/shared_lock/[Hostname]-请求类型-序号
的临时顺序节点,例如shared_lock/192.168.0.1-R-0000000000001
,那么,这个节点就代表了一个共享锁。如下所示:
-/shared_lock
-host1-R-00000000000001
-host2-W-00000000000002
-host5-R-00000000000005
2.2.2. 获取锁
需要获取共享锁时,所有客户端都会到shared_lock
这个节点下创建一个临时顺序节点,如果当前是读请求,那么就创建例如/shared_lock/192.168.0.1-R-000000000001
的节点,如果是写请求,那么就创建例如/shared_lock/192.168.0.1-W-000000000001
2.2.3. 判断读写顺序
根据共享锁的定义,不同的事务都可以同时对同一个数据对象进行读取操作,而更新操作必须在当前没有任何事务进行读写操作的情况下进行,基于这个原则,zk是如何来确定分布式读写顺序。分为如下4个步骤:
-
创建完节点后,获取
shared_lock
节点下所有的子节点,并对该节点注册子节点变更的Watcher监听 -
确定自己的节点序号在所有子节点中的顺序
-
对于读请求:
如果没有比自己序号小的子节点,或者是所有比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑
如果比自己序号小的子节点中有写请求,那么就需要进入等待。
对于写请求:
如果自己不是序号最小的子节点,那么就需要进入等待。 -
接收到Watcher通知后,重复步骤1
2.2.4. 释放锁
释放锁和排他锁是一致的,可参照上面。
整个流程示意图:
2.3. 羊群效应
上面提到的方案,如果集群规模不是特别大(10台以内),是能满足业务需求的。但是当机器规模扩大之后, 判断读写顺序 就会有问题。结合实例看看:
-/shared_lock
-192.168.0.1-R-00000000000000001
-192.168.0.2-W-00000000000000001
-192.168.0.5-R-00000000000000001
-192.168.0.6-W-00000000000000001
-192.168.0.7-W-00000000000000001
-
192.168.0.1这台机器首先进行读取操作,完成读操作后将节点
-192.168.0.1-R-00000000000000001
删除 -
余下的四台机器都收到了这个节点被移除的通知,然后重新从
/shared_lock
节点上获取一份新的子节点列表。 -
每个机器判断自己的读写顺序,.0.2这台机器检测到自己已经是序号最小的机器了,于是开始进行写操作,余下的机器发现没有轮到自己进行读取或者更新操作,于是继续等待。
-
继续上个过程........
问题就很明显了,.0.1这个客户端移除了自己的共享锁后,zk发送了节点变更Watcher通知给所有机器,然后这个通知除了给.0.2这台机器产生了实际影响外,对于余下的其他所有机器都没有任何作用
。
在整个分布式锁的竞争过程中,大量的Watcher通知
和子节点列表
获取两个操作重复运行,并且大多数的运行结果都显示自己并非是序号最小的节点,从而继续等待下一次通知,显然不太合理。客户端无端接收了多多和自己并不相关的通知,如果在集群规模比较大的情况下,不仅对zk服务器造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个节点对应的客户端完成事务或者事务中断引起节点消失,zk服务器就会在短时间内向其余客户端发送大量的事件通知---这就是所谓的羊群效应
。
上面出现羊群效应的根源在于,没有找准客户端真正的关注点。获取锁竞争的真正关注点在于:判断自己是否是所有子节点中序号最小的。因此改进方案是:客户端只需要关注比自己序号小的那个节点的变更情况
。
2.4. 改进方案
改动在于,每个锁竞争者,只需要关注/shared_lock
节点下序号比自己小的那个节点是否存在即可。
-
客户端调用create() 方法创建一个类似于
shared_lock/[Hostname]-请求类型-序号
的临时顺序节点。 -
客户端调用getChildren() 接口获取所有已经闯将的子节点列表。
-
如果无法获取共享锁,那么就调用 exist() 来对比自己小的那个节点注册Watcher。具体为:
读请求:向比自己序号小的最后一个写请求节点注册Watcher监听。
写请求:向比自己序号小的最后一个节点注册Watcher监听。 -
等待Watcher通知,继续进入步骤2.
改进后的流程图:
2.5 锁的特性分析:
优点:
-
锁安全性高,zk可持久化,且能实时监听获取锁的客户端状态。一旦客户端宕机,则瞬时节点随之消失,zk因而能第一时间释放锁。这也省去了用分布式缓存实现锁的过程中需要加入超时时间判断的这一逻辑。
-
zookeeper支持watcher机制,这样实现阻塞锁,可以watch锁数据,等到数据被删除,zookeeper会通知客户端去重新竞争锁。
-
zookeeper的数据可以支持临时节点的概念,即客户端写入的数据是临时数据,在客户端宕机后,临时数据会被删除,这样就实现了锁的异常释放。使用这样的方式,就不需要给锁增加超时自动释放的特性了。
缺点:
- 性能开销比较高。因为其需要动态产生、销毁瞬时节点来实现锁功能。所以不太适合直接提供给高并发的场景使用。
来自
《从paxos到zookeeper 分布式一致性原理与实战》
,稍微进行了归纳总结。
标题:Zookeeper应用场景之分布式锁
作者:guobing
地址:http://www.guobingwei.tech/articles/2019/03/09/1552090958174.html