分布式系统总要面对天然的失败场景,这可能是网络分区、节点故障或者线程死亡等等五花八门的问题。在失败场景下保证服务整体对外的可用性(Availability)是分布式系统质量的一个重要衡量标准。
FLINK 使用的高可用服务提供了在 Master 失败的情况下已提交的作业会重新运行,并且重新运行的进度不早于最后一次 checkpoint 的保证。
本文从拆解 FLINK 使用的高可用服务的框架和职责入手,先介绍 FLINK 高可用服务的整体结构,接着指出社区现有实现的若干问题,进而介绍我在 FLINK-10333 提出的对高可用服务的改造的提议以及它如何解决这些问题。
FLINK 的高可用服务大体可以分为两块,一块是 LeaderElectionService 和 LeaderRetrievalService 组成的选举和名称服务,另一块是由 JobGraphStore、CheckpointStore 和 JobRegistry 组成的作业管理服务。这两块服务均可在 Master 失败的情况下保持整体对外的可用性。
FLINK 针对选举和名称服务提供了三种不同的实现,其中真正高可用的是基于 ZooKeeper 的实现,Standalone 实现是简单的预配置信息,Embedded 实现是在内存中模拟 ZooKeeper 实现的选举过程。选举的算法和执行过程在下文介绍对高可用服务的改造时会细讲,这里仅介绍其抽象上地使用方式。
LeaderElectionService 通过选举算法在 Master 实例中选出 Leader 后,Leader 将发布自己的地址,随后 LeaderRetrivalService 从 Leader 发布的位置上发现新任 Leader 的地址并连接(Standalone 的情况下,地址是预配置的,也就是说 LeaderRetrievalService 天生知道 Leader 在哪)。例如,TaskManager 上有一个发现 JobManager Leader 的 LeaderRetrivevalService,在 JobManager 选出 Leader 后即可发现其地址,并连上 JobManager Leader 开始通信和工作。当 Leader 失败挂掉时,新选出的 Leader 发布自己的地址,LeaderRetrievalService 发现新的 Leader,断开丢掉 Leadership 的 JobManager 的链接并和新的 Leader 建立连接。
在 Master 失败并重启之后,FLINK 保证已提交的作业会重新运行,这依赖于 JobGraphStore。当作业成功提交之后,作业对应的 JobGraph 会被持久化到 JobGraphStore 上。当 Master 失败并重启之后,它会通过配置重新构建 JobGraphStore 并获取此前持久化的 JobGraph 并从中恢复作业。
JobRegistry 是用于协助作业管理的元信息,它保存了作业是否处于 RUNNING 或者 DONE 的状态。一旦作业被某个 JobManager 执行,这就意味着作业已经进入 RUNNING 状态;而如果有 JobManager 已经完成了作业,这个作业在 JobRegistry 上的状态就会变成 DONE。RUNNING 状态主要用于使 JobManager 失败并重启时有尝试直接恢复现有作业的能力,具体细节将在下文提及;DONE 状态主要用于避免作业重复提交和 standby 的 JobManager 在获得 Leadership 后执行已完成的任务。
CheckpointStore 持久化了已完成的 checkpoint 的元数据,当 Master 失败并重启之后,可以恢复任务完成过的 checkpoint,并从 checkpoint 的位置重新开始任务,避免了任务完全从头开始。
上面提到的种种功能均是 FLINK 高可用服务理想情况下应该工作的样子,然而,由于实现上的问题,实际上社区不仅缺少了一些功能,甚至已有功能的实现也是有缺陷的。危言耸听地说,社区版本 FLINK 所提供的高可用服务是个假的高可用服务,在可以预见的边界情况下无法保证正确性。
我们知道,在 Spark 等其他计算引擎中,发生 Master failover 时,由于挂掉的是 Master,可能 Task 还是正常的在运行。在 Master 恢复之后应该首先尝试接管现有任务,如果所有 Task 都正常运行,就直接将作业状态同步后进行下一步调度。
但是,在社区版本的 FLINK 实现中,发生 Master failover 时,TaskManager 发现 Master Leader 丢失,将立刻 fail 其上所有隶属于这个 JobManager 的 Task,也就是说,由于 Master 挂掉,所有的 Task 也被迫挂掉。这显然是不可接受的。
FLIP-6 的设计中是包含 Master failover 时接管现有任务的逻辑的。总的来说,在 Master 失败并重启之后,如果它看到 JobRegistry 上记录的作业状态是 RUNNING 的话,它就认为这个作业已经有运行的实例,并开始尝试接管现有任务。在 TaskManager 端,它会发现新发布的 Leader 的信息,连上新的 Leader 后如果自己有正在运行的属于这个 Job 的 Task,就会主动向 Master 汇报。Master 从 TaskManager 的汇报中恢复出现有的作业,如果作业状态能够完全成功恢复,则接管现有任务继续调度和运行。如果汇报上来的状态有失败的或者超时前没有接收到 TaskManager 的汇报,则 Master 认为接管失败,将所有的 Task fail 之后重新调度作业。
发布 Leader 的信息和修改 JobGraphStore/JobRegistry/CheckpointStore 的内容,这类操作都需要保证修改人是当前的 Leader,否则可能出现 Leader 信息发布出来的是一个不是 Leader 的实例的信息,或者不是 Leader 的实例改动了 JobGraphStore 等持久化存储的内容,使得作业管理时状态不一致。
社区版本 FLINK 的实现并不能保证这一点,它采取的方案是在访问对应的 ZK 节点时挂一个 ephemeral 类型的子节点,类似于分布式锁的功能,只有 ephemeral 节点的 owner 才能读写对应的存储节点。然而,这个锁节点的创建和修改本身是不受保证的。虽然代码逻辑中只会在 Master 成为 Leader 后才去尝试创建/修改这个节点,但是 Leader 选举的状态是以 ZK 上节点的状态为准的,可能在 Leader 丢失 Leadership 后收到通知前,新 Leader 收到通知并开始工作时,集群中有两个 Master 均认为自己是 Leader,从而并发的操作同一份数据导致不一致。
为了解决这个问题,保证只有 Leader 能够修改用于高可用服务的持久化数据,我们提出了将检查 LeaderShip 和执行修改动作原子化执行的方案,即不可能出现 Leader-1 检查 LeaderShip 为真,在执行修改前插入了 Leader-2 的修改的情况。具体方案和实现如下。
社区对应的 issue 是 FLINK-10333,总的设计文档在这里,实现语义与 ZK 社区和 Curator 社区的讨论邮件在这里。
总的来说,我们希望把 LeaderShip 的检查和对数据的修改原子化的执行,保证在确认自己是 Leader 和执行修改之间不会插入其他对数据的修改。对于 ZooKeeper 的具体实现来说,我们可以使用其提供的 multi-op 机制,即在同一个 ZK Transaction 中执行复合动作(下面代码示例使用 Curator 的流式接口)
client.inTransaction()
.check().forPath(leaderLatchNode).and()
.create().withMode(CreateMode.EPHEMERAL).forPath(path, data).and()
.commit();
由于 Curator 提供的 LeaderLatch recipe 没有暴露出内部竞选逻辑可以用于检验 LeaderShip 的 leaderLatchPath,所以我们仿照 LeaderLatch recipe 实现了一个 FLINK scope 的基于 ZooKeeper 的选举服务,从而可以访问 leaderLatchPath。具体的算法可以参考 ZooKeeper 文档的介绍。
有了这个基础的查询保障之后,我们就有了原子化的检查 LeaderShip 和修改的原语,我们复用这个原语构造出访问和修改持久化数据的接口 LeaderStore,从而保证了只有 Leader 才能修改用于高可用服务的持久化数据。有了这个保证以后,社区复杂的 best effort(仍然可能出错)的检查 LeaderShip 的逻辑都可以被清理。
上面的实现是 ZooKeeper 特殊的实现,但是对于 etcd 等其他用于分布式共识的组件,也可以实现类似的保证。如果不能保证执行修改动作的一定是 Leader,那也就破坏了高可用服务的正确性保证,是错误的实现。对于非高可用的情况,我们配置一个内存中的 LeaderStore,在 Master 故障后丢失数据,因此流程上是相似的,但是效果上是非高可用的。
在实现了 LeaderStore 之后重写原先的 JobGraphStore 等作业管理逻辑的过程中,发现了原先 Dispatcher 等组件的生命周期,Leader 任期周期和作业的生命周期管理都需要重新梳理。社区版本的 FLINK 没有一个清楚的生命周期模型,只是针对具体的 BUG 打补丁式的补漏,我们在实现 LeaderStore 后讨论作业状态的管理详细地描述了修改数据和不同生命周期事件的先后顺序和各种失败场景下的处理和假定。对于分布式系统中的共识问题,究竟以哪个状态为准,如何维护 happens before 的关系,是值得仔细推敲和落实到具体顺序图的。
另外,使用 Curator 的 LeaderLatch recipe 来做 Leader 选举的分布式组件非常多,实际上它们都面临了一样的问题,例如 Spark。这个问题同时也记录在了 Curator 的技术文档中。Spark 中几乎不可复现这一问题,是因为这个问题只在网络不稳定等情况下出现特定的执行顺序才会发生,此外,Spark 的写频率低,Master 在丢掉 Leader 后粗暴的 System.exit 而不是像 FLINK 一样企图恢复从而会有其他的后台进程需要异步地终止,以及 Spark 持久化的是 App Worker 和 Driver,在 Worker/Driver 状态错误的情况下也可以通过其他手段容忍,因此这个问题几乎没有造成什么实际的影响。而在 FLINK 中,由于 Curator 版本和使用方式的原因,ConnectionLoss(ZK 客户端与服务端 TCP 连接断开,本来是 ZK 会自动重连的一个可恢复故障)即会导致 Leader 重新选举,在 ZK 稍微不稳定的情况下就有概率发生问题,并且在 ZK 短时间内抖动频繁的情况下并发地修改持久化数据,且一次持久化数据修改耗时长,发生问题的概率也就大大上升了。
编辑于 2019-10-31