以下描述中用zk
代指ZooKeeper
,源码解释均基于ZooKeeper 3.4.6
写在前面
好吧,这又是一篇:
不加全局理论抽象,局部解读具体逻辑细节的文章
然而,我目前的能力只能解决这些非常具体的问题。其实总结出来的文章并不能够给其它同学带来什么收益,真的想懂细节,确实只有“自己研读或者谷歌”
。
但是,我读完了代码,我会想:
- 我能不能帮忙别人来加快理解这个问题呢?
- 我这个理解是不是正确的呢,能不能
publish
出来让大家拍一拍呢? - 想给自己一个交代。我始终相信,写文章才能把头脑中可能忽略的细节都收集起来,才能发现思考时会遗漏的问题
当然,我非常认可需要做全局理论抽象
,这是我目前的恐慌区
,需要努力去跨越,自勉。
背景
继上次的Session
问题后,好学的小冷
同学又认真地研究了下ZooKeeper Cluster
的原理,问了我下面5
个问题:
ZooKeeper
集群在发生Leader
切换的时候,所有的Follower
会选择新的Leader
进行全量的数据同步吗?- 如果一次写入,由于丢包,导致某条日志没有写入,会怎么样呢?
- 扩容的时候加了个节点,这时候新的写入会不会同步到这个新节点呢?
Follower
会主动向Leader
发ping
包么?- 集群在选举的时候,四字命令都会返回
This ZooKeeper instance is not currently serving requests
,什么时候会变回正常可服务状态?
这几个问题我都不太确定,于是,踏上了新一轮的啃码之旅
。
问题A
ZooKeeper
集群在发生Leader
切换的时候,所有的Follower
会选择新的Leader
进行全量的数据同步吗?
这篇帖子里面的解释是我比较认同:
1、SNAP-全量同步
条件:peerLastZxid<minCommittedLog
说明:证明二者数据差异太大,follower数据过于陈旧,leader发送快照SNAP指令给follower全量同步数据,即leader将所有数据全量同步到follower2、DIFF-增量同步
条件:minCommittedLog<=peerLastZxid<=maxCommittedLog
说明:证明二者数据差异不大,follower上有一些leader上已经提交的提议proposal未同步,此时需要增量提交这些提议即可3、TRUNC-仅回滚同步
条件:peerLastZxid>minCommittedLog
说明:证明follower上有些提议proposal并未在leader上提交,follower需要回滚到zxid为minCommittedLog对应的事务操作4、TRUNC+DIFF-回滚+增量同步
条件:minCommittedLog<=peerLastZxid<=maxCommittedLog且特殊场景leader a已经将事务truncA提交到本地事务日志中,但没有成功发起proposal协议进行投票就宕机了;然后集群中剔除原leader a重新选举出新leader b,又提交了若干新的提议proposal,然后原leader a重新服务又加入到集群中,不管是否被选举为新leader。
说明:此时a,b都有一些对方未提交的事务,若b是leader, a需要先回滚truncA然后增量同步新leader a上的数据
对应的代码在LearnerHandler.run
中:
|
|
问题B
如果一次写入,由于丢包,导致某条日志没有写入,会怎么样呢?
先引入上次的Session
问题中使用过的一张图:
Leader
会向Follower
发送Proposal
和Commit
请求,其中,Follower
收到Proposal
请求之后,会写入日志;收到Commit
请求之后,会更改内存中的DataTree
。
问题中提到的日志没有写入
,也就是发送到某个Follower
的Proposal
请求被丢弃了, 对应的Follower
会是怎样一个逻辑呢?
回答这个问题,最直观的方法就是模拟场景,在实验中将Proposal
请求丢弃,观察对应的Follower
和Leader
的表现。
那么,问题来了,如何模拟呢?
在Jepsen
中,使用的是iptables
来模拟网络故障(周期性地丢包、网络分区等)。在我的模拟环境中,zk
集群都是部署在本地,使用iptables
来操作会比较繁琐,而且我的需求是精确地丢弃掉Proposal
请求,而不是Follower
和Leader
之间发送的所有的请求。
最终还是使用了Byteman
,对应的脚本如下:
|
|
使用Byteman
做故障场景模拟并不是我的原创,Cassandra
中使用了Byteman
来做故障场景注入。这种方法的优点是可以完成代码级别在精确错误注入;缺点也很明显,需要待注入服务是运行在JVM
之上的。
进行了场景模拟之后,发现被测的丢弃Proposal
请求的Follower
进入了LOOKING
状态,然后重新加入了集群。原因是Leader
主动断开了和Proposal
的连接。
那么,为什么Follower
丢弃Proposal
请求会导致Leader
主动断开了和Proposal
的连接呢?
这个逻辑和LearnerHandler$SyncLimitCheck
有关,Leader
会定时去调用LearnerHandler.ping
向Follower
发送Leader.PING
请求,逻辑如下:
|
|
如果Leader
发送出去的Leader.PROPOSAL
请求在一段时间内(这个时间由conf/zoo.cfg
中的syncLimit
决定)没有收到对应的ACK
,就会导致syncLimitCheck.check
失败,从而调用LearnerHandler.shutdown
关闭到这个Follower
的连接,并停止对应的发送、接收请求的线程。
在Follower
这边,由于Leader
连接关闭,调用Learner.readPacket
时会抛出异常,退出Follower.followLeader
方法,重新进入LOOKING
状态。
综上,我们知道了,发送到某个Follower
的Proposal
请求被丢弃,会导致对应的Follower
重新进入LOOKING
状态。
那么,如果被丢弃的请求是Commit
请求呢?
同样使用Byteman
进行了模拟,由于Commit
请求是不需要返回ACK
给Leader
的,所以,如果模拟时有两个写入请求ReqA
、ReqB
,如果两个请求对应的Commit
请求都丢弃了,这个时候其实对系统并没有什么影响,但是连接到对应Follower
上的客户端看到的数据就是stale
的。
如果丢弃ReqA
对应的Commit
请求之后就撤销故障场景,ReqB
对应的Commit
请求正常执行会是什么情况呢?对应的逻辑在FollowerZooKeeperServer.commit
中:
|
|
是的,对应的Follower
进程退出了。
问题C
扩容的时候加了个节点,这时候新的写入会不会同步到这个新节点呢?
会的。虽然这时候Leader
的conf/zoo.cfg
里面还没有新加入节点的信息,但是Leader
会为这个节点创建相应的LearnerHandler
,对应的逻辑在Leader$LearnerCnxAcceptor.run
中:
|
|
新加入的节点也会经历如下的阶段:
上图中的步骤是在Learner.registerWithLeader
和Learner.syncWithLeader
中完成的,也是新加入节点从Leader
中同步数据的步骤。再看看Leader
是如何把增量的数据同步到Follower
的。
Leader
向Follower
发送请求的方法Leader.sendPacket
实现如下:
|
|
新加入节点的LearnerHandler
是在LearnerHandler.run
中通过调用Leader.startForwarding
加入到Leader.forwardingFollowers
中的,加入之后Leader
就会开始同步数据到新的节点了。
那么,在计算QuorumVerifier.containsQuorum
的时候,会涉及到新加入的节点么?
答案是,某种程度上,会。
验证Ping
或者Proposal
是否达到大多数的逻辑是在QuorumPeer.quorumConfig
中实现的。QuorumPeer.quorumConfig
这个field
对应的类型是QuorumVerifier
这个接口
,这个接口
有两个实现:QuorumMaj
和QuorumHierarchical
,我们的部署比较简单,没有weight
相关的配置,所以使用的实现都是QuorumMaj
。QuorumMaj
中有一个field
叫half
,标志着这个集群里面半数的值(对于3节点
的集群,half
为1
;对于4节点
的集群,half
为2
;依此类推);对应的containsQuorum
方法实现也非常简单粗暴:
|
|
由于QuorumPeer.setQuorumVerifier
只有在节点启动的时候才会被调用,所以QuorumMaj.half
的值在节点启动之后就不会改变。
如果在一个3节点
(zk0
、zk1
、zk2
)的集群中,扩容一个新节点zk3
。在没有启动集群原有3节点
的情况下,Leader
中的QuorumMaj.half
会一直为1
,只是这时候,containsQuorum
方法的输入可能是一个大小为4
的集合。
可以理解为Leader
中判断Proposal
是否达到大多数的标准是没有变化的,但是输入产生了变化。
问题D
Follower
会主动向Leader
发ping
包么?
不会,Follower
向Leader
发的Leader.PING
包只是response
,并没有线程会定期向Leader
来发。那么,这时候会有个问题,如果Follower
长时间没有收到Leader
发的Leader.PING
请求会怎么样呢?
依然是使用Byteman
将Leader
发送给Follower
的Leader.PING
请求给丢弃掉,对应的脚本如下:
|
|
可以看到对应的Follower
会不断进入LOOKING
状态,连上Leader
之后相隔10s
就会有如下日志,错误为Read timed out
:
|
|
为什么是10s
这个时间呢?
原因在于Leader.syncWithLeader
这个方法中,在收到Leader.UPTODATE
后,会调用:
|
|
在conf/zoo.cfg
中,我们目前的配置为:
|
|
因此,和Leader
建立的socket
的读写超时时间为2000ms * 5 = 10s
。
不止Follower
会超时断连,Leader
的日志显示Leader
也会出现读超时:
|
|
然后,关闭对应的LearnerHandler
。
问题E
集群在选举的时候,四字命令都会返回
This ZooKeeper instance is not currently serving requests
,什么时候会变回正常可服务状态?
先看一下四字命令返回ZK_NOT_SERVING
的逻辑,以mntr
这个四字命令为例,对应的代码在NIOServerCnxn$MonitorCommand.commandRun
中:
|
|
NIOServerCnxn
这个对象是每个连接都会创建一个的,创建NIOServerCnxn
对象的逻辑在NIOServerCnxnFactory.createConnection
中:
|
|
NIOServerCnxn
构造方法参数里面使用的是ServerCnxnFactory.zkServer
,那么,ServerCnxnFactory.zkServer
是在什么时候设置的呢?分别看下Follower
和Leader
中对应的逻辑。
Follower
Follower
会在收到Leader
发送的Leader.UPTODATE
之后去设置,对应的逻辑在Learner.syncWithLeader
中,调用ServerCnxnFactory.setZooKeeperServer
来设置ServerCnxnFactory.zkServer
:
|
|
Leader
Leader
会在Leader.leader
中waitForNewLeaderAck
之后去设置,对应的逻辑如下:
|
|
代码里面可以看到,调用ServerCnxnFactory.setZooKeeperServer
的前提是zookeeper.leaderServes
这个属性是设置为true
的(默认为true
)。这个属性的含义是,Leader
节点是否接受客户端的请求。
总结
其实这些细节在实际维护中应用到的比较少,维护中会遇到的问题可能是“我的snap
太多了,应该怎么清理”、“我想给某个包加个自定义的日志级别怎么办”、“我的Curator
报这个错是什么意思”。虽然如此,我比较认可的观点仍然是:
维护开源产品不了解源码,或者没有找到看的有效入口,是很被动的,缺少定位解决问题的根本手段
有了源码定位以及相关工具的经验,遇到问题才不会轻易方
、不会轻易炸毛
、不会轻易甩锅
。当这些代码不再是坨翔而是My precious
时,解决问题就变成一种愉悦的体验了。