网络故障可以说是分布式系统天生的宿敌。如果永远不发生网络故障,我们实际上可以设计出高可用强一致的分布式系统。可惜的是不发生网络故障的分布式环境还不存在,ZK 使用过程中也需要小心的应付网络故障。

让我们先忘掉故障发生的情况,首先来看到 ZK 对网络连接的处理。ZK 客户端启动时带有所有可用的服务器的信息,它会随机选择和其中一台服务器尝试连接,在正常的成功连接的情况下,ZK 客户端和服务端会建立起一个会话(session),在会话超时之前服务端会响应客户端的请求,每次新的请求都会刷新会话超时的时间。当 ZK 客户端和当前服务器失联时,它会试着从可用的服务器列表中重新连接到一台服务器上。

总体地看过 ZK 正常的网络连接处理之后,我们来看看网络故障在 ZK 世界中的抽象。网络故障在 ZK 的层面被转换为两种异常,一种是 ConnectionLossException ,一种是 SessionExpireException。前者发生在超时时间之前 ZK 客户端与某台服务器断开之后,后者发生在服务端通知客户端会话超时的时候。

ConnectionLossException

这个异常肯定是 ZK 中最让人头痛的异常之一了。ZK 客户端通过 socket 和 ZK 服务端的某台服务器连接,在客户端由 ClientCnxn 管理,在服务端由 ServerCnxn 管理。ConnectionLossException 发生在 ZK 客户端失去与 ZK 服务器的连接的时候,它仅仅表明 ZK 客户端发现自己失去了和当前服务器的连接,除此之外什么也不知道。这里存在三个重要的问题。

从可恢复的故障中恢复

ConnectionLossException 是一个可恢复的异常,它仅仅代表着与当前服务器的连接失效,客户端完全有可能稍后连接上另一个服务器并重新开始发送请求。在客户端与 ZK 连接不稳定的情况下,我们需要特别小心的处理这类异常。否则,因为网络抖动而使上层应用崩溃是不可接受的。此外,重新创建 ZK 客户端,开启一个新的会话则只会加剧网络的不稳定性。这是因为客户端不重连的情况下服务端只能通过会话超时来释放与客户端的连接,如果由于连接过多导致响应不稳定,开启新的会话只会恶化这个情况。

一种常见的容忍 ConnectionLossException 的方式是重做动作,也就是形如下面代码的处理逻辑

operation(...) {
  zk.create(path, data, ids, mode, callback, data);
}

callback = (rc, path, ctx, name) -> {
  switch (Code.get(rc)) {
    case CONNECTIONLOSS:
      operation(...);
      break;
    
    // ...
  }
}

服务端上操作可能已经成功

上面提到从可恢复的故障中回复的时候,介绍了一种通过重做动作的方法。然而,重做动作是有风险的。这是因为先前的动作可能在客户端上已经成功。

ConnectionLossException 只表明 ZK 客户端发现自己与服务端的连接断开,但是在断开之前,对应的请求完全可能已经发送出去,已经到达服务端并被处理。只是由于客户端与服务端的连接断开而收不到回应而是触发 ConnectionLossException 罢了。

对于读操作,重试通常没有什么问题,因为我们总能得到重试成功的时候读操作应有的返回值(或异常)。对于写操作,情况稍微微妙一些。

对于 setData 操作,在重试成功的情况下,不考虑具体的业务逻辑,我们可以认为问题不大。因为两次把节点设置为同一个值是幂等操作,对于前一次操作更新了 version 从而导致重试操作 version 不匹配的情况,我们也可以由吞掉异常或者触发业务相关的异常逻辑。

对于 delete 操作,重试可能导致意外的 NoNodeException,我们可以吞掉这个异常或者触发业务相关的异常逻辑。

对于 create 操作,情况稍微复杂一点。在非 sequential 的情况下,create 可能成功或者触发一个 NodeExistException,这跟 delete 大约是对应的处理方式。但是在 sequential 的情况下,有可能先前的操作已经成功,而重试的操作也成功。由于我们丢失了先前操作的返回值,因此先前操作的 sequential 节点就成了孤儿,这有可能导致资源泄露或者更严重的一致性问题。例如,基于 ZK 的 leader 选举算法依赖于 sequential 节点的排序,一个序号最小的孤儿节点将导致整个算法失败,这是因为孤儿节点成为了 leader 其上的 Watcher 却被 ConnectionLossException 触发了,任何客户端也没有对它的所有权,因此它也不会被删除以推动算法继续进行。

客户端可能错过状态变化

ZK 的 Watcher 是单次触发的,在前一次 Watcher 被触发到重新设置 Watcher 并被触发的间隔之间的事件可能会丢失。这本身是 ZK 上层应用需要考虑的一个重要的问题。 ConnectionLossException 会触发 Watcher 接收到一个 WatchedEvent(EventType.None, KeeperState.Disconnected) 的事件。一旦收到这个事件,ZK 客户端必须假定 ZK 上的状态可能发生任意变化。对于依赖于某些状态例如自己是应用程序中的 leader 的动作,需要挂起动作,在恢复链接后确认状态之后再重新执行动作。

这里有一个设计上的细节需要注意,不同于一般的 WatchedEvent 会在触发 Watcher 后将其移除,EventType.None 的 WatchedEvent 在不设置系统属性 zookeeper.disableAutoWatchReset=true 的情况下只会触发 Watcher 而不将其移除。同时,在成功重新连接服务器之后会将当前的所有 Watcher 通过 setWatches 请求重新注册到服务端上。服务端通过对比 zxid 的数值来判断是否触发 Watcher。从而避免了由于网络抖动而强迫用户代码在 Watcher 的处理逻辑中处理 ConnectionLossException 并重新执行操作设置 Watcher 的负担。特别是,当前客户端上注册的所有的 Watcher 都将受到网络抖动的影响。但是要注意重新注册的 Watcher 中监听 NodeCreated 事件的 Watcher 可能会错过该事件,这是因为在重新建立连接的过程中该节点由于其他客户端的动作可能先被创建后被删除,由于仅就有无节点判断而没有 zxid 来帮助判断,这里我们遇到了所谓的 ABA 问题。

SessionExpiredException

这个异常比 ConnectionLossException 好处理的地方在于它是严格不可恢复的故障,ZK 客户端会话超时之后无法重新和服务端成功连接,因此我们通常只需要重新创建一个 ZK 客户端实例并重新开始开始工作。但是会话超时会导致 ephemeral 节点被删除,如果上层应用逻辑与此相关的话,就需要仔细的处理 SessionExpiredException

会话超时的检测

ZK 客户端与服务器成功建立连接后,ClientCnxn.SendThread 会周期性的向服务器发送 ping 信息,服务器在处理 ping 信息的时候重置会话超时的时间。如果服务器在超时时间内没有收到客户端发来的任何新的信息,那么它就会宣布这个会话超时,并显式的关掉对应的链接。ZK 会话超时相关的逻辑在 SessionTracker 中,所有会话检查和超时的判断都是由 leader 作出的,也就是所谓的仲裁动作(quorum operation),因此客户端超时是所有服务器一致的共识。

在 ZK 客户端的连接被服务端关闭后,客户端尝试重新连接服务器,仅当它重新连接上某个服务器时,该服务器查询服务端的会话列表,发现这个重连请求属于超时会话,通过返回非正整数的超时剩余时间通知客户端会话已超时。随后,客户端得知自己已经超时并执行相应的退出逻辑。

这里有一个非常 tricky 的事情,就是 ZK 客户端的会话超时永远是由服务端通知的。那么,在一种很合理的超时情况,即服务端挂了或者客户端与服务端彻底分区的情况下,实际上 ZK 客户端是无法得知自己的会话已经超时的。ZK 目前没有办法处理这一情况,只能依赖上层应用自己去处理。例如,在确定之后主动地关闭 ZK 客户端并重启。在 Curator 中通过 ConnectionStateManager#processEvents 周期性的检查在收到最后一个 disconnect 事件后过去的时间,从而从客户端的角度在必然超时的时候注入会话超时事件。

ephemeral 节点的删除

跟 ephemeral 节点的删除相关的最大的问题是关于基于 ZK 的 leader 选举的。ZK 提供了 leader 选举的 recipe 参考[1],总的来说是基于一系列 ephemral sequential 节点的排序来做的。当上层应用基于 ZK 做 leader 选举时,如果 ZK 客户端与服务端超时,由于 ZK 相关的操作和相应往往和上层应用的主线程是分开的,这样在上层应用得知自己不是 leader 之前就有可能作出很多越权的操作。

例如,在 FLINK 中,理论上只有成为 leader 的 JobManager 才有权限写入 checkpoint,但是由于 ZK 上产生丢失 leadership 的消息,到客户端得知这一消息,再到通知上层应用,这几个步骤之间都是异步的,所以此前的 leader 并不能第一时间得知自己丢失 leadership 了。同时,其他的 JobManager 可能在同一时间被通知当选 leader。此时,集群中就会有两个 JobManager 认为自己是 leader。如果对它们写入 checkpoint 的动作不做其他限制,即只要 JobManager 认为自己有权限,就是有权限的话,就可能导致两个 leader 并发的写入 checkpoint 从而导致状态不一致。这个由于响应时间带来的问题 Curator 的技术注意事项中已有提及[2],由于发生概率较小,而且实现上依赖于”及时地“响应远端信息,因此虽然不少系统都有这个理论上的 BUG,但是很多时候只是作为注意事项帮助开发者和使用者在极端情况下理解发生了什么。

FLINK-10333[3] 和 ZK 邮件列表上我发起的这个讨论[4]详细讨论了这种情况下面临的挑战和解决方法。

[1] https://zookeeper.apache.org/doc/r3.5.5/recipes.html#sc_leaderElection

[2] https://cwiki.apache.org/confluence/display/CURATOR/TN10

[3] https://issues.apache.org/jira/browse/FLINK-10333

[4] https://lists.apache.org/x/thread.html/594b66ecb1d60b560a5c4c08ed1b2a67bc29143cb4e8d368da8c39b2@%3Cuser.zookeeper.apache.org%3E

版权声明:本文为tisonkun原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/tisonkun/p/11612868.html