ZooKeeper 的网络通信协议详解

在 ZooKeeper 中无论是客户端和服务器之间的通信,还是集群之间服务器的内部协同工作都是基于网络进行通信的。而网络通信协议则是影响 ZooKeeper 性能和稳定性的核心点。

ZooKeeper 协议简述

说到网络通信协议我们最为熟悉的应该就是 TCP/IP 协议。
而 ZooKeeper 则是在 TCP/IP 协议的基础上实现了自己特有的通信协议格式。在 ZooKeeper 中一次客户端的请求协议由请求头、请求体组成。而在一次服务端的响应协议中由响应头和响应体组成。

ZooKeeper 协议的底层实现

了解了 ZooKeeper 中的网络通信协议的结构后。接下来我们看一下在 ZooKeeper 中的内部对于网络通信协议的底层是怎么样实现的。
 
 

客户端请求头底层解析

请求协议就是客户端向服务端发送的协议。比如我们经常用到的会话创建、数据节点查询等操作。都是客户端通过网络向 ZooKeeper 服务端发送请求协议完成的。
首先,我们先看一下请求头的内部的实现原理。在 ZooKeeper 中请求头是通过 RequestHeader 类实现的。首先 RequestHeader 类实现了 Record 接口,用于之后在网络传输中进行序列化操作。
我们可以看到 RequestHeader 类中只有两个属性字段分别是 xid 和 type。这两个字段在我们第一节课 ZooKeeper 的数据模型中介绍过,分别代表客户端序号用于记录客户端请求的发起顺序以及请求操作的类型。
class RequestHeader implements Record{ private int xid; private int type; }
我们接下来再看一下客户端请求协议的请求体,协议的请求体包括了协议处理逻辑的全部内容,一次会话请求的所有操作内容都涵盖在请求体中。
在 ZooKeeper 的内部实现中,根据不同的请求操作类型,会采用不同的结构封装请求体。
接下来我们就以最常用的创建一次会话和数据节点的查询和更新这三种操作来介绍,深入底层看看 ZooKeeper 在内部是如何实现的。

会话创建

ZooKeeper 客户端发起会话时,会向服务端发送一个会话创建请求,该请求的作用就是通知 ZooKeeper 服务端需要处理一个来自客户端的访问链接。
而服务端处理会话创建请求时所需要的所有信息都包括在请求体内。
在 ZooKeeper 中该请求体是通过 ConnectRequest 类实现的,其内部一共包括了五种属性字段。
分别是 protocolVersion 表示该请求协议的版本信息、lastZxidSeen 最后一次接收到的服务器的 zxid 序号、timeOut 会话的超时时间、会话标识符 sessionId 以及会话的密码 password。
有了这些信息 ZooKeeper 服务端在接收一个请求时,就可以根据请求体的信息进行相关的操作了。
ConnectRequest implements Record { private int protocolVersion; private long lastZxidSeen; private int timeOut; private long sessionId; private byte[] passwd; }

节点查询

通过客户端 API 查询 ZooKeeper 服务器上的数据节点时,客户端会向服务端发送 GetDataRequest 会话请求。
与上面介绍的会话请求不同。ZooKeeper 在处理获取数据节点会话请求时,选择了另一种结构作为该协议的请求体。而具体的实现类则是 GetDataRequest
在 GetDataRequest 类中首先实现了 Record 接口用于序列化操作。其具有两个属性分别是字符类型 path 表示要请求的数据节点路径以及布尔类型 watch 表示该节点是否注册了 Watch 监控。
节点路径如下:
public class GetDataRequest implements Record { private String path; private boolean watch;

节点更新

最后,我们来看一下最后一种会话操作类型即节点的更新操作,同样的在客户端向服务端发送一个数据节点更新操作时,其在网络上实际发送的是更新操作的请求协议。
而在 ZooKeeper 中对于协议内部的请求体,ZooKeeper 通过 SetDataRequest 类进行了封装。在 SetDataRequest 内部也包含了三种属性,分别是 path 表示节点的路径、data 表示节点数据信息以及 version 表示节点期望的版本号用于锁的验证。
public class SetDataRequest implements Record { private String path; private byte[] data; private int version; }
到目前为止我们就对 ZooKeeper 客户端在一次网络会话请求中所发送的请求协议的内部结构和底层实现都做了介绍,然而这些都是客户端向服务器端的请求协议,接下来我们就继续分析 ZooKeeper 服务端向客户端发送的响应协议是如何实现的。

响应协议

在服务端接收到客户端的请求后,执行相关操作将结果通知给客户端。
而在 ZooKeeper 服务端向客户单发送的响应协议中,也是包括了请求头和请求体。
而与客户端的请求头不同的是在 ZooKeeper 服务端的请求头多了一个错误状态字段。具体的实现类是 ReplyHeader。
public class ReplyHeader implements Record { private int xid; private long zxid; private int err; }

服务端请求体解析

下面我们再看一下响应协议的请求体部分,服务端的请求体可以理解为对客户端所请求内容的封装,一个服务端的请求体包含了客户端所要查询的数据而对于不同的请求类型,在 ZooKeeper 的服务端也是采用了不同的结构进行处理的。与上面我们讲解客户端请求体的方法一样,我们还是通过会话的创建、数据节点的查询和修改这三种请求操作来介绍,看看 ZooKeeper 服务端是如何响应客户端请求的。

响应会话创建

对于客户端发起的一次会话连接操作,ZooKeeper 服务端在处理后,会返回给客户端一个 Response 响应。而在底层代码中 ZooKeeper 是通过 ConnectRespose 类来实现的。在该类中有四个属性,分别是 protocolVersion 请求协议的版本信息、timeOut 会话超时时间、sessionId 会话标识符以及 passwd 会话密码。
public class ConnectResponse implements Record { private int protocolVersion; private int timeOut; private long sessionId; private byte[] passwd; }
响应节点查询
在客户端发起查询节点数据的请求时,服务端根据客户端发送的节点路径,并验证客户端具有相应的权限后,会将节点数据返回给客户端。
而 ZooKeeper 服务端通过 GetDataResponse 类来封装查询到的节点相关信息到响应协议的请求体中。
在 GetDataResponse 内部有两种属性字段分别是 data 属性表示节点数据的内容和 stat 属性表示节点的状态信息。
public class GetDataResponse implements Record { private byte[] data; private org.apache.zookeeper.data.Stat stat; }

响应节点更新

在客户端发送一个节点变更操作后, ZooKeeper 服务端在处理完相关逻辑后,会发送一个响应给客户端。而在 ZooKeeper 中更新完节点后会将操作结果返回给客户端,节点更新操作的响应协议请求体通过 SetDataResponse 类来实现。而在该类的内部只有一个属性就是 stat 字段,表示该节点数据更新后的最新状态信息。
public class SetDataResponse implements Record { private org.apache.zookeeper.data.Stat stat; }

会话属性

notion image
下面我们来分别介绍一下这三个部分:
  • 会话 ID:会话 ID 作为一个会话的标识符,当我们创建一次会话的时候,ZooKeeper 会自动为其分配一个唯一的 ID 编码。
  • 会话超时时间:
    • 一般来说,一个会话的超时时间就是指一次会话从发起后到被服务器关闭的时长。
      而设置会话超时时间后,服务器会参考设置的超时时间,最终计算一个服务端自己的超时时间。
      zookeeper会话超时设置的坑
      如果一个会话的超时时间是在服务器端实现的。会话超时服务器端会主动关闭会话。
      所以在会话请求的请求体中包括了设置会话超时的属性字段,而我们经常会遇到一个问题就是明明通过客户端将超时时间设置了一个值,而在实际执行的时候会话超时的时间可能远远小于我们设置的时间。
      这是因为在 ZooKeeper 处理会话超时时间的时候不只是简单地使用客户端传来的超时时间还会根据 minSessionTimeout maxSessionTimeout 这两个参数来调整时间的设置
      而这个超时时间则是最终真正用于 ZooKeeper 中服务端用户会话管理的超时时间。
  • 会话关闭状态:会话关闭 isClosing 状态属性字段表示一个会话是否已经关闭。
    • 如果服务器检查到一个会话已经因为超时等原因失效时, ZooKeeper 会在该会话的 isClosing 属性值标记为关闭,再之后就不对该会话进行操作了。

会话状态

通过上面的学习,我们知道了 ZooKeeper 中一次会话的内部结构。下面我们就从系统运行的角度去分析,一次会话从创建到关闭的生命周期中都经历了哪些阶段。
notion image
上面是来自 ZooKeeper 官网的一张图片。该图片详细完整地描述了一次会话的完整生命周期。而通过该图片我们可以知道,在 ZooKeeper 服务的运行过程中,会话会经历不同的状态变化。而这些状态包括:正在连接(CONNECTING)、已经连接(CONNECTIED)、正在重新连接(RECONNECTING)、已经重新连接(RECONNECTED)、会话关闭(CLOSE)等。
当客户端开始创建一个与服务端的会话操作时,它的会话状态就会变成 CONNECTING,之后客户端会根据服务器地址列表中的服务器 IP 地址分别尝试进行连接。如果遇到一个 IP 地址可以连接到服务器,那么客户端会话状态将变为 CONNECTIED。
而如果因为网络原因造成已经连接的客户端会话断开时,客户端会重新尝试连接服务端。而对应的客户端会话状态又变成 CONNECTING ,直到该会话连接到服务端最终又变成 CONNECTIED。
在 ZooKeeper 服务的整个运行过程中,会话状态经常会在 CONNECTING 与 CONNECTIED 之间进行切换。最后,当出现超时或者客户端主动退出程序等情况时,客户端会话状态则会变为 CLOSE 状态。

会话底层实现

一个会话可以看作是由四种不同的属性字段组成的一种数据结构。而在整个 ZooKeeper 服务的运行过程中,会话管理的本质就是围绕这个数据结构进行操作。
说到 ZooKeeper 中会话的底层实现,就不得不说 SessionTracker 类,该类可以说是 ZooKeeper 实现会话的核心类,用来实现会话管理和维护等相关操作。可以说,在 ZooKeeper 会话的整个生命周期中都离不开 SessionTracker 类的参与。
SessionTracker 是一个接口类型,其规定了 ZooKeeper 中会话管理的相关操作行为。而具体的实现方式则是通过 SessionTrackerImpl 类来完成的。
class SessionTrackerImpl implements SessionTracker{ ConcurrentHashMap<Long, SessionImpl> sessionsById; ConcurrentMap<Long, Integer> sessionsWithTimeout; }
SessionTrackerImpl 类实现了 SessionTracker 接口。在其中有两个属性字段,分别是:
  • sessionsById,用于根据会话 ID 来管理具体的会话实体。
  • sessionsWithTimeout,根据不同的会话 ID 管理每个会话的超时时间。
而在 SessionTrackerImpl 类初始化的时候,首先会调用 initializeNextSession 方法来生成一个会话 ID ,该会话 ID 会作为一个唯一的标识符,在 ZooKeeper 服务之后的运行中用来标记一个特定的会话。
public static long initializeNextSession(long id) { long nextSid; nextSid = (Time.currentElapsedTime() << 24) >>> 8; nextSid = nextSid | (id <<56); return nextSid; }
通过上面的代码,我们可以了解到生成会话 ID 的过程,首先以毫秒为单位获取系统的当前时间,之后将该值通过位运算方式向左移动 24 位,再向右移动 8 位。最后根据服务器的 SID 进行或运算,得到的最终结果就作为该会话的 ID 编码。
有了会话 ID 和超时时间等信息后,一个会话相关的所有数据就基本具备了,也就完成了一个会话的创建工作。接下来就是服务器接收到会话请求后的处理等操作了。

会话异常

在平时的开发工作中,我们最常遇到和处理的场景就是会话超时异常。
在 ZooKeeper 中,会话的超时异常包括客户端 readtimeout 异常和服务器端 sessionTimeout 异常。在我们平时的开发中,要明确这两个异常的不同之处在于一个是发生在客户端,而另一个是发生在服务端。
而对于那些对 ZooKeeper 接触不深的开发人员来说,他们常常踩坑的地方在于,虽然设置了超时间,但是在实际服务运行的时候 ZooKeeper 并没有按照设置的超时时间来管理会话。
这是因为 ZooKeeper 实际起作用的超时时间是通过客户端和服务端协商决定
ZooKeeper 客户端在和服务端建立连接的时候,会提交一个客户端设置的会话超时时间,而该超时时间会和服务端设置的最大超时时间和最小超时时间进行比对,如果正好在其允许的范围内,则采用客户端的超时时间管理会话。
如果大于或者小于服务端设置的超时时间,则采用服务端设置的值管理会话。
ZooKeeper 生成会话 ID 编码的算法看起来比较复杂,那么这个算法是否严谨呢?
该算法实现上看起比较复杂,但是其实并不完美。其最主要的问题在于其算法的基础是通过Java 语言获取系统的当前时间作为算法的基本参数。而在一些情况下,在位运算的过程中会产生负数等问题,不过这个问题在 3.4.6 版本后得到了解决,这里请你注意,在使用 ZooKeeper 的时候,如果是在维护旧有系统时要考虑这个问题。

会话管理策略

我们知道 ZooKeeper 作为分布式系统的核心组件,在一个分布式系统运行环境中经常要处理大量的会话请求,而 ZooKeeper 之所以能够快速响应客户端操作,这与它自身的会话管理策略密不可分。
ZooKeeper 中为了保证一个会话的存活状态,客户端需要向服务器周期性地发送心跳信息。而客户端所发送的心跳信息可以是一个 ping 请求,也可以是一个普通的业务请求。
ZooKeeper 服务端接收请求后,会更新会话的过期时间,来保证会话的存活状态。从中也能看出,在 ZooKeeper 的会话管理中,最主要的工作就是管理会话的过期时间。
ZooKeeper 中采用了独特的会话管理方式来管理会话的过期时间,网络上也给这种方式起了一个比较形象的名字:“分桶策略”。
如下图所示,在 ZooKeeper 中,会话将按照不同的时间间隔进行划分,超时时间相近的会话将被放在同一个间隔区间中,这种方式避免了 ZooKeeper 对每一个会话进行检查,而是采用分批次的方式管理会话。这就降低了会话管理的难度,因为每次小批量的处理会话过期也提高了会话处理的效率。
notion image
通过上面的介绍,我们对 ZooKeeper 中的会话管理策略有了一个比较形象的理解。
而为了能够在日常开发中使用好 ZooKeeper,面对高并发的客户端请求能够开发出更加高效稳定的服务,根据服务器日志判断客户端与服务端的会话异常等。
下面我们从技术角度去说明 ZooKeeper 会话管理的策略,进一步加强对会话管理的理解。

底层实现

说到 ZooKeeper 底层实现的原理,核心的一点就是过期队列这个数据结构。
所有会话过期的相关操作都是围绕这个队列进行的。可以说 ZooKeeper 底层就是采用这个队列结构来管理会话过期的。
而在讲解会话过期队列之前,我们首先要知道什么是 bucket。
简单来说,一个会话过期队列是由若干个 bucket 组成的。而 bucket 是一个按照时间划分的区间。
ZooKeeper 中,通常以 expirationInterval 为单位进行时间区间的划分,它是 ZooKeeper 分桶策略中用于划分时间区间的最小单位。
在 ZooKeeper 中,一个过期队列由不同的 bucket 组成。
每个 bucket 中存放了在某一时间内过期的会话。将会话按照不同的过期时间段分别维护到过期队列之后,
在 ZooKeeper 服务运行的过程中,具体的执行过程如下图所示。
首先,ZooKeeper 服务会开启一个线程专门用来检索过期队列,找出要过期的 bucket,而 ZooKeeper 每次只会让一个 bucket 的会话过期,每当要进行会话过期操作时,ZooKeeper 会唤醒一个处于休眠状态的线程进行会话过期操作,之后会按照上面介绍的操作检索过期队列,取出过期的会话后会执行过期操作。
notion image
下面我们再来看一下 ZooKeeper 底层代码是如何实现会话过期队列的,
在 ZooKeeper 底层中,使用 ExpiryQueue 类来实现一个会话过期策略。
如下面的代码所示,在 ExpiryQueue 类中具有一个 elemMap 属性字段。
它是一个线程安全的 HaspMap 列表,用来根据不同的过期时间区间存储会话。
而 ExpiryQueue 类中也实现了诸如 remove 删除、update 更新以及 poll 等队列的常规操作方法。
public class ExpiryQueue<E> {   private final ConcurrentHashMap<E, Long> elemMap; public Long remove(E elem) {...} public Long update(E elem, int timeout) {...} public Set<E> poll() {...} }
通过 ExpiryQueue 类实现一个用于管理 ZooKeeper 会话的过期队列之后,ZooKeeper 会将所有会话都加入 ExpiryQueue 列表中进行管理。
接下来最主要的工作就是何时去检查该列表中的会话,并取出其中的过期会话进行操作了。
一般来说,当一个会话即将过期时,就要对列表进行操作。
而一个会话的过期时间 = 系统创建会话的时间 + 会话超时时间。而每个会话的创建时间又各不相同,ZooKeeper 服务没有时刻去监控每一个会话是否过期。
而是通过 roundToNextInterval 函数将会话过期时间转化成心跳时间的整数倍,根据不同的过期时间段管理会话。
private long roundToNextInterval(long time) {         return (time / expirationInterval + 1) * expirationInterval; }
如上面的代码所示,roundToNextInterval 函数的主要作用就是以向上取正的方式计算出每个会话的时间间隔,当会话的过期时间发生更新时,会根据函数计算的结果来决定它属于哪一个时间间隔。
计算时间间隔公式是(time / ExpirationInterval + 1) ExpirationInterval,比如我们取 expirationInterval 的值为 2,会话的超时 time 为10,那么最终我们计算的 bucket 时间区间就是 12。
现在我们已经介绍了 ZooKeeper 会话管理的所有前期准备工作,当 ZooKeeper 服务进行会话超时检查的时候,会调用 SessionTrackerImpl 类专门负责此工作。
在前面的课程中,我们介绍过 SessionTrackerImpl 是一个线程类。
如下面的代码所示,在 run 方法中会首先获取会话过期的下一个时间点,之后通过 setSessionClosing 函数设置会话的关闭状态。最后调用 expire 方法进行会话清理工作。
public void run() { try { while (running) { long waitTime = sessionExpiryQueue.getWaitTime(); if (waitTime > 0) { Thread.sleep(waitTime); continue; } for (SessionImpl s : sessionExpiryQueue.poll()) { setSessionClosing(s.sessionId); expirer.expire(s); } } ... }
接下来我们再深入到 expire 方法内部来看看 ZooKeeper 一次会话管理中的最后一步:会话的过期清理工作。
如下面的代码所示,在 expire 函数的内部,主要工作就是发起一次会话过期的请求操作。
首先通过 close 函数向整个 ZooKeeper 服务器发起一次会话过期的请求操作。接收到请求后,ZooKeeper 就会执行诸如删除该会话的临时节点、发起 Watch 通知等操作。
private void close(long sessionId) { Request si = new Request(null,sessionId,0,OpCode.closeSession, null, null); setLocalSessionFlag(si); submitRequest(si); }
,ZooKeeper 这种会话管理的好处,在实际生产中为什么它能提高服务的效率?
答案是 ZooKeeper 这种分段的会话管理策略大大提高了计算会话过期的效率,如果是在一个实际生产环境中,一个大型的分布式系统往往具有很高的访问量。而 ZooKeeper 作为其中的组件,对外提供服务往往要承担数千个客户端的访问,这其中就要对这几千个会话进行管理。在这种场景下,要想通过对每一个会话进行管理和检查并不合适,所以采用将同一个时间段的会话进行统一管理,这样就大大提高了服务的运行效率。将过期时间相近的会话放在一个桶中 backet,这样的话,在检查的时候和在执行过期的时候以backet为单位,会提高检查的效率,不用一个个的检查了。

服务端是如何处理一次会话请求的?

会话的创建过程,当客户端需要和 ZooKeeper 服务端进行相互协调通信时,首先要建立该客户端与服务端的连接会话,在会话成功创建后,ZooKeeper 服务端就可以接收来自客户端的请求操作了。

流程

ZooKeeper 服务端在处理一次客户端发起的会话请求时,所采用的处理过程很像是一条工厂中的流水生产线。比如在一个毛绒玩具加工厂中,一条生产线上的工人可能只负责给玩具上色这一个具体的工作。
notion image
ZooKeeper 处理会话请求的方式也像是一条流水线,在这条流水线上,主要参与工作的是三个“工人”,分别是 PrepRequestProcessorProposalRequestProcessor 以及 FinalRequestProcessor
这三个“工人”会协同工作,最终完成一次会话的处理工作,而它的实现方式就是我们之前提到的责任链模式。
下面我将分别对这三个部分进行讲解:
作为第一个处理会话请求的“工人”,PrepRequestProcessor 类主要负责请求处理的准备工作,比如判断请求是否是事务性相关的请求操作。
PrepRequestProcessor 完成工作后,ProposalRequestProcessor 类承接接下来的工作,对会话请求是否执行询问 ZooKeeper 服务中的所有服务器之后,执行相关的会话请求操作,变更 ZooKeeper 数据库数据。最后所有请求就会走到 FinalRequestProcessor 类中完成踢出重复会话的操作。

底层实现

通过上面的介绍,我们对 ZooKeeper 服务端在处理一次会话请求的方法过程会有比较具体的了解。
接下来我们再从底层实现的角度分析一下在代码层面的实现中,ZooKeeper 有哪些值得我们注意和学习的地方。

请求预处理器

在 ZooKeeper 服务端,第一个负责处理请求会话的类是 PrepRequestProcessor。
它是 ZooKeeper 责任链处理模式上的第一个处理器。
PrepRequestProcessor 实现了 RequestProcessor 接口,并继承了线程类 Thread,说明其可以通过多线程的方式调用。
在 PrepRequestProcessor 类内部有一个 RequestProcessor 类型的 nextProcessor 属性字段,从名称上就可以看出该属性字段的作用是指向下一个处理器。
public class PrepRequestProcessor extends Thread implements RequestProcessor { RequestProcessor nextProcessor; }
PrepRequestProcessor 类的主要作用是分辨要处理的请求是否是事务性请求,比如创建节点、更新数据、删除节点、创建会话等,这些请求操作都是事务性请求,在执行成功后会对服务器上的数据造成影响。
当 PrepRequestProcessor 类收到请求后,如果判断出该条请求操作是事务性请求,就会针对该条请求创建请求事务头、事务体、会话检查、ACL 检查和版本检查等一系列的预处理工作。
如下面的代码所示,上述所有操作的逻辑都是在 PrepRequestProcessor 类中的 pRequest 函数实现的。
PrepRequestProcessor 类的主要作用是分辨要处理的请求是否是事务性请求,比如创建节点、更新数据、删除节点、创建会话等,这些请求操作都是事务性请求,在执行成功后会对服务器上的数据造成影响。
当 PrepRequestProcessor 类收到请求后,如果判断出该条请求操作是事务性请求,就会针对该条请求创建请求事务头、事务体、会话检查、ACL 检查和版本检查等一系列的预处理工作。
如下面的代码所示,上述所有操作的逻辑都是在 PrepRequestProcessor 类中的 pRequest 函数实现的。
protected void pRequest(Request request) throws RequestProcessorException { switch (request.type) { case OpCode.create: CreateRequest createRequest = new CreateRequest(); pRequest2Txn(request.type, zks.getNextZxid(), request,createRequest, true); break; case OpCode.delete: } }
在 pRequest 函数的内部,首先根据 OpCode.create 等字段值来判断请求操作的类型是否是事务操作,如果是事务操作,就调用 pRequest2Txn 函数进行预处理,这之后将该条请求交给 nextProcessor 字段指向的处理器进行处理。

事物处理器

PrepRequestProcessor 预处理器执行完工作后,就轮到 ProposalRequestProcessor 事物处理器上场了,ProposalRequestProcessor 是继 PrepRequestProcessor 后,责任链模式上的第二个处理器。
其主要作用就是对事务性的请求操作进行处理,而从 ProposalRequestProcessor 处理器的名字中就能大概猜出,其具体的工作就是“提议”。所谓的“提议”是说,当处理一个事务性请求的时候,ZooKeeper 首先会在服务端发起一次投票流程,该投票的主要作用就是通知 ZooKeeper 服务端的各个机器进行事务性的操作了,避免因为某个机器出现问题而造成事物不一致等问题。
在 ProposalRequestProcessor 处理器阶段,其内部又分成了三个子流程,分别是:Sync 流程、Proposal 流程、Commit 流程,下面我将分别对这几个流程进行讲解。

Sync 流程

首先我们看一下 Sync 流程,该流程的底层实现类是 SyncRequestProcess 类。
SyncRequestProces 类的作用就是在处理事务性请求时,ZooKeeper 服务中的每台机器都将该条请求的操作日志记录下来,完成这个操作后,每一台机器都会向 ZooKeeper 服务中的 Leader 机器发送事物日志记录完成的通知。

Proposal 流程

在处理事务性请求的过程中,ZooKeeper 需要取得在集群中过半数机器的投票,只有在这种情况下才能真正地将数据改变。
而 Proposal 流程的主要工作就是投票和统计投票结果。投票的方式大体上遵循多数原则,更详细的内容在之后的课程中会展开讲解。

Commit 流程

请你注意,在完成 Proposal 流程后,ZooKeeper 服务器上的数据不会进行任何改变,成功通过 Proposal 流程只是说明 ZooKeeper 服务可以执行事务性的请求操作了,而要真正执行具体数据变更,需要在 Commit 流程中实现,这种实现方式很像是 MySQL 等数据库的操作方式。
在 Commit 流程中,它的主要作用就是完成请求的执行。其底层实现是通过 CommitProcessor 实现的。
如下面的代码所示,CommitProcessor 类的内部有一个 LinkedList 类型的 queuedRequests 队列,queuedRequests 队列的作用是,当 CommitProcessor 收到请求后,并不会立刻对该条请求进行处理,而是将其放在 queuedRequests 队列中。
class CommitProcessor { LinkedList queuedRequests }
之后再调用 commit 方法取出 queuedRequests 队列中等待的请求进行处理,如下面的代码所示:
synchronized public void commit(Request request) {   committedRequests.add(request);   notifyAll(); }
到目前为止,我们就对 ProposalRequestProcessor 处理器的三个流程做了一个大体介绍

最终处理器

在经过了上面一系列的处理过程后,请求最终会到达责任链上的最后一个处理器:FinalRequestProcessor。该处理器的作用是检查请求的有效性。而所谓的有效性就是指当前 ZooKeeper 服务所处理的请求是否已经处理过了,如果处理过了,FinalRequestProcessor 处理器就会将该条请求删除;如果不这样操作,就会重复处理会话请求,这样就造成不必要的资源浪费。