直播回顾:云上全托管 HDFS 技术解析(含视频)

苏锐 2021.01.04

主讲内容介绍

本次演讲将介绍 HDFS 在公有云环境中的使用挑战,替代方案的比较,Juicedata 实现全托管 HDFS 服务的新思路,以及在架构设计、兼容性、性能等方面的比较。

内容回顾

导读:

大家好,我是来自 Juicedata 的合伙人苏锐,我分享的话题是「云上全托管 HDFS 技术解析」。我们这家公司是一家初创的存储服务公司,在做一个云原生的分布式文件系统产品,叫做 JuiceFS,用在大数据场景里可以把它看做一个全托管的HDFS。HDFS 已经发布了十几年的时间,是大数据领域里很普及的一个存储系统,但是在今天全球的公有云上却很少有提供全托管 HDFS 服务的。

在接下来的分享中,我会介绍一下可以如何实现 HDFS 全托管服务。内容会包括几个方面:

第一个是先为大家比较一下在公有云上 Hadoop 生态在存储方面的选择有哪些,差异是什么?

第二是讲讲我们理想中的大数据存储应该是个什么样子?

第三部分是分享的重点,通过我们熟悉的 HDFS 架构来一步一步的推导出我们是如何把 HDFS 做成一个全托管服务的。如何去做的架构的改造,如何实现完全兼容 HDFS。

第四部分会讲一下在数据生态的基础之上还做了哪些增强。然后最后有一个提问环节,也欢迎大家有什么问题多多交流。希望分享能有给大家带来一些收获。

接下来开始我们正式的分享内容。

公有云大数据存储的选择与差异

首先看公有云上大数据的存储选择,今天最多的选择是自建 HDFS 或者使用公有云提供的对象存储,这里用 S3 举例。自建 HDFS 不用多介绍,大家用了很多年了。对象存储是云厂商给出的一个替代方案,它和云厂商自己的 Hadoop 半托管环境 EMR 都有整合。

我们在这里从多个维度来做比较,首先作为一个云服务,表格中的前三行是非常重要的。全托管服务是云的优势,如果还要自己从代码开始构建、维护,就体验不到云的优势了。第二个是存算分离,因为传统的 Hadoop 架构是存算耦合的,默认的 HDFS 也要求用存算耦合的方式去部署,放在云上带来的主要问题是当计算和数据都同时部署在一个节点的时候,整个 Hadoop 集群是无法实现弹性伸缩的。理论上,「伸」还有可能,而「缩」则是非常困难的一个事情。所以,全托管、存算分离、弹性伸缩是相关关联的三个特性,HDFS 在这个问题上完全做不到。接下来看大数据存储上几个关键能力,包括文件系统兼容性、I/O 能力、性能等,最后还有成本。

我做了一个直观的表格,绿色代表优势,橙黄色代表劣势。HDFS 难以实现云服务需要的能力,作为替代品 S3 在文件系统的关键能力上又有缺失。这里重点说一下文件系统兼容性,HDFS 是一个文件系统,而对象存储是一个 KV 存储系统,虽然都是存储,但在文件读取、访问的 API 上有很多的差异。Hadoop 生态中的计算引擎都是以 HDFS API 为标准去设计的,当存储系统要替换成 S3 时就需要一个 Driver 将 S3 的 API 转换成 HDFS API,但是在对象存储的设计目标下,有一些能力是天然的劣势。

比如 Listing 列目录这个操作,文件系统中是非常常用的操作,性能也非常好,因为文件系统的数据结构就是层层关联的,而对象存储的数据之间是扁平的,Listing 需要按照 Object Key 的前缀去匹配,这样需要对海量的 Key 建立索引,又会引入性能和一致性的问题。

再举一个 Rename 修改目录名字的例子,在文件系统中文件和目录都是一个 inode,Rename 就是更新对应 inode 的信息,是原子操作,无论目录里包含多少文件和子目录,都可以瞬间完成。但是在对象存储中,对一个目录做 Rename 需要找找到所有符合目录名前缀的 Object,拷贝成新的名字,然后再删除旧的数据,这样的操作意味着会发生大量的 I/O 操作,目录内包含的文件数量越多,过程就越慢。

在 I/O 吞吐能力上,HDFS 取决于集群规模,也就是每块磁盘能产生的 I/O 能力的聚合,这方面对象存储一般不用担心。

还有一个加速问题,HDFS 的存算耦合架构有一个非常重要的能力是数据本地性(Data Locality)。当我们的计算任务在分发的时候,调度器可以感知到这个任务所需要的数据在哪,然后把这个任务分发到数据所在的节点上完成计算,这样就让计算和数据在同一个节点发生,减少网络上的开销。这样的调度模型是在 Hadoop 设计之初提出来的,当时机房里大部分都在使用百兆网络,今天机房大部分是万兆或者更好的网络,I/O 吞吐已经不再是明显的瓶颈,于是才有存储计算分离架构的提出。存算分离以后,可以给部署、运维都带来很多的好处,但是分离之后还是会增加很多网络开销,尤其是增加的时延。访问对象存储任何一个 API 请求,都有固定的时间开销,如果每一次都要访问对象存储,时延开销累计起来就是一个非常可观的开销了,会明显拖慢整个的计算/查询的时长,在后面我有放一页 Benchmark 做对比。

如何改善存算分离的开销呢?有没有办法来弥补这里的性能损失?在我们用 JuiceFS 实现 HDFS 全托管服务的过程中提出了本地缓存的策略。在后面的分享里会详细分析一下我们如何来做缓存机制的。

在上面的表格对比中,已经能得到一个我们理想的大数据存储应有的样子了。

理想的大数据存储

这一页讲讲 「理想的大数据存储」,也正是我们设计 JuiceFS 的目标。首先我们认为提供全托管服务肯定是第一要紧的事,因为运维一个存储服务是非常头大的,远比运维计算引擎复杂的多,风险也大的多,稍有不慎就可能造成数据丢失、损坏。弹性伸缩也非常重要,不应该再像以前那样提前做容量规划,手工扩容,这样做首先的一个好处就是提高效率,同时还能提高资源利用率,提高效率就意味着降低成本。增效降本是所有客户永远不变的一个需求。

还有永远不变的朴素需求,就是要高可用,高性能。HDFS 的高可用是主从互备方案,在 HDFS 发布之初并不是高可用的,是单点。在 09 年的时候 Facebook 给 HDFS 加上了高可用方案,类似 MySQL 的主从互备。运维过 MySQL 和 HDFS 的同学一定知道,主从互备方案通常不是同步互备,备节点与主节点之间一般有一定的滞后性,在切换的时候需要先同步状态,确保备节点的数据状态与主节点完全一致后才能切换服务,这个过程在 Facebook 一直都是手动完成的,这也意味着主从切换的时间代价比较高,一定影响可用性。

除了以上这些,现在还需要非常棒的海量数据管理的用户体验,这里说的海量数据已经不仅是数据的容量大,还包括文件数量大。运维过 HDFS 的同学应该有经验,一个 HDFS 集群管理五亿左右文件之后就开始越来越难运维了,而今天和未来我们要面对的是十倍、甚至百倍的数量。

最后我还要提一点就是 POSIX 兼容性,文件系统语义的能力经常被忽视。很多时候我们都在笼统的讲一个存储,并没有去考虑它的接口是什么样的。我们使用电脑最初的体验是本地磁盘,它是一个 POSIX 文件系统,文件系统定义了一套 API 来管理数据,和物理设备发生交互。HDFS 也是一个文件系统,但是在设计的时候为了解决海量数据分布式管理的问题,在 API 上已经对 POSIX 的 API 做了裁剪,是 POSIX API 的子集,放弃了一些接口。比如 HDFS 是一个 Append-Only 的文件系统,数据只能追加不能修改。

到了公有云上,对象存储的 API 就更加简单了,它没有文件系统树状结构,所有的对象之间没有任何关联,这样的设计带来足够的扩展性,但如果我们想基于目录去做查询,基于目录做个改名,在对象存储里就是代价很高的操作了。除了性能上变差,还会引出一个很重要又很容易被忽视的问题 - 数据一致性的差异。在 POSIX 和 HDFS 文件系统里面,数据是保证强一致性的,但是对象存储大多是对最终一致性的。

这里我放了右边的这张漫画,它就是在讲什么叫最终一致性。举了一个例子,一位阿姨的猫上树了,她希望这个人帮助她取下树上的猫,结果这个人给了阿姨一只猫,但是阿姨发现树上还有一只猫!为什么会有两只猫?这个人走的时候说了一句很重要的话:这只是(状态)同步的一点小问题,你再检查一下。果然,阿姨再看树上,猫已经没有了(猫最终只有一只)。这个最终一致性的问题在对象存储里也会发生,比如更新一个已有的对象,然后再马上读出来,可能会读到新的结果,也有可能会读到旧的结果。再比如,当你新创建一个对象,然后马上执行 Listing,结果可能包含你创建的对象,也可能没有包含。因为这个「状态」在对象存储里需要同步,需要一定的时间。这个在文件系统里是不可以的,必须保证强一致性。

以上,说了一个理想的大数据存储的样子,也是 JuiceFS 的设计目标。接下来我们参照 HDFS 的架构,重点解析一下如何一步步改造,演进出全托管 HDFS 服务。

JuiceFS 的架构设计

这里画了一个 HDFS 架构图,图中包括了一个最小的 HDFS 集群会包括的核心组件,需要将近十个节点。可见,HDFS 的部署和运维不是简单的工作,需要投入不少的时间精力。

我们分析一下 HDFS 每个组件的功能,左边 NameNode 负责管理所有的元数据,包括目录、文件名、时间戳、权限等信息。右边 DataNode 负责管理所有的数据,也就是文件内容。在 NameNode 左边的 JournalNode 存的是对元数据增删改查的所有操作日志,用来备份。重放这些日志可以得到一个 NameNode 的元数据状态。在图里可以看到两个 NameNode 节点,一个响应请求,一个做后备,实现高可用。这两个 NameNode 之间的状态协调依赖 ZooKeeper,它也需要保证高可用,要部署三个节点。最下面是 Client,一个 Java 的 Jar 文件作为 HDFS 驱动提供给上层计算引擎做数据访问。

在这样的一个架构中,我们先看存数据的 DataNode,它负责将集群节点中的磁盘统一管理起来,存储数据内容,而文件的内容被切割为一个个 Block 存在 DataNode 中。从功能和接口上看都非常类似对象存储。我们就考虑在公有云环境中可以用对象存储服务来替换 DataNode,把数据持久化的工作交给对象存储,它在扩展性、持久性、成本上都做的非常优秀,我们不需要自己重造这个轮子。这样我们不再维护 DataNode 节点,把它服务化。我们需要解决的是对象存储元数据性能低,不是强一致性的等问题。

DataNode 用对象存储服务替代之后实现了数据部分的全托管、弹性伸缩,而且还一起实现了存算分离,接下来要做一些网络通信的改造。如图,NameNode 和 DataNode 本来是有通信的,NameNode 承载了 DataNode Manager 的角色,管理集群节点的状态。当 DataNode 被对象存储替代之后,NameNode 与 DataNode 之间的通信就不需要了。

DataNode 替换成对象存储,我们不再关心内部的情况(也不让关心了 >O<),架构图简化为下面的样子。

考虑在公有云中的实际网络架构,对象存储和用户的 Client 应该在相同的 VPC 网络中(对象存储是 Region 级服务,在这先简单看做在用户的 VPC 中),这样当用户使用托管的 HDFS 服务时,数据内容也是保存在自己的对象存储账号中的,保证数据隐私安全。

接下来要考虑的是如何改造左边的元数据部分,让它也称为一个全托管服务。

作为全托管服务,元数据的部分显然不在用户的 VPC 里,如图所示用户 Client 与托管元数据服务之间可以通过 EIP 访问,并用 TLS 加密来保证元数据传输安全。与对象存储的通用使用 HTTPs API,同样保证传输安全。

元数据服务为了简化运维管理的难度,需要实现多租户支持,不能为每个用户单独部署一套元数据服务,那一定是管理不过来的。我们可以在每一个公有云的每一个服务区(Region)上部署一套多租的元数据服务,存储该服务区上所有用户的文件系统元数据。

分析 HDFS NameNode 的架构设计,需要实现高可用、高性能、多租户、垂直和水平的弹性伸缩。目前的 HDFS 方案我们认为设计的过于复杂了,于是改造简化成了下面的样子。

元数据服务由主从互备改为使用 Raft 协议,实现了高可用,也保证了数据强一致性。三个节点中有任意一个节点宕机不会影响可用性,自动切换 Leader。这样 HDFS 元数据服务中的六七个组件简化成了一个服务,在三个节点中运行即可,这样简化了很多运维管理的工作。

运维过大型 HDFS 集群的同学一定知道,当 NameNode 管理的文件数量多了以后,就容易出现 FullGC 问题,需要很多 JVM 调优,当 FullGC 发生时会导致集群不可用。JuiceFS 在设计元数据服务的时候,使用 Go 语言来开发,同时没有使用 Go 提供的内存模型,而是像 C 语言一样,自己手工管理内存来避免 GC 带来的潜在风险,而且这样还能进一步提升内存效率。

最后我们讲客户端的改造。

HDFS Client 是一个 Jar 文件。JuiceFS 在设计之初完整支持了 POSIX 的元数据结构和接口定义,Client 是基于 FUSE 实现的,这样用户可以把 JuiceFS 像本地盘一样挂载到系统中。在 Hadoop 生态中,我们把这个 FUSE 客户端作为基础,实现一个 Java SDK,通过 JNI 调用 FUSE 客户端,同时完整实现 HDFS API 定义的所有 Interface,并保证相同的实现逻辑,这样就确保了 JuiceFS 与 HDFS 的 100% 兼容性。

大家不要小看这个兼容性,它带给用户最大的好处就是做对接、迁移的时候不用考虑上层引擎任何的兼容问题,如果是使用对象存储,就要把所有上层引擎做一一适配和验证,工作量巨大。

到这里,我们一步步的分析 HDFS 组件,做改造,讲解了如何实现了一个全托管 HDFS 服务,也就是 JuiceFS 的过程。下面也说几个 HDFS 与 POSIX 之间的不同之处。

第一个是 Rename,在 POSIX 文件系统中做一个 Rename 操作,如果目标存在会自动覆盖,但是在 HDFS 里是不会覆盖,而是返回一个 File is Existed 异常。这是两个文件系统对 API 定义的不同,在 JuiceFS 中会按照它们各自定义的方式去实现。

第二个是递归删除,在 POSIX 中是不支持的,必须从叶子节点一层一层的删除。但是在 HDFS 中可以快速删除整个目录。

第三个是符号链接,在 POSIX 里列目录可以看到哪些文件是符号链接,链接到哪个文件。但是在 HDFS 的定义中不能直接看到链接到哪里,列目录时看起来都一样。JuiceFS 会完全按照两个文件系统的接口定义实现,这样的好处是在各自的应用生态中可以保证兼容性,不会带来意外。

这里一部分接口定义的不同,也源于在底层数据结构上设计的不同。在 POSIX 文件系统中,是基于 inode 设计,整个文件系统是一棵树,每个文件和目录都是 inode,彼此相连。当要查询一个文件或目录的时候,必须一层层顺着树的结构做深度遍历才能找到最终的 inode,然后再取到相关属性。HDFS 的元数据是基于路径的,这样在原数据索引里面可以一次拿到请求的最终的文件,不需要像 POSIX 那样层层查找,可以减少很多次元数据请求。JuiceFS 也在自己的 Java SDK 中也做了一个优化,可以一次完成多级查找返回最终的 inode,这样在元数据的访问开销上做到和 HDFS 一致。

下面看看使用 TPC-DS 数据集测试得到的 Benchmark。

性能比较

这是我们合作伙伴最近做的测试,使用公有云的 EMR 环境,TPC-DS 100G 数据集,ORC 列存数据格式,Spark 做计算引擎完成的性能比较。橙色是对象存储,蓝色的 JuiceFS,绿色是 HDFS,左边图标是查询消耗的实际时间,右边是以耗时最长的对象存储时间为 100% 做百分比转换,可以看到 JuiceFS 和 HDFS 非常接近,综合性能比对象存储快了几倍。

在文章开始讲到过 HDFS 有 Data Locality 的特性来加速计算,JuiceFS 做了存算分离之后,理论上增加了数据请求的网络开销,为什么还能做到和 HDFS 性能接近呢?

下面就讲讲 JuiceFS 提供的加速机制。

缓存加速

JuiceFS 为了减少存算分离架构下增加的网络开销,设计了一套缓存机制,包括数据缓存和元数据缓存。希望在计算和查询场景中,可以把反复使用的热数据都缓存在计算集群本地,而不需要每次都通过网络访问,较少网络开销。

讲到缓存设计,大家肯定会问缓存粒度、缓存效率、缓存更新这些是如何设计和实现的呢?这里必须先讲一下 JuiceFS 数据写入的策略。

JuiceFS 数据写入到对象存储时和 HDFS 类似,把一个文件做了分片,每一个分片作为一个 Object 保存在对象存储中,默认我们按照 4MB 作为 Object 大小对文件分片,比如一个文件 foo,容量十几 MB,被分成三个 Object,图中 foo 右边的三个字符串就是 Object Key。JuiceFS 的数据写入保证每个 Object 不会被更新,这样就避免了对象存储的最终一致性问题。

既然每个 Object 都是不变的,就可以直接缓存在计算节点上。当数据更新时,会生成新的 Object 写到对象存储,访问时被重新缓存到本地,旧的 Object 将不再被访问,会随着时间按照 LRU 算法淘汰,不必担心读到脏缓存的问题。

缓存策略确定后,缓存应该如何建立来保证命中率呢?

在 HDFS 中提供了一个 Block Location 的 API 可以告诉调度器计算任务需要的数据在哪个节点,然后可以将计算任务调度过去。我们就利用这个 API,用数据文件名等信息通过一致性哈希算法为数据分配缓存节点,之后调度器可以通过这个 API 知道数据在哪个节点,会把计算任务尽量分配到那个节点上,这样可以实现类似 HDFS Data Locality 的效果。

但是有一些计算框架没有完全遵守这样的调度机制,比如 Spark 会把大量的小文件做随机合并后再调度,这样就破坏了前面为 Data Locality 做的那些工作。于是我们又提供了一个 P2P Cache 机制,让指定的节点可以组成一个 P2P 网络,当计算任务分配后,在自己的节点上找不到需要的数据缓存时,会先到 P2P Cache Group 中找需要的数据,如果还找不到再请求对象存储。在 P2P 网络中查找数据的开销仍是明显小于对象存储 HTTP API 的时延开销的,这样形成两级 Cache 进一步提升命中率。

在前面基于 ORC 列存数据的查询场景中包含大量随机读,如果每次都访问网络,开销会很大,缓存的帮助就很大。如果是对文件完整扫描的顺序读场景,更看重 I/O 吞吐能力,这是对象存储的优势,有时甚至优于缓存盘能提供的吞吐能力,JuiceFS 做的是通过预读策略等提升吞吐,这时缓存的价值不大,JuiceFS 支持只缓存随机读数据,提升缓存空间的利用率。

说完了数据缓存的设计,再说说元数据缓存的实现方式。前文提到元数据服务也有非常高的访问量,所以我们想是不是也能提供元数据缓存能力,来降低元数据服务的负载,同时也能减少网络开销提升性能。

元数据缓存要解决过期更新问题,如上面图片中显示的,如果一个 Client 修改了元数据,JuiceFS 元数据服务会主动通知所有缓存过这个元数据的客户端,做缓存过期。这里当元数据缓存开启后,数据就会变成确定时间窗口(2 TTL)的最终一致性。在大数据场景中数据一般是相当静止的,不会被频繁修改,所以我们会推荐客户开启元数据缓存。

多应用生态融合

上面的部分通过分析 HDFS 架构,一步步解析如何把 HDFS 改造实现全托管、弹性伸缩、存算分离等。同时也提到了好多次 JuiceFS 不仅兼容 HDFS,还是完全兼容 POSIX 的。它能带来什么优势呢?

如果只在大数据生态里面往往不容易看到 POSIX 的意义,因为 Hadoop 生态都是基于 HDFS 设计的,但是在今天我们的应用生态正在发生一些变化,尤其最近两年深度学习和机器学习的应用越来越普及,很多团队都在用 TensorFlow、PyTorch 这样的深度学习框架做模型训练。这些框架大部分是基于 POSIX 文件系统设计的,而这些数据集的规模往往又非常庞大,很多时候要用 Hadoop 的计算引擎做预处理。这就意味着我们需要把数据在 HDFS 中做预处理,把结果再搬到 POSIX 文件系统中做训练。

如果有一个文件系统,同时兼容 POSIX、HDFS,可能还有其他的接口,能够支持你需要的所有应用生态,数据可以在其中被共享访问,Data Pipeline 可以简化很多。

而且,它不是只在某一朵云上,而是所有的云都可以使用,用户都有一模一样的使用体验。

Recap

最后总结一下,在前面的分享中从基于 HDFS 的架构,一步一步推导出我们如何通过架构改造,实现一个全托管的 JuiceFS 服务。其中着重讲解了改造过程中如何提升可用性、实现弹性伸缩,如何在存算分离的架构中加上本地缓存来提升性能。

也给大家介绍了在不同业务应用中 POSIX、HDFS 多接口的支持能带来什么样的优势。最后还有一个彩蛋,就是我们最近发布了一个新的 Feature,可以帮助用户实现从 HDFS 到 JuiceFS 的无感数据迁移,在回顾文章中我们直接放出链接吧 -「巧用符号链接迁移 HDFS 数据,业务完全无感知!

今天分享就到这里,希望大家有所收获。