ZooKeeper 原理 | ZooKeeper 网络故障应对法

HBase技术社区

共 5257字,需浏览 11分钟

 ·

2021-03-14 16:45

网络故障可以说是分布式系统设计的一生之敌。如果永远不发生网络故障,我们实际上可以设计出高可用强一致的分布式系统。可惜的是网络故障在长时间运行的分布式系统中必然发生,ZooKeeper(ZK) 在运行过程中也会遇到网络故障。

首先,我们看看没有故障的时候,ZK 如何处理网络连接。

ZK 客户端启动时,会从配置文件中读取所有可用服务器的位置信息,随后随机地尝试和其中一台服务器连接。如果成功建立起连接,ZK 客户端和服务器会建立起一个会话(session),在会话超时之前,服务器会响应客户端的请求。每次新的请求都会刷新会话超时的时间,没有业务请求的时候,客户端也会通过定期的心跳来维持会话。当 ZK 客户端和当前连接的服务器失联时,客户端会尝试重新连接到可用服务器列表中的一台服务器上。

接下来,我们来了解网络故障在 ZK 的世界里如何被抽象。

网络故障在 ZK 的层面被抽象为两种异常,一种是 ConnectionLossException,另一种是 SessionExpireException。前者发生在 ZK 客户端与当前服务器断开之后,后者发生在 ZK 服务器通知客户端会话超时的时候。

ConnectionLossException

这个异常是 ZK 中最让人头痛的异常之一。

ZK 客户端通过 socket 和 ZK 集群的某台服务器连接,这个连接在客户端由 ClientCnxn 管理,在服务器由 ServerCnxn 管理。ConnectionLossException 在 ZK 客户端与当前服务器的连接异常关闭时抛出,它仅仅表明 ZK 客户端发现自己与当前服务器的连接断开,除此之外什么也不知道。因为不知道更多信息,实践中,我们需要对不同的断开原因进行探测和处理。

从可恢复的故障中恢复

ConnectionLossException 是一个可恢复的异常,它仅代表 ZK 客户端与当前服务器的连接断开,ZK 客户端完全有可能稍后连接上另一个服务器并重新开始发送请求。

在 ZK 集群网络不稳定的情况下,我们要特别小心地处理这类异常,不能直接层层外抛。否则,因为网络抖动导致上层应用崩溃是不可接受的。

同时,在这种异常情况下重新创建一个 ZK 客户端开启一个新的会话,只会加剧网络的不稳定性。这是因为 ZK 客户端不重连的情况下,服务器只能通过会话超时来释放与客户端的连接。如果由于连接过多导致响应不稳定,开启新的会话只会恶化这个情况,原理类似于 DDOS 攻击。

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

operation(...) {    zk.create(path, data, ids, mode, callback, data);}
callback = (rc, path, ctx, name) -> { switch (Code.get(rc)) { case CONNECTIONLOSS: operation(...); break; }}

操作可能已经在服务器上成功

在上一节中,我们介绍了通过重做动作来从 ConnectionLossException 中恢复的方法。然而,重做动作是有风险的,这是因为先前的动作可能在客户端上已经成功。如果当前动作是写动作且不幂等,就可能在应用层面观察到意图与实际执行的操作不一致。

ConnectionLossException 仅代表 ZK 客户端与当前服务器的连接断开。但是,在断开之前,对应的请求可能已经发送出去,已经到达服务器,并被处理。只是由于客户端与服务器的连接断开,导致 ZK 客户端在收到回应之前抛出 ConnectionLossException。

对于读操作,重试通常没有什么问题,因为我们总能得到重试成功的时候读操作应有的返回值或异常。

对于写操作,情况则复杂一些,我们分开来讨论。

对于 setData 操作,在重试成功的情况下,不考虑具体的业务逻辑,我们可以认为问题不大。因为两次把节点设置为同一个值是幂等操作,对于前一次操作更新了 version 从而导致重试操作 version 不匹配的情况,我们也可以对应处理这种可解释的异常。

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

对于 create 操作,情况则再复杂一点。在不带 sequential 要求的情况下,create 可能成功或者触发一个 NodeExistException,可以采取跟 delete 对应的处理方式;在 sequential 的情况下,有可能先前的操作已经成功,而重试的操作也成功,也就是创建了两个 sequential 节点。由于我们丢失了先前操作的返回值,因此先前操作的 sequential 节点就成了孤儿,这有可能导致资源泄露或者一致性问题。

例如,基于 ZK 的一种 leader 选举算法依赖于 sequential 节点的排序,一个序号最小的孤儿节点将导致整个算法无法推进且无法产生 leader。在这种情况下,孤儿节点获取了 leader 权限,但其 callback 却在早前被 ConnectionLossException 触发了,因此当选 leader 及后续响应无法触发。同时,由于该节点成为孤儿,它也不会被删除,从而算法不再往下运行。

Curator 作为 ZK 的客户端库,提供了 withProtection 和 idempotent 两种设置方法来处理上面提到的问题。

客户端可能错过状态变化

ZK 的 Watcher 是单次触发的,前一次 Watcher 触发到重新设置 Watcher 并触发的间隔之间的事件可能会丢失。这本身是 ZK 上层应用需要考虑的一个重要的问题。

ConnectionLossException 会触发 Watcher 接收到一个 WatchedEvent(EventType.None, KeeperState.Disconnected) 的事件。一旦收到这个事件,ZK 客户端必须假定 ZK 上的状态可能发生任意变化。对于依赖于某些状态的回调,需要先被挂起,在恢复连接并确认状态无误之后再执行回调。

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

SessionExpiredException

这个异常比起 ConnectionLossException 来说是更加严重的故障,但却更好处理一些。因为它是不可恢复的故障,所以我们无需考虑状态恢复的问题。

ZK 客户端会话超时之后无法重新和服务器取回连接。因此,我们通常只需要重新创建一个 ZK 客户端实例并重新开始开始工作。但是会话超时会导致 ephemeral 节点被删除,如果上层应用逻辑与此相关的话,就需要相应地处理 SessionExpiredException。

会话超时的检测

ZK 客户端与服务器成功建立连接后,ClientCnxn.SendThread 线程会周期性的向服务器发送心跳请求,服务器在处理心跳请求时重置会话超时的时间。

如果服务器在超时时间内没有收到客户端发来的任何新的请求,包括心跳请求和查询、写入等业务请求,那么它将宣布这个会话超时,并显式的关掉对应的链接。

ZK 会话超时相关的逻辑在 SessionTracker 类中。所有会话检查和超时的判断都是由 ZK 集群的 leader 作出的,也就是所谓的仲裁动作(quorum operation)。换句话说,客户端超时是所有服务器的共识。

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

这就引出一个 tricky 的逻辑,ZK 客户端的会话超时永远是由服务器通知的。考虑这样一种超时情况,在服务器挂了或者客户端与服务器网络分区的情况下,ZK 客户端是无法得知自己的会话已经超时的。ZK 目前没有办法处理这一情况,只能依赖上层应用自己去处理。例如,通过其他逻辑确定会话已超时之后,主动地关闭 ZK 客户端并重启。

Curator 作为 ZK 的客户端库,通过 ConnectionStateManager#processEvents 周期性检测在收到最后一个 disconnect 事件后过去的时间,从而在必然超时的时候通过反射向 ZK 客户端注入会话超时事件。

ephemeral 节点的删除

ephemeral 节点的删除发生在会话超时之后。实践中与 ephemeral 节点的删除相关的问题,主要是关于基于 ZK 的 leader 选举的。

ZK 提供了 leader 选举的参考实现[1],这是一个基于有序的 ephemral sequential 节点队列的算法。当上层应用采用这种算法做 leader 选举时,如果 ZK 客户端与服务器超时,由于处理 ZK 相关操作的线程与上层应用的线程常常是分开的,在上层应用异步地得知自己不是 leader 之前,就可能误认自己还是 leader,从而作出越权的操作。

例如,在 Flink 的设计中,只有 JobManager 中的 leader 才有权限写 checkpoint 数据。

但是,由于丢失 leadership 的消息,也就是对应 ephemral sequential 节点被删除的消息,从 ZK 集群传递到 ZK 客户端,在从 ZK 客户端的线程通知到上层应用,这几个步骤之间是异步的,丢失 leadership 的 JobManager leader 并不能第一时间得知这一情况。同时,其他的 JobManager 可能在同一时间段被通知当选 leader。此时,集群中就会有两个 JobManager 认为自己是 leader,也就是常说的脑裂。

如果对它们写 checkpoint 数据的动作不做其他限制,就可能导致两个 leader 并发地写 checkpoint 数据而导致状态不一致。这个由于响应时间带来的问题在 Curator 的技术注意事项中已有提及[2],由于发生概率较小,而且仅在未能及时响应 ZK 服务器信息时才会发生,因此虽然不少系统都有这个理论上的 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

浏览 123
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报