zookeeper是一个分布式协调的工具,具有扩展性,可靠性,时序性。
站在微服务架构的角度上来说,每种服务都是不同的角色负责不同的功能,zookeeper有三种角色,分别是 leader,follower,observer。
其中leader和follower是主从模型,而主从模型可以做读写分离。leader可以触发增删改查,follower和observer可以触发read操作和转发write操作给leader。但是为什么触发read操作需要分为follower和observer两类呢?
因为zookeeper有一个特性就是响应快,响应快可以从两方面来讲:
- zookeeper正常运转,读写操作响应快。
- zookeeper 从 非可用状态 恢复到 可用状态 快(leader挂了 -> 选举出新的leader)。
那么zookeeper是何如从非可用状态 快速 恢复到可用状态呢?
举个例子:从1000人中选择出1个领导 对比 3个人中选出1个领导,哪个更快?换句话说就是 从1000台follower中选出一个leader 和 3台follower中选出1个 leader 哪个更快?答案一定是从3台follower中选出一台leader快。
假如现在有1000台zk做读写分离,如果leader挂了,从剩下的999 台 zk 中选取leader就需要999台zk进行投票,必然会影响性能,因此在设计上引入了observer的概念。
在zk集群中只有follower才能参与投票(选举投票的速度 和 follower 的数量 成正比),observer其实是比follower更低的级别,只负责read操作和转发write操作给leader 并不参与选主。leader挂掉的时候,只需要followers进行选举leader,选举成功后observer只需要追随leader同步数据和接收Client查询即可。
例如: 30台zkserver,可以配置leader + follower = 5台,其余zkserver全部为observer。
如何把一台zk更改为observer呢? 需要把zoo.cfg文件中指定的zk更改为:
server.1=192.168.116.135:2888:3888
server.2=192.168.116.131:2888:3888
server.3=192.168.116.132:2888:3888
server.4=192.168.116.133:2888:3888:observer // 把这台zk修改为observer服务器
这样做的好处(扩展性):
就是随意增添新的zkserver,除了让从 非可用状态 快速 切换到可用状态,还能极限的放大 查询能力。
可靠性可以总结成一句话: 攘其外 必先 安其内。
其中 “攘其外” 表示对外提供数据的可靠性(数据的可靠 可用 一致性),“安其内” 则表示 服务的可靠性 (快速选leader)。简单的讲,就是服务能够在不可用状态 快速恢复 可用状态的前提下 ,并且对外提供可靠的数据。
zookeeper是如何保证数据的可靠性呢?
zk集群是一个分布式集群,在分布式环境中如何做到数据一致性?其中涉及Paxso分布式数据一致性算法。
参考:https://www.douban.com/note/208430424/
zk对paxos算法做了一个更简单的实现:ZAB协议(原子广播协议),ZAB协议作用在zk集群可用状态。
ZAB协议(假设有leader和followers一共三台):
create /ooxx
节点create /ooxx
转发给leadercreate /ooxx
生效。虽然follower2没有对之前的log回复ok,但是只要follower2没有挂掉,最终能消费掉FIFO的消息,那么最终follower2的数据和其他zkserver的数据是一致的(最终一致性)以上涉及的要点:原子广播(原子 + 广播),FIFO队列。
原子:要么全部成功,要么要不失败,没有中间态(基于队列实现)。
广播:分布式多节点,不一定所有人都能接收到广播,超过一半就可以生效。
FIFO队列:push和pop的顺序性。如果follower收到的Zxid小于自身的Zxid,该操作会被拒绝。
zookeeper是如何保证服务的可靠性呢?
快速选举leader分两种场景:
集群第一次启动(没有数据、版本、历史状态)。
重启集群,或者leader挂掉重选leader(之前运行时产生过数据,可能有的szkser数据多,有的数据少)。
并且每台zkserver都有自己的myid,和Zxid(事务ID)。
那么根据上述条件,要选取出leader要满足什么条件呢?
比较规则:收到其他zkserver的数据后,都是先比较zxid,如果zxid相同,再比较myid。
集群第一次启动:启动zkserver数量的达到 最大数的一半 + 1 就可以根据myid最大来选出leader,后启动的zkserver只能追随已经选举出的leader。
重启集群或者leader挂掉了:无论谁先发现了leader挂掉了触发了投票包,投票包一定会发送到node02(zxid最大 的zkserver && myid最大) ,node02收到投票后就一定会触发自身发起投票 ,只要node02发起了投票,那么其他zkserver一定会选择投它。
算法分析:
参考:https://blog.csdn.net/Alyson_han/article/details/80044047
在3.4.0后的Zookeeper的版本只保留了TCP版本的FastLeaderElection选举算法。当一台机器进入Leader选举时,当前集群可能会处于以下两种状态:
· 集群中已经存在Leader。
· 集群中不存在Leader。
对于集群中已经存在Leader而言,此种情况一般都是某台机器启动得较晚,在其启动之前,集群已经在正常工作,对这种情况,该机器试图去选举Leader时,会被告知当前服务器的Leader信息,对于该机器而言,仅仅需要和Leader机器建立起连接,并进行状态同步即可。而在集群中不存在Leader情况下则会相对复杂,其步骤如下:
(1) 第一次投票。 无论哪种导致进行Leader选举,集群的所有机器都处于试图选举出一个Leader的状态,即LOOKING状态,LOOKING机器会向所有其他机器发送消息,该消息称为投票。投票中包含了SID(服务器的唯一标识)和ZXID(事务ID),(SID, ZXID)形式来标识一次投票信息。假定Zookeeper由5台机器组成,SID分别为1、2、3、4、5,ZXID分别为9、9、9、8、8,并且此时SID为2的机器是Leader机器,某一时刻,1、2所在机器出现故障,因此集群开始进行Leader选举。在第一次投票时,每台机器都会将自己作为投票对象,于是SID为3、4、5的机器投票情况分别为(3, 9),(4, 8), (5, 8)。
(2) 变更投票。 每台机器发出投票后,也会收到其他机器的投票,每台机器会根据一定规则来处理收到的其他机器的投票,并以此来决定是否需要变更自己的投票,这个规则也是整个Leader选举算法的核心所在,其中术语描述如下
· vote_sid:接收到的投票中所推举Leader服务器的SID。
· vote_zxid:接收到的投票中所推举Leader服务器的ZXID。
· self_sid:当前服务器自己的SID。
· self_zxid:当前服务器自己的ZXID。
每次对收到的投票的处理,都是对(vote_sid, vote_zxid)和(self_sid, self_zxid)对比的过程。
规则一:如果vote_zxid大于self_zxid,就认可当前收到的投票,并再次将该投票发送出去。
规则二:如果vote_zxid小于self_zxid,那么坚持自己的投票,不做任何变更。
规则三:如果vote_zxid等于self_zxid,那么就对比两者的SID,如果vote_sid大于self_sid,那么就认可当前收到的投票,并再次将该投票发送出去。
规则四:如果vote_zxid等于self_zxid,并且vote_sid小于self_sid,那么坚持自己的投票,不做任何变更。
结合上面规则,给出下面的集群变更过程。
(3) 确定Leader。 经过第二轮投票后,集群中的每台机器都会再次接收到其他机器的投票,然后开始统计投票,如果一台机器收到了超过半数的相同投票,那么这个投票对应的SID机器即为Leader。此时Server3将成为Leader。
由上面规则可知,通常那台服务器上的数据越新(ZXID会越大),其成为Leader的可能性越大,也就越能够保证数据的恢复。如果ZXID相同,则SID越大机会越大。
zookeeper是有序的。 更新操作无论发送到哪个 zkserver 都会被转发到 leader 节点,leader单机有利于维护执行顺序会很容易,ZooKeeper为每个更新加盖一个zxid,zxid反映了所有ZooKeeper事务的顺序。后续操作可以使用该顺序实现更高级别的抽象,比如同步原语。
watch是监控和观察的意思,如果想要了解watch需要先知道:
有Client1和Client2两个客户端,Client2想要动态的发现Client1的服务。
就可以在zookeeper统一视图和目录树的模型的条件下,有一个/ooxx
的节点,如果Client1在/ooxx
节点下创建一个节点/a
数据为自身IP来代表自己(节点结构为/ooxx/a
),Client2连接这个zookeeper集群,一定能通过统一视图拿到/a
节点的信息,就可以动态的发现Client2服务了。
但是如果Client1挂掉,Client2想要依赖Client1挂掉的事件关闭自身的服务,就需要Client1和Client2手动开启一个连接的socket发送心跳,同样也有更便捷高效的方式:向zookeeper注册watch。
发送心跳和zookeeper的watch相比区别在于方向性和时效性。
方向性:
自己手动实现心跳需要其中一个Client按一定频率发送心跳。
而watch只需要Client1在zookeeper上创建/a
节点时设定为临时节点和session绑定即可,Client2在获取/a
节点时注册watch删除事件,当Client1挂了/a
节点就会被清理而产生事件,zookeeper就会触发回调Client2的watch的方法。
时效性:
zookeeper的实效性必然高于手动建立心跳的实效性,因为一旦Client1挂了,Client的watch方法会立马被回调。
心跳方式 最长需要要等一个心跳的间隔才能发现Client挂了。
新建Maven项目,导入zookeeper客户端jar包:
org.apache.zookeeper
zookeeper
3.4.6
代码:
public class App
{
public static void main( String[] args ) throws Exception {
System.out.println( "Hello World!" );
//zk有session的概念,但是没有线程池的概念。因为每一个连接会得到一个独立的session,监控watch时就会出现混乱。
/**
* 在new zk的时候注册session级别的watch是异步的,
* 如果想要成功连接后再查看zk状态,需要先阻塞。
*/
CountDownLatch latch=new CountDownLatch(1);
/**
* 参数1:zookeeper集群的所有IP地址
* 参数2:Client程序停止运行后,session的保留时间。
* 参数3:在zookeeper里面watch分为两类(观察和回调):
* 第一类:new zk的时候,传入的watch,这个watch是session级别的,和path没有关系。(当前参数)
* 第二类:path级别的,并且watch的注册 只发生在读类型,调用get,exites
*/
ZooKeeper zk = new ZooKeeper("192.168.116.135:2181,192.168.116.131:2181,192.168.116.132:2181,192.168.116.133:2181",
3000, new Watcher() {
/**
* 回调方法
* @param event
*/
@Override
public void process(WatchedEvent event) {
//事件状态
Event.KeeperState state = event.getState();
//事件类型
Event.EventType type = event.getType();
String path = event.getPath();
System.out.println("new zk watch::"+event.toString());
switch (state) {
case Unknown:
break;
case Disconnected:
break;
case NoSyncConnected:
break;
case SyncConnected:
System.out.println("connected...");
latch.countDown();
break;
case AuthFailed:
break;
case ConnectedReadOnly:
break;
case SaslAuthenticated:
break;
case Expired:
break;
}
switch (type) {
case None:
break;
case NodeCreated:
break;
case NodeDeleted:
break;
case NodeDataChanged:
break;
case NodeChildrenChanged:
break;
}
}
});
latch.await();
ZooKeeper.States state = zk.getState();
//根据zk状态不同,打印不同的字符串
switch (state) {
case CONNECTING:
System.out.println("ing....");
break;
case ASSOCIATING:
break;
case CONNECTED:
System.out.println("ed....");
break;
case CONNECTEDREADONLY:
break;
case CLOSED:
break;
case AUTH_FAILED:
break;
case NOT_CONNECTED:
break;
}
/**
* 增加节点
* create创建有两种形式,一种是阻塞,一种是非阻塞回调。(这里使用传统阻塞)
* 参数1:节点名称。 参数2:节点数据(二进制) 参数3:权限
* 参数4:节点类型(临时节点,因为在new zk的时候设定3000毫秒,所以程序运行结束后3秒消失)
*/
String nodeName = zk.create("/ooxx", "olddata".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
Stat stat = new Stat();
/**
* 查询节点
* getDate方法分为两大类(同步异步),四种方式。
* 参数1:查询数据的节点。
* 参数2:get方法注册的watch(第二类型,监控级别是path,是一次性的)
* 参数3:全量数据。
*/
byte[] data = zk.getData("/ooxx", new Watcher() {
@Override
public void process(WatchedEvent event) {
System.out.println("getdata event ::"+event.toString());
try {
/**
* 回调处理完业务逻辑,可以直接在逻辑后添加再次注册
* 注意:第二个参数如果是true,则代表修改的时候调用的是default watch(new zk的watch)。
* false表示不注册。
* 写this代表path级别的watch
*/
zk.getData("/ooxx",this,stat);
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},stat);
System.out.println("stat::"+stat.getVersion()+"::"+stat.getCzxid());
System.out.println("查询节点获得结果 ::"+ new String(data));
/**
* 修改节点,
* 一旦修改节点,就会触发之前getData方法中注册的回调。
*/
Stat newdata = zk.setData("/ooxx", "newdata".getBytes(), 0);
//第二次修改不会触发getData的watch回调,因为注册watch回调是一次性的,需要从新注册才能再次触发
Stat newdata01 = zk.setData("/ooxx", "newdata01".getBytes(), newdata.getVersion());
/**
* 在zookeeper安装目录的conf目录下有log4j的文件,可以拿出来放到项目的resources目录下打印日志
* 观察:
* 连接的节点
* 生成sessionID和临时节点归属匹配
*
* 在连接过程中如果zkserver如果挂掉了,程序会从新连接其他可用的zkserver,并且sessionID不会断。
*/
Thread.sleep(7777777);
}
}
运行后对比项目日志的session和Linux里节点的Session:
[zk: localhost:2181(CONNECTED) 0] ls /
[zookeeper, ooxx]
[zk: localhost:2181(CONNECTED) 1] get /ooxx
newdata01
cZxid = 0xa00000004
ctime = Wed Jun 17 14:14:32 CST 2020
mZxid = 0xa00000006
mtime = Wed Jun 17 14:14:32 CST 2020
pZxid = 0xa00000004
cversion = 0
dataVersion = 2
aclVersion = 0
ephemeralOwner = 0x172c0e6cd0a0000
dataLength = 9
numChildren = 0
[zk: localhost:2181(CONNECTED) 2]
然后把连接的zkserver stop,Client程序会连接到健康的zkserver:
/**
* 异步的方式是没有返回值的,获取值之后会回调processResult方法
*/
System.out.println("-------async start--------");
zk.getData("/ooxx", false, new AsyncCallback.DataCallback() {
/**
*
* @param rc 状态码
* @param path 路径
* @param ctx 上下文,其实就是getData时自己定义的abc
* @param data 数据
* @param stat 元数据
*/
@Override
public void processResult(int rc, String path, Object ctx, byte[] data, Stat stat) {
System.out.println("-------async call back--------");
System.out.println("async getdata::"+new String(data));
}
},"abc");
System.out.println("-------async over--------");
输出结果:
-------async start--------
-------async over--------
-------async call back--------
async getdata::newdata01