当前位置:首页 > 投融资 > 产业 > 科技前沿 > 正文

9年演进史:字节跳动10EB级大数据存储实战

来源:OSC开源社区 发布时间: 2022-11-03 16:14:52 编辑:夕歌

导读:作为目前字节跳动内部存储量及集群规模最大的分布式存储系统,HDFS一直伴随着字节跳动关键业务的飞速扩张而快速发展。本文会从HDFS发展历程入手,介绍发展路径上的重大挑战及解决方案。

以下文章来源于字节跳动技术团队,作者基础架构团队

作为目前字节跳动内部存储量及集群规模最大的分布式存储系统,HDFS一直伴随着字节跳动关键业务的飞速扩张而快速发展。本文会从HDFS发展历程入手,介绍发展路径上的重大挑战及解决方案。

HDFS简介

因为HDFS这样一个系统已经存在了非常长的时间,应用的场景已经非常成熟了,所以这部分我们会比较简单地介绍。

HDFS全名HadoopDistributedFileSystem,是业界使用最广泛的开源分布式文件系统。原理和架构与Google的GFS基本一致。它的特点主要有以下几项:

和本地文件系统一样的目录树视图

AppendOnly的写入(不支持随机写)

顺序和随机读

超大数据规模

易扩展,容错率高

字节跳动特色的HDFS

字节跳动应用HDFS已经非常长的时间了,经历了7年的发展,目前已直接支持了十多种数据平台,间接支持了上百种业务发展。从集群规模和数据量来说,HDFS平台在公司内部已经成长为总数几万台服务器的大平台,支持了EB级别的数据量。

在深入相关的技术细节之前,我们先看看字节跳动的HDFS架构。

架构介绍

接入层

接入层是区别于社区版本最大的一层,社区版本中并无这一层定义。在字节跳动的落地实践中,由于集群的节点过于庞大,我们需要非常多的NameNode实现联邦机制来接入不同上层业务的数据服务。但当NameNode数量也变得非常多了以后,用户请求的统一接入及统一视图的管理也会有很大的问题。为了解决用户接入过于分散,我们需要一个独立的接入层来支持用户请求的统一接入,转发路由;同时也能结合业务提供用户权限和流量控制能力;另外,该接入层也需要提供对外的目录树统一视图。

该接入层从部署形态上来讲,依赖于一些外部组件如Redis,MySQL等,会有一批无状态的NNProxy组成,他们提供了请求路由,Quota限制,Tracing能力及流量限速等能力。

元数据层

这一层主要模块有NameNode,ZKFC,和BookKeeper(不同于QJM,BookKeeper在大规模多节点数据同步上来讲会表现得更稳定可靠)。

NameNode负责存储整个HDFS集群的元数据信息,是整个系统的大脑。一旦故障,整个集群都会陷入不可用状态。因此NameNode有一套基于ZKFC的主从热备的高可用方案。

NameNode还面临着扩展性的问题,单机承载能力始终受限。于是HDFS引入了联邦(Federation)机制。一个集群中可以部署多组NameNode,它们独立维护自己的元数据,共用DataNode存储资源。这样,一个HDFS集群就可以无限扩展了。但是这种Federation机制下,每一组NameNode的目录树都互相割裂的。于是又出现了一些解决方案,能够使整个Federation集群对外提供一个完整目录树的视图。

数据层

相比元数据层,数据层主要节点是DataNode。DataNode负责实际的数据存储和读取。用户文件被切分成块复制成多副本,每个副本都存在不同的DataNode上,以达到容错容灾的效果。每个副本在DataNode上都以文件的形式存储,元信息在启动时被加载到内存中。

DataNode会定时向NameNode做心跳汇报,并且周期性将自己所存储的副本信息汇报给NameNode。这个过程对Federation中的每个集群都是独立完成的。在心跳汇报的返回结果中,会携带NameNode对DataNode下发的指令,例如,需要将某个副本拷贝到另外一台DataNode或者将某个副本删除等。

主要业务

先来看一下当前在字节跳动HDFS承载的主要业务:

Hive,HBase,日志服务,Kafka数据存储

Yarn,Flink的计算框架平台数据

Spark,MapReduce的计算相关数据存储

发展阶段

在字节跳动,随着业务的快速发展,HDFS的数据量和集群规模快速扩大,原来的HDFS的集群从几百台,迅速突破千台和万台的规模。这中间,踩了无数的坑,大的阶段归纳起来会有这样几个阶段。

第一阶段

业务增长初期,集群规模增长趋势非常陡峭,单集群规模很快在元数据服务器NameNode侧遇到瓶颈。引入联邦机制(Federation)实现集群的横向扩展。

联邦又带来统一命名空间问题,因此,需要统一视图空间帮助业务构建统一接入。这里我们引入了NameNodeProxy组件实现统一视图和多租户管理等功能。为了解决这个问题,我们引入了NameNodeProxy组件实现统一视图和多租户管理等功能,这部分会在下文的NNProxy章节中介绍。

第二阶段

数据量继续增大,Federation方式下的目录树管理也存在瓶颈,主要体现在数据量增大后,Java版本的GC变得更加频繁,跨子树迁移节点代价过大,节点启动时间太长等问题。因此我们通过重构的方式,解决了GC,锁优化,启动加速等问题,将原NameNode的服务能力进一步提高。容纳更多的元数据信息。为了解决这个问题,我们也实现了字节跳动特色的DanceNN组件,兼容了原有Java版本NameNode的全部功能基础上,大大增强了稳定性和性能。相关详细介绍会在下面的DanceNN章节中介绍。

第三阶段

当数据量跨过EB,集群规模扩大到几万台的时候,慢节点问题,更细粒度服务分级问题,成本问题和元数据瓶颈进一步凸显。我们在架构上进一步在包括完善多租户体系构建,重构数据节点和元数据分层等方向进一步演进。这部分目前正在进行中,因为优化的点会非常多,本文会给出慢节点优化的落地实践。

关键改进

在整个架构演进的过程中,我们做了非常多的探索和尝试。如上所述,结合之前提到的几个大的挑战和问题,我们就其中关键的NameNodeProxy和DanceNameNode这两个重点组件做一下介绍,同时,也会介绍一下我们在慢节点方面的优化和改进。

NNProxy(NameNodeProxy)

作为系统的元数据操作接入端,NNProxy提供了联邦模式下统一元数据视图,解决了用户请求的统一转发,业务流量的统一管控的问题。

先介绍一下NNProxy所处的系统上下游。

我们先来看一下NNProxy都做了什么工作。

路由管理

在上面Federation的介绍中提到,每个集群都维护自己独立的目录树,无法对外提供一个完整的目录树视图。NNProxy中的路由管理就解决了这个问题。路由管理存储了一张mounttable,表中记录若干条路径到集群的映射关系。

例如/user->hdfs://namenodeB,这条映射关系的含义就是/user及其子目录这个目录在namenodeB这个集群上,所有对/user及其子目录的访问都会由NNProxy转发给namenodeB,获取结果后再返回给Client。

匹配原则为最长匹配,例如我们还有另外一条映射/user/tiger/dump->hdfs://namenodeC,那么/user/tiger/dump及其所有子目录都在namenodeC,而/user目录下其他子目录都在namenodeB上。如下图所示:

Quota限制

使用过HDFS的同学会知道Quota这个概念。我们给每个目录集合分配了额定的空间资源,一旦使用超过这个阈值,就会被禁止写入。这个工作就是由NNProxy完成的。NNProxy会通过Quota实时监控系统获取最新Quota使用情况,当用户进行元数据操作的时候,NNProxy就会根据用户的Quota情况作出判断,决定通过或者拒绝。

Trace支持

ByteTrace是一个Trace系统,记录追踪用户和系统以及系统之间的调用行为,以达到分析和运维的目的。其中的Trace信息会附在向NNProxy的请求RPC中。NNProxy拿到ByteTrace以后就可以知道当前请求的上游模块,USER及ApplicationID等信息。NNProxy一方面将这些信息发到Kafka做一些离线分析,一方面实时聚合并打点,以便追溯线上流量。

流量限制

虽然NNProxy非常轻量,可以承受很高的QPS,但是后端的NameNode承载能力是有限的。因此突发的大作业造成高QPS的读写请求被全量转发到NameNode上时,会造成NameNode过载,延时变高,甚至出现OOM,影响集群上所有用户。因此NNProxy另一个非常重要的任务就是限流,以保护后端NameNode。目前限流基于路径+RPC以及用户+RPC维度,例如我们可以限制/user/tiger/warhouse路径的create请求为100QPS,或者某个用户的delete请求为5QPS。一旦该用户的访问量超过这个阈值,NNProxy会返回一个可重试异常,Client收到这个异常后会重试。因此被限流的路径或用户会感觉到访问HDFS变慢,但是并不会失败。

DanceNN(DanceNameNode)

解决的问题

如前所述,在数据量上到EB级别的场景后,原有的Java版本的NameNode存在了非常多的线上问题需要解决。以下是在实践过程中我们遇到的一些问题总结:

Java版本NameNode采用Java语言开发,在INode规模上亿时,不可避免的会带来严重的GC问题;

Java版本NameNode将INodemeta信息完全放置于内存,10亿INode大约占用800GB内存(包含JVM自身占用的部分nativememory),更进一步加重了GC;

我们目前的集群规模下,NameNode从重启到恢复服务需要6个小时,在主备同时发生故障的情况下,严重影响上层业务;

Java版本NameNode全局一把读写锁,任何对目录树的修改操作都会阻塞其他的读写操作,并发度较低;

从上可以看出,在大数据量场景下,我们亟需一个新架构版本的NameNode来承载我们的海量元数据。除了C++语言重写来规避Java带来的GC问题以外,我们还在一些场景下做了特殊的优化。

目录树锁设计

HDFS对内是一个分布式集群,对外提供的是一个unified的文件系统,因此对文件及目录的操作需要像操作Linux本地文件系统一样。这就要求HDFS满足类似于数据库系统中ACID特性一样的原子性,一致性、隔离性和持久性。因此DanceNN在面对多个用户同时操作同一个文件或者同一个目录时,需要保证不会破坏掉ACID属性,需要对操作做锁保护。

不同于传统的KV存储和数据库表结构,DanceNN上维护的是一棵树状的数据结构,因此单纯的key锁或者行锁在DanceNN下不适用。而像数据库的表锁或者原生NN的做法,对整棵目录树加单独一把锁又会严重的影响整体吞吐和延迟,因此DanceNN重新设计了树状锁结构,做到保证ACID的情况下,读吞吐能够到8w,写吞吐能够到2w,是原生NN性能的10倍以上。

这里,我们会重新对RPC做分类,像createFile,getFileInfo,setXAttr这类RPC依然是简单的对某一个INode进行CURD操作;像deleteRPC,有可能删除一个文件,也有可能会删除目录,后者会影响整棵子树下的所有文件;像renameRPC,则是更复杂的另外一类操作,可能会涉及到多个INode,甚至是多棵子树下的所有INode。

DanceNN启动优化

由于我们的DanceNN底层元数据实现了本地目录树管理结构,因此我们DanceNN的启动优化都是围绕着这样的设计来做的。

多线程扫描和填充BlockMap

在系统启动过程中,第一步就是读取目录树中保存的信息并且填入BlockMap中,类似Java版NN读取FSImage的操作。在具体实现过程中,首先起多个线程并行扫描静态目录树结构。将扫描的结果放入一个加锁的Buffer中。当Buffer中的元素个数达到设定的数量以后,重新生成一个新的Buffer接收请求,并在老Buffer上起一个线程将数据填入BlockMap。

接收块上报优化

DanceNN启动以后会首先进入安全模式,接收所有DateNode的块上报,完善BlockMap中保存的信息。当上报的DateNode达到一定比例以后,才会退出安全模式,这时候才能正式接收client的请求。所以接收块上报的速度也会影响DateNode的启动时长。DanceNN这里做了一个优化,根据BlockID将不同请求分配给不同的线程处理,每个线程负责固定的Slice,线程之间无竞争,这样就极大的加快了接收块上报的速度。如下图所示:

慢节点优化

慢节点问题在很多分布式系统中都存在。其产生的原因通常为上层业务的热点或者底层资源故障。上层业务热点,会导致一些数据在较短的时间段内被集中访问。而底层资源故障,如出现慢盘或者盘损坏,更多的请求就会集中到某一个副本节点上从而导致慢节点。

通常来说,慢节点问题的优化和上层业务需求及底层资源量有很大的关系,极端情况,上层请求很小,下层资源充分富裕的情况下,慢节点问题将会非常少,反之则会变得非常严重。在字节跳动的HDFS集群中,慢节点问题一度非常严重,尤其是磁盘占用百分比非常高以后,各种慢节点问题层出不穷。其根本原因就是资源的平衡滞后,许多机器的磁盘占用已经触及红线导致写降级;新增热资源则会集中到少量机器上,这种情况下,当上层业务的每秒请求数升高后,对于P999时延要求比较高的一些大数据分析查询业务就容易出现一大批数据访问(>10000请求)被卡在某个慢请求的处理上。

我们优化的方向会分为读慢节点和写慢节点两个方面。

读慢节点优化

我们经历了几个阶段:

最早,使用社区版本,其SwitchRead以读取一个packet的时长为统计单位,当读取一个packet的时间超过阈值时,认为读取当前packet超时。如果一定时间窗口内超时packet的数量过多,则认为当前节点是慢节点。但这个问题在于以packet作为统计单位使得算法不够敏感,这样使得每次读慢节点发生的时候,对于小IO场景(字节跳动的一些业务是以大量随机小IO为典型使用场景的),这些个积攒的Packet已经造成了问题。

后续,我们研发了HedgedRead的读优化。HedgedRead对每一次读取设置一个超时时间。如果读取超时,那么会另开一个线程,在新的线程中向第二个副本发起读请求,最后取第一第二个副本上优先返回的response作为读取的结果。但这种情况下,在慢节点集中发生的时候,会导致读流量放大。严重的时候甚至导致小范围带宽短时间内不可用。

基于之前的经验,我们进一步优化,开启了FastSwitchRead的优化,该优化方式使用吞吐量作为判断慢节点的标准,当一段时间窗口内的吞吐量小于阈值时,认为当前节点是慢节点。并且根据当前的读取状况动态地调整阈值,动态改变时间窗口的长度以及吞吐量阈值的大小。下表是当时线上某业务测试的值:

进一步的相关测试数据:

写慢节点优化

写慢节点优化的适用场景会相对简单一些。主要解决的是写过程中,Pipeline的中间节点变慢的情况。为了解决这个问题,我们也发展了FastFailover和FastFailover+两种算法。

FastFailover

FastFailover会维护一段时间内ACK时间过长的packet数目,当超时ACK的数量超过阈值后,会结束当前的block,向namenode申请新块继续写入。

FastFailover的问题在于,随意结束当前的block会造成系统的小block数目增加,给之后的读取速度以及namenode的元数据维护都带来负面影响。所以FastFailover维护了一个切换阈值,如果已写入的数据量(block的大小)大于这个阈值,才会进行block切换。

但是往往为了达到这个写入数据大小阈值,就会造成用户难以接收的延迟,因此当数据量小于阈时需要进额外的优化。

FastFailover+

为了解决上述的问题,当已写入的数据量(block的大小)小于阈值时,我们引入了新的优化手段——FastFailover+。该算法首先从pipeline中筛选出速度较慢的datanode,将慢节点从当前pipeline中剔除,并进入PipelineRecovery阶段。PipelineRecovery会向namenode申请一个新的datanode,与剩下的datanode组成一个新的pipeline,并将已写入的数据同步到新的datanode上(该步骤称为transferblock)。由于已经写入的数据量较小,transferblock的耗时并不高。统计p999平均耗时只有150ms。由PipelineRecovery所带来的额外消耗是可接受的。

下表是当时线上某业务测试的值:

一些进一步的实际效果对比:

结尾

HDFS在字节跳动的发展历程已经非常长了。从最初的几百台的集群规模支持PB级别的数据量,到现在几万台级别多集群的平台支持EB级别的数据量,我们经历了7年的发展。伴随着业务的快速上量,我们团队也经历了野蛮式爆发,规模化发展,平台化运营的阶段。这过程中我们踩了不少坑,也积累了相当丰富的经验。当然,最重要的,公司还在持续高速发展,而我们仍旧不忘初心,坚持“DAYONE”,继续在路上。