ZooKeeper
1 Zookeeper 数据模型
1.1 znode节点类型与特性
- 持久节点:该节点一旦创建为持久节点,该数据节点就会一直存储在ZOokeeper服务器上,即使创建节点的客户端与服务端的会话关闭了,该节点依然不会被删除,如果想要删除持久节点,就要显示调用delete函数进行删除
- 临时节点:当创建该临时节点的客户端会话因超时或发生异常而关闭时,该节点也相应在 ZooKeeper 服务器上被删除。同样,我们可以像删除持久节点一样主动删除临时节点。可以用作集群运行状况统计,在平时的开发中,我们可以利用临时节点的这一特性来做服务器集群内机器运行情况的统计,将集群设置为“/servers”节点,并为集群下的每台服务器创建一个临时节点“/servers/host”,当服务器下线时该节点自动被删除,最后统计临时节点个数就可以知道集群中的运行情况
- 有序节点:所谓节点有序是说在我们创建有序节点的时候,ZooKeeper 服务器会自动使用一个单调递增的数字作为后缀,追加到我们创建节点的后边。
每个节点都维护这些内容:
一个二进制数组(byte data[]) 用来存储节点的数据、ACL访问控制信息、自节点数据(因为临时节点不允许有子节点,所以其子节点的字段为null),除此之外每个节点还有一个记录滋生状态信息的stat。
节点状态信息:
数据节点的版本:
在Zookeeper中为数据节点引入了版本的概念,每个数据节点有3种类型的版本信息,对数据节点的任何更新操作都会引起版本号的变化,Zookeeper的版本信息表示的是对节点数据内容、子节点信息或是ACL信息的修改次数。
Zookeeper不能通过相对路径来查找节点,因为是使用了节点的绝对路径来作为key存储数据。
2 发布订阅模式:Watch
Zookeeper的客户端可以通过watch机制来订阅当服务器上的某一节点的数据状态发生变化时收到相应的通知,通过向Zookeeper客户端的构造方法中传递watch参数的方式实现:new ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)
- connectString 服务端地址
- sessionTimeout:超时时间
- Watcher:监控事件
这个Watch作为整个Zookeeper回话期间的上下文,一直被保存在客户端中。除此之外,ZOokeeper客户端也可以通过getData,exist, getChildren三个接口向服务器中注册watcher。
上图中列出了客户端在不同的会话状态下,相应的服务器节点所能支持的事件类型。
2.1 Watch 机制的底层原理
//创建zk客户端对象实例时注册,这个 Watcher 将作为整个 ZooKeeper 会话期间的默认 Watcher,
//会一直被保存在客户端 ZKWatchManager 的 defaultWatcher 里面,
//如果这个被创建的节点在其它时候被创建watcher并注册,则这个默认的watcher会被覆盖
//watcher触发一次就会失效,不管是创建节点时的 watcher 还是以后创建的 watcher.因为服务端每次触发之后就会删掉服务端的watcher
ZooKeeper(String connectString, int sessionTimeout, Watcher watcher)
getChildren(String path, Watcher watcher)
//Boolean watch表示是否使用上下文中默认的watcher,即创建zk实例时设置的watcher
getChildren(String path, boolean watch)
getData(String path, boolean watch, Stat stat)
getData(String path, Watcher watcher, AsyncCallback.DataCallback cb, Object ctx)
exists(String path, boolean watch)
exists(String path, Watcher watcher)
客户端Watch注册实现过程,在发送一个Watch监控事件的会话请求时,Zookeeper 客户端主要做了两个工作:
- 标记该会话时一个带有Watch事件的请求
- 将Watch事件存储到ZKWatchManager
2.2 服务端Watch注册实现过程
Zookeeper服务端处理Watch事件基本有2个过程:
- 解析收到的请求是否带有Watch注册事件
- 将对应的Watch事件存储到WatchManager
在 ZooKeeper 中,Packet 是一个最小的通信协议单元,即数据包。Pakcet 用于进行客户端与服务端之间的网络传输,任何需要传输的对象都需要包装成一个 Packet 对象。在 ClientCnxn 中 WatchRegistration 也会被封装到 Pakcet 中,然后由 SendThread 线程调用 queuePacke 方法把 Packet 放入发送队列中等待客户端发送,这又是一个异步过程,分布式系统采用异步通信是一个普遍认同的观念。随后,SendThread 线程会通过 readResponse 方法接收来自服务端的响应,异步地调用 finishPacket 方法从 Packet 中取出对应的 Watcher 并注册到 ZKWatchManager 中去。
2.3 服务端Watch事件的触发过程
setData方法内部执行完对节点数据的变更后,会调用WatchManager.triggerWatch方法触发数据变更事件。
2.4 客户端回调的处理过程
客户端使用SendThread.readResponse()方法来统一处理服务端的响应。
首先反序列化服务器发送请求头信息 replyHdr.deserialize(bbia, "header"),并判断相属性字段 xid 的值为 -1,表示该请求响应为通知类型。
第 1 步按照通知的事件类型,从 ZKWatchManager 中查询注册过的客户端 Watch 信息。客户端在查询到对应的 Watch 信息后,会将其从 ZKWatchManager 的管理中删除。因此这里也请你多注意,客户端的 Watcher 机制是一次性的,触发后就会被删除。
2.5 总结
Zookeeper中Watch机制,实现方式大体是通过客户端和服务端分别创建有观察者的信息列表。客户端调用getData、exist等接口时,首先将对应的Watch事件放到本地的ZKWatchManager中进行管理。服务端在接受到客户端的请求后根据请求类型判断是否含有Watch事件,并将对应的事件放到WatchManager中进行管理。
事件触发的时候服务端通过节点的路径信息查询相应的 Watch 事件通知给客户端,客户端在接收到通知后,首先查询本地的 ZKWatchManager 获得对应的 Watch 信息处理回调操作。这种设计不但实现了一个分布式环境下的观察者模式,而且通过将客户端和服务端各自处理 Watch 事件所需要的额外信息分别保存在两端,减少彼此通信的内容。大大提升了服务的处理性能。
1、客户端发起getData请求,并带上Watch对象(异步,将请求封装后放入ClientCnxn的queuePacket 发送队列中),客户端本地存储Watcher
2、服务端接收到请求后,判断是否带有Watcher,交给WatchManager管理
3、事件触发,比如setData后,通过节点路径查询到相应的Watch事件,并通知客户端
4、客户端有一个专门的Event线程循环进行请求处理,然后回调接口,进行自己的业务逻辑处理
订阅发布场景实现。
- Watch是一次性的,每次都需要重新注册。并且客户端在会话异常结束时不会收到任何通知,而快速重链接时仍不影响接收通知。
- Watch的回调执行都是顺序的,并且客户端在没有收到关注数据的变化事件通知之前是不会看到最新的数据,另外,不要在watch回调逻辑中阻塞整个客户端的watch回调。
- watch时轻量的,watchEvent时最小的通信单元,结构上只包含通知状态、事件类型和节点路径。
3 ACL权限控制
ACL的使用:
一个ACL权限设置通常分为3个部分,分别是:权限模式(Schema)、授权对象(ID)】、权限信息(Permission)。最终组成一条例如“schemaid:permission”格式的ACL请求信息。
权限模式:schema
权限模式就是用来设置 ZooKeeper 服务器进行权限验证的方式。ZooKeeper 的权限验证方式大体分为两种类型,一种是范围验证,另外一种是口令验证。
授权对象ID:
所谓的授权对象就是说我们要把权限赋予谁,而对应于 4 种不同的权限模式来说,如果我们选择采用 IP 方式,使用的授权对象可以是一个 IP 地址或 IP 地址段;而如果使用 Digest 或 Super 方式,则对应于一个用户名。如果是 World 模式,是授权系统中所有的用户。
权限信息(Permission):
在Zookeeper中已经定义好的权限有5中:
数据节点(create)创建权限,授予权限的对象可以在数据节点下创建子节点;
数据节点(wirte)更新权限,授予权限的对象可以更新该数据节点;
数据节点(read)读取权限,授予权限的对象可以读取该节点的内容以及子节点的信息;
数据节点(delete)删除权限,授予权限的对象可以删除该数据节点的子节点;
数据节点(admin)管理者权限,授予权限的对象可以对该数据节点体进行 ACL 权限设置。
每个节点都维护自身的ACL权限数据,即使该节点的自节点也是有自己的ACL权限而不是直接继承其父节点的权限。
实现自己的权限控制
实现 ZooKeeper 提供的权限控制器接口 AuthenticationProvider。
ACL内部实现原理
客户端处理过程,ACL权限控制机制的客户端实现相对简单,只是封装请求类型为权限请求,方便服务器识别处理,而发送到服务器的信息包括我们之前提到的权限校验信息。
服务端实现过程
在processPacket方法的内部:
- 首先反序列化客户端的请求信息并封装到AuthPacket对象中
- 之后通过getServerProvider方法根据不同的scheme判断具体的实现类
- 只有通过handlerAuthentication方法进行权限验证
- 如果返回KeeperException.Code.OK,则表示该请求已经通过了权限验证
- 如果返回的状态是其他或者抛出异常,则表示权限验证失败
AddAuthInfo函数:
- 其作用是将解析到的权限信息存储到Zookeeper服务器的内存中
- 该信息在整个会话存活期间一直会保存在服务器中
- 如果会话关闭,该信息则会被删除,这个特性很像数据节点中的临时节点。
服务器如何进行权限校验:
- 首先,在处理一次权限请求时,先通过PerRequestProcessor中的checkAcl函数检查对应的请求权限
- 如果该节点没有任何权限设置,则直接返回
- 如果该节点有权限设置,则循环遍历节点信息进行检查
- 如果有相应的权限,则直接返回表明权限认证成功,否则抛出验证失败的异常。
4 Zookeeper如何进行序列化
使用Zookeeper实现一些功能的主要方式,就是通过客户端与服务端之间的相互通信,首相要解决的问题时通过网络传输数据,而要想通过网络传输数据,必须要先对其进行序列化。
序列化是指将我们定义好的 Java 类型转化成数据流的形式。之所以这么做是因为在网络传输过程中,TCP 协议采用“流通信”的方式,提供了可以读写的字节流。而这种设计的好处在于避免了在网络传输过程中经常出现的问题:比如消息丢失、消息重复和排序等问题。
Zookeeper 中序列化方案:
在 ZooKeeper 中并没有采用和 Java 一样的序列化方式,而是采用了一个 Jute 的序列解决方案作为 ZooKeeper 框架自身的序列化方式。
如何使用Jute实现序列化:
如果我们要想将某个定义的类进行序列化,首先需要该类实现 Record 接口的 serilize 和 deserialize 方法,这两个方法分别是序列化和反序列化方法。在实现了Record 接口后,具体的序列化和反序列化逻辑要我们自己在 serialize 和 deserialize 函数中完成。
Jute在Zookeeper中的底层实现:
Record 接口的内部实现逻辑非常简单,只是定义了一个 序列化方法 serialize 和一个反序列化方法 deserialize 。而在 Record 起到关键作用的则是两个重要的类:OutputArchive 和 InputArchive ,其实这两个类才是真正的序列化和反序列化工具类。
Jute序列化的原理:
Jute 框架给出了 3 种序列化方式,分别是 Binary 方式、Csv 方式、XML 方式。
序列化方式可以通俗地理解成我们将 Java 对象通过转化成特定的格式,从而更加方便在网络中传输和本地化存储。
Jute内部核心算法:
- Binary方式的序列化:Binary 序列化方式,即二进制的序列化方式。正如我们前边所提到的,采用这种方式的序列化就是将 Java 对象信息转化成二进制的文件格式。
- XML方式的序列化:而采用 XML 方式进行序列化的优点则是,通过可扩展标记协议,不同平台或操作系统对序列化和反序列化的方式都是一样的,不存在因为平台不同而产生的差异性,也不会出现如 Binary 二进制序列化方法中产生的大小端的问题。而缺点则是序列化和反序列化的性能不如二进制方式。在序列化后产生的文件相比与二进制方式,同样的信息所产生的文件更大。
- CSV方式的序列化:它和 XML 方式很像,只是所采用的转化格式不同,Csv 格式采用逗号将文本进行分割,我们日常使用中最常用的 Csv 格式文件就是 Excel 文件。
三者对比:
- 二进制底层的实现方式最为简单,性能也最好。
- 而 XML 作为可扩展的标记语言跨平台性更强。
- 而 CSV 方式介于两者之间实现起来也相比 XML 格式更加简单。
在 ZooKeeper 中默认的序列化实现方式是 Binary 二进制方式。这是因为二进制具有更好的性能,以及大多数平台对二进制的实现都不尽相同。
6 Zookeeper 的网络通信协议
说到网络通信协议我们最为熟悉的应该就是 TCP/IP 协议。而 ZooKeeper 则是在 TCP/IP 协议的基础上实现了自己特有的通信协议格式。在 ZooKeeper 中一次客户端的请求协议由请求头、请求体组成。而在一次服务端的响应协议中由响应头和响应体组成。
请求协议:是客户端向服务端发送的协议,比如会话创建、数据节点查询等操作。
客户端请求头底层协议:
class RequestHeader implements Record{
private int xid;// 代表客户端序号用于记录客户端请求的发起顺序
private int type;// 请求操作的类型
}
客户端请求底层解析:
协议的请求体包括了协议处理逻辑的全部内容,一次会话请求的所有操作内容都涵盖在请求体中。
会话创建:在 ZooKeeper 中该请求体是通过 ConnectRequest 类实现的,其内部一共包括了五种属性字段。分别是 protocolVersion 表示该请求协议的版本信息、lastZxidSeen 最后一次接收到的服务器的 zxid 序号、timeOut 会话的超时时间、会话标识符 sessionId 以及会话的密码 password。
节点查询:而具体的实现类则是 GetDataRequest 。在 GetDataRequest 类中首先实现了 Record 接口用于序列化操作。其具有两个属性分别是字符类型 path 表示要请求的数据节点路径以及布尔类型 watch 表示该节点是否注册了 Watch 监控。
节点更新:而在 ZooKeeper 中对于协议内部的请求体,ZooKeeper 通过 SetDataRequest 类进行了封装。在 SetDataRequest 内部也包含了三种属性,分别是 path 表示节点的路径、data 表示节点数据信息以及 version 表示节点期望的版本号用于锁的验证。
响应协议:
服务端请求头响应
服务端请求体协议:
- 响应会话创建:而在底层代码中 ZooKeeper 是通过 ConnectRespose 类来实现的。在该类中有四个属性,分别是 protocolVersion 请求协议的版本信息、timeOut 会话超时时间、sessionId 会话标识符以及 passwd 会话密码。
- 响应节点查询:而 ZooKeeper 服务端通过 GetDataResponse 类来封装查询到的节点相关信息到响应协议的请求体中。在 GetDataResponse 内部有两种属性字段分别是 data 属性表示节点数据的内容和 stat 属性表示节点的状态信息。
- 响应节点更新:节点更新操作的响应协议请求体通过 SetDataResponse 类来实现。而在该类的内部只有一个属性就是 stat 字段,表示该节点数据更新后的最新状态信息。
7 单机模式
启动准备实现:Zookeeper服务的准备阶段大体上分为启动程序入口、zoo.cfg配置文件解析、创建历史文件清理器等。
QuorumPeerMain 类是 ZooKeeper 服务的启动接口,可以理解为 Java 中的 main 函数。 通常我们在控制台启动 ZooKeeper 服务的时候,输入 zkServer.cm 或 zkServer.sh 命令就是用来启动这个 Java 类的。
在 ZooKeeper 启动过程中,首先要做的事情就是解析配置文件 zoo.cfg。
ZooKeeper 采用了 DatadirCleanupManager 类作为历史文件的清理工具类。
可以通过在 zoo.cfg 文件中配置 autopurge.snapRetainCount 和 autopurge.purgeInterval 这两个参数实现数据文件的定时清理功能,autopurge.purgeInterval 这个参数指定了清理频率,以小时为单位,需要填写一个 1 或更大的整数,默认是 0,表示不开启自己清理功能。autopurge.snapRetainCount 这个参数和上面的参数搭配使用,这个参数指定了需要保留的文件数目,默认是保留 3 个。
服务初始化:
经过上面的饿配置文件解析等准备阶段后,Zookeeper碍事服务的初始化节点。初始化阶段可以理解为根据解析准备阶段配置信息,实例化服务对象。服务初始化阶段的主要工作是创建用于服务统计的工具类,如下图所示主要有以下几种:
- ServerStats类,用于服务运行信息统计,ServerStats 类用于统计 ZooKeeper 服务运行时的状态信息统计。主要统计的数据有服务端向客户端发送的响应包次数、接收到的客户端发送的请求包次数、服务端处理请求的延迟情况以及处理客户端的请求次数。在日常运维工作中,监控服务器的性能以及运行状态等参数很多都是这个类负责收集的。
- FI了TxnSnapLog类,可以用于数据管理,该类的作用是用来管理 ZooKeeper 的数据存储等相关操作,可以看作为 ZooKeeper 服务层提供底层持久化的接口。在 ZooKeeper 服务启动过程中,它会根据 zoo.cfg 配置文件中的 dataDir 数据快照目录和 dataLogDir 事物日志目录来创建 FileTxnSnapLog 类。
- 会话管理类 ,设置服务器 TickTime 和会话超时时间、创建启动会话管理器等操作。
ServerCnxnFactory类创建:而我们可以通过 ServerCnxnFactory 类来设置 ZooKeeper 服务器,从而在运行的时候使用我们指定的 NIO 框架。
这是因为 ZooKeeper 启动后,还需要从本地的快照数据文件和事务日志文件中恢复数据。这之后才真正完成了 ZooKeeper 服务的启动。
初始化请求处理链:
这种处理请求的逻辑方式就是责任链模式。而本课时主要说的是单机版服务器的处理逻辑,主要分为PrepRequestProcessor、SyncRequestProcessor、FinalRequestProcessor 3 个请求处理器,而在一个请求到达 ZooKeeper 服务端进行处理的过程,则是严格按照这个顺序分别调用这 3 个类处理请求中的对应逻辑,如下图所示。
8 集群模式
8.1 Zookeeper 集群模式的特点:
在Zookeeper集群中的服务器分为Leader、Follow、Observer三种角色服务器,在集群运行期间这三种服务器所负责的工作各不相同:
- Leader 角色服务器负责管理集群中其他的服务器,是集群中工作的分配和调度者。
- Follow 服务器的主要工作是选举出 Leader 服务器,在发生 Leader 服务器选举的时候,系统会从 Follow 服务器之间根据多数投票原则,选举出一个 Follow 服务器作为新的 Leader 服务器。
- Observer 服务器则主要负责处理来自客户端的获取数据等请求,并不参与 Leader 服务器的选举操作,也不会作为候选者被选举为 Leader 服务器。因此在写请求的时候,不需要等Observer节点的返回确认信息,不会影响写性能。而可以提高读的性能。
8.2 底层实现原理
程序启动:首先,在 ZooKeeper 服务启动后,系统会调用入口 QuorumPeerMain 类中的 main 函数。在 main 函数中的 initializeAndRun 方法中根据 zoo.cfg 配置文件,判断服务启动方式是集群模式还是单机模式。
QuorumPeer类:在 ZooKeeper 服务的集群模式启动过程中,一个最主要的核心类是 QuorumPeer 类。我们可以将每个 QuorumPeer 类的实例看作集群中的一台服务器。在 ZooKeeper 集群模式的运行中,一个 QuorumPeer 类的实例通常具有 3 种状态,分别是参与 Leader 节点的选举、作为 Follow 节点同步 Leader 节点的数据,以及作为 Leader 节点管理集群中的 Follow 节点。
Leader服务器启动过程:在 ZooKeeper 集群中,Leader 服务器负责管理集群中其他角色服务器,以及处理客户端的数据变更请求。在 ZooKeeper 集群选举 Leader 节点的过程中,首先会根据服务器自身的服务器 ID(SID)、最新的 ZXID、和当前的服务器 epoch (currentEpoch)这三个参数来生成一个选举标准。
Follow 服务器启动过程:Follow 机器的主要工作就是和 Leader 节点进行数据同步和交互。当 Leader 机器启动成功后,Follow 节点的机器会收到来自 Leader 节点的启动通知。而该通知则是通过 LearnerCnxAcceptor 类来实现的。该类就相当于一个接收器。专门用来接收来自集群中 Leader 节点的通知信息。
9 会话创建
会话是 ZooKeeper 中最核心的概念之一。客户端与服务端的交互操作中都离不开会话的相关的操作。
会话组成:
- 会话 ID:会话 ID 作为一个会话的标识符,当我们创建一次会话的时候,ZooKeeper 会自动为其分配一个唯一的 ID 编码。
- 会话超时时间:一般来说,一个会话的超时时间就是指一次会话从发起后到被服务器关闭的时长。而设置会话超时时间后,服务器会参考设置的超时时间,最终计算一个服务端自己的超时时间。而这个超时时间则是最终真正用于 ZooKeeper 中服务端用户会话管理的超时时间。
- 会话关闭状态:会话关闭 isClosing 状态属性字段表示一个会话是否已经关闭。如果服务器检查到一个会话已经因为超时等原因失效时, ZooKeeper 会在该会话的 isClosing 属性值标记为关闭,再之后就不对该会话进行操作了。
会话状态:正在连接(CONNECTING)、已经连接(CONNECTIED)、正在重新连接(RECONNECTING)、已经重新连接(RECONNECTED)、会话关闭(CLOSE)等。
在Zookeeper 服务的整个运行过程中,会话状态经常会在Connecting 和Connected之间进行切换,最后,当出现超时或者客户端主动退出程序等情况,客户端会话状态则会变成close状态。
会话底层实现:SessionTracker类。
两个字段:
- sessionsById,用于根据会话 ID 来管理具体的会话实体。
- sessionsWithTimeout,根据不同的会话 ID 管理每个会话的超时时间。
会话异常:
在 ZooKeeper 中,会话的超时异常包括客户端 readtimeout 异常和服务器端 sessionTimeout 异常。在我们平时的开发中,要明确这两个异常的不同之处在于一个是发生在客户端,而另一个是发生在服务端。
ZooKeeper 实际起作用的超时时间是通过客户端和服务端协商决定。
10 ClientCnxn:客户端核心工作类工作原理解析
客户端核心类:
在 ZooKeeper 客户端的底层实现中,ClientCnxn 类是其核心类,所有的客户端操作都是围绕这个类进行的。ClientCnxn 类主要负责维护客户端与服务端的网络连接和信息交互。
客户端向服务端发送创建数据节点或者添加Watch监控等操作,都会先将请求信息封装成Packet对象,Packet类中具有一些请求协议的相关属性字段,包括:
- 请求头信息(RequestHeader)
- 响应头信息 (ReplyHeader)
- 请求信息体(Request)
- 响应信息体(Response)
- 节点路径(clientPath ServerPath)
- Watch 监控信息等
请求队列:
对请求信息进行封装和序列化之后,Zookeeper不会立即将一个请求信息通过网络直接发送给服务端,而是将请求信息添加到队列中,之后通过sendThread线程类来处理相关的请求发送操作。
sendThread:
用来管理操作客户端和服务端IO等。
- 将客户端的请求发送给服务端
- 发送客户端是否存活的心跳检查,sendThread类负责定期向服务端发送PIN包来实现心跳检查。
EventThread:
主要工作可以理解为负责客户端向服务端发送请求等操作。主要负责客户端的事件处理,比如在客户端接受watch通知时,触发客户端的相关方法。
11 如何实现高效的会话管理
在Zookeeper 的会话管理中,最主要的工作就是管理会话的过期时间。
在 ZooKeeper 中,会话将按照不同的时间间隔进行划分,超时时间相近的会话将被放在同一个间隔区间中,这种方式避免了 ZooKeeper 对每一个会话进行检查,而是采用分批次的方式管理会话。这就降低了会话管理的难度,因为每次小批量的处理会话过期也提高了会话处理的效率。
底层实现:
一个会话过期队列是由若干个bucket组成的,而bucket时一个按照时间划分的区间。在zookeeper中,通常以expirationinterval为单位进行时间区间的划分,他是zookeeper分桶策略中用于划分时间区间最小的单位。
在 ZooKeeper 中,一个过期队列由不同的 bucket 组成。每个 bucket 中存放了在某一时间内过期的会话。将会话按照不同的过期时间段分别维护到过期队列之后,在 ZooKeeper 服务运行的过程中,具体的执行过程如下图所示。首先,ZooKeeper 服务会开启一个线程专门用来检索过期队列,找出要过期的 bucket,而 ZooKeeper 每次只会让一个 bucket 的会话过期,每当要进行会话过期操作时,ZooKeeper 会唤醒一个处于休眠状态的线程进行会话过期操作,之后会按照上面介绍的操作检索过期队列,取出过期的会话后会执行过期操作。
ZooKeeper 这种分段的会话管理策略大大提高了计算会话过期的效率,如果是在一个实际生产环境中,一个大型的分布式系统往往具有很高的访问量。而 ZooKeeper 作为其中的组件,对外提供服务往往要承担数千个客户端的访问,这其中就要对这几千个会话进行管理。在这种场景下,要想通过对每一个会话进行管理和检查并不合适,所以采用将同一个时间段的会话进行统一管理,这样就大大提高了服务的运行效率。
12 服务端是如何进行一次会话请求的
处理会话请求的三个Processor:PrepRequestProcessor 、ProposalRequestProcessor 以及 FinalRequestProcessor。
PrepRequestProcessor 类主要负责请求处理的准备工作,比如判断请求是否是事务性相关的请求操作。在 PrepRequestProcessor 完成工作后,ProposalRequestProcessor 类承接接下来的工作,对会话请求是否执行询问 ZooKeeper 服务中的所有服务器之后,执行相关的会话请求操作,变更 ZooKeeper 数据库数据。最后所有请求就会走到 FinalRequestProcessor 类中完成踢出重复会话的操作。
底层实现:
PrepRequestProcessor的主要作用是要分辨要处理的请求是否是事务性请求,比如创建节点、更新数据、删除节点、创建会话等,这些请求操纵都是事务性请求,在执行成功后会对服务器上的数据造成影响。当 PrepRequestProcessor 类收到请求后,如果判断出该条请求操作是事务性请求,就会针对该条请求创建请求事务头、事务体、会话检查、ACL 检查和版本检查等一系列的预处理工作。
事务处理器:ProposalRequestProcessor主要作用是对事务性请求机械能处理。所谓的提议(proposal)就是说,当处理一个事务性请求的时候,ZooKeeper 首先会在服务端发起一次投票流程,该投票的主要作用就是通知 ZooKeeper 服务端的各个机器进行事务性的操作了,避免因为某个机器出现问题而造成事物不一致等问题。在 ProposalRequestProcessor 处理器阶段,其内部又分成了三个子流程,分别是:Sync 流程、Proposal 流程、Commit 流程,下面我将分别对这几个流程进行讲解。
最终处理器:
是为了检查请求的有效性,而所谓的有效性就是指当前 ZooKeeper 服务所处理的请求是否已经处理过了,如果处理过了,FinalRequestProcessor 处理器就会将该条请求删除;如果不这样操作,就会重复处理会话请求,这样就造成不必要的资源浪费。
13 Curator:如何降低zookeeper使用的复杂性
Curator 是一套开源的,Java 语言编程的 ZooKeeper 客户端框架,Curator 把我们平时常用的很多 ZooKeeper 服务开发功能做了封装,例如 Leader 选举、分布式计数器、分布式锁。
14 Leader选举
在Zookeeper集群中,服务器分为了 Leader 服务器、 Follower 服务器以及 Observer 服务器。
Leader 选举是一个过程,在这个过程中 ZooKeeper 主要做了两个重要工作,一个是数据同步,另一个是选举出新的 Leader 服务器。
其实 ZooKeeper 中实现的一致性也不是强一致性,即集群中各个服务器上的数据每时每刻都是保持一致的特性。在 ZooKeeper 中,采用的是最终一致的特性,即经过一段时间后,ZooKeeper 集群服务器上的数据最终保持一致的特性。
底层实现:
Zookeeper采用的是多数原则方式,当一个事务性的请求导致服务器上的数据发生改变时,Zookeeper只要保证集群上的多数机器的数据都正确变更了,就可以保证系统数据的一致性。
广播模式:
Zookeeper在代码层的定义中定义了一个HashSet,来管理集群中FOllower服务器。然后向集群中的Follower服务器发送数据变更的会话请求。follower收到服务变更请求后,实现数据变更操作。
恢复模式:
在Zookeeper中,选举Leader服务器会经历一段时间,因此理论上在Zookeeper集群中会短暂的没有Leader服务器。
在这种情况下接受到事务性操作的时候,Zookeeper服务会先将这个会话进行挂起,挂起的会话不会计算会话超时时间,之后在Leader服务器产生后,系统会同步执行这些会话操作。
LearnerHandler:
可以看作是所有learner服务器内部工作的处理者,它所负责的工作有:进行follower,Observer服务器与leader服务器的数据同步、事务性会话请求以及Proposal提议投票等功能。
Learner是一个多线程类,在Zookeeper集群服务运行过程中,一个follower或observer服务器就对应一个LearnerHandler,在乎去吧非要我要去彼此协调工作的过程中,Leader服务器会与没有给Learner服务器维持一个长链接,并启动一个单独的LearnerHandler线程进行处理。
15 Zookeeper 是如何选中Leader的
服务启动时的Leader选举:发起投票、接收投票、统计投票。
发起投票:
在Zookeeper服务器集群初始化启动的时候,集群中的每一台服务器都会将自己作为Leader服务器进行投票,也就是每次投票时,发送的服务器的myid(服务器标识符)和zxid(集群投票信息标识符)等投票信息字段都指向本机服务器。每一个投票信息都是通过这两个字段组成的。
接收投票:
集群中每个服务器在发起投票的同时,也通过网络接收来自集群中其他服务器的投票信息。
在接收到网络中的投票信息后,服务器内部首先会判断该条投票信息的有效性。检查该条投票信息的时效性,是否是本轮最新的投票,并检查该条投票信息是否是处于 LOOKING 状态的服务器发出的。
统计投票:
对于每条接收到的投票信息,集群中的每一台服务器都会将自己的投票信息与接收到的zookeeper集群中的其他投票信息进行对比。主要对比的内容是zxid,zxid树枝比较大的投票信息优先作为leader服务器,如果zxid相同,则比较myid,myid比较大的作为leader服务器,然后重新向zookeeper集群中的服务器发送投票信息。
而当每轮投票过后,ZooKeeper 服务都会统计集群中服务器的投票结果,判断是否有过半数的机器投出一样的信息。如果存在过半数投票信息指向的服务器,那么该台服务器就被选举为 Leader 服务器。
当 ZooKeeper 集群选举出 Leader 服务器后,ZooKeeper 集群中的服务器就开始更新自己的角色信息,除被选举成 Leader 的服务器之外,其他集群中的服务器角色变更为 Following。
服务运行时leader选举:
而整个 ZooKeeper 集群在重新选举 Leader 时也经过了四个过程,分别是变更服务器状态、发起投票、接收投票、统计投票。其中,与初始化启动时 Leader 服务器的选举过程相比,变更状态和发起投票这两个阶段的实现是不同的。
变更状态:
leader服务器崩溃后,zookeeper集群中的其他服务器会将自身状态信息变为LOOKING状态,表示服务器已经做好了选举新leader服务器的准备了。
底层实现:QuorumCnxManager作为核心的实现类。,用来管理Leader服务器与follow服务器的tcp通信,以及消息的接收与发送等功能。在quorumCnxManager中,主要定义了ConcurrentHashMap
16 Zookeeper 集群中Leader与Follower的数据同步策略
Zookeeper集群中的服务要进行数据同步,而主要的数据同步是从Learning服务器同步Leader服务器上的数据。
同步方法:
- 同步条件:何时触发同步。要想进行集群中的数据同步,首先需要 ZooKeeper 集群中存在用来进行数据同步的 follower服务器。 也就是说,当 ZooKeeper 集群中选举出 Leader 节点后,除了被选举为 Leader 的服务器,其他服务器都作为follower 服务器,并向 Leader 服务器注册。之后系统就进入到数据同步的过程中。
- 同步过程:事务性的会话请求会被同步,而数据节点查询等非事务性请求不在数据同步的操作范围。四种数据同步方式:
- DIFF同步,即差异化同步
- TRUNC+DIFF同步,代表先回滚再执行差异的同步,这种方式一般发生在Learning服务器上存在一条事务性的操作日志,但在集群中的Leader服务器上并不存在的情况,发生这种秦光的可能原因是Leader服务器以及将事务记录在本地食物日志中,但没有发起Proposal流程。
- TRUNC同步:指仅回滚操作,就是将Learning服务器上的操作日志回滚到与Leader服务器上的操作日志数据一致的状态下,之后并不惊醒DIFF方式的数据同步。
- SNAP同步,是全量同步。
- 同步后的处理:learning服务器接收到事务日志,然后进行本地化
底层实现:
底层实现是用Learner.syncWithLeader(),在确定了数据同步的方式后,再调用 packetsCommitted.add(qp.getZxid()) 方法将事物操作同步到处理队列中,之后调用事物操作线程进行处理。
17 集群中Leader的作用
事务的请求处理与调度分析
事务性请求处理:
在 ZooKeeper 集群内部,集群中除 Leader 服务器外的其他角色服务器接收到来自客户端的事务性会话请求后,必须将该条会话请求转发给 Leader 服务器进行处理。 ZooKeeper 集群中的 Follow 和 Observer 服务器,都会检查当前接收到的会话请求是否是事务性的请求,如果是事务性的请求,那么就将该请求以 REQUEST 消息类型转发给 Leader 服务器。
Leader事务处理分析:
- 预处理阶段:在预处理阶段,主要工作是通过网络 I/O 接收来自客户端的会话请求。判断该条会话请求的类型是否是事务性的会话请求,之后将该请求提交给PrepRequestProcessor,封装请求头并检查会话是否过期,最后反序列化事务请求信息创建setDataRequest请求,包含要创建的数据节点、路径节点的内容信息以及数据节点的版本信息,最后将请求存放在outstandingChanges队列中等待之后的处理。
- 事务处理阶段:在事务处理阶段,ZooKeeper 集群内部会将该条会话请求提交给 ProposalRequestProcessor 处理器进行处理。
- 事务执行阶段:在处理数据变更的过程中,ZooKeeper 内部会将该请求会话的事务头和事务体信息直接交给内存数据库 ZKDatabase 进行事务性的持久化操作。之后返回 ProcessTxnResult 对象表明操作结果是否成功。
- 响应客户端:
事务底层处理实现:
首先调用PrepRequestProcessor.pRequest()来判断客户端发送的会话请求类型。如果是setData数据节点创建等事务性的会话请求,就调用pRequest2Txn方法进一步处理。
18 集群中Follow的作用
非事务请求的处理与Leader的选举分析。
非事务性请求处理过程。所谓事务性请求,是指 ZooKeeper 服务器执行完该条会话请求后,是否会导致执行该条会话请求的服务器的数据或状态发生改变,进而导致与其他集群中的服务器出现数据不一致的情况。
当zookeeper集群接收到来自客户端发送的查询会话请求后,会讲该请求分配给follow服务器进行请求,而在follow服务器内部,也采用了责任链的处理方式来处理来自客户端的每一个会话请求。
FollowerRequestProcessor 作为第一个处理器,主要负责筛选该条会话请求是否是事务性的会话请求。如果是事务性的会话请求,则转发给 Leader 服务器进行操作。如果不是事务性的会话请求,则交由 Follow 服务器处理链上的下一个处理器进行处理。而下一个处理器是 CommitProcessor ,该处理器的作用是对来自集群中其他服务器的事务性请求和本地服务器的提交请求操作进行匹配。匹配的方式是,将本地执行的 sumbit 提交请求,与集群中其他服务器接收到的 Commit 会话请求进行匹配,匹配完成后再交由 Follow 处理链上的下一个处理器进行处理。最终,当一个客户端会话经过 Final 处理器操作后,就完成了整个 Follow 服务器的会话处理过程,并将结果响应给客户端。
底层实现:
FollowZookeeperServer:封装Follow服务器的属性和行为,你可以把该类当作一台follow服务器的代码抽象。
选举过程:
在leader服务器崩溃的时候,重新选举出leader服务器。
ZooKeeper 集群重新选举 Leader 的过程本质上只有 Follow 服务器参与工作。而在 ZooKeeper 集群重新选举 Leader 节点的过程中,如下图所示。主要可以分为 Leader 失效发现、重新选举 Leader 、Follow 服务器角色变更、集群同步这几个步骤。
Leader失效:
和我们之前介绍的保持客户端活跃性的方法,它是通过客户端定期向服务器发送 Ping 请求来实现的。在 ZooKeeper 集群中,探测 Leader 服务器是否存活的方式与保持客户端活跃性的方法非常相似。
- 首先,Follow 服务器会定期向 Leader 服务器发送 网络请求,在接收到请求后,Leader 服务器会返回响应数据包给 Follow 服务器
- 而在 Follow 服务器接收到 Leader 服务器的响应后,如果判断 Leader 服务器运行正常,则继续进行数据同步和服务转发等工作,反之,则进行 Leader 服务器的重新选举操作。
Leader重新选举:
如果是集群中个别的 Follow 服务器发现返回错误,并不会导致 ZooKeeper 集群立刻重新选举 Leader 服务器,而是将该 Follow 服务器的状态变更为 LOOKING 状态,并向网络中发起投票,当 ZooKeeper 集群中有更多的机器发起投票,最后当投票结果满足多数原则的情况下。ZooKeeper 会重新选举出 Leader 服务器。
Follow角色变更:
变更后需要同步数据
集群同步数据:
在 ZooKeeper 集群成功选举 Leader 服务器,并且候选 Follow 服务器的角色变更后。为避免在这期间导致的数据不一致问题,ZooKeeper 集群在对外提供服务之前,会通过 Leader 角色服务器管理同步其他角色服务器
底层实现:
选举leader服务器时,通过FastLeaderElection类来实现。
选举过程中,首先调用toSend函数像Zookeeper集群中的其他服务器发送本机的投票信息,其他服务器在接受到投票信息后,会对投票信息进行有效性验证,之后,Zookeeper集群统计投票信息,如果过半数的机器投票信息一致,则集群重新选举出新的leader服务器。
需要注意:
重新选举leader服务器的过程中,zookeeper集群理论上无法进行事务的请求处理,因此,发送到zookeeper集群中的事务性会话会被挂起,暂时不执行,等到选举出新的leader服务器后再进行操作。
Paxos算法
- 第一阶段:Prepare阶段。Proposer向Acceptors发出Prepare请求,Acceptors针对收到的Prepare请求进行Promise承诺。
- 第二阶段:Accept阶段。Proposer收到多数Acceptors承诺的Promise后,向Acceptors发出Propose请求,Acceptors针对收到的Propose请求进行Accept处理。
- 第三阶段:Learn阶段。Proposer在收到多数Acceptors的Accept之后,标志着本次Accept成功,决议形成,将形成的决议发送给所有Learners。
活锁:
p1发送prepare请求,收到过半响应,p2发出m2的prepare请求,也收到过半,并承诺不再接受编号小于m2的请求
第二阶段,p1发出的accept请求被acceptor忽略,因为m1
p2在阶段二的accept请求被忽略,因为m2
解决方法:选取主proposer来保证活性
ZAB
zxid:Epoch+Counter,各32位
实际上当新的leader选举成功后,会拿到当前集群中最大的一个ZXID,并去除这个ZXID的epoch,并将此epoch进行加1操作,作为自己的epoch。
消息广播具体步骤
1)客户端发起一个写操作请求。
2)Leader 服务器将客户端的请求转化为事务 Proposal 提案,同时为每个 Proposal 分配一个全局的ID,即zxid。
3)Leader 服务器为每个 Follower 服务器分配一个单独的队列,然后将需要广播的 Proposal 依次放到队列中取,并且根据 FIFO 策略进行消息发送。
4)Follower 接收到 Proposal 后,会首先将其以事务日志的方式写入本地磁盘中,写入成功后向 Leader 反馈一个 Ack 响应消息。
5)Leader 接收到超过半数以上 Follower 的 Ack 响应消息后,即认为消息发送成功,可以发送 commit 消息。
6)Leader 向所有 Follower 广播 commit 消息,同时自身也会完成事务提交。Follower 接收到 commit 消息后,会将上一条事务提交。
zookeeper 采用 Zab 协议的核心,就是只要有一台服务器提交了 Proposal,就要确保所有的服务器最终都能正确提交 Proposal。这也是 CAP/BASE 实现最终一致性的一个体现。
Leader 服务器与每一个 Follower 服务器之间都维护了一个单独的 FIFO 消息队列进行收发消息,使用队列消息可以做到异步解耦。 Leader 和 Follower 之间只需要往队列中发消息即可。如果使用同步的方式会引起阻塞,性能要下降很多。
崩溃恢复
Leader选举
Zab协议需要保证选举出来的Leader需要满足以下条件:
1)新选举出来的 Leader 不能包含未提交的 Proposal 。
即新选举的 Leader 必须都是已经提交了 Proposal 的 Follower 服务器节点。
2)新选举的 Leader 节点中含有最大的 zxid 。
这样做的好处是可以避免 Leader 服务器检查 Proposal 的提交和丢弃工作。
数据恢复
1)完成 Leader 选举后(新的 Leader 具有最高的zxid),在正式开始工作之前(接收事务请求,然后提出新的 Proposal),Leader 服务器会首先确认事务日志中的所有的 Proposal 是否已经被集群中过半的服务器 Commit。
2)Leader 服务器需要确保所有的 Follower 服务器能够接收到每一条事务的 Proposal ,并且能将所有已经提交的事务 Proposal 应用到内存数据中。等到 Follower 将所有尚未同步的事务 Proposal 都从 Leader 服务器上同步过啦并且应用到内存数据中以后,Leader 才会把该 Follower 加入到真正可用的 Follower 列表中。
在 Zab 的事务编号 zxid 设计中,zxid是一个64位的数字。
其中低32位可以看成一个简单的单增计数器,针对客户端每一个事务请求,Leader 在产生新的 Proposal 事务时,都会对该计数器加1。而高32位则代表了 Leader 周期的 epoch 编号。
epoch 编号可以理解为当前集群所处的年代,或者周期。每次Leader变更之后都会在 epoch 的基础上加1,这样旧的 Leader 崩溃恢复之后,其他Follower 也不会听它的了,因为 Follower 只服从epoch最高的 Leader 命令。
每当选举产生一个新的 Leader ,就会从这个 Leader 服务器上取出本地事务日志充最大编号 Proposal 的 zxid,并从 zxid 中解析得到对应的 epoch 编号,然后再对其加1,之后该编号就作为新的 epoch 值,并将低32位数字归零,由0开始重新生成zxid。
Zab 协议通过 epoch 编号来区分 Leader 变化周期,能够有效避免不同的 Leader 错误的使用了相同的 zxid 编号提出了不一样的 Proposal 的异常情况。
基于以上策略
当一个包含了上一个 Leader 周期中尚未提交过的事务 Proposal 的服务器启动时,当这台机器加入集群中,以 Follower 角色连上 Leader 服务器后,Leader 服务器会根据自己服务器上最后提交的 Proposal 来和 Follower 服务器的 Proposal 进行比对,比对的结果肯定是 Leader 要求 Follower 进行一个回退操作,回退到一个确实已经被集群中过半机器 Commit 的最新 Proposal。
LOOKING状态:
1.如果对方的logicalclock大于本地的logicalclock,则更新本地的logicalclock并清空本地投票信息统计箱recvset,并将自己作为候选和投票中的leader进行比较,选择大的作为新的投票,然后广播出去,否则进入步骤2
2.如果对方的logicalclock小于本地的logicalclock,则忽略对方的投票,重新进入下一轮选举流程,否则进入步骤3
3.如果两方的logicalclock相等,则比较当前本地被推选的leader和投票中的leader,选择大的作为新的投票,然后广播出去
4.把对方的投票信息保存到本地投票统计箱recvset中,判断当前被选举的leader是否在投票中占了大多数(大于一半的server数量),如果是则需再等待finalizeWait时间(从recvqueue继续poll投票消息)看是否有人修改了leader的候选,如果有则再将该投票信息再放回recvqueue中并重新开始下一轮循环,否则确定角色,结束选举