分布式协调服务组件Zookeeper必备知识点
Zookeeper介绍
Zookeeper是一个开源的分布式协调服务组件。是分布式集群的监控者,能对集群中节点的状态变更进行及时的反馈。
Zookeeper对分布式一致性的保证:
-
顺序一致性:保证服务端的执行顺序是按照客户端操作的发送顺序来执行的
-
原子性:客户端操作结果要么成功、要么失败。
-
单一视图:无论客户端连接到那个服务端,看到的视图是一致的。
-
可靠性:客户端操作请求一定执行成功,则执行结果会一直持久到下一次操作
-
最终一致性:保证一定时间内客户端看到的视图是最新的数据(允许一定时间内看到的数据不是最新的)
Zookeeper默认保证客户端拿到的视图是连接到的服务器此时的顺序一致性视图,通过客户端调用SyncCommand保证是客户端看到的视图是此时集群中所有节点的最新强一致性视图。
注意:最终一致性存在在一段时间内客户端看到的视图不一定是最新数据
Zookeeper常用API
ZooKeeper -server host:port -client-configuration properties-file cmd args
addWatch [-m mode] path # optional mode is one of [PERSISTENT, PERSISTENT_RECURSIVE] - default is PERSISTENT_RECURSIVE
addauth scheme auth
close
config [-c] [-w] [-s]
connect host:port
create [-s] [-e] [-c] [-t ttl] path [data] [acl]
delete [-v version] path
deleteall path [-b batch size]
delquota [-n|-b] path
get [-s] [-w] path
getAcl [-s] path
getAllChildrenNumber path
getEphemerals path
history
listquota path
ls [-s] [-w] [-R] path
printwatches on|off
quit
reconfig [-s] [-v version] [[-file path] | [-members serverID=host:port1:port2;port3[,...]*]] | [-add serverId=host:port1:port2;port3[,...]]* [-remove serverId[,...]*]
redo cmdno
removewatches path [-c|-d|-a] [-l]
set [-s] [-v version] path data
setAcl [-s] [-v version] [-R] path acl
setquota -n|-b val path
stat [-w] path
sync path
version
whoami
Zookeeper数据模型
Zookeeper数据是存储在内存中的具有层次结构的命名空间,类似于多级别的文件目录结构一样,不同的是每个命名空间可以挂载数据。命名空间不应该使用以下字符:\u0000、\u0001-\u001F、\u007F、\ud800-uF8FF、\uFFF0-uFFFF、保留关键字:zookeeper。命名空间都是绝对路径,不存在相对路径,以斜杠路径分隔符划分层级。
/meta1 /meta2
|-- /meta1/node1 |-- /meta2/node1
|-- /meta1/node2 |--|--/meta2/node2/child
|--|-- /meta1/node3/child |-- /meta2/node3
|-- /meta1/node4 |-- /meta2/node4
Zookeeper是Java语言开发的,节点称为Znode,树形存储结构对象为:DataTree,节点存储对象为:ConcurrentHashMap<String, DataNode> nodes。
Zookeeper节点本身存储无大小限制,但是数据序列化传输时有限制,默认上限1M,可通过系统属性:"jute.maxbuffer"配置,详细可见类:BinaryInputArchive。但是Zookeeper在集群中节点数据传输较多,不建议存储过大的数据,一旦修改该上限,集群中所有节点都要修改。
Zookeeper数据节点类型
Zookeeper支持四种类型的节点,节点只能逐个创建:v3.5.3+支持对持久节点设置TTL
- 临时节点(EPHEMERAL):节点生命周期绑定客户端会话,在客户端结束会话时会自动删除,节点下不能创建新的节点
- 临时有序节点:(EPHEMERAL_SEQUENTIAL):具有临时节点特点,节点创建后节点名自动追加序号,多次创建序号自增,10位数字
- 持久节点(PERSISENT):持久落盘保存,重启不丢失,需要手动调用delete命令删除
- 持久有序节点(PERSISTENT_SEQUENTIAL):具有持久节点特点,节点创建后节点名会自动追加序号,多次创建序号自增,10位数字
- 容器节点(CONTAINER):当其子节点被完全删除时,该节点会自动删除
- 限时节点(TTL):对持久节点增加限时时长,默认禁用该功能,启用属性为:zookeeper.extendedTypesEnabled
Zookeeper集群节点数量
Zookeeper在分布式协调中遵循ZAB协议,具有过半有效特点,要求集群节点数量符合:2N+1。也就是可以单机部署,集群部署最少3个节点,最多宕机1台,否则集群不可用(永远不会过半,也不会转为单机运行,会报错),这里也说明Zookeeper保证的是分布式中CAP定理中的CP,一致性和分区容错性。注意:Zookeeper集群中最少时2台机器就可以运行了,但是宕机任意一台集群就不可用。
Zookeeper集群节点角色与状态
在Zookeeper启动同时会启动一个线程轮询服务器状态,每台集群节点服务只会存在以下状态:
- LOOKING:表示当前集群中不存在Leader节点,集群中所有可参与选举的节点正在进行Leader节点选举
- LEADING:表示该节点是集群中的领导者leader
- leader角色具有对外提供读写视图能力,集群中只有Leader节点能执行事务(增、删、改)操作,其他节点都需要转发到Leader来执行
- FOLLOWING:表示该节点是集群中的跟随者follower,说明当前集群中存在某个其他节点已经是Leader节点。
- follower角色具有同步视图数据、转发事务请求到Leader以及对外提供读视图能力,在Zookeeper中配置为participant:参与者
- OBSERVING:表示该节点是集群中的观察者Observer
- Observer角色主要扩展集群的非事务处理能力,对外提供读视图能力、转发事务请求到Leader,不具有选举能力。
- observer与Follower统称为learner
Zookeeper的二阶段提交2PC
二阶段提交是为保证分布式系统中数据的一致性而将事务操作行为分为两个阶段来完成。
第一阶段:leader服务节点将该事务请求是否能够执行通过提议方式询问集群中的所有Follower节点,各个Follower节点对该提议进行确认ACK。
第二阶段:Leader对收到的ACK进行过半判断,判定成功则进行事务提交,异步通知各Follower节点也进行事务提交。如果判定失败则进行事务回滚。
事务一般都是通过日志文件的形式来实现的,zookeeper也不例外。第一阶段针对事务请求会先进行日志记录,然后发出询问。在第二阶段收到各Follower节点确认后,根据之前的日志,更新数据库来完成事务。
Zookeeper的ZAB(ZooKeeper Atomic Broadcast)协议:zookeeper原子广播协议
主要指2两种运行状态,在Leader失联时进入崩溃恢复状态,进行Leader选举,当选举完成后进入原子广播协议,Leader节点将事务请求广播到Follower节点
Zookeeper的Leader选举
Zookeeper集群中是必须要一台Leader角色的节点的,在不存在Leader节点时就需要集群内部自行选举,这种情况分为两种:1:集群首次启动时,2:集群中Leader节点失联时,二者的选举流程一致,只是前者时启动时发现Leader不存在,后者是运行时发现Leader失联。
Zookeeper节点启动时默认的状态为:LOOKING,即集群中不存在Leader节点,需要进行Leader选举。对于节点状态的监控是一个死循环,随服务启动而开始,随服务停止而结束,因此首次启动会立马根据选举算法开始Leader选举,v3.5+选举算法只有一个: FastLeaderElection
在Zookeeper的选票中存在四个很重要的数据:myid(集群服务ID)、zxid(事务ID)、peerEpoch(leader轮期)、electionEpoch(选举轮次)。
- myid表示集群节点的服务唯一ID,这是配置文件中配置的ID,是身份识别码。
- zxid表示集群节点中记录的事务日志ID,是一个64位Long类型数字,其中高32位为epoch,低32位为自增计数器counter。
- epoch则表示当前集群中Leader所处的轮期,每次Leader变更都会自增1,是Zxid的高32位。
- electionEpoch表示自节点启动时集群中进行过Leader选举的轮次,每次Leader选举内部多个轮次
zxid结构如下
public static long makeZxid(long epoch, long counter) {
return (epoch << 32L) | (counter & 0xffffffffL);
}
zxid整体是自增的,Zookeeper通过自增来保证数据的顺序性。根据Zookeeper保证的顺序一致性:zxid越大则该事务执行的越晚,保存的数据越完整。
Leader选举前后要保证的就是数据的尽可能一致性,因此Leader选举的实质就是找出存活的Follower节点中数据保存最完善的那一台作为Leader。
寻找方式就是比较zxid大小。如果zxid都一致,则说明存活的Follower节点数据保存完整性是一样的,随便选一台都行,Zookeeper默认选中myid最大的那一台。
寻找算法则是FastLeaderElection的实现过程
在Leader选举流程中,存在一个阻塞队列,用来接收其他集群节点中发送的选票,而当前节点也会持有一个选票。选票内容主要包括:myid(集群节点身份识别唯一ID)、zxid(集群节点最大事务日志ID)、peerEpoch(集群节点事务所处轮期)、electionEpoch(集群已进行Leader选举的次数)。
在选举流程开始时首先将投向自己的选票(选票内容均指向自身节点信息)发送出去,由于机器中节点在启动时都会发送一个选票,因此阻塞队列就会收到其他节点的选票。选票的发送属于异步发送,方法为:sendNotifications,先把内容ToSend放入阻塞队列,然后发送线程去逐个发送。
节点使用logicalclock(逻辑时钟)来记录当前集群进行Leader选举的轮次electionEpoch。并在每次投票之后以轮次为基础进行流水记录,便于后续分析,节点只记录最新轮次的投票流水。
当其他节点的electionEpoch大于当前节点的electionEpoch时,说明,当前节点错过了某轮投票,需要更新logicalclock,并在选票PK之后更新自身持有的投票信息,为数据较为完整的节点投上一票。(说明之前的投票所处轮次已过时,需要为最新轮次重新投一票)
// 选票PK方法
return ((newEpoch > curEpoch)|| ((newEpoch == curEpoch) && ((newZxid > curZxid)|| ((newZxid == curZxid)&& (newId > curId)))));
当其他节点的electionEpoch小于当前节点的electionEpoch时,说明,其他节点错过了某轮投票,本身之前投票记录是有效的,其他节点的投票所处轮次是过时,因此舍弃这个过时投票,不参与统计。(本身的票已经投出去了,不需要重复投票)
当其他节点的electionEpoch等于当前节点的electionEpoch时,说明,两者节点都是正常投票,则进行选票PK,比较本节点的zxid、myid和选票节点的对应值大小。哪个节点的值大则说明哪个节点记录的数据较为完整,此时需要更新选票信息,为数据较为完整的节点投上一票。(说明之前的投票的节点数据不是集群中记录最完整的,需要重新投一票)
以轮次为基础,为数据较为完整的节点投上一票后,阻塞队列就又接收到了新一批选票,然后再次比较,依次循环下去
Leader选举就是选出数据最完整的那台节点,因此选举流程不会一直循环PK下去,会在流水记录符合过半确认时终止循环。而这个判断条件是在为可能完整节点投票后,以服务ID为表示,记录集群中为该服务节点投票信息,当集群中过半节点都对该节点投了票时就认定该节点有资格成为Leader节点,从而结束Leader选举。
图示如下
Zookeeper的数据同步
Zookeeper集群中选举出Leader节点后,Leader节点会进行LEADING状态。Follower节点则会进行FOLLOWING状态,开启Leader确认、同步流程。而observer节点则进入OBSERVING状态,同样准备进入同步流程。
Leader节点
预选Leader节点会先与Learner节点建立网络连接,等待Follower、Observer节点接入。
cnxAcceptor = new LearnerCnxAcceptor();
// 这是一个连接线程
cnxAcceptor.start();
这个网络连接十分重要,每个Leader会构建一个LearnerCnxAcceptorHandler的处理器,核心过程如下
BufferedInputStream is = new BufferedInputStream(socket.getInputStream());
LearnerHandler fh = new LearnerHandler(socket, is, Leader.this);
fh.start();
这个LearnerHandler处理所有Leader与learner的网络IO交互
消息包
public class QuorumPacket implements Record {
private int type;
private long zxid;
private byte[] data;
private java.util.List<org.apache.zookeeper.data.Id> authinfo;
}
1.--> learner节点
Follower节点进入FOLLOWING状态后,首先会去查找Leader地址,然后建立网络连接
QuorumServer leaderServer = findLeader();
connectToLeader(leaderServer.addr, leaderServer.hostname);
建立连接之后便发出type=FOLLOWERINFO的消息包,xid=ZxidUtils.makeZxid(self.getAcceptedEpoch(), 0),data=LearnerInfo
主要功能就是将当前节点的epoch发送(所有连接节点都会发送INFO信息来给Leader提供集群中最大的epoch,以便确认Leader的最新任期:lastAcceptedEpoch + 1,Leader节点会阻塞等待Follower节点接入直至满足过半确认时获取最大的epoch)
long newEpochZxid = registerWithLeader(Leader.FOLLOWERINFO);
消息发出后等待Leader节点指示
2.--> Leader节点
Follower发出的FOLLOWERINFO消息包会在LearnerHandler被接收转为:learnerInfoData,获取Follower提供的zxid中的epoch
long lastAcceptedEpoch = ZxidUtils.getEpochFromZxid(qp.getZxid());
然后参与到新任Leader的epoch选举中
long newEpoch = learnerMaster.getEpochToPropose(this.getSid(), lastAcceptedEpoch);
Leader接收到过半Follower提供的epoch后,发送最终epoch确认消息包
QuorumPacket newEpochPacket = new QuorumPacket(Leader.LEADERINFO, newLeaderZxid, ver, null);
等待Follower确认最终的epoch
learnerMaster.waitForEpochAck(this.getSid(), ss);
3.--> learner节点
Follower之前阻塞在registerWithLeader中的INFO信息发送后,此时接收到Leader指示获取Leader的epoch和zxid,发出确认接收消息
QuorumPacket ackNewEpoch = new QuorumPacket(Leader.ACKEPOCH, lastLoggedZxid, epochBytes, null);
在接收到Leader给的zxid后,将自身状态设为同步状态,开启数据同步流程,等待Leader的同步数据。
self.setZabState(QuorumPeer.ZabState.SYNCHRONIZATION);
syncWithLeader(newEpochZxid);
4.-->Leader节点
Leader接收到足够的Follower的epoch确认消息后,将自身状态设为数据同步状态,与各个Learner开启数据同步,阻塞等待Learner数据同步完成然后执行自身的Leading
waitForNewLeaderAck(self.getId(), zk.getZxid());
数据同步发生在各个Leader建立的网络连接中:LearnerHandler,在确认到Learner接收了最新epoch消息后,立马开启数据同步流程
boolean needSnap = syncFollower(peerLastZxid, learnerMaster);
同步过程使用重入读写锁:ReentrantReadWriteLock。并且获取此Leader节点的lastProcessedZxid、minCommittedLog、maxCommittedLog
同步类型:DIFF:差异同步、TRUNC:回滚同步、SNAP: 全量同步,主要是TRUNC+DIFF,也就是learner上事务日志比leader上多的zxid进行回滚,少的从leader接收进行重做。
只有系统变量:zookeeper.forceSnapshotSync 配置了强制全量同步时,会使用快照文件开启全量同步。
到底是learner的事务日志多还是leader节点的事务日志多,是通过两个节点的zxid比较的。事务日志是会落盘形成快照的,节点会缓存一定条数的事务日志,那部分具有minCommittedLog、maxCommittedLog的范围属性。在此范围的数据可以直接发送,否则需要从磁盘读取快照文件再次发送。
系统变量:zookeeper.snapshotSizeFactor 配置每次从磁盘读取的快照数据所占快照总数的比例,默认0.33即三分之一
系统变量:zookeeper.commitLogCount 配置节点缓存的事务日志数量,默认500条
5.--> learner节点
Follower节点阻塞在syncWithLeader中等待Leader同步的数据,Leader同步数据主要就三种,DIFF、TRUNC、SNAP。其中DIFF是逐条的执行提议,Follower节点接收后会执行,TRUNC接收到Follower则直接截断日志文件到指定位置即可,对于SNAP则清空dataTree根据传输的日志文件反序列即可。处理完毕则发送确认消息,正式对外提供服务。Leader节点接收到Follower的同步结束消息后,结束waitForNewLeaderAck,也会正式对外提供服务。
同步数据主流程如图所示
数据同步细节如图所示
Zookeeper的Watch机制
Zookeeper支持客户端对数据节点Znode注册Watch监听,当节点发生变化会及时反馈给该客户端。Watch机制是Zookeeper分布式锁、对外Leader选举功能的基础。
watch类型
v3.6.0之前:watch是一次性注册,一旦反馈后会删除,如果需要重复监听,则需要重复注册
v3.6.0+ :支持配置监听模式:STANDARD(标准类型)、PERSISTENT(持久类型)、PERSISTENT_RECURSIVE(持久递归类型)。标准类型就是之前版本的一次性注册类型;持久类型则是自动重复注册类型;持久递归类型则是持久类型的加强版本,不仅重复监听指定节点,还重复监听节点的子节点。后两种模式需要主动移除Watch才能取消监听。
API
支持Watch的API:exist、getData、getChildren、addWatch
移除Watch的API:removeWatches
触发Watch的API:setData、delete、create
保证
- 只有注册了Watch监听的客户端才会收到反馈通知
- 客户端收到的反馈通知顺序与服务端节点处理顺序一致
注意
- Watch依赖网络连接的一次性通知,客户端重连期间可能会导致Wacth丢失
- 标准Watch是一次性,多次触发的Watch如果客户端没有来得及重置,会导致后续Watch通知丢失
- 标准Watch是一次性,持久Wach是服务端自动注册的,实质还是一次性通知
- 持久Watch如果不主动移除会一直通知下去
步骤
- 客户端对指定节点注册Watch
- 服务保存Watch,一旦触发事件则发送消息通知
- 客户端回调watch事件,进一步处理
原理
Watch监听注册对象在Zookeeper中为WatchRegistration,不同API对应的注册对象不同
// getData、getConfig ---> DataWatchRegistration
public class GetDataRequest implements Record {
private String path;
private boolean watch;
}
// exists ---> ExistsWatchRegistration
public class ExistsRequest implements Record {
private String path;
private boolean watch;
}
// getChildren --> ChildWatchRegistration
public class GetChildrenRequest implements Record {
private String path;
private boolean watch;
}
// @since 3.6.0 addWatch --> AddWatchRegistration
public class AddWatchRequest implements Record {
private String path;
private int mode;
}
这个请求又一个很明显特点:watch是一个布尔类型,也就是说只标识是否watch
watch注册
1.--> 客户端:在发起API请求时构建queuePacket对象。该对象主要包含RequestHeader、ReplyHeader、Record(Request)、Record(Response)、WatchRegistration等。其中RequestHeader标记本次请求的操作类型 type 和事务zxid。服务端根据操作类型会有不同的解析流程。在queuePacket的构造方法中会把queuePacket放入到一个阻塞队列中outgoingQueue,该队列会有专用的线程sendThread#clientCnxnSocket#doTransport从队列中读取元素并处理。如果是事务请求则把zxid++,然后序列化RequestHeader和Request,只把这两者发送出去。queuePacket会放到pendingQueue中等待服务器返回,也就是说WatchRegistration并没有发送出去,只是发送了操作标识、zxi、path、watch标志。
2.--> 服务端:在Zookeeper启动时会通过startServerCnxnFactory开启服务端连接,此时会开启AcceptThread来处理客户端的连接,客户端建立连接时会在这里处理,在接入时会构建SelectorThread来通过handleIO处理客户端的IO请求,然后将以此IO交互抽象为IOWorkRequest交给线程池去异步处理doWork-->doIO,然后开始解析网络消息包readPayload,然后解析消息包processPacket,解析之后放到阻塞队列submittedRequests中,队列请求在RequestThrottler中处理,然后submitRequestNow交给ZkServer的处理链去链式处理。处理链初始化时指定了FinalRequestProcessor会处理与数据库的交互。根据操作标识type不同有不同的解析流程并反序列化数据构建对应的Request。如果Request中存储watch标识则在DataTree中通过IWatchManager添加记录,记录包含监听的路径Path和客户端的连接ServerCnxn等,这步就是服务端注册,这里IWatchManager区分为当前节点和子节点2种。
private IWatchManager dataWatches;
private IWatchManager childWatches;
将Request处理完毕就写入Response中,构建ReplyHeader、操作标识、响应状态stat等返回了
3.--> 客户端:在SendThread#readResponse中接收服务端的响应,此时阻塞队列pendingQueue中取出之前outgoingQueue中保存的queuePacket对象,处理好响应内容就finishPacket,此时客户端就完成Watch注册和对应API功能的响应。finishPacket接收的是queuePacket对象,会处理其中的WatchRegistration,调用其register方法。register首先会拿到客户端的ZKWatchManager中对应类型的存储列表。其中ZKWatchManager存储以下列表
private final Map<String, Set<Watcher>> dataWatches = new HashMap<>();
private final Map<String, Set<Watcher>> existWatches = new HashMap<>();
private final Map<String, Set<Watcher>> childWatches = new HashMap<>();
private final Map<String, Set<Watcher>> persistentWatches = new HashMap<>();
private final Map<String, Set<Watcher>> persistentRecursiveWatches = new HashMap<>();
不同Map存储不同类型的Watcher,register方法就是将WatchRegistration的实例存储到HashMap中。这就是客户端注册。
图示如下
Watch触发
1.--> 客户端:当节点发送NodeCreated,NodeDeleted、NodeDataChanged、NodeChildrenChanged事件时,会触发Watch反馈,对应API为:create、delete、setData。
2.--> 服务端:在以上API操作DataTree数据时,会触发IWatchManager#triggerWatch,不同API触发的事件类型不同。triggerWatch会查询相关Watch并调用其process放啊,当然这是服务端的watch,也就是在服务端注册的与操作节点Path相关的ServerCnxn。此时ServerCnxn会构建一个notification类型客户端响应并发送给客户端。如果WatchMode是默认类型则触发一次就移除了Watch标识,否则会持续触发。
public static final WatcherMode DEFAULT_WATCHER_MODE = WatcherMode.STANDARD;
3.--> 客户端:同样在SendThread#readResponse中接收服务端的响应,解析是发现是notification类型响应,则构建一个WatcherEvent,通过eventThread.queueEvent(we)构建事件和对应Watch列表的WatcherSetEventPair对象,然后放入阻塞队列waitingEvents。构建WatcherSetEventPair时会查找所有在客户端ZKWatchManager中注册的对指定事件感兴趣的客户端Watch。EventThread则不断从waitingEvents取出事件,如果是WatcherSetEventPair则遍历调用Watch的process方法,该方法就是客户端需要重新的Watch监听处理方法。这样就完成了整个Watch触发机制
图示如下
Zookeeper的Chroot特性
在V3.2.0+ 添加了 Chroot 特性,该特性允许每个客户端为自己设置一个命名空间。这样客户端的所有操作都会在改命名空间下面,生成数据隔离。
Zookeeper的权限控制
Zookeeper支持对每个节点配置ACL权限,其中添加认证用户命令为:addauth scheme auth ,auth格式为:<username>:<passwprd>
ACL权限方案
方案 | 描述 | 设置命令 |
world | 只有一个用户:anyone,代表所有人(默认) | setAcl path world:anyobe:acl |
ip | 使用IP地址认证 | setAcl path ip:<ip>:acl |
auth | 使用已添加认证的用户认证 | setAcl path auth:<username>:acl |
digest | 使用“用户名:密码”方式认证 | setAcl path digest:<username>:<passwprd>:acl |
ACL权限标识
权限 | ACL简写 | 描述 |
CREATE | c | 可以创建子节点(只能逐级创建,不能跨级别创建节点) |
DELETE | d | 可以删除子节点(仅下一级节点) |
READ | r | 可以读取节点数据及显示子节点列表 |
WRITE | w | 可以设置节点数据 |
ADMIN | a | 可以设置节点访问控制列表权限 |
Zookeeper集群宕机与动态扩容
集群数量符合2N+1原则,当集群宕机数量少于1/2时,仍然能对外服务
v3.5.0+ 支持集群动态配置,在主配置文件开启属性reconfigEnabled=true,配置属性为:dynamicConfigFile= basePath/fileName ,动态配置文件名格式为:configFilename.dynamic[.version]。 内容只支持server、group 、 weight属性,其中server配置格式为
server.<positive id> = <address1>:<port1>:<port2>[:role];[<client port address>:]<client port>
角色role可选值为:participant(参与者,运行中为follower,默认值)、observer(观察者)。修改后需要调用客户端命令reconfig
Zookeeper集群监控
v3.6.0+ 支持度量指标监控,可配合Prometheus.io通过Jetty服务器对外输出。访问地址为:http://hostname:httpPort/metrics。此时可以使用grafana进行可视化监控。