zookeeper为什么说是一个简单又复杂的东西,复杂是指从理论上上来看,真的很复杂,很多人根本看不懂,为什么又说简单呢?简单是指从代码层面上来说,实现理论并不复杂,反而异常的清晰。下面说下复杂的东西:paxos。
paxos统治了现在基本上所有一致性算法的理论基础,chubby和zookeeper都是以paxos为理论的一致性算法,但是由于paxos不容易实现和即使实现在使用当中也会出现一些问题,基本上没有中间件完全的用paxos,基本上都是在理论基础上的变种。下面简单的说一下paxos:
paxos有三种角色:Proposer,Acceptor,Learner。由于Learner并不参加选举,这里就不多说了。Proposer负责提出议案,Acceptor负责对提案进行投票。算法将围绕下面的图片进行聊:
从上面的图也可以看出来:
下面说第一种情况:
一阶段(prepare):
A1在1发出prepare(maxn=1)发出提议,A3在接收到提议之后,自己的maxn=null,把自己的maxn=1,在2回复A1 ok,promise(maxn=1);其他的A2,A4,A5也和A3一样的回复A1 ok,promise(maxn=1);A1收到过半的promise回复,开启二阶段的accept过程。
二阶段(accept):
A1在3发出accept(maxn=1,value=10%),A3收到accept之后,比较自身的maxn=1,等于A1发出的accpet的maxn=1,在4回复A1ok,accepted(maxn=1,value=10%),其他的A2,A4,A5也和A3一样的回复A1 ok,accepted(maxn=1,value=10%),A1收到过半的accepted回复之后,开启之后的leaner过程。
这是最顺利的过程,现实往往不会如此,会存在异常情况,下面分析第二种情况:
一阶段(prepare):
A1在1发出prepare(maxn=1)发出提议,A3在接收到提议之后,自己的maxn=null,把自己的maxn=1,在2回复A1 ok,promise(maxn=1);其他的A2,A4,A5也和A3一样的回复A1 ok,promise(maxn=1);A1收到过半的promise回复,开启二阶段的accept过程。
一阶段和第一种情况的没有什么区别,主要在二阶段上。
二阶段(accept):
A5在5发出prepare(maxn=2)发出提议,A3在接受到提议之后,自己的maxn=1<2,把自己的maxn=2,在6回复A5 ok,promise(maxn=2,value=10%),并带上次accepted的maxn=1,假设A1,A2,A4也和A3一样,回复A5 ok,promise(maxn=2,value=10%),并带上次accepted的maxn=1,这样A5得到半数的promise回复,A5发出accepted(2,10%),同时A1也收到了半数的accepted请求,这只是理想情况,最终的结果就是A1把accept(maxn=1,value=10%)和A5把(maxn=2,value=10%)都收到了过半回复,value=10%都被传播了。
这种情况也很顺利,并没有进行二次选举,只是10%被二次accepte,这也是理论的问题所在,不可控。后面zab就有了leader的概念。下面说下第三种情况:
一阶段(prepare):
A1在1发出prepare(maxn=1)发出提议,A3在接收到提议之后,自己的maxn=null,把自己的maxn=1,在2回复A1 ok,promise(maxn=1);其他的A2,A4,A5也和A3一样的回复A1 ok,promise(maxn=1);A1收到过半的promise回复,开启二阶段的accept过程。
一阶段和第二种情况的没有什么区别,主要在二阶段上。
二阶段(accept):
A5在5发出prepare(maxn=2)发出提议,A3在接受到提议之后,自己的maxn=1<2,把自己的maxn=2,在6回复A5 ok,promise(maxn=2,value=10%),并带上次accepted的maxn=1,假设现在A2和A4都和A3一样接受了A5发出的prepare(maxn=2)的提议,但是这个时候A2,A4,A5这个时候和A3不一样,这个时候A2,A4,A5,还没有收到A1的accept(maxn=1,value=10%),在7的时候A1发出了accept(maxn=1,value=10%),但是这个时候A2,A4,A5,的maxn=2,所以A1被A2,A4,A5在8的reject拒绝,不得不进行下次的promise(3),而A5得到A2,A4的promise(maxn=2,value=null),A3的promise(maxn=2,value=10%),得到了多数的promise回复,挑选最大的maxn的value,这里选择value=10%,也可能不是,所以进行accept(maxn=2,value=10%或者value=20%),然后又回到了之前,所以这种就会循环,也就是大家所熟知的活锁。其他的情况可以以此类推,不会有太大变化,很类似,不再说其他情况。
说到这里paxos就结束了,我自己都感觉复杂,对现实情况实现起来更是没有太大的头绪,这也是这个算法的通病,basic paxos难以理解,难以实现。线面到正题了,zookeeper的zab,在paxos理论上变种,单纯的理论意义如果不能实现,那么就没有太大的意义。
zab协议分为三个部分:崩溃恢复模式,数据同步模式,消息广播模式,也有人把崩溃恢复和数据同步列为一起(一阶段),消息广播模式(二阶段),不影响大家理解。
1:初始化投票,每个人都把选票投给自己,(1,1)代表server1把选票投给server1(自己),(2,2)代表server2把选票投给server2(自己),(3,3)代表server1把选票投给server3(自己)
2:发送初始化选票,server1分别发送给server2(1,1)和server3(1,1),server2发送给server1(2,2)和server3(2,2),server3发送给server1(3,3),server2(3,3)
3:接受外部投票,server2和server3在这里接受server1的投票,server1和server3接受server2的投票,server1和server2接受server3的投票。
4:更新选票,更新选票的原则是闲判断全局的时钟(epoch),如果epoch相等,再判断zx_id,事务id的大小,如果zx_id一样大,就判断server_id(my_id),服务器的编码,每个服务器都有一个id,因为是一开始启动服务器,所以只需要比较server_id(my_id)就行,由于server1的my_id小于server2的my_id,由于server2的my_id小于server3的my_id,server1的接到server2的(2,2),变更自己的投票变成(1,2),(2,2),接到server3的投票变成(1,3),(2,2),(3,3),server2接到server1的投票,不进行变更,此时投票(1,1),(2,2),接到server3的投票变成(1,1),(2,3),(3,3),server3的投票接到server1和server2的没有变化,此时(1,1),(2,2),(3,3)。
server1(1,3),(2,2),(3,3)
server2(1,1),(2,3),(3,3)
server3(1,1),(2,2),(3,3)
由于server1和server2的投票变更了,需要把变更的投票投出去,所以server1要吧(1,2)和(1,3)投票投出去,我不列举(1,2)了,只列举(1,3),server1投出去(1,3),server2投出去(2,3),server3没变更投票,无需继续投
5:发送变更的选票,server1发送给server2和server3(1,3),server2发送给server1和server3(2,3),server3不需要发送投票。
5:接着更新选票,server1投给server2和server3都不需要变更投票,只需要记录新的投票,server2投给server1的也不需要变更投票,只需要更新记录,server1接到server2(2,3)的投票,进行更新(1,3)(2,3)(3,3),server2接到server1的投票,更新投票(1,3)(2,3)(3,3),server3接到server1和server2的投票,进行更新(1,3)(2,3)(3,3)
server1(1,3)(2,3)(3,3)
server2(1,3)(2,3)(3,3)
server3(1,3)(2,3)(3,3)
都没有进行变更投票,至此结束了,最终的结果:
从上面可以看出server3获得了大多数机器的统一,成为了leader。选举的代码类是FastLeaderElection,代码如下,感兴趣的自己看下,我就不分析了,和上面基本完全一样:
public Vote lookForLeader() throws InterruptedException {
try {
self.jmxLeaderElectionBean = new LeaderElectionBean();
MBeanRegistry.getInstance().register(
self.jmxLeaderElectionBean, self.jmxLocalPeerBean);
} catch (Exception e) {
LOG.warn("Failed to register with JMX", e);
self.jmxLeaderElectionBean = null;
}
if (self.start_fle == 0) {
self.start_fle = System.currentTimeMillis();
}
try {
HashMap recvset = new HashMap();
HashMap outofelection = new HashMap();
int notTimeout = finalizeWait;
synchronized(this){
logicalclock++;
updateProposal(getInitId(), getInitLastLoggedZxid(), getPeerEpoch());
}
LOG.info("New election. My id = " + self.getId() +
", proposed zxid=0x" + Long.toHexString(proposedZxid));
sendNotifications();
/*
* Loop in which we exchange notifications until we find a leader
*/
while ((self.getPeerState() == ServerState.LOOKING) &&
(!stop)){
/*
* Remove next notification from queue, times out after 2 times
* the termination time
*/
Notification n = recvqueue.poll(notTimeout,
TimeUnit.MILLISECONDS);
/*
* Sends more notifications if haven't received enough.
* Otherwise processes new notification.
*/
if(n == null){
if(manager.haveDelivered()){
sendNotifications();
} else {
manager.connectAll();
}
/*
* Exponential backoff
*/
int tmpTimeOut = notTimeout*2;
notTimeout = (tmpTimeOut < maxNotificationInterval?
tmpTimeOut : maxNotificationInterval);
LOG.info("Notification time out: " + notTimeout);
}
else if(self.getVotingView().containsKey(n.sid)) {
/*
* Only proceed if the vote comes from a replica in the
* voting view.
*/
switch (n.state) {
case LOOKING:
// If notification > current, replace and send messages out
if (n.electionEpoch > logicalclock) {
logicalclock = n.electionEpoch;
recvset.clear();
if(totalOrderPredicate(n.leader, n.zxid, n.peerEpoch,
getInitId(), getInitLastLoggedZxid(), getPeerEpoch())) {
updateProposal(n.leader, n.zxid, n.peerEpoch);
} else {
updateProposal(getInitId(),
getInitLastLoggedZxid(),
getPeerEpoch());
}
sendNotifications();
} else if (n.electionEpoch < logicalclock) {
if(LOG.isDebugEnabled()){
LOG.debug("Notification election epoch is smaller than logicalclock. n.electionEpoch = 0x"
+ Long.toHexString(n.electionEpoch)
+ ", logicalclock=0x" + Long.toHexString(logicalclock));
}
break;
} else if (totalOrderPredicate(n.leader, n.zxid, n.peerEpoch,
proposedLeader, proposedZxid, proposedEpoch)) {
updateProposal(n.leader, n.zxid, n.peerEpoch);
sendNotifications();
}
if(LOG.isDebugEnabled()){
LOG.debug("Adding vote: from=" + n.sid +
", proposed leader=" + n.leader +
", proposed zxid=0x" + Long.toHexString(n.zxid) +
", proposed election epoch=0x" + Long.toHexString(n.electionEpoch));
}
recvset.put(n.sid, new Vote(n.leader, n.zxid, n.electionEpoch, n.peerEpoch));
if (termPredicate(recvset,
new Vote(proposedLeader, proposedZxid,
logicalclock, proposedEpoch))) {
// Verify if there is any change in the proposed leader
while((n = recvqueue.poll(finalizeWait,
TimeUnit.MILLISECONDS)) != null){
if(totalOrderPredicate(n.leader, n.zxid, n.peerEpoch,
proposedLeader, proposedZxid, proposedEpoch)){
recvqueue.put(n);
break;
}
}
/*
* This predicate is true once we don't read any new
* relevant message from the reception queue
*/
if (n == null) {
self.setPeerState((proposedLeader == self.getId()) ?
ServerState.LEADING: learningState());
Vote endVote = new Vote(proposedLeader,
proposedZxid,
logicalclock,
proposedEpoch);
leaveInstance(endVote);
return endVote;
}
}
break;
case OBSERVING:
LOG.debug("Notification from observer: " + n.sid);
break;
case FOLLOWING:
case LEADING:
/*
* Consider all notifications from the same epoch
* together.
*/
if(n.electionEpoch == logicalclock){
recvset.put(n.sid, new Vote(n.leader,
n.zxid,
n.electionEpoch,
n.peerEpoch));
if(ooePredicate(recvset, outofelection, n)) {
self.setPeerState((n.leader == self.getId()) ?
ServerState.LEADING: learningState());
Vote endVote = new Vote(n.leader,
n.zxid,
n.electionEpoch,
n.peerEpoch);
leaveInstance(endVote);
return endVote;
}
}
/*
* Before joining an established ensemble, verify
* a majority is following the same leader.
*/
outofelection.put(n.sid, new Vote(n.version,
n.leader,
n.zxid,
n.electionEpoch,
n.peerEpoch,
n.state));
if(ooePredicate(outofelection, outofelection, n)) {
synchronized(this){
logicalclock = n.electionEpoch;
self.setPeerState((n.leader == self.getId()) ?
ServerState.LEADING: learningState());
}
Vote endVote = new Vote(n.leader,
n.zxid,
n.electionEpoch,
n.peerEpoch);
leaveInstance(endVote);
return endVote;
}
break;
default:
LOG.warn("Notification state unrecognized: {} (n.state), {} (n.sid)",
n.state, n.sid);
break;
}
} else {
LOG.warn("Ignoring notification from non-cluster member " + n.sid);
}
}
return null;
} finally {
try {
if(self.jmxLeaderElectionBean != null){
MBeanRegistry.getInstance().unregister(
self.jmxLeaderElectionBean);
}
} catch (Exception e) {
LOG.warn("Failed to unregister with JMX", e);
}
self.jmxLeaderElectionBean = null;
LOG.debug("Number of connection processing threads: {}",
manager.getConnectionThreadCount());
}
}
zookeeper缓存了最近的500个请求,为了方便数据同步,所以数据同步也是以此为基础的,minCommintedLog和maxCommintedLog,代表缓存的最大值和最小值,以此区间判断怎么同步,很多主流的中间件都有这个东西。
1:follower 的 peerLastZxid 介于 maxCommittedLog, minCommittedLog 两者之间
1.1:follower 的 peerLastZxid 等于 leader 的 peerLastZxid,follower 与 leader 数据一致,采用 DIFF 方式同步,
也 即是无需同步。
1.2:follower 的 peerLastZxid 小于 leader 的 peerLastZxid,peerLastZxid在leader节点存在,采用 DIFF 方式同步
leader 0x500000001, 0x500000002, 0x500000003, 0x500000004, 0x500000005,0x600000001
follower peerLastZxid为0x500000003
只需要把0x500000004和0x500000005,0x600000001发送给follower
1.3:follower 的 peerLastZxid 小于 leader 的 peerLastZxid,peerLastZxid在leader节点不存在。
采用 TRUNC+DIFF(先回滚再差异化同步)。
leader 0x500000001, 0x500000002, 0x500000003, 0x500000004, 0x500000005,0x600000001
follower peerLastZxid为0x500000006
follower把0x500000006进行回滚,然后把0x600000001发送给follower
2:follower 的 peerLastZxid 大于 leader 的 maxCommittedLog,则告知 follower 回滚至 maxCommittedLog;
该场景 可以认为是 TRUNC+DIFF 的简化模式
3:follower 的 peerLastZxid 小于 leader 的 minCommittedLog 或者 leader 节点上不存在提案缓存队列时,
将采用 SNAP 全量同步方式。 该模式下 leader 首先会向 follower 发送 SNAP 报文,随后从内存数据库中获取全量数据序
列化传输给 follower, follower 在接收全量数据后会进行反序列化加载到内存数据库中。
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 消息后,会将上一条事务提交。
在这里我是要贴一段足够经验的代码:
public void run() {
try {
int logCount = 0;
// we do this in an attempt to ensure that not all of the servers
// in the ensemble take a snapshot at the same time
setRandRoll(r.nextInt(snapCount/2));
while (true) {
Request si = null;
if (toFlush.isEmpty()) {
si = queuedRequests.take();
} else {
si = queuedRequests.poll();
if (si == null) {
flush(toFlush);
continue;
}
}
if (si == requestOfDeath) {
break;
}
if (si != null) {
// track the number of records written to the log
if (zks.getZKDatabase().append(si)) {
logCount++;
if (logCount > (snapCount / 2 + randRoll)) {
randRoll = r.nextInt(snapCount/2);
// roll the log
zks.getZKDatabase().rollLog();
// take a snapshot
if (snapInProcess != null && snapInProcess.isAlive()) {
LOG.warn("Too busy to snap, skipping");
} else {
snapInProcess = new ZooKeeperThread("Snapshot Thread") {
public void run() {
try {
zks.takeSnapshot();
} catch(Exception e) {
LOG.warn("Unexpected exception", e);
}
}
};
snapInProcess.start();
}
logCount = 0;
}
} else if (toFlush.isEmpty()) {
// optimization for read heavy workloads
// iff this is a read, and there are no pending
// flushes (writes), then just pass this to the next
// processor
if (nextProcessor != null) {
nextProcessor.processRequest(si);
if (nextProcessor instanceof Flushable) {
((Flushable)nextProcessor).flush();
}
}
continue;
}
toFlush.add(si);
if (toFlush.size() > 1000) {
flush(toFlush);
}
}
}
} catch (Throwable t) {
handleException(this.getName(), t);
running = false;
}
LOG.info("SyncRequestProcessor exited!");
}
这段代码说的什么,说的是如果我们提交一个写请求,如果此时系统很空闲,那么我们进行提交写操作,如果此时系统很频繁的工作,就积攒写请求到1000个,进行处理,以前我认为的实时写操作,写完一个执行完,把结果返回用户,但是实际上的都是异步的,我能想到网络I/O上的异步,却没想到这么执行写操作,开拓了眼界。值得吐槽的地方是:zk对文件的操作有点low,把数据保存的时候序列化一下就算完了,所有的数据加载到内存里,没有所谓的索引常驻内存一说,就是一个大字典,所以zk吃内存,这个东西和redis有的比,真的很像,特别像,只不过redis还有一些出色的数据结构。
这篇只说了怎么选举,怎么保证一致性,对于zk的一些基本知识:
PERSISTENT 持久化节点
PERSISTENT_SEQUENTIAL 顺序自动编号持久化节点,这种节点会根据当前已存在的节点数自动加 1
EPHEMERAL 临时节点, 客户端session超时这类节点就会被自动删除
EPHEMERAL_SEQUENTIAL 临时自动编号节点
这些东西我没有说,还有leader,follower,observer这些概念没有说,zookeeper为什么使用EPHEMERAL节点做分布式锁,没用用过的还是要看下这些内容,我用过zk,但是大型的集群也没使用过,可能有地方理解有误差。这篇真的很难写,很多东西我都是一点点梳理的,太耗时间了,本来想再写一片redis的,时间不够了。下个周写下redis吧。
分布式一致性(consistency)和 共识性算法(consensus)在概念上的确是二个东西,却有着千丝万缕的关系,主要分析下他们之间的联系和区别。
这里只说下2pc,不说3pc了,2pc大体分为2个过程,3个步骤
1:preCommit(预提交)
1.1:事务询问
协调者向所有参与者发送事务询问
2.1:执行事务
参与者执行事务,事务成功,写入redo log 和 undo log,并不进行commint操作,返回协调者事务成功,如果事务失败,
返回给协调者事务失败
2:doCommit
2.1:如果所有参与者返回成功,进行commit,如果有任何一个返回失败,进行rollback操作。
如果协调者挂了怎么办?一直阻塞,一直不可用,能不能再选出来一个?所有的参与者都得回复完才能提交?如果网络出问题了怎么办?异地机房网络波动很可能发生,怎么办?
上面我们提到的问题如果碰到,都是一个灾难性的问题,这里称zookeeper为公共性算法,而不是一致性算法,是有原因的。
协调者:leader相当于2pc的协调者
参与者:follower相当于2pc的参与者
为了解决2pc协调者贮机之后,整个系统不可用的状态,所以让大家重新选出一个leader,这就需要超过半数参与者达成共识,
这就是选 举的意义所在,之后就是进行2pc了,
1:preCommit(预提交)
1.1:事务询问
leader向所有follower发送事务询问
2.1:执行事务
follower执行事务,事务成功,写入txn log,并不进行commint操作,返回leader事务成功,如果事务失败,
返回给leader事务失败
2:doCommit
2.1:如果参与者中的超过一半返回成功,进行commit,否则就不会进行commit,类似rollback操作。
为什么是超过一半?同城多机房的时候,不会出现脑裂的问题,特别是异地多机房的应用,请尽量不要使用zk,
可能集群因为网络延迟,大多数时间都在选举和数据复制。
总结一下,zab为什么能够实现一致性,这里指最终一致性,可以说zab是一个二阶段2pc的协议,但也可以说不是,
不同的人有不同的理解,我认为zab是为了2pc为了实现可用性的进阶版,解决了2pc在理论上存在的致命问题。