Zookeeper概述
Zookeeper是一个开源的分布式框架,为分布式应用提供协调服务的Apache项目。
Zookeeper工作机制
Zookeeper从设计模式角度来理解,是一个基于观察者模式的分布式服务管理框架,它负责存储和管理大家都关心的数据,然后接受观察者的注册,一旦这些数据的状态发生变化,Zookeeper就将负责通知已经在Zookeeper上注册的那些观察者,作出相应的反应。
Zookeeper = 文件系统 + 通知机制

Zookeeper集群特点
- Zookeeper集群是一个领导者(Leader)和多个跟随者(Follower)组成的集群。
- 集群中只要有半数以上的节点存活,Zookeeper就能正常工作。
- 全局数据一致性,每个Server保存一份相同的数据,Client无论连接到哪个Server,数据都是一致的。
- 更新请求顺序进行,来自同一个Client的更新请求按其顺序依此执行。
- 数据更新原子性,一次数据更新要么全部成功,要么全部失败。
- 实时性:在一定的时间范围内,Client能读到最新数据。
Zookeeper数据结构
Zookeeper数据结构与Unix文件系统很类似,整体上可以看作是一棵树,每个节点称作一个ZNode,每一个ZNode默认能够存储1MB的数据。每个ZNode都可以通过其路径唯一标示。
Zookeeper应用场景
提供的服务包括:统一命名服务、统一配置管理、同一集群管理、服务器节点动态上下线、软负载均衡等。
在分布式环境下,经常需要对应用/服务进行统一命名,便于识别。例如:IP不容易记住,而域名很容易记住。
Zookeeper单节点安装步骤
- 解压Zookeeper
- 修改conf包下配置文件名字:zoo_sample_cfg修改为zoo.cfg
- 启动但节点Zookeeper:
bin/zkServer.sh start
Zookeeper集群安装
vim修改zoo.cfg配置文件

- tickTime:在节点的Zookeeper集群中,节点之间的沟通成为tick,tickTime=2000表示每2s沟通一次。
- initLimit:Zookeeper的数据时全局同步的,在刚启动Zookeeper集群时有一个全局同步阶段,这里initLimit=10表示同步时间最多占用10个tickTime,就是20s。
- syncLimit:syncLimit=5表示从发送一个请求到得到通知,最大等待时间是5个tickTime。
- dataDir:表示快照的存储目录,这里需要修改,因为注释说明了不要存储在/tmp目录下。
- clientPort:表示客户端连接Zookeeper时的端口,默认为2181.
- 在zoo.cfg末尾添加如下配置:
server.1 = IP地址/主机host:2888:3888
server.2 = IP地址/主机host:2888:3888
server.3 = IP地址/主机host:2888:3888
- 上面的配置表示了由三个Zookeeper节点组成的Zookeeper集群,其中2888表示通信端口号,用来实现Zookeeper节点间的通信,3888表示选举端口号。
- 在Zookeeper中,Leader和Follower不是通过配置设置的,而是通过选举得出的,3888端口号就是用来选举Leader的。
- 根据dataDir的属性在在对应的目录创建zkData文件夹,然后新建文件myid(名字不能错),vim编辑myid文件,添加对应的数字。例如:当前Zookeeper节点名称为server.1,则myid文件中就写1,只要保证每台Zookeeper节点的myid不同即可。
- vim配置conf目录下的zkEnv.sh,配置jdk路径以及log日志存放目录:ZOO_LOG_DIR='(自定义路径,一般放在Zookeeper的安装目录下)'.
- 到此,一个三台Zookeeper的Zookeeper集群配置完毕!可以使用
bin/zkServer.sh start命令分别启动三台Zookeeper,启动过程中,会进行Leader选举。
Zookeeper客户端常用命令
| 命令基本语法 | 功能描述 |
|---|---|
| help | 显示所有操作命令 |
| ls path [watch] | 使用 ls 命令来查看当前znode中所包含的内容 |
| ls2 path [watch] | 查看当前节点数据并能看到更新次数等数据 |
| create | 普通创建、 -s:含有序列、 -e:临时(重启或者超时消失) |
| get path [watch] | 获得节点的值 |
| set | 设置节点的具体值 |
| stat | 查看节点状态 |
| delete | 删除节点 |
| rmr | 递归删除节点 |
Zookeeper节点类型
Zookeeper一共有四种节点类型

持久(Persistent):客户端和服务器断开连接后,创建的节点不删除 短暂(Ephemeral):客户端和服务器断开连接后,创建的节点自动删除
- 持久化目录节点:客户端与Zookeeper断开连接后,该节点依旧存在。
- 持久化顺序编号节点:客户端与Zookeeper断开连接后,该节点依旧存在,而且Zookeeper给该节点名称进行顺序编号。
- 临时目录节点:客户端与Zookeeper断开连接后,该节点被删除。
- 临时顺序编号目录节点:客户端与Zookeeper与客户端断开连接后,该节点被删除,并且在创建该节点时,Zookeeper给该节点名称进行顺序编号。
创建ZNode设置顺序标识,ZNode名称后会附加一个值,顺序号是一个单调递增的计数器,由父节点维护。在分布式系统中,顺序号可以用于为所有的事件进行全局排序,这样可以通过顺序号推断时间的顺序。
stat指令(节点的stat结构体)

-
zxid:每次修改ZooKeeper状态都会收到一个zxid形式的时间戳,也就是ZooKeeper事务ID。事务ID是ZooKeeper中所有修改总的次序。每个修改都有唯一的zxid,如果zxid1小于zxid2,那么zxid1在zxid2之前发生。假设
zxid=0x200000003则zxid分为两部分,0x2为前一部分,00000003为后一部分,组合起来表示这是第2次启动、第3次操作。 -
cZxid:这是创建该ZNode的zxid,则上图中创建/aaa节点的cZxid表示为:第2次启动、第3次操作创建的/aaa节点。
-
mZxid:最后一次修改节点/aaa节点的zxid。
-
pZxid:最后一次修改/aaa节点的字节点的zxid。
-
aclVersion:该节点的acl被修改了几次。acl的意思是 Access Control List(访问控制列表),表示访问这个节点的权限。Zookeeper可以给指定的节点设置访问权限。
则
/aaa节点的stst可表示为:该节点是第2次启动服务器、第3次操作创建的,该节点最后一次修改是第2次启动、第10次操作,该节点的字节点的最后一次修改是第2次启动、第15次操作。
Zookeeper API的使用
idea新建MAVEN项目,倒入依赖
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.8.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.zookeeper/zookeeper -->
<dependency>
<groupId>org.apache.zookeeper</groupId>
<artifactId>zookeeper</artifactId>
<version>3.4.10</version>
</dependency>
</dependencies>
resource文件夹新建log4j.properties文件用于打印日志
log4j.rootLogger=INFO, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - %m%n
log4j.appender.logfile=org.apache.log4j.FileAppender
log4j.appender.logfile.File=target/spring.log
log4j.appender.logfile.layout=org.apache.log4j.PatternLayout
log4j.appender.logfile.layout.ConversionPattern=%d %p [%c] - %m%n
新建测试类ZkClient
import org.apache.zookeeper.*;
import org.apache.zookeeper.data.Stat;
import org.junit.Before;
import org.junit.Test;
import java.io.IOException;
import java.util.List;
public class ZkClient {
private ZooKeeper zkCli;//客户端对象
private static final String CONNECT_STRING = "192.168.51.101:2181,192.168.51.102:2181,192.168.51.103:2181";//集群的ip地址
private static final int SESSION_TIMEOUT = 2000;
//初始化Zookeeper对象并设置默认回调函数
@Before
public void before() throws IOException {
zkCli = new ZooKeeper(CONNECT_STRING, SESSION_TIMEOUT, e -> {
System.out.println("默认回调函数");
});
}
// 相当于 "ls /" 命令
@Test
public void ls() throws Exception {
List<String> children = zkCli.getChildren("/", e -> {
System.out.println("自定义回调函数");
});
System.out.println("===============================");
for (String child : children) {
System.out.println(child);
}
System.out.println("===============================");
Thread.sleep(Long.MAX_VALUE);
}
// create方法后面的俩参数,第一个是设置访问权限,第二个是和create命令中的参数一致,设置节点种类
@Test
public void create() throws Exception {
String nodePath = zkCli.create("/Idea", "Idea2019".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
Thread.sleep(Long.MAX_VALUE);
System.out.println(nodePath);
}
@Test
public void get() throws Exception {
byte[] data = zkCli.getData("/Idea", true, new Stat());
String string = new String(data);
System.out.println(string);
}
//version是节点的版本号,若输入的版本号和实际节点的版本号不符,表示输入的版本已经被修改过了,会报错,这样设计有一定的保护作用
@Test
public void set() throws Exception {
Stat stat = zkCli.setData("/ttt12340000000002", "11111111".getBytes(), 1);
System.out.println(stat.getCversion());
System.out.println(stat.getMzxid());
}
@Test
public void stat() throws Exception {
Stat stat = zkCli.exists("/Idea", false);
if (stat != null) {
System.out.println(stat.toString());
System.out.println(stat.getAversion());
System.out.println(stat.getEphemeralOwner());
} else {
System.out.println("没有此节点");
}
}
// 循环注册
public void register() throws Exception {
byte[] data = zkCli.getData("/aaa", new Watcher() {
@Override
public void process(WatchedEvent watchedEvent) {
try {
register();
} catch (Exception e) {
e.printStackTrace();
}
}
}, null);
System.out.println(new String(data));
}
//测试循环注册
@Test
public void textRegister() throws Exception {
try {
register();
Thread.sleep(Long.MAX_VALUE);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Zookeeper内部原理
监听器原理
- 在main线程中创建Zookeeper客户端时会创建两个线程,一个负责网络连接通信(sendThread),一个负责监听(eventThread)。
- 通过sendThread线程将注册的监听事件发送给Zookeeper。
- 在Zookeeper的注册监听列表中将注册的监听事件添加到列表中。
- Zookeeper监听到有数据或者路径发生变化,就会将这个消息发送给eventThread线程。
- eventThread线程内部调用了process方法。

监听器源码解析
我们在构建zkCli的代码行打断点

这里调用了构造起,我们进入此构造方法
这里new了一个ClientCnxn对象,在newClientCnxn对象时,创建了eventThread和sendThread,在此打断点进入构造方法
接着进入下图的构造方法
可以看到,在此构造方法下方分别对sendThread和eventThread进行初始化,这两个类都继承自ZookeeperThread,而ZookeeperThread又继承了Thread,所以这两个类都是线程类。
我们跳出到构建ClientCnxn对象时的代码,可以看到在ClientCnxn构建完毕之后,调用了该对象的start方法。我们在此打断点,进入start方法。
可以看到在ClientCnxn的start方法中,分别调用了sendThread和eventThread。
Zookeeper的ZAB协议
ZAB协议概述
Zookeeper Atomic Broadcast Zookeeper原子广播协议简称ZAB协议。
Zookeeper的主要功能我认为有两个
- 没有Leader选Leader:崩溃恢复。
- 有Leader就干活:正常读写。
在Zookeeper刚创建好的时候是没有Leader的,所以首先要选出Leader。选出Leader后Zookeeper就可以正常工作了,当选出的Leader挂掉之后,会重新进行Leader选举,选出训得Leader。而Follower挂掉之后没有关系,除非Zookeeper集群中仅有不超过一半的节点存活。Zookeeper会在选举Leader和正常工作间不断地切换。
Paxos算法
Zookeeper的选举机制

半数机制,集群中半数以上机器存活,Zookeeper集群可用,所以Zookeeper适合安装奇数台服务器。
对于Zookeeper的选举机制,Zookeeper提供了三种算法
- LeaderElection
- AuthFastLeaderElection
- FastLeaderElection
在当前最新版本的Zookeeper中默认算法是FastLeaderElection
选举流程
首先明确Zookeeper节点的状态
- LOOKING:在没有确定Leader之前,所有节点状态都为LOOKING。
- FOLLOWING:在确定了Leader之后,除了leader节点,其余节点状态都为FOLLOWING。
- LEADING:在确定了Leader之后,Leader所在节点状态为LEADING。
假定Zookeeper集群中有五台机器,编号分别为1,2,3,4,5,他们的选举流程如下
- 服务器1启动,发起一次选举,服务器1投自己1票,此时服务器1一票,不够半数以上(5台机器,半数以上至少为3),选举无法完成,服务器1状态为LOOKING。
- 服务器2启动,再发起一次投票,服务器1和2分别投自己一票并交换投票信息,由于此时都是刚启动,zxid相同,但是服务器2的id比服务器1的要大,所以服务器1更改选票为服务器2,此时服务器1一票,服务器2两票,但此时仍然没有半数以上的投票指向同一节点,所以服务器1和2都处于LOOKING状态。
- 服务器3启动,发起一次投票,由上一步得出:此时服务器1和2都会更改选票为服务器3。此时服务器1和2都是0票,而服务器3三票,服务器3的票数已经达到了半数以上,所以服务器3当选Leader,服务器1和2状态改为FOLLOWING,服务器3状态为LEADING。
- 服务器4启动,发起一次投票,由于此时服务器1和2状态已经是服务器1和2状态改为FOLLOWING,所以不会更改投票信息,此时交换投票信息,服务器3三票,服务器4一票,所以服务器4少数服从多数,更改选票信息为服务器3,状态改为FOLLOWING。
- 服务器5启动和服务器4启动一致,启动后状态为FOLLOWING。
投票过程中的交换信息
交换信息分为发送投票信息和接收投票信息两部分。
发送的投票信息包含所选Leader的Serverid、Zxid、Epoch(逻辑时钟)。Epoch会随着选举轮数的增加而递增。
接收投票信息
- 假设服务器B收到了服务器A的投票信息,此时服务器A处于选举状态(LOOKING状态)。
- 若发送过来的逻辑时钟Epoch值大于目前的逻辑时钟。首先会更新本逻辑时钟Epoch,同时会清空本轮逻辑时钟收集到的来自其它server的选举数据。然后判断是否需要更新当前自己选出的Leader的Server id。判断规则:使用保存的zxid最大值和leader Serverid来判断,先看数据zxid,zxid大的Server胜出,其次在判断leader的Serverid值,Serverid大的胜出。然后再将自身最新的选举结果(包含选择的Serverid、zxid、Epoch)广播给其它server。
- 若发送过来的逻辑时钟Epoch小于目前的逻辑时钟。说明对方server在一个相对较早的Epoch中,这里只需要将本机已经选出的投票信息发送过去就行了。
- 若发送过来的逻辑时钟Epoch等于目前的逻辑时钟,再根据上述判断规则进行判断,选出Leader,然后将自身最新的投票信息广播给其它Server。
- 其次,判断是不是已经收到了所有服务器的选举状态,若是,根据选举结果设置自己的状态(FOLLOWING还是LEADER),然后退出选举。
- 最后,若没有收集到所有服务器的选举状态,可以判断一下根据以上过程之后最新的选举leader是不是得到了超过半数以上服务器的支持,如果是,那么尝试在200ms内接收一下数据,如果没有新的数据到来,说明大家都已经默认了这个结果,同样也设置角色退出选举过程。
如果所接收服务器A处在其它状态(FOLLOWING或者LEADING)
- 逻辑时钟Epoch等于目前的逻辑时钟,将该数据保存到recvset。此时Server已经处于LEADING状态,说明此时这个server已经投票选出结果。若此时这个接收服务器宣称自己是leader, 那么将判断是不是有半数以上的服务器选举它,如果是则设置选举状态退出选举过程。
- 否则这是一条与当前逻辑时钟不符合的消息,那么说明在另一个选举过程中已经有了选举结果,于是将该选举结果加入到outofelection集合中,再根据outofelection来判断是否可以结束选举,如果可以也是保存逻辑时钟,设置选举状态,退出选举过程。
Zookeeper写数据流程
写数据成功案例(所有server都同意写请求)

写数据失败案例

集群中有小部分节点不同意写请求案例

server接收到写请求之后投票的依据
每条写请求都有唯一的编号,假设某一个server的zxid为10,而接收到的写请求编号为11,则该server一定会同意该写请求。若发来的编号为9,则该server一定不会同意该写请求。
写请求不同意的情况
首先说明待写队列:client新发送的请求都会一次加入代写队列中,并且每次处理待写请求时,都是选择队列编号最小的待写请求,当待写请求处理完成后,从待写队列中删除该请求。
例如某一时刻集群中的server的zxid都为9,且此时client发来两条数据,编号分别为10和11,首先发送编号为10的请求,此时leader和server1正常运行,同意这次请求,并将这次请求依次放入leader和server1的待写队列中,但是server2由于网络原因,没有收到编号为10的请求,而是先收到了编号为11的请求,并将该放入待写队列中,并且同意了写请求,然后leader、server1、server2分别执行了自己的待写队列中的请求。在server2执行完毕之后,接收到了编号为10的请求,但是此时server1和leader接收到的是编号为11的请求,由于此时server1和leader的zxid都为10,会同意编号为11的请求,但是server2不会同意编号为10的请求。所以server2只能自杀并重启后同步Leader中的数据。
Zookeeper的Observer状态
在Zookeeper集群中,如果server较少的情况下,写流程都很快,但是当server数很多时,虽然提高了读性能,但是写流程会很慢很慢,所以引入Observer。
Observer概述
Observer充当观察者角色,观察zk集群的最新状态变化并将这些状态同步过来,对于非写请求可以进行独立的处理,对于事务请求,则会转发给Leader服务器进行处理,Observer不会参与任何形式的投票,包括写请求的投票和Leader选举的投票。







