如何提升 Hadoop 访问对象存储 US3 的效率?我们做了这些技术实践
在信息爆炸的大数据时代,如何以更低成本来解决海量数据的存储问题,已成为企业大数据业务中的重要一环。UCloud 自研的新一代对象存储服务 US3,在过去一段时间,针对大数据业务场景推出了计算存储分离和大数据备份解决方案。这背后的主要原因包括:
1、由于网络技术的高速发展,使得网络传输性能不再是大数据场景下高吞吐业务需求的瓶颈;
2、Hadoop 技术栈中的 HDFS 存储解决方案运维复杂且成本高昂;
3、云平台基于海量存储资源池构建的对象存储服务 US3 具备按需使用、操作简单、可靠稳定、价格便宜的优势,是替换 HDFS 的最佳存储方案选择。
因此,为了让用户能够更加方便的在 Hadoop 场景下,使用 US3 实现计算存储分离和大数据备份解决方案,US3 自研了 US3Hadoop 适配器、US3Vmds、US3Distcp 三个组件。本文主要介绍 US3Hadoop 适配器在研发设计过程中的一些思路和问题解决。
总体设计思路
Hadoop 生态里对存储的操作基本上都是通过一个通用的文件系统基类 FileSystem 来进行的。US3Hadoop 适配器(简称:适配器)是通过 US3FileSystem 实现该基类来操作 US3。类似于 HDFS 实现的 DistributedFileSystem 和基于 AWS S3 协议实现的 S3AFileSystem。适配器直接把 IO 和索引都请求发给 US3,架构如下图所示:
这里的索引操作主要是不涉及读写数据的 API,如: HEADFile, ListObjects, Rename, DeleteFile, Copy(用于修改 metadata);IO 操作的 API,如 GetFile,PutFile(小于 4M 文件)已经分片上传相关的 4 个 API: InitiateMultipartUpload,UploadPart,FinishMultipartUpload,AbortMultipartUpload。
US3 有了这些 API 后,怎么跟 FileSystem 的成员方法能对应起来,可以看下 FileSystem 需要重写哪些方法。结合实际需求和参考 DistributedFileSystem、S3AFileSystem 的实现,我们确定了需要重写的主要方法:
initialize、create、rename、getFileStatus、open、listStatus、mkdirs、setOwner、setPermission、setReplication、setWorkingDirectory、getWorkingDirectory、getSchem、getUri、getDefaultBlockSize、delete。同时对一些难以模拟的方法,重写为异常不支持,如 Append 成员方法。
其实从上面 FileSystem 的成员方法说明来看,其语义和单机文件系统引擎的接口语义类似,基本上也是以目录树结构来组织管理文件系统。US3 提供的 ListObjects API 刚好也提供了目录树拉取的一种方式,当 Hadoop 调用 listStatus 方法时,就可以通过 ListObjects 循环拉取到当前目录(前缀)下所有子成员从而返回对应的结果。
设置文件所属用户/组,操作权限等相关操作则利用了 US3 的元数据功能,把这些信息都映射到文件的 KV 元数据对上。写入文件流则会优先缓存在内存中最多 4MB 数据,再根据后续的操作来决定采用 PutFile 还是分片上传的 API 来实现。
读取文件流则通过 GetFile 返回流实例来读取期待的数据。虽然这些方法实现看上去很直白,但是潜在着很多值得优化的地方。
getFileStatus 的时空博弈
通过分析 FileSystem 的调用情况,可以知道索引操作在大数据场景中占比达 70%以上,而 getFileStatus 在索引操作重占比最高,所以有必要对其进行优化。那优化点在哪里呢?
首先因为 US3 中的“目录”(对象存储是 KV 存储,所谓目录只是模拟而已)是以‘/’结尾的 Key,而 FileSystem 的对文件的操作是通过 Path 结构进行,该结构的路径都不会以‘/’结尾,所以通过 Path 拿到的 Key 去 US3 中进行 HeadFile 时,有可能由于该 Key 在 US3 中是目录,HeadFile 就会返回 404, 必须通过第二次用“Key/”去 US3 中 Head 才能确认。如果这个 Key 目录还是不存在,就会导致 getFileStatus 时延大大增加了。
因此 US3 适配在创建目录时做了以下两件事:
1.向 US3 写入 mime-type 为“file/path”, 文件名为“Key”的空文件;
2.向 US3 写入 mime-type 为“application/x-director”, 文件名为“Key/”的空文件;
而普通文件 mime-type 为“application/octet-stream”。这样在 getFileStatus 中通过一次 HeadFile API 就判断当前 Key 到底是文件还是目录,同时当该目录下为空时,也能在 US3 控制台展现出该目录。而且由于 Hadoop 场景写入的主要是大文件,增加一次空文件索引的写入耗时在 ms 级别,时延基本可忽略。
此外,getFileStatus 在 Hadoop 的使用中具备明显的“时空局部性”特征,在具体的 FileSystem 实例中最近被 getFileStatus 操作的 Key,在短时间会被多次操作。利用这个特点,US3FileSystem 在实现过程中,getFileStatus 得到对应的结果在 FileStatus 返回之前,会把有 3s 生命周期的 FileStatus 插入到 Cache 中。那后续 3 秒内对该 Key 的操作就会复用 Cache 中该 Key 的 FileStatus 信息,当然 delete 操作会在 US3 中删除完 Key 后,直接把 Cache 中的有效 FileStatus 标记为有 3s 生命周期的 404 Cache,或者直接插入一个有 3s 生命周期的 404 Cache。如果是 rename,会复用源的 Cache 来构造目的 Key 的 Cache,并删除源,这样就能减少大量跟 US3 的交互。Cache 命中(us 级别)会减少 getFileStatus 上百倍的时延。
当然这会引入一定的一致性问题,但仅限于在多个 Job 并发时至少有一个存在“写”的情况,如 delete 和 rename 的情况下,如果仅仅只有读,那么无影响。不过大数据场景基本属于后者。
ListObjects 一致性问题
US3 的 ListObjects 接口跟其他对象存储方案类似,目前都只能做到最终一致性(不过 US3 后续将推出强一致性保证的 ListObjects 接口),因此其他对象存储实现的适配器也都会存在写入一个文件,然后立即调用 listStatus 时会偶尔出现这个文件不存在的情况。其他对象存储方案有时会通过引入一个中间件服务(一般是数据库),当写入一个文件会向这个中间件写入这个文件索引,当 listStatus 时会跟中间件的索引信息进行合并,这样确实缓解了这种情况,进一步提高了一致性。
但还不够,比如写入对象存储成功,但写入中间件时程序奔溃了,这样就导致不一致的问题,又回到了最终一致性的问题。
US3Hadoop 适配器的实现相对更加简单有效,不需要借助额外的服务,能提供索引操作级别的 Read-Your-Writes 一致性,而该一致性级别在 Hadoop 大部分场景基本等同于强一致性。US3Hadoop 适配器不像 S3AFileSystem 的实现,在 create 或者 rename、delete 后立马返回,而是在内部调用 ListObjects 接口做了一次“对账”,直到“对账”结果符合预期则返回。
当然这里也是有优化空间的,比如 delete 一个目录时,对应会把这个目录下所有文件先拉出来,然后依次调用 DeleteFile API 去删除,如果每次 DeleteFile API 删除都“对账”一次,那么整个时延会翻倍。US3Hadoop 适配器的做法是只对最后一次索引操作进行“对账”,这是由于索引的 oplog 是按时序同步到列表服务中,如果最后一条索引“对账”成功,那么前面的 oplog 一定在列表服务中写入成功。
Rename 的深度定制
前面提到的 rename 也是 US3 的一个重要优化点,其他对象存储方案的实现一般通过 Copy 的接口会先把文件复制一遍,然后再删除源文件。可以看出如果 rename 的文件很大,那么 rename 的整个过程势必导致时延很高。
US3 根据该场景的需求,专门开发了 Rename 的 API 接口,因此 US3Hadoop 适配器实现 rename 的语义相对比较轻量,而且时延保持在 ms 级别。
保证 read 高效稳定
读是大数据场景的高频操作,所以 US3Hadoop 适配器的读取流实现,不是对 http 响应的 body 简单封装,而是考虑了多方面的优化。例如,对读取流的优化,通过加入预读 Buffer,减少网络 IO 系统调用频率,降低 read 操作的等待时延,特别是大批量顺序读的 IO 提升效果明显。
另外,FileSystem 的读取流具有 seek 接口,也就是需要支持随机读,这里又分两种场景:
1、seek 到已读流位置的前置位置,那么作为 Underlay Stream 的 Http 响应的 body 流就要作废关闭掉,需要重新发起一个从 seek 的位置开始分片下载的 GetFile API,获得其 Http 响应的 body 流来作为新的 Underlay Stream。但是实际测试过程中发现,很多 seek 操作过后不一定会进行 read 操作,有可能直接关闭,或者 seek 回到已读取流位置的后置位置,所以在 seek 发生时,US3Hadoop 适配器的实现是只做 seek 位置标记,在 read 的时候根据实际情况对 Underlay Stream 做延迟关闭打开处理。此外如果 seek 的位置还在 Buffer 中,也不会重新打开 Underlay Stream,而是通过修改 Buffer 的消费偏移。
2、随机读的另一种场景就是,seek 到已读流位置的后置位置。这里同样跟前面一样采用延迟流打开,但是在确定要做真实的 seek 操作时,不一定会通过关闭老的 Underlay Stream,重新在目标位置打开新的 Underlay Stream 来实现。因为当前已读的位置跟 seek 的后置位置可能距离很近,假设只有 100KB 距离,说不定这段距离完全在预读 Buffer 的范围中,这时也可以通过修改 Buffer 的消费偏移来实现。
事实上 US3Hadoop 适配器确实也是这么做的,不过目前的规则是 seek 的后置位置到当前已读流位置的距离小于等于预读 Buffer 剩余空间加上 16K 的和,则直接通过修改预读 Buffer 的消费偏移和消费 Underlay Stream 中的数据来定位到 seek 的后置位置上。之所以还加了 16K 是考虑到 TCP 接收缓存中的数据。当然后续确定从一个 ready 的 Underlay Stream 中消费 N 字节数据的时间成本大致等于重新发起一个 GetFile API 并在准备传输该 Http 响应 body 之前的时间成本,也会考虑把这 N 字节的因素计入偏移计算过程中。
最后流的优化还要考虑到 Underlay Stream 异常的情况,比如 HBase 场景长时间持有打开的流,却由于其他操作导致长时间没有操作该流,那么 US3 可能会主动关闭释放 Underlay Stream 对应的 TCP 连接,后续对在 Underlay Stream 上的操作就会报 TCP RST 的异常。为了提供可用性,US3Hadoop 适配器的实现是在已经读取位置点上进行 Underlay Stream 的重新打开。
写在最后
US3Hadoop 适配器的实现在借鉴开源方案下,进一步优化了相关核心问题点,提升了 Hadoop 访问 US3 的可靠性与稳定性,并在多个客户案例中发挥着打通 Hadoop 与 US3 的重要桥梁作用,帮助用户提升大数据场景下的存储读写效率。
但 US3Haoop 适配器还存在很多可提升的空间,相比于 HDFS,索引、IO 的时延还有差距,原子性保障上也相对比较弱,这些也是我们接下来要思考解决的问题。目前推出的 US3Vmds 解决了索引时延的大部分问题,使得通过 US3Hadoop 适配器操作 US3 的性能得到大幅提升,并在部分场景接近原生 HDFS 的性能。具体数据可以参考官方文档
(https://docs.ucloud.cn/ufile/tools/us3vmds/testdata?id=%e6%b5%8b%e8%af%95%e6%95%b0%e6%8d%ae)。
未来,US3 产品会不断改进优化大数据场景下的存储解决方案,在降低大数据存储成本的同时,进一步提升用户在大数据场景下的 US3 使用体验。
版权声明: 本文为 InfoQ 作者【UCloud技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/3374330e82d2ef307548c7f1d】。文章转载请联系作者。
评论