先抛一个结论:多数人第一反应会觉得 NameNode 是决定输入分片的那一方,毕竟它掌握着整个 HDFS 的文件状态和块分布,分片这种事听上去就是元数据层面的活。但真实答案是,决定输入分片的代码运行在提交作业的客户端进程里,NameNode 全程只扮演“被查询”的角色——它负责告诉你“这个文件有哪些块、每个块在哪几个 DataNode 上”,而“切成多少片、每片多大、每片从哪个偏移量开始”完全由客户端里的 InputFormat 计算出来。
这个误解容易埋下很多坑,比如你改了分片参数发现 map 数量没变化,比如明明设置了某个 minSize 却出现了一个超大分片,又比如跑线上任务时数据本地性总是稀烂。要搞清楚这些现象,就必须把客户端和 NameNode 的职责边界、分片计算的完整链路、数据本地性是怎么形成的弄明白。这篇文章就沿着“谁来决定输入分片”这条主线,把从作业提交到任务调度的整个过程都过一遍,适合正在啃 MapReduce 原理的人,也适合做大数据平台运维和调优的同学。
1. 两个角色的真实分工:一个做规划,一个做查询
1.1 从一次作业提交看分片诞生全过程
当一个 MapReduce 作业被提交时,客户端这边会经历一个典型的提交流程:构造 Job、写入配置、上传依赖、然后通过 JobSubmitter 去计算分片。分片的计算入口是 FileInputFormat.getSplits(),它在客户端进程内执行,算完之后生成一个 FileSplit 列表,序列化到 HDFS 上的暂存目录里。
这个列表里记录的是什么?每个 FileSplit 包含四个关键信息:输入文件的路径、分片在文件中的起始偏移量(start)、分片长度(length)、以及这个分片对应的存储主机列表(hosts)。也就是说,客户端不仅要决定“切几片”,还要把“每一片的数据在哪些机器上”一起算出来,方便后续调度器做本地性分配。
而 NameNode 在这个阶段做的事,用一句话概括就是:客户端问它要文件状态、要块位置,它如实返回。客户端调用的方法大概是 FileSystem.listStatus() 拿到文件大小,再用 FileSystem.getFileBlockLocations() 拿到每个文件的块级位置。NameNode 维护的是文件名空间和块到 DataNode 的映射,它对这些调用负责应答,但完全不管 MapReduce 作业的并行度、分片大小、压缩格式可分割性这些计算层面的东西。
1.2 NameNode 负责什么:提供元数据而不是计算结果
NameNode 在 HDFS 里的核心职责是三类:维护文件系统目录树和文件属性,管理文件与块的映射关系,以及跟踪每个块的副本所在位置。它对外提供的接口多是元数据查询和修改类操作,比如 create、delete、rename、getFileInfo、getBlockLocations。这些操作有一个共同点:它们处理的是“现状”,而不是“计划”。
到了 getBlockLocations 这个层面,NameNode 返回的是一个 LocatedBlocks 结构,里面包含每一个 LocatedBlock。每个 LocatedBlock 又包含 blockId、该 block 的长度、以及一组 DatanodeInfo,也就是这个块的所有副本节点地址。它就是给出一份“数据到底放在哪”的事实清单。
这里要踩住的边界是:NameNode 不读 mapreduce.input.fileinputformat.split.minsize 之类的参数,它根本不知道一个分片应该多大,更不知道某个作业想要多少 map。它只知道一个文件被切成多少块、每块多大、副本在哪些节点上。至于“把这些块组装成多少个处理单元”,那是计算框架的事。
1.3 一个接地气的类比:图书馆总台与借书人
打个比方,NameNode 就像图书馆总台的检索员,它知道馆里每本书在哪个楼层、哪个书架、哪个位置,甚至知道一本书被拆成几册、每册放哪。当你说“我想查一下某本书在哪”,检索员可以准确告诉你书架号。但检索员不会替你决定“你该先读第几章,再读第几章,分几次读完”。读哪一章、怎么规划阅读节奏,是由你这个借书人自己决定的。
MapReduce 客户端就是那个借书人。它先去总台问清楚整本书的物理位置,然后按自己的阅读计划(分片策略)把书切分成若干阅读单元。总台只提供“找书”服务,不参与“读书计划”。这样的分工保证了图书馆总台能快速响应所有人,而每个读者又可以根据自己的需求自由安排阅读方式。
2. 客户端侧的决定:FileInputFormat.getSplits 完整决策链
2.1 splitSize 的公式与默认值
FileInputFormat 计算单个分片大小的公式是:
splitSize = max(minSize, min(maxSize, blockSize))其中 minSize 对应参数 mapreduce.input.fileinputformat.split.minsize,默认值为 1;maxSize 对应 split.maxsize,默认值为 Long.MAX_VALUE。blockSize 通常是 HDFS 的块大小,默认 128MB。
代入默认值之后,minSize 几乎不起作用,maxSize 也形同虚设,所以 splitSize 直接用 blockSize。这就是为什么在默认配置下,一个 128MB 块的文件会产生与块数量相等的 map 任务。很多人把这个现象记成了“块大小决定分片大小”,严格说它是“块大小作为分片的默认目标值”,而非不可违背的硬约束。
只要把 minSize 调大,或把 maxSize 调小,分片大小就会偏离块大小。举个实战例子,把 minSize 配成 256MB,那么即使块是 128MB,单个 split 也会变成 256MB,一个 block 所在的文件只产生 1 个 map,但这个 map 要读取两个块的数据。反过来,把 maxSize 配成 64MB,那一个 128MB 的块会被拆成两个 split,产生两个 map。
2.2 真实文件大小下的算术与 SPLIT_SLOP 效应
分片并不是简单地用文件大小除以 splitSize 然后向上取整。源码里有一个很关键的特殊值 SPLIT_SLOP,默认是 1.1。计算逻辑大致是这样:
long bytesRemaining = fileLength; while (bytesRemaining / splitSize > 1.1) { // 切出一个 splitSize 大小的分片 bytesRemaining -= splitSize; } // 剩余部分作为一个分片这个“剩余部分直接作为一个分片”的逻辑会带来一个很容易被忽略的结果:最后一个分片的大小可能超过 splitSize。举个例子,一个 260MB 的文件,块大小 128MB:
- 260MB / 128MB = 2.03,大于 1.1,所以先切出第一个 128MB 的分片,剩余 132MB;
- 132MB / 128MB = 1.03,不大于 1.1,跳出循环;
- 剩余 132MB 直接作为一个分片。
最终结果是 2 个分片(128MB + 132MB),而不是简单用 ceil(260/128)=3 算出来的 3 个分片。第二个分片的大小超过了 splitSize,并且跨越了两个块。这类细节如果只看文档不看源码,很容易在 map 数量预估上失手。
我把不同配置下的计算结果整理成一张表,方便对照:
| 文件大小 | blockSize | minSize | maxSize | splitSize | 分片结果 |
|---|---|---|---|---|---|
| 260MB | 128MB | 1 | Long.MAX | 128MB | 128MB + 132MB,共2个 |
| 256MB | 128MB | 1 | Long.MAX | 128MB | 128MB + 128MB,共2个 |
| 300MB | 128MB | 1 | Long.MAX | 128MB | 128MB + 128MB + 44MB,共3个 |
| 1GB | 128MB | 256MB | Long.MAX | 256MB | 4个 |
| 1GB | 128MB | 1 | 64MB | 64MB | 16个 |
注意这里的分片只是“逻辑划分”,分片大小并不强制等于块大小。一个 split 可以大于一个块,也可以小于一个块,这跟文件能不能被拆分、以及该 split 的 host 列表怎么计算都有关系。
2.3 可分割性:压缩格式悄悄改写结果
FileInputFormat 在遍历每个文件时,会先调用 isSplitable() 判断这个文件是否支持拆分。这个判断本质上是看压缩算法的能力。常见的压缩格式里,gzip 是不可分割的,因为它的压缩流是无缝的,必须从头连续解码,否则解不出来;而 lzo、snappy、bzip2 这类格式带同步标记,支持从任意位置开始解压,因此是可分割的。
一旦文件不可分割,前面的 splitSize 公式就全部失效了:整个文件会作为一个分片。假设你有一个 1.2GB 的 gzip 文件,按块大小 128MB 算应该有约 10 个块,但因为不可分割,最终只有 1 个分片,也就是全文件一个 map。这个 map 会被调度到某个节点上,但它需要跨节点读取分布在多个 DataNode 上的数据块,本地性和性能都会很难看。
所以做分片预估时,不要只盯着文件大小和参数配置,先看一眼文件是不是用可分割的压缩格式写的。Hive 表里如果存储格式选了 gzip 压缩的文本文件,任务数量往往和文件数一样,而不是和数据量一样,根源就在这里。
2.4 决策结果去哪里:job.split 与 ApplicationMaster
分片列表算完之后,JobSubmitter 会把它写成一个 job.split 文件,放到 HDFS 的作业暂存目录里。同时写入的还有 job.splitmetainfo,用来存分片数量等摘要信息。这两个文件的用途是交给后续启动的 ApplicationMaster 读取。
在 YARN 架构里,ApplicationMaster 是作业的管家。它启动后会从暂存目录读取 job.split,知道这个作业到底有多少个 map 任务,然后逐个为这些 map 任务向 ResourceManager 申请容器。也就是说,客户端早已在提交阶段就把“多少个 map”这个数字钉死了,后续的调度执行只是按图索骥。
如果作业提交之后你才想着调整 map 数量,修改配置是来不及的,必须回到提交前的客户端重新计算分片。这也是为什么很多调参工具都需要在提交阶段介入,而不是在运行中动态修改。
3. 数据本地性的形成:分片怎么带上主机列表
3.1 host 列表是怎么填进 FileSplit 的
FileInputFormat.getSplits() 在确定一个分片的边界(start、length)之后,还会调用一个非常关键的方法:getSplitHosts()。它的作用是找到这个分片范围覆盖了哪些块,然后把这些块的所有副本节点地址收集起来,作为该分片的 host 列表。
更准确地说,它先通过文件系统拿到该文件所有块的 BlockLocation 数组,然后从数组里挑出“与当前分片范围有重叠”的那几个块。比如一个 split 从偏移量 0 开始、长度 128MB,那么这个 split 对应第一个块;如果 split 跨越两个块,就把这两个块的副本节点都收进来。
getSplitHosts 会优先把块的主副本节点放在前面,这主要是为了后续调度器在分配容器时有一个“首选”顺序。最终写进 FileSplit 的 hosts 是一个二维数组,每个块对应一组候选节点,代表这个块的所有副本位置。MapTask 运行时并不只从一个节点读取,它可以直接向多个 DataNode 发起读请求,所以 host 列表记录得越准,任务调度就越容易做到数据本地。
3.2 分片跨块:本地性如何被稀释
默认情况下 splitSize=blockSize,一个 split 通常恰好覆盖一个块内的连续区域,因此它的 host 列表就是这个块的三个副本节点。调度器只要把 map 任务放到这三个节点中的任意一个,就能实现节点本地读取。
但当你把 minSize 调到大于 blockSize 以后,split 变得比块还大,一个 split 就要跨多个块。比如 splitSize=256MB,而每个块只有 128MB,那么一个 split 覆盖两个块。这两个块各自的副本节点可能完全不同,有的在同一节点上,有的分散在不同机架。此时这个 split 的 host 列表会包含两个块的所有副本节点,可它无法做到“只从一台机器读完整份数据”:任务如果被调度到块 A 的某个节点,读取块 B 时就可能远程访问。
这就产生了一个常见的调优拉扯:调大 split 可以显著减少 map 数量,降低调度开销,但代价是本地性变差;调小 split 能提升本地性,却会带来更多任务启动开销。一般经验是让 splitSize 尽量等于块大小,既不跨块也不拆分块,在并行度和本地性之间取平衡。
3.3 调度器拿到 host 列表后如何分配容器
ApplicationMaster 给 map 任务申请容器时,会把该任务的 host 列表传给 ResourceManager 的调度器。调度顺序遵循三级策略:第一优先是分片数据所在的某个节点,也就是 NODE_LOCAL;如果该节点没有可用资源,则退一步申请同一机架内的其他节点,也就是 RACK_LOCAL;实在没有,才允许跨机架调度,也就是 OFF_SWITCH。
这里的细节是,因为一个块默认有 3 个副本,所以一个普通 split 通常有 3 个候选节点。在小集群或者高并发时期,3 个节点全忙是常有的事,于是任务会被调度到同机架甚至跨机架节点上。很多人只在本地性差的时候才怀疑“分片是不是没在客户端做好”,其实调度时的资源竞争才是更常见的元凶。
3.4 为什么要优先本地:带宽与延迟
同样是读一份数据,从本地磁盘读走的是磁盘 IO 带宽,吞吐可以到几百 MB/s 甚至更高,延迟是毫秒级;从千兆网络跨机架读,单流吞吐可能只有几十 MB/s,还要叠加网络抖动。100GB 的输入如果全部跨机架读取,运行时间可能差出好几倍。
MapReduce 的基本哲学是“移动计算而不是移动数据”,输入分片设计正是为了支撑这一哲学。它让数据的位置信息随着分片一起流动,调度器用最短的决策路径把计算推过去。所以不要小看分片里那串 host 列表,它才是整个本地性机制的载体。
4. NameNode 那条被动但必要的调用路径
4.1 从 FileSystem 到 NameNode 的 RPC 链路
客户端要拿到块位置信息,不是一步到位的。顺着调用链往下走,能看到一条清晰的 RPC 路径:
- FileInputFormat 调用 FileSystem.getFileBlockLocations(),这是一个文件系统抽象层的方法;
- 文件系统实现如果是 HDFS,就会进入 DistributedFileSystem,它会通过 ClientProtocol 接口发起远程调用;
- 底层由 ClientNamenodeProtocolTranslatorPB 把调用封装成 Protobuf 消息,走 RPC 到 NameNode 的 NameNodeRpcServer;
- NameNodeRpcServer 把请求转给 FSNamesystem.getBlockLocations() 执行;
- 执行完返回 LocatedBlocks 对象,一路序列化回客户端。
这条链路由“谁发起”也可以看出职责边界:分片计算完全在客户端,服务端只是被动应答。FileSystem 抽象在这里起到了关键作用,因为 InputFormat 只依赖 FileSystem 接口,不直接依赖 NameNode,所以同一套分片代码能跑在 HDFS、本地文件系统、对象存储等各种底层之上。
4.2 NameNode 内部要做什么
NameNode 处理 getBlockLocations 时,会先拿文件、检查文件是否存在、校验客户端权限,然后从内部的 BlocksMap 结构里找出该文件对应的所有块,再逐个查询每个块的副本所在的 DataNode 列表。为了保证一致性,整个查询过程会对元数据加读锁,但因为它只读,所以多个查询之间是可以并发的。
为了让调用方不能直接操作内部数据结构,NameNode 返回的时候会包装成 LocatedBlock,把 block ID、长度、DatanodeInfo 这些信息复制一份出来。这一步也说明 NameNode 的接口设计非常克制:你问什么,它给什么,不附带任何计算框架相关的逻辑。
4.3 为什么分片决策不能放进 NameNode
从架构上看,分片决策和 NameNode 的职责是冲突的。分片大小受很多计算框架参数影响,比如 minSize、maxSize、压缩格式、是否使用 CombineFileInputFormat;不同引擎的分片策略也不同,MapReduce 有 FileInputFormat,Spark 有自己的分区计算,Hive 也可能走 CombineFileInputFormat。如果把这些逻辑都塞进 NameNode,NameNode 就必须感知所有计算框架,这显然是不可接受的。
NameNode 最宝贵的资源是它的名称节点内存和响应速度,它已经被定位成全集群强一致性的唯一权威。把任何非必要的 CPU 密集计算加入它的调用路径,都会放大这个单点的风险。分片计算虽然不重,但在海量文件场景下需要大量的循环和数组操作,让 NameNode 来承担纯属吃力不讨好。
还有一个很实际的问题:分片结果需要在客户端和后续的 ApplicationMaster 之间传递,它本质上是作业的执行计划,属于计算框架内部对象。如果 NameNode 来计算,它还得把结果再通过 RPC 返回给客户端,或者写入某个位置,反而多此一举。所以让客户端计算分片是更自然的选型。
4.4 小文件场景:调用压力放大效应的实证
理解了调用路径,就能更深刻地明白为什么小文件是 HDFS 的敌人。假设一个作业有 1 万个 10KB 的小文件,客户端在 getSplits 阶段会先 listStatus 拿到 1 万个文件状态,然后对每个文件都发起 getFileBlockLocations 调用。NameNode 为此要做至少 1 万次元数据查询,每次都要查文件、查块、查副本位置。
这还只是分片阶段。作业执行阶段,这 1 万个 split 会生成 1 万个 map 任务,每个任务的开销都很大。NameNode 的元数据内存也会被这 1 万个文件永久占据。所以“小文件导致 NameNode 压力大”这句话,不仅仅指存储层的内存占用,连作业提交时的元数据查询也会被放大。想缓解这个问题,要么在存储层把小文件合并成大文件,要么在读取层用 CombineFileInputFormat 把多个小文件合并成一个逻辑分片。
5. 实战中被分片决策鞭打的场景与排查手段
5.1 map 数量与预期不符
我见过最多的问题是“我把 minSize 改成 256MB,为什么 map 数没降下来”。遇到这种情况,先不要怀疑配置没生效,先看这几个要素:
- 文件到底多大?是否小于 splitSize;
- 文件是不是不可分割的压缩格式;
- 是不是有多个文件,每个文件都被单独分片;
- 是否多个小文件各占一个小 split,导致 map 数被文件数顶住。
最快的方法是写个简短的 Java 程序,在提交作业前直接调用 FileInputFormat.getSplits(),把返回结果里的每个分片路径、start、length、hosts 打出来。一遍就能看到真实分片情况,比在调度日志里猜快得多。
5.2 数据本地性差
任务在运行报告中显示大量 RACK_LOCAL 甚至 OFF_SWITCH,优先要查的是资源紧张和分片跨块问题,而不是直接怪调度器。调度器只能尽力,如果某个时刻集群只剩一个节点有空,无论 split 的 host 列表有多精确,都会出现跨机架调度。
解决方案要从两个方向切:一是调整分片大小,让 split 尽可能对齐块边界,不要为了减少 map 数把 split 调得过大;二是控制提交作业的并发度,避免太多大作业同时抢资源。还有一点经常被忽略,就是不可分割压缩文件造成的天然跨块分片,这类任务只适合通过换压缩格式解决。
5.3 客户端内存暴涨
当输入文件的数量级到达几十万级别时,分片列表本身会成为客户端内存的负担。每个 FileSplit 都保存路径、偏移量、长度、host 数组,几十万个对象很容易把客户端 JVM 堆撑爆。这是“分片客户端计算”这一架构的固有代价。
处理思路一般不是去调大客户端堆内存,而是从源头减少分片数量。如果这些小文件可以被逻辑合并,优先使用 CombineFileInputFormat;如果业务允许,先做一轮小文件合并,把输入文件数量降到百以内。实在要硬扛,再考虑给客户端 JVM 加内存,但那只治标不治本。
5.4 从参数到工具的全套化解手段
给定一个输入数据集,想要控制 map 数量,可行的手段大致有四个层次:
- 调分片参数:调整 split.minsize 和 split.maxsize,这是最直接的方式;
- 改压缩格式:把 gzip 换成可分割的 lzo/snappy/bzip2,让文件可分割;
- 用 CombineFileInputFormat:让小文件多个合并,减少逻辑 split 数;
- 在下游直接调整读取引擎配置:比如 Hive 里可以开启合并小文件相关开关,本质上也是走 Combine 类逻辑。
这四招可以组合使用。先把不可分割的去掉,再决定要不要合并小文件,最后才考虑用 minSize/maxSize 微调。
6. 延伸思考:“客户端算分区”不只是 Hadoop 的选择
6.1 Spark 的分区规划路径
Spark 在从文件系统读取数据时,同样绕开了 NameNode 决策。Driver 端会根据输入文件的列表和每个文件的块信息,自己计算出一组分区的元数据。以文件输入的 RDD 为例,它在构造时会调用与 FileStatus、BlockLocation 相关的接口,把文件按目标大小切成一个个 InputPartition。
这一步的很多思路和 MapReduce 的 FileInputFormat 是一脉相承的,只是 Spark 的“分区”在后续执行中不一定产生一个独立任务,可能多个分区合并处理,也可能一个分区被切分。但关键点是一样的:分区边界由计算引擎在 Driver 侧决定,文件系统只负责提供数据的位置信息。
6.2 放客户端有什么好处与代价
把分区决策放到客户端,最大的好处是让文件系统保持纯粹。HDFS 只做分布式存储的事情,它不需要了解计算引擎的语义,更不需要为每种引擎定制分片算法。计算引擎则获得了极大的灵活性,可以根据文件大小、压缩格式、目标并行度自由决定分片策略。
代价也很明显,客户端机器需要一个能够访问文件系统的完整客户端环境,而且分片计算的算力和内存都要消耗在客户端侧。对于超大作业,提交机的配置往往会被忽略,一旦分片数达到百万级别,提交阶段就可能变得非常慢甚至 OOM。这是分布式系统架构中“职责分离”的典型取舍:为了服务端的稳定和灵活,把一部分计算放到了客户端。
6.3 如果真让 NameNode 来计算分片
可以设想一下反事实的架构:如果 NameNode 来做分片,它需要拿到每个作业的分片参数、压缩格式识别器、可插拔的 InputFormat 实现类。这意味着 NameNode 进程要能加载任意用户的代码,这本身就是灾难。更别说分片结果还要回传给客户端,相当于把一条原本可以并行的计算链路改成串行瓶颈。
再往深一层想,分片本质上属于作业执行计划的一部分,而执行计划会随框架、版本、用户配置而变,不可能统一收敛到存储层。因此“客户端计算分片”不是权宜之计,而是一种更符合分层架构原则的长期选择。理解这一点,比记住几个参数名更有价值。
最后补一句实操体会
我在实际排查 map 数量和本地性问题时,最常用的一个动作就是先打印 split 列表。构造一个小小的测试类,把你关心的文件路径丢进去,直接看 FileInputFormat.getSplits() 的返回值,每个分片的路径、起始偏移、长度、候选主机全都在眼前。这样能立刻区分出问题的根源到底在分片参数、压缩格式、文件数量,还是调度资源不足。踩过几次坑之后,你就不会再对着 NameNode 白找原因了。