一、ZooKeeper 基础
在分布式协作中,应用间常常共享各种原语,比如分布式锁机制就组成了一个重要的原语,并暴露出创建、获取、释放三个API方法。而 ZooKeeper 不直接暴露原语,而是暴露由一小部分调用方法组成的类似文件系统的API,以便运行应用实现自己的原语。
在 ZooKeepr 中,每数据节点被称为znode
,采用类似于文件系统的层级树状结构进行管理,下面以主-从模式为例说明 ZooKeeper 的数据树结构。
- /master:主节点,存储的数据为当选节点的服务器ID,服务器ID唯一标识每个节点。
- /workers:作为父节点,其下每个 znode 子节点保存了系统中一个可用从节点信息。
- /tasks:作为父节点,其下每个 znode 子节点保存了所有已经创建并等待从节点执行的任务的信息,主-从模式的应用的客户端在 /tasks 下添加一个 znode 子节点,用来表示一个新任务,并等待任务状态的 znode节点。
- /assign:作为父节点,其下每个 znode 子节点保存了分配到某个从节点的一个任务信息,当主节点为某个从节点分配了一个任务,就会在 /assign 下增加一个子节点。
1、ZooKeeper API
ZooKeeper有一个绑定Java和C的官方API。Zookeeper社区为大多数语言(.NET,python等)提供非官方API。使用ZooKeeper API,应用程序可以连接,交互,操作数据,协调,最后断开与ZooKeeper集合的连接。
ZooKeeper API具有丰富的功能,以简单和安全的方式获得ZooKeeper集合的所有功能,并提供同步和异步方法,主要暴露以下方法:
create /path data
创建一个名为 /path 的 znode 节点,并包含数据 data。delete /path
删除名为 /path 的 znode。exists /path
检查是否存在名为 /path 的节点。setData /path data
设置名为 /path 的 znode 的数据为 data。getData /path
返回名为 /path 节点的数据信息。getChildren /path
返回 /path 节点的所有子节点
2、znode 的类型
znode被分为持久(persistent)节点,顺序(sequential)节点和临时(ephemeral)节点。
持久节点:即使在创建该特定znode的客户端断开连接后,持久节点仍然存在。默认情况下,除非另有说明,否则所有znode都是持久的。在主从模式中,
/tasks
和assign
就适合定义为持久节点,这样即使主节点崩溃,也能保证任务和调度的数据不丢失。临时节点:客户端活跃时,临时节点就是有效的。当客户端与ZooKeeper集合断开连接时,临时节点会自动删除。因此,只有临时节点不允许有子节点。如果临时节点被删除,则下一个合适的节点将填充其位置。临时节点在leader选举中起着重要作用。在主-从模式中,
/master
和workers
的子节点就适合定义为临时节点,这样 zookeeper 就能动态检测节点是否崩溃,进而选举出新的主节点和重新派发崩溃的从节点的任务。顺序节点:顺序节点可以是持久的或临时的。当一个新的znode被创建为一个顺序节点时,ZooKeeper通过将10位的序列号附加到原始名称来设置znode的路径。例如,如果将具有路径 /myapp 的znode创建为顺序节点,则ZooKeeper会将路径更改为 /myapp0000000001 ,并将下一个序列号设置为0000000002。如果两个顺序节点是同时创建的,那么ZooKeeper不会对每个znode使用相同的数字。顺序节点在锁定和同步中起重要作用。
顺序节点可以跟持久和临时节点进行组合,变成持久有序节点和临时有序节点。
3、监视机制
由于 zookeeper 共享存储了协同数据,所以客户端操作时一般都需要先获取 znode 的数据。
(1)客户端C2读取任务列表,其初始值为空。
(2)客户端C2再次读取任务列表,看是否有新的任务。
(3)客户端C1创建了一个新任务。
(4)客户端C2再次读取任务列表,并发现了变化。
假如客户端需要获取所有任务,每次都要读取节点数据,代价比较大,很可能出现多次读取的结果是一样的,没有读取到新的任务数据的情况,假如节点任务有变化能主动通知到客户端,可以减少这种不必要的轮训带来的消耗,ZooKeeper 提供了这样的通知(notification)机制:客户端向 ZooKeeper 注册需要接收通知的 znode,通过对 znode 设置监视点(watch)来接收通知。
(1)客户端C2读取任务列表,其初始值为空,并设置一个监控变更的监视点。
(2)当发生变化时通知客户端。
(3)客户端C2读取/tasks的子节点,以发现新任务。
zookeeper 监听机制的规则
(1)客户端在 zk 上设置监视点,当对应的监视节点有变化时,就会通知客户端。
(2)监视点是一个单次触发的操作,只会通知一次,如果想持续收到通知,客户端需要在收到通知之后重新设置新的监视点。
Q1:如果客户端监视的节点发生了两次连续的变化,第一次变化有通知,第二次变化发生在重新设置监视点之前,这样是否就会错过第二次通知?
(1)客户端C2读取任务列表,其初始值为空,并设置监视点
(2)客户端C1创建了一个任务,通知C1
(3)客户端C1创建了第二个任务
(4)客户端C2读取任务列表,获取task-1、task-2
如果从通知的维度来看的话,不会有连续的通知,会错过第二次变化的通知,但如果c2在收到第一次通知后再去读取节点的任务子节点列表,就不会错过连续的变更。
Q2:为什么zk的通知机制中的监视点被设计为只发送一次通知?
针对上面连续两次变化却只发送一次通知的问题,有人可能会觉得假如监视点被设计为有变化就发送通知岂不是更加方便,这样做其实会有问题,很可能由于网络通信延迟等原因,客户端很可能先收到第二次变化的通知,后收到第一次变化,如果两次变化没有什么关联,哪个先后没什么影响,但如果对变化的处理有先后顺序或者关联的要求,会导致系统数据的不一致,甚至可能造成更大的负面影响。
为了保障客户端以全局的顺序观察 ZooKeeper 的状态,监视点只通知一次,这样客户端收到的通知肯定是第一次变化的通知,第二次变化在收到第一次变化的通知后客户端再主动读取 ZooKeeper 节点数据即可,并且在变更节点之前,会先发送通知,第二次变化必须等第一次变化变更完后才进行处理,严格保证处理的顺序。
4、版本
每个 znode 都会有一个版本号,会随着数据变化而自增,会导致数据变化的操作有修改(setData)和删除数据(delete),同样这两个操作允许有条件地修改和删除数据,可以传入一个版本号,如果当前 znode 版本号与传入的版本号一致,则允许变化数据。
如果不设置监视点和主动访问 znode,客户端可能会错失节点的一些连续变化,但有时候我们不想设置监视点这么复杂,因为不需要处理连续的变化,只想确认,这个节点还是不是当初那个数据和状态(这可以通过版本号体现出来),如果一开始保留了版本号,在某个时间点要做某个操作,前提是节点还是原来的数据和状态,就可以通过传入版本号来确认,如果版本一直,就可以执行操作,否则返回失败报错版本不正确。
(1)客户端C1写入了第一个版本的/config。
(2)客户端C2读取/config并写入了第二个版本。
(3)客户端C1尝试对/config进行写入,但因版本号不匹配而请求失败。
二、ZooKeeper 架构
ZooKeeper 服务器端运行于两种模式之下:独立模式(standalone)和仲裁模式(quorum)。独立模式就是只有一台单一的服务器,为了避免单点故障,一般生产采用仲裁模式,即由多台服务器组成 ZooKeeper 集合(或者说集群,ZooKeeper ensemble),它们之间可以进行状态的复制,通知服务响应客户端请求。
1、ZooKeeper 仲裁
仲裁模式下,集群中的每台服务器都有完整的 ZooKeeper 数据树。集群初始化时会选举出一个节点作为 Leader,其他服务器节点作为 Follewer,不管是 Leader 还是 Follewer,都允许客户端与其连接,并且接收处理客户端请求。
Q3:Leader 是如何选举出来的?
每个节点(即 zk 服务器)在加入集群时会先检测集群中是否已经有节点,如果有就乖乖当一个 Follewer,并且跟 Leader 通讯并同步其数据到本节点中来;如果没有,则提出一个提案,内容是推荐哪个节点做 Leader,一般一开始会是自己,提案会带上机器序号还有事务标识等信息,节点发出提案,同样也会收到其他节点广播的提案,按照一定的规则,如果节点放弃当前的提案,更新为某个节点发出的提案,就会再次发出广播。
经过一定时间的通讯选举,如果某个时间点,有法定数量(超过一半)的节点推荐A节点成为 Leader,则可以认定为A节点成功当选为 Leader。
Q4:为什么要有法定数量的节点认可才能通过提案?法定数量是如何界定的?这样做还有什么好处?
这个主要为了防止集群产生分区隔离,造成脑裂,防止出现集群中部分节点认可A节点为 Leader、而集群中另外一部分节点认可B为 Leader 的情况,这样整个集群的数据、状态、通讯都产生隔离。
只要提案有法定数量的节点支持,由于法定数量超过一半,所有不会出现同时有两个提案被法定数量的节点投票通过的情况。
通过少数服从多数的原则,我们可以容许一定数量服务器的崩溃。比如有5台服务器,法定数量是3,可以容许2个服务器崩溃,因为剩下的3个服务器可以组成法定数量的节点参与投票,一般集群的数量最好是奇数个,因为可以容忍更多的服务器崩溃,提高可用性,假如集群是4台服务器,法定数量是3,那么只能容许一台服务器崩溃,假如再崩溃一台,剩下两台服务器无法组成法定数量的节点。
如果是读操作,客户端可以直接从与其连接的服务器上读取;如果是写操作,要求在每一台服务器上写入,当然只要有法定数量的节点写入了则可任务操作成功,最终数据会被同步到集群中所有的服务器上。
Q5:如果读操作时,对应的服务器上的数据不是最新的怎么办?
ZooKeeper 严格控制全局变化的顺序,就算客户端连接的 zk server 是状态变化暂时落后的 Follower,最终 Leader 也会把滞后的变化更新到 Follewer上,客户端可以通过监视点和查询的方式,不会错过任何变化。
Q6:为什么写操作需要写入全部服务器,实际处理是怎么样的?
写入全部服务器是最终的处理状态,实际处理时,客户端连接的 zk server 假如收到一个修改写入数据的请求,会将其先发给 Leader,Leader 再将这个操作包装成一个事务广播通知所有 Follower ,Follower 收到后会记录一条事务日志,然后回复 Leader accept 该处理,只要有收到法定数量的 Follower 的确认,Leader 就认为这个提案可以通过了,先修改 Leader 上的数据,再广播通知所有的 Follower commit事务。
当 zk 服务端响应写操作成功时,不一定所有的服务器都同步写入数据成功了,但过一段时间会同步到集群中的每一台服务器上。
Q7:ZooKeeper 集群中服务器数量是不是越多性能越强?
ZooKeepr 主要是为读操作场景设计的,当执行读操作时,集群中的任何一个节点都可以处理该操作,所以服务器数量越多,读操作的吞吐率越大;如果是写操作,由于数据需要写入到法定数量节点才算写入操作成功,所以服务器越多写入处理速度越慢,吞吐率越低。两者一平衡,其实性能上没有太大提升,但如果应用是读操作为主,确实性能上会有明显提升。
2、会话
一般我们常见的会话,都是客户端与服务端失去连接之后,会话就会失效和被删除,但是 ZooKeeper 中的情况有所不同。
客户端向 ZooKeeper 服务端发起请求之前必须先建立会话,会话建立后,客户端会跟集群中的某台服务器保持TCP连接(如果是独立模式就只有那台服务器了),并且每隔一段时间会发送一个心跳检测并且给会话延续超时时间,如果由于网络或其他问题导致连接丢失,或者连接的那台服务器负载太重导致不能及时响应客户端而超时,客户端都会认为当前连接的这台服务器不可用了,并尝试连接集群中的另外一台服务器。
会话并不会马上随着连接丢失重连其他服务器而失效,只要在超时时间内客户端连接上另外一台服务器,会话就可以被转移到另一台服务器上。会话提供了顺序保障,同个会话中的请求会以 FIFO 顺序被执行。
(1)会话的状态
会话的状态有 NOT_CONNECTED
、CONNECTING
、CONNECTED
、CLOSED
四种。
一个会话刚创建时,会话是
NOT_CONNECTED
;当客户端初始化完成后转换到 CONNECTING
,并尝试与 ZooKeeper 连接;连接成功后,会话转换到 CONNECTED
;当客户端与 zk server 的通讯中断,或者客户端无法收到服务端响应时,会切换到 CONNECTING
状态,并先尝试连接原来的 ZooKeeper 服务器,如果超时连接时间后还连不上,就尝试连接集群中其他 ZooKeeper 服务器;如果重连成功,则状态有转换到 CONNECTED
,否则当会话过期时,转换到 CLOSED
状态,当客户端完成工作,主动关闭时,会话也会转换到 CLOSED
状态。
(2)会话的生命周期
ZooKeeper 集群对声明会话超时负责,而不是客户端负责。当客户端与服务器通信因超时中断时,客户端仍然会保持 CONNECTING
状态,除非在通信恢复正常后,从服务端获悉会话已超时,或者显式关闭会话,否则客户端不能声明自己的会话超时。
对于服务端来说,当一个会话被声明时,是有带超时时间的,假设为 t
,如果在一个 t
时间周期内,客户端跟服务端连接一直断开,则服务端声明会话超时。对客户端,在 t/3
时间未收到响应信息,客户端将想服务器发送心跳信息;在 2t/3
时间后,如果还连接不上,客户端就会从集群中选一台其他服务器去连接,并将会话转移到新的服务器上。
Q8:当客户端无法重连原来的服务器选择 ZooKeeper 集群中的其他机器连接时,是否任意一台机器都可以?
不是。在 ZooKeeper 中,写操作(包括写入、修改数据,创建、删除节点)被看成是一个事务,并且会根据每一个写操作的顺序为每个事务常见一个事务标识符(zxid),可以用来表示服务器的状态。当客户端重连到一台新的服务器时,必须保证该服务器的 ZooKeeper 状态不能滞后与客户端最后连接的服务器,由于重连前客户端状态是与最后连接的服务器保持一致的,也就是说客户端不能连接到这样的服务器:它未发现更新而客户端却已经发现的更新。进而保证客户端接收更新的与 ZooKeeper 保持全局有序。
(1)客户端连接s1。
(2)客户端执行创建操作。操作成功并获得服务器分配的 zxid 1。
(3)客户端与s1断开连接。
(4)客户端尝试连接s2,但是服务器有一个较低的 zxid。
(5)客户端尝试连接s2并成功。