过去几年中 Hadoop 社区涌现过很多的 NameNode 共享存储方案,比如 shared NAS+NFS、BookKeeper、BackupNode 和 QJM(Quorum Journal Manager) 等等。目前社区已经把由 Clouderea 公司实现的基于 QJM 的方案合并到 HDFS 的 trunk 之中并且作为默认的共享存储实现,本部分只针对基于 QJM 的共享存储方案的内部实现原理进行分析。为了理解 QJM 的设计和实现,首先要对 NameNode 的元数据存储结构有所了解。
1、NameNode 的元数据存储概述
一个典型的 NameNode 的元数据存储目录结构如图 3 所示 (图片来源于参考文献 [4]),这里主要关注其中的 EditLog 文件和 FSImage 文件:
NameNode 的元数据存储目录结构
NameNode 在执行 HDFS 客户端提交的创建文件或者移动文件这样的写操作的时候,会首先把这些操作记录在 EditLog 文件之中,然后再更新内存中的文件系统镜像。内存中的文件系统镜像用于 NameNode 向客户端提供读服务,而 EditLog 仅仅只是在数据恢复的时候起作用。记录在 EditLog 之中的每一个操作又称为一个事务,每个事务有一个整数形式的事务 id 作为编号。EditLog 会被切割为很多段,每一段称为一个 Segment。正在写入的 EditLog Segment 处于 in-progress 状态,其文件名形如 edits_inprogress_
NameNode 会定期对内存中的文件系统镜像进行 checkpoint 操作,在磁盘上生成 FSImage 文件,FSImage 文件的文件名形如 fsimage_
2、基于 QJM 的共享存储系统的总体架构
基于 QJM 的共享存储系统主要用于保存 EditLog,并不保存 FSImage 文件。FSImage 文件还是在 NameNode 的本地磁盘上。QJM 共享存储的基本思想来自于 Paxos 算法 (参见参考文献 [3]),采用多个称为 JournalNode 的节点组成的 JournalNode 集群来存储 EditLog。每个 JournalNode 保存同样的 EditLog 副本。每次 NameNode 写 EditLog 的时候,除了向本地磁盘写入 EditLog 之外,也会并行地向 JournalNode 集群之中的每一个 JournalNode 发送写请求,只要大多数 (majority) 的 JournalNode 节点返回成功就认为向 JournalNode 集群写入 EditLog 成功。如果有 2N+1 台 JournalNode,那么根据大多数的原则,最多可以容忍有 N 台 JournalNode 节点挂掉。
基于 QJM 的共享存储系统的内部实现架构图如图 4 所示,主要包含下面几个主要的组件:
基于 QJM 的共享存储系统的内部实现架构图
FSEditLog:这个类封装了对 EditLog 的所有操作,是 NameNode 对 EditLog 的所有操作的入口。
JournalSet: 这个类封装了对本地磁盘和 JournalNode 集群上的 EditLog 的操作,内部包含了两类 JournalManager,一类为 FileJournalManager,用于实现对本地磁盘上 EditLog 的操作。一类为 QuorumJournalManager,用于实现对 JournalNode 集群上共享目录的 EditLog 的操作。FSEditLog 只会调用 JournalSet 的相关方法,而不会直接使用 FileJournalManager 和 QuorumJournalManager。
FileJournalManager:封装了对本地磁盘上的 EditLog 文件的操作,不仅 NameNode 在向本地磁盘上写入 EditLog 的时候使用 FileJournalManager,JournalNode 在向本地磁盘写入 EditLog 的时候也复用了 FileJournalManager 的代码和逻辑。
QuorumJournalManager:封装了对 JournalNode 集群上的 EditLog 的操作,它会根据 JournalNode 集群的 URI 创建负责与 JournalNode 集群通信的类 AsyncLoggerSet, QuorumJournalManager 通过 AsyncLoggerSet 来实现对 JournalNode 集群上的 EditLog 的写操作,对于读操作,QuorumJournalManager 则是通过 Http 接口从 JournalNode 上的 JournalNodeHttpServer 读取 EditLog 的数据。
AsyncLoggerSet:内部包含了与 JournalNode 集群进行通信的 AsyncLogger 列表,每一个 AsyncLogger 对应于一个 JournalNode 节点,另外 AsyncLoggerSet 也包含了用于等待大多数 JournalNode 返回结果的工具类方法给 QuorumJournalManager 使用。
AsyncLogger:具体的实现类是 IPCLoggerChannel,IPCLoggerChannel 在执行方法调用的时候,会把调用提交到一个单线程的线程池之中,由线程池线程来负责向对应的 JournalNode 的 JournalNodeRpcServer 发送 RPC 请求。
JournalNodeRpcServer:运行在 JournalNode 节点进程中的 RPC 服务,接收 NameNode 端的 AsyncLogger 的 RPC 请求。
JournalNodeHttpServer:运行在 JournalNode 节点进程中的 Http 服务,用于接收处于 Standby 状态的 NameNode 和其它 JournalNode 的同步 EditLog 文件流的请求。
下面对基于 QJM 的共享存储系统的两个关键性问题同步数据和恢复数据进行详细分析。
3、基于 QJM 的共享存储系统的数据同步机制分析
Active NameNode 和 StandbyNameNode 使用 JouranlNode 集群来进行数据同步的过程如图 5 所示,Active NameNode 首先把 EditLog 提交到 JournalNode 集群,然后 Standby NameNode 再从 JournalNode 集群定时同步 EditLog:
基于 QJM 的共享存储的数据同步机制
Active NameNode 提交 EditLog 到 JournalNode 集群
当处于 Active 状态的 NameNode 调用 FSEditLog 类的 logSync 方法来提交 EditLog 的时候,会通过 JouranlSet 同时向本地磁盘目录和 JournalNode 集群上的共享存储目录写入 EditLog。写入 JournalNode 集群是通过并行调用每一个 JournalNode 的 QJournalProtocol RPC 接口的 journal 方法实现的,如果对大多数 JournalNode 的 journal 方法调用成功,那么就认为提交 EditLog 成功,否则 NameNode 就会认为这次提交 EditLog 失败。提交 EditLog 失败会导致 Active NameNode 关闭 JournalSet 之后退出进程,留待处于 Standby 状态的 NameNode 接管之后进行数据恢复。
从上面的叙述可以看出,Active NameNode 提交 EditLog 到 JournalNode 集群的过程实际上是同步阻塞的,但是并不需要所有的 JournalNode 都调用成功,只要大多数 JournalNode 调用成功就可以了。如果无法形成大多数,那么就认为提交 EditLog 失败,NameNode 停止服务退出进程。如果对应到分布式系统的 CAP 理论的话,虽然采用了 Paxos 的“大多数”思想对 C(consistency,一致性) 和 A(availability,可用性) 进行了折衷,但还是可以认为 NameNode 选择了 C 而放弃了 A,这也符合 NameNode 对数据一致性的要求。
Standby NameNode 从 JournalNode 集群同步 EditLog
当 NameNode 进入 Standby 状态之后,会启动一个 EditLogTailer 线程。这个线程会定期调用 EditLogTailer 类的 doTailEdits 方法从 JournalNode 集群上同步 EditLog,然后把同步的 EditLog 回放到内存之中的文件系统镜像上 (并不会同时把 EditLog 写入到本地磁盘上)。
这里需要关注的是:从 JournalNode 集群上同步的 EditLog 都是处于 finalized 状态的 EditLog Segment。“NameNode 的元数据存储概述”一节说过 EditLog Segment 实际上有两种状态,处于 in-progress 状态的 Edit Log 当前正在被写入,被认为是处于不稳定的中间态,有可能会在后续的过程之中发生修改,比如被截断。Active NameNode 在完成一个 EditLog Segment 的写入之后,就会向 JournalNode 集群发送 finalizeLogSegment RPC 请求,将完成写入的 EditLog Segment finalized,然后开始下一个新的 EditLog Segment。一旦 finalizeLogSegment 方法在大多数的 JournalNode 上调用成功,表明这个 EditLog Segment 已经在大多数的 JournalNode 上达成一致。一个 EditLog Segment 处于 finalized 状态之后,可以保证它再也不会变化。
从上面描述的过程可以看出,虽然 Active NameNode 向 JournalNode 集群提交 EditLog 是同步的,但 Standby NameNode 采用的是定时从 JournalNode 集群上同步 EditLog 的方式,那么 Standby NameNode 内存中文件系统镜像有很大的可能是落后于 Active NameNode 的,所以 Standby NameNode 在转换为 Active NameNode 的时候需要把落后的 EditLog 补上来。
4、基于 QJM 的共享存储系统的数据恢复机制分析
处于 Standby 状态的 NameNode 转换为 Active 状态的时候,有可能上一个 Active NameNode 发生了异常退出,那么 JournalNode 集群中各个 JournalNode 上的 EditLog 就可能会处于不一致的状态,所以首先要做的事情就是让 JournalNode 集群中各个节点上的 EditLog 恢复为一致。另外如前所述,当前处于 Standby 状态的 NameNode 的内存中的文件系统镜像有很大的可能是落后于旧的 Active NameNode 的,所以在 JournalNode 集群中各个节点上的 EditLog 达成一致之后,接下来要做的事情就是从 JournalNode 集群上补齐落后的 EditLog。只有在这两步完成之后,当前新的 Active NameNode 才能安全地对外提供服务。
补齐落后的 EditLog 的过程复用了前面描述的 Standby NameNode 从 JournalNode 集群同步 EditLog 的逻辑和代码,最终调用 EditLogTailer 类的 doTailEdits 方法来完成 EditLog 的补齐。使 JournalNode 集群上的 EditLog 达成一致的过程是一致性算法 Paxos 的典型应用场景,QJM 对这部分的处理可以看做是 Single Instance Paxos(参见参考文献 [3]) 算法的一个实现,在达成一致的过程中,Active NameNode 和 JournalNode 集群之间的交互流程如图 6 所示,具体描述如下:
图 6.Active NameNode 和 JournalNode 集群的交互流程图
生成一个新的 Epoch
Epoch 是一个单调递增的整数,用来标识每一次 Active NameNode 的生命周期,每发生一次 NameNode 的主备切换,Epoch 就会加 1。这实际上是一种 fencing 机制,为什么需要 fencing 已经在前面“ActiveStandbyElector 实现分析”一节的“防止脑裂”部分进行了说明。产生新 Epoch 的流程与 Zookeeper 的 ZAB(Zookeeper Atomic Broadcast) 协议在进行数据恢复之前产生新 Epoch 的过程完全类似:
Active NameNode 首先向 JournalNode 集群发送 getJournalState RPC 请求,每个 JournalNode 会返回自己保存的最近的那个 Epoch(代码中叫 lastPromisedEpoch)。
NameNode 收到大多数的 JournalNode 返回的 Epoch 之后,在其中选择最大的一个加 1 作为当前的新 Epoch,然后向各个 JournalNode 发送 newEpoch RPC 请求,把这个新的 Epoch 发给各个 JournalNode。
每一个 JournalNode 在收到新的 Epoch 之后,首先检查这个新的 Epoch 是否比它本地保存的 lastPromisedEpoch 大,如果大的话就把 lastPromisedEpoch 更新为这个新的 Epoch,并且向 NameNode 返回它自己的本地磁盘上最新的一个 EditLogSegment 的起始事务 id,为后面的数据恢复过程做好准备。如果小于或等于的话就向 NameNode 返回错误。
NameNode 收到大多数 JournalNode 对 newEpoch 的成功响应之后,就会认为生成新的 Epoch 成功。
在生成新的 Epoch 之后,每次 NameNode 在向 JournalNode 集群提交 EditLog 的时候,都会把这个 Epoch 作为参数传递过去。每个 JournalNode 会比较传过来的 Epoch 和它自己保存的 lastPromisedEpoch 的大小,如果传过来的 epoch 的值比它自己保存的 lastPromisedEpoch 小的话,那么这次写相关操作会被拒绝。一旦大多数 JournalNode 都拒绝了这次写操作,那么这次写操作就失败了。如果原来的 Active NameNode 恢复正常之后再向 JournalNode 写 EditLog,那么因为它的 Epoch 肯定比新生成的 Epoch 小,并且大多数的 JournalNode 都接受了这个新生成的 Epoch,所以拒绝写入的 JournalNode 数目至少是大多数,这样原来的 Active NameNode 写 EditLog 就肯定会失败,失败之后这个 NameNode 进程会直接退出,这样就实现了对原来的 Active NameNode 的隔离了。
选择需要数据恢复的 EditLog Segment 的 id
需要恢复的 Edit Log 只可能是各个 JournalNode 上的最后一个 Edit Log Segment,如前所述,JournalNode 在处理完 newEpoch RPC 请求之后,会向 NameNode 返回它自己的本地磁盘上最新的一个 EditLog Segment 的起始事务 id,这个起始事务 id 实际上也作为这个 EditLog Segment 的 id。NameNode 会在所有这些 id 之中选择一个最大的 id 作为要进行数据恢复的 EditLog Segment 的 id。
向 JournalNode 集群发送 prepareRecovery RPC 请求
NameNode 接下来向 JournalNode 集群发送 prepareRecovery RPC 请求,请求的参数就是选出的 EditLog Segment 的 id。JournalNode 收到请求后返回本地磁盘上这个 Segment 的起始事务 id、结束事务 id 和状态 (in-progress 或 finalized)。
这一步对应于 Paxos 算法的 Phase 1a 和 Phase 1b(参见参考文献 [3]) 两步。Paxos 算法的 Phase1 是 prepare 阶段,这也与方法名 prepareRecovery 相对应。并且这里以前面产生的新的 Epoch 作为 Paxos 算法中的提案编号 (proposal number)。只要大多数的 JournalNode 的 prepareRecovery RPC 调用成功返回,NameNode 就认为成功。
选择进行同步的基准数据源,向 JournalNode 集群发送 acceptRecovery RPC 请求 NameNode 根据 prepareRecovery 的返回结果,选择一个 JournalNode 上的 EditLog Segment 作为同步的基准数据源。选择基准数据源的原则大致是:在 in-progress 状态和 finalized 状态的 Segment 之间优先选择 finalized 状态的 Segment。如果都是 in-progress 状态的话,那么优先选择 Epoch 比较高的 Segment(也就是优先选择更新的),如果 Epoch 也一样,那么优先选择包含的事务数更多的 Segment。
在选定了同步的基准数据源之后,NameNode 向 JournalNode 集群发送 acceptRecovery RPC 请求,将选定的基准数据源作为参数。JournalNode 接收到 acceptRecovery RPC 请求之后,从基准数据源 JournalNode 的 JournalNodeHttpServer 上下载 EditLog Segment,将本地的 EditLog Segment 替换为下载的 EditLog Segment。
这一步对应于 Paxos 算法的 Phase 2a 和 Phase 2b(参见参考文献 [3]) 两步。Paxos 算法的 Phase2 是 accept 阶段,这也与方法名 acceptRecovery 相对应。只要大多数 JournalNode 的 acceptRecovery RPC 调用成功返回,NameNode 就认为成功。
向 JournalNode 集群发送 finalizeLogSegment RPC 请求,数据恢复完成
上一步执行完成之后,NameNode 确认大多数 JournalNode 上的 EditLog Segment 已经从基准数据源进行了同步。接下来,NameNode 向 JournalNode 集群发送 finalizeLogSegment RPC 请求,JournalNode 接收到请求之后,将对应的 EditLog Segment 从 in-progress 状态转换为 finalized 状态,实际上就是将文件名从 edits_inprogress_
只要大多数 JournalNode 的 finalizeLogSegment RPC 调用成功返回,NameNode 就认为成功。此时可以保证 JournalNode 集群的大多数节点上的 EditLog 已经处于一致的状态,这样 NameNode 才能安全地从 JournalNode 集群上补齐落后的 EditLog 数据。
需要注意的是,尽管基于 QJM 的共享存储方案看起来理论完备,设计精巧,但是仍然无法保证数据的绝对强一致,下面选取参考文献 [2] 中的一个例子来说明:
假设有 3 个 JournalNode:JN1、JN2 和 JN3,Active NameNode 发送了事务 id 为 151、152 和 153 的 3 个事务到 JournalNode 集群,这 3 个事务成功地写入了 JN2,但是在还没能写入 JN1 和 JN3 之前,Active NameNode 就宕机了。同时,JN3 在整个写入的过程中延迟较大,落后于 JN1 和 JN2。最终成功写入 JN1 的事务 id 为 150,成功写入 JN2 的事务 id 为 153,而写入到 JN3 的事务 id 仅为 125,如图 7 所示 (图片来源于参考文献 [2])。按照前面描述的只有成功地写入了大多数的 JournalNode 才认为写入成功的原则,显然事务 id 为 151、152 和 153 的这 3 个事务只能算作写入失败。在进行数据恢复的过程中,会发生下面两种情况:
JournalNode 集群写入的事务 id 情况
如果随后的 Active NameNode 进行数据恢复时在 prepareRecovery 阶段收到了 JN2 的回复,那么肯定会以 JN2 对应的 EditLog Segment 为基准来进行数据恢复,这样最后在多数 JournalNode 上的 EditLog Segment 会恢复到事务 153。从恢复的结果来看,实际上可以认为前面宕机的 Active NameNode 对事务 id 为 151、152 和 153 的这 3 个事务的写入成功了。但是如果从 NameNode 自身的角度来看,这显然就发生了数据不一致的情况。
如果随后的 Active NameNode 进行数据恢复时在 prepareRecovery 阶段没有收到 JN2 的回复,那么肯定会以 JN1 对应的 EditLog Segment 为基准来进行数据恢复,这样最后在多数 JournalNode 上的 EditLog Segment 会恢复到事务 150。在这种情况下,如果从 NameNode 自身的角度来看的话,数据就是一致的了。
事实上不光本文描述的基于 QJM 的共享存储方案无法保证数据的绝对一致,大家通常认为的一致性程度非常高的 Zookeeper 也会发生类似的情况,这也从侧面说明了要实现一个数据绝对一致的分布式存储系统的确非常困难。
5、NameNode 在进行状态转换时对共享存储的处理
下面对 NameNode 在进行状态转换的过程中对共享存储的处理进行描述,使得大家对基于 QJM 的共享存储方案有一个完整的了解,同时也作为本部分的总结。
NameNode 初始化启动,进入 Standby 状态
在 NameNode 以 HA 模式启动的时候,NameNode 会认为自己处于 Standby 模式,在 NameNode 的构造函数中会加载 FSImage 文件和 EditLog Segment 文件来恢复自己的内存文件系统镜像。在加载 EditLog Segment 的时候,调用 FSEditLog 类的 initSharedJournalsForRead 方法来创建只包含了在 JournalNode 集群上的共享目录的 JournalSet,也就是说,这个时候只会从 JournalNode 集群之中加载 EditLog,而不会加载本地磁盘上的 EditLog。另外值得注意的是,加载的 EditLog Segment 只是处于 finalized 状态的 EditLog Segment,而处于 in-progress 状态的 Segment 需要后续在切换为 Active 状态的时候,进行一次数据恢复过程,将 in-progress 状态的 Segment 转换为 finalized 状态的 Segment 之后再进行读取。
加载完 FSImage 文件和共享目录上的 EditLog Segment 文件之后,NameNode 会启动 EditLogTailer 线程和 StandbyCheckpointer 线程,正式进入 Standby 模式。如前所述,EditLogTailer 线程的作用是定时从 JournalNode 集群上同步 EditLog。而 StandbyCheckpointer 线程的作用其实是为了替代 Hadoop 1.x 版本之中的 Secondary NameNode 的功能,StandbyCheckpointer 线程会在 Standby NameNode 节点上定期进行 Checkpoint,将 Checkpoint 之后的 FSImage 文件上传到 Active NameNode 节点。
NameNode 从 Standby 状态切换为 Active 状态
当 NameNode 从 Standby 状态切换为 Active 状态的时候,首先需要做的就是停止它在 Standby 状态的时候启动的线程和相关的服务,包括上面提到的 EditLogTailer 线程和 StandbyCheckpointer 线程,然后关闭用于读取 JournalNode 集群的共享目录上的 EditLog 的 JournalSet,接下来会调用 FSEditLog 的 initJournalSetForWrite 方法重新打开 JournalSet。不同的是,这个 JournalSet 内部同时包含了本地磁盘目录和 JournalNode 集群上的共享目录。这些工作完成之后,就开始执行“基于 QJM 的共享存储系统的数据恢复机制分析”一节所描述的流程,调用 FSEditLog 类的 recoverUnclosedStreams 方法让 JournalNode 集群中各个节点上的 EditLog 达成一致。然后调用 EditLogTailer 类的 catchupDuringFailover 方法从 JournalNode 集群上补齐落后的 EditLog。最后打开一个新的 EditLog Segment 用于新写入数据,同时启动 Active NameNode 所需要的线程和服务。
NameNode 从 Active 状态切换为 Standby 状态
当 NameNode 从 Active 状态切换为 Standby 状态的时候,首先需要做的就是停止它在 Active 状态的时候启动的线程和服务,然后关闭用于读取本地磁盘目录和 JournalNode 集群上的共享目录的 EditLog 的 JournalSet。接下来会调用 FSEditLog 的 initSharedJournalsForRead 方法重新打开用于读取 JournalNode 集群上的共享目录的 JournalSet。这些工作完成之后,就会启动 EditLogTailer 线程和 StandbyCheckpointer 线程,EditLogTailer 线程会定时从 JournalNode 集群上同步 Edit Log。