我入行大数据那会儿,啃得最久也最值的就是 Hadoop 这套东西。很多人分不清 HDFS、YARN 和 MapReduce 到底各管什么,一上来就对着文档看,结果越看越懵:文件存到哪里去?作业跑起来谁在调度?Map 和 Reduce 中间发生了什么?这些问题搞不透,后面写 MR 作业、调优、排查故障都会四处碰壁。
这篇就用我实际摸索的经验,把 HDFS、YARN、MapReduce 三件套的原理和工作流程一层层拆开来讲。不求你一次背下所有参数,但求你读完能自己在脑子里把一条数据从写入到算完的完整链路画出来。适合刚学 Hadoop 的初学者、准备面试的开发者,还有那些写过 MR 但没系统梳理过底层机制的人。
1. 先搞清楚三件套各自干什么
1.1 用生活化类比拆解 HDFS、YARN、MapReduce
我经常跟新人打一个比方:Hadoop 集群就是一个大型加工厂。
HDFS 是原料仓库。所有文件都切成一块块(Block)存在仓库的不同货架上,仓库管理员(NameNode)负责记录每块料放在哪个货架(DataNode)上。你只管告诉管理员“我要存一个文件”,剩下的切块、复制、摆放都不用操心。
YARN 是工厂的调度中心。车间里有多少台机器、每台机器能开几条生产线(Container),调度中心一清二楚。哪个订单要用多少资源、排到哪台机器上跑,都由它统一分配,谁也别想独占整条产线。
MapReduce 是加工流水线的作业规范。一项生产任务拆成两道工序:Map 工序负责把原材料拆解加工成半成品,Reduce 工序负责把同类的半成品汇总包装成成品。两道工序之间还有个强制的“分拣传送带”(Shuffle),把相同标签的半成品送到同一个工位。
这个类比虽然不是百分之百精确,但能帮你建立三条主线:存储归 HDFS,资源归 YARN,计算归 MapReduce。后续所有细节都是围绕这三条线展开的。
1.2 为什么这三件事必须拆开
很多人问:为什么不能像单机一样,文件管理、资源分配、计算逻辑都揉在一个软件里?早期 Hadoop 1.x 就是揉在一起的,JobTracker 既要管资源调度又要管作业监控,结果集群一上规模它就成瓶颈,单点故障还直接导致整个集群不可用。
后来 Hadoop 2.x 做了个关键动作:把资源管理从计算框架中剥离出来,形成独立的 YARN。从此以后,MapReduce 只是 YARN 上面的一个“租户”,Spark、Flink 这些计算引擎也可以跑在 YARN 上,共享同一套资源池。存储层面 HDFS 保持独立,谁需要大规模数据存储都能用它,不用绑定某个计算框架。
这种拆开的设计思想,本质上就是解耦。存储、资源、计算各自演进、各自扩展,互不拖后腿。这也是为什么后来很多大数据体系里,即便不用 MapReduce 了,HDFS 和 YARN 依然存活得很好。理解了这个演进逻辑,你再看新框架就会容易很多:Hudi 解决 HDFS 上数据入湖的问题,K8s 某种程度上在做和 YARN 类似的事,各层关注点始终没变。
2. HDFS原理与读写流程
2.1 HDFS架构:NameNode、DataNode、SecondaryNameNode
HDFS 采用主从架构,核心角色就两个:NameNode 和 DataNode。
NameNode 是“大脑”,管理整个文件系统的元数据。比如文件名、目录结构、文件的权限、每个文件被切成哪些 Block、Block 又分布在哪些 DataNode 上,这些信息都存在 NameNode 内存里。注意,文件的实际数据不经过 NameNode,它只回答“数据在哪儿”这个问题。
DataNode 是“手脚”,真正存放 Block 数据的地方。一个 Block 默认 128MB(旧版本是 64MB),文件写入时会被切分成若干个 Block,每个 Block 默认存 3 份副本,分散在不同的机器上。DataNode 每 3 秒向 NameNode 发送一次心跳,同时上报自己持有的 Block 列表,让 NameNode 随时掌握集群健康状况。
SecondaryNameNode 这个名字很误导人,它不是 NameNode 的热备,主要工作是定期合并 NameNode 的 edits 日志和 fsimage 镜像,生成新的检查点,帮助 NameNode 加快重启速度,顺便减少日志膨胀。真正的高可用靠的是 Active/Standby NameNode 加 Zookeeper 那一套,那是另一篇文章的量。
这里有个值得想明白的问题:为什么副本数默认是 3?这是可靠性和成本的折中。一份放本机架,一份放同机架的另一台机器,一份放不同机架,这样任何一台机器宕机数据都不会丢,同时读数据时还能从最近或最空闲的副本读取。副本数设少了怕丢数据,设多了存储成本翻倍,3 是企业实践里最常见的默认值,你可以用hdfs dfs -setrep -R 2 /path按目录调低,但一般情况下别调,除非你很清楚自己在干什么。
机架感知(Rack Awareness)也值得一提。NameNode 在分配副本时,并不是随机扔给三个 DataNode,而是遵循一个策略:第一个副本优先放客户端所在的机器,第二个副本放在不同机架的一台机器上,第三个副本放在与第二个副本同机架的另一个节点上。这样既保证容错(至少跨一个机架),又保证内部带宽不至于跨机架拉满。
2.2 HDFS写入流程拆解
HDFS 写入流程是面试高频题,也是理解 HDFS 设计精髓的一把钥匙。你把一个大文件往 HDFS 里写,背后其实经历了一整套“确认—分配—传输—确认”的流程:
- 客户端调用 DistributedFileSystem.create() 向 NameNode 发起创建文件请求。
- NameNode 检查路径是否已存在、客户端是否有权限,检查通过后返回一个 FSDataOutputStream 给客户端。
- 客户端开始写入数据,先把文件切分成一个个 Block(默认 128MB),向 NameNode 申请“我要写第一个 Block,放哪几台机器?”
- NameNode 根据机架感知和副本策略,返回一个有序的 DataNode 列表,比如 [dn1, dn2, dn3]。
- 客户端把 Block 数据推给 dn1,dn1 收到一部分后边存边转给 dn2,dn2 再转给 dn3,这就是管道复制(Pipeline Replication)。
- 数据写完一整个 Block 后,dn3 往回传 ack,dn2 传给 dn1,dn1 传回客户端,这一轮写入才算成功。
- 所有 Block 写完后,客户端调用 close() 关闭流,最终 NameNode 提交文件,记录元数据。
这套流程里有几个细节新手很容易忽略。一是写失败的容错:如果 dn1 写入中途挂了,客户端会收到异常,NameNode 会把这一个 Block 的管道重新分配,把已写入的副本复制到新节点,最后保证副本数达标,然后把故障节点上的 Block 标记为“待复制”。二是ack 机制保证一致性:只有所有副本节点都写成功了,客户端才收到成功响应,否则就重试,这就避免了“部分节点有新数据、部分节点还停留在旧状态”的混沌局面。
还有一点很多人不知道:HDFS 对已经写入的文件不支持随机修改,只能追加写(append)。这是刻意做的简化,因为大数据场景下“一次写入、多次读取”是常态,允许随机改会造成巨大的复杂度和性能代价。所以设计 HDFS 时干脆把写后不可变作为铁律,代价是使用方式要配合,比如通过分区目录、批处理任务来管理数据的更新,而不是指望像 MySQL 一样 update 一行。
2.3 HDFS读取流程与常用命令
读流程比写简单得多。客户端调用 open(),NameNode 返回文件每个 Block 对应的 DataNode 列表;客户端就近挑选一个副本节点建立连接,开始流式读取数据。这里“就近”体现在两个层面:如果客户端就在某台 DataNode 上并且正好持有该 Block 的副本,那就本地读;否则优先选择同机架的副本,减少跨机架带宽消耗。
命令层面,我平时用得最多的就这几个:
# 列出目录 hdfs dfs -ls /data # 上传本地文件到 HDFS hdfs dfs -put /local/path/file.txt /data/ # 下载到本地 hdfs dfs -get /data/file.txt ./ # 查看文件内容(只能读文本类) hdfs dfs -cat /data/file.txt # 建目录、删目录 hdfs dfs -mkdir -p /data/ods hdfs dfs -rm -r /data/tmp # 查看集群存储概况 hdfs dfsadmin -report # 检查文件块的健康状态 hdfs fsck /data/file.txt -files -blocks -locationsfsck 命令值得多说一句。它常用来排查“某个文件有几个副本、哪个副本丢了、Block 是否损坏”这类问题。但有个安全大坑:HDFS 默认配置下,fsck 操作无需身份认证,任何人只要能访问 NameNode 的 RPC 端口就能执行,这相当于把文件系统的“病历本”敞开给人看。我在后面的常见问题章节会专门讲怎么排查和修复。
3. MapReduce原理与编程实践
3.1 MapReduce核心思想与执行流程
MapReduce 的核心思想就四个字:分而治之。一个大数据集的计算问题,拆成可以在不同机器上并行处理的小任务,最后把中间结果汇总成最终结果。
以最经典的 WordCount 为例。输入是一堆文本文件,每个文件按行切分,Map 阶段每读到一个单词就输出<单词, 1>,Reduce 阶段把相同单词的 1 全部加起来得到总次数。看起来简单对吧?但中间隔着的那条“Shuffle 传送带”,才是整个框架最复杂的部分。
Shuffle 发生在 Map 输出之后、Reduce 输入之前,笼统分四步:
- 分区(Partition):每条 Map 输出的键值对根据 key 的哈希值决定进入哪个 Reduce 分区。默认分区器是 HashPartitioner,公式是
(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks。你可以自定义,比如按年份分区、按地域分区。 - 排序(Sort):每个分区内部的键按字典序排序。这个排序是 MapReduce 的骨架性设计:有了全局有序,Reduce 端做合并和分组就非常轻松。
- 溢写(Spill):Map 输出不是无限堆在内存里的,环形缓冲区默认 100MB,写到 80% 就开始溢写本地磁盘,先分区排序再合并成一个文件。溢写过程中还有可选的 Combiner,相当于在 Map 端做一次局部合并,能显著减少网络传输量。
- 归并(Merge):Reduce 端会从多个 Map 任务拉取属于自己分区的数据,做多路归并,形成有序的 key 集合。拉数据时有并发拉取上限,默认 5 个并行,调大这个参数能加快 shuffle 但会增加内存压力。
整个过程可以用一句话概括:Map 负责粗加工,Shuffle 负责分拣运送,Reduce 负责精加工。
3.2 一个完整的MapReduce编程实例
动手写一个完整可跑的 WordCount Java 作业,胜过看十遍原理文档。下面这个例子基本是各种课程和实践的“标准答案”,但每一行注释都是我当时踩坑后加的:
import java.io.IOException; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.LongWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.Mapper; import org.apache.hadoop.mapreduce.Reducer; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; public class WordCount { public static class TokenizerMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private final static IntWritable one = new IntWritable(1); private Text word = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // key 是行偏移量,value 是整行文本 String[] words = value.toString().split("\\s+"); for (String w : words) { if (w.isEmpty()) { continue; } word.set(w); context.write(word, one); } } } public static class IntSumReducer extends Reducer<Text, IntWritable, Text, IntWritable> { private IntWritable result = new IntWritable(); @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable val : values) { sum += val.get(); } result.set(sum); context.write(key, result); } } public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "word count"); job.setJarByClass(WordCount.class); job.setMapperClass(TokenizerMapper.class); // 这里注意:Combiner 和 Reducer 复用同一个类, // 在 Map 端先局部求和,能大幅减少网络 IO。 job.setCombinerClass(IntSumReducer.class); job.setReducerClass(IntSumReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }几个容易被新手下看的地方:
- Writable 序列化:Hadoop 不用 Java 自带 Serializable,而是自己搞了一套 Writable,机制更轻量,序列化结果更紧凑,适合网络传输和磁盘写入。IntWritable、Text、LongWritable 这些类对应 Java 的 Integer、String、Long。
- Combiner 别乱用:Combiner 不是啥都能当。它必须满足一个条件:重复作用于数据子集的结果,和作用于全量数据集的结果一致。求和、求最大最小没问题,但有状态依赖的计算,比如全局平均数,用了 Combiner 就会算出错的数字。
- 输出目录不能已存在:输出目录如果已存在,作业会直接报错,这是 Hadoop API 故意设的保护,防止你误覆盖别人的结果。跑重复任务记得先
hdfs dfs -rm -r /output。
3.3 排序、分组排序、倒排索引专题
排序是 MapReduce 里最容易翻车也最常考的点,很多实训作业都围绕它展开,比如自定义排序、分组排序、倒排索引。
先说自定义排序。默认排序按 key 的字典序来,但业务里经常要按数字、按多个字段复合排序。比如要按“销售额降序、地区升序”来排,就得自定义一个 key 类,实现 WritableComparable 接口:
public class SalesKey implements WritableComparable<SalesKey> { private double amount; private String region; @Override public int compareTo(SalesKey o) { // 金额大的排前面,金额相同按地区字典序 int res = Double.compare(o.amount, this.amount); if (res != 0) return res; return this.region.compareTo(o.region); } // write() 和 readFields() 按对应字段序列化 }自定义 key 时记住两个要点:compareTo 决定排序逻辑,write/readFields 的字段顺序与序列化顺序必须一致。
然后说分组排序。这里的“分组”指的是 Reduce 端将哪些 key 视为同一组一起处理。默认按 key 的相等性分组,但有些场景要打破这个规则。最典型的是“每个用户最近的一笔订单”:Map 阶段输出<用户ID, 订单金额>,如果不自定义分组,每个 Reduce 会拿到同一个用户的多条记录,但如何取最大金额?可以自定义 Partition 让同一个用户进同一个 Reduce,再自定义 Comparator 让金额大的排前面,再用自定义 GroupingComparator 让同一个用户分到同一组,然后 Reduce 里只取第一个就是最大金额。三条线配合,才能实现这种“每组取 TopN”的需求。
再提一嘴倒排序索引(很多人直接说倒排索引)。场景是“给定单词,找出它出现在哪些文档里”。Map 阶段输出<单词:文档名, 1>,Reduce 阶段做两件事:同一单词合并,堆一个文档列表;然后得考虑全局排序的问题——这时通常要二次优化,把所有结果按单词字典序排。实现思路是在 Reducer 里写到 Context 时,key 换成纯单词加一个全排序技巧(比如在单词前拼上某个前缀),或者干脆在 Driver 里设置多个 Reduce 然后接受“不同单词各输出一份文件”的结果。实操时我用过一个取巧做法:Map 阶段输出<单词, 文档名>,Reduce 阶段把同单词的多个文档名拼成一个逗号分隔的字符串,单词天然字典序排序,就满足倒排需求。关键是想清楚你要的是“文档列表”还是“完整倒排结构”,后者复杂度会陡增。
4. YARN架构与作业调度全流程
4.1 YARN设计思路与核心组件
YARN 的设计动机前面说过:把资源管理和作业调度/监控从 JobTracker 的“大总管”模式里拆出来。拆出来以后,YARN 留下两个核心常驻进程:
- ResourceManager(RM):全局唯一的管理者,负责整个集群的资源管理和作业调度。它只做宏观决策:哪个作业申请多少资源、分配到哪些节点,自己并不直接跑计算任务。
- NodeManager(NM):每个节点一个,负责管理本节点上的资源,监控本节点上运行的容器(Container)状态,定期向 RM 汇报心跳。
还有一个按作业临时出现的角色:ApplicationMaster(AM)。每个作业提交后,RM 会找一台有空闲资源的节点启动一个 AM,这个 AM 全权负责该作业的生命周期:向 RM 申请容器、把这些容器分配给作业内部的 Map/Reduce 任务、监控任务进度、失败重试、最终清理收尾。AM 是 YARN 最重要的抽象,它让不同计算框架能在 YARN 上“各自为政”:MapReduce 有 MRAppMaster,Spark 有 SparkContext 作为 AM 近似物。
Container 也值得说清楚。它不是 Docker 容器,而是 YARN 对资源的一个抽象单位:一段内存 + CPU 核心数 + 本地磁盘配额。一个作业的多个任务独享各自的 Container,互不干扰。调优时,你要根据机器总内存和 CPU 核数,规划一个 Container 给多大内存、集群最多能同时跑多少 Container。
4.2 作业提交到YARN的完整流程
这一步面试必考,也是理解 YARN“双层调度”的关键。你输完hadoop jar wordcount.jar input output之后,背后发生了这些事:
- 客户端创建 Job 对象,向 RM 提交作业。RM 收到请求后,返回一个提交路径(通常在 HDFS 的
/tmp/hadoop-yarn/staging/下),客户端把作业的 jar 包、配置文件、输入分片信息上传到该路径。 - RM 将作业放入调度队列,等待某个 NM 上出现可用资源。
- RM 在某个 NM 上启动一个 Container,在里面拉起该作业的 ApplicationMaster(MRAppMaster)。
- AM 启动后,先从 HDFS 下载作业描述,然后根据输入分片数量等信息反推需要多少 Map 任务和 Reduce 任务(Map 数一般等于输入分片数,Reduce 数由客户端参数或代码指定)。
- AM 向 RM 发送资源申请请求,RM 根据当前集群资源和队列调度策略,把空闲 Container 分配给 AM。
- AM 拿到 Container 列表后,让对应的 NM 启动 Container,在 Container 里运行具体的 MapTask 和 ReduceTask。
- 每个任务运行期间,Task 通过 AM 的进度心跳上报状态;AM 汇总进度上报给 RM,RM 同时把进度回传给客户端。
- 所有任务结束后,AM 向 RM 注销自己,释放全部 Container,作业完成。
这里面有两层“申请—分发”:作业级由客户端到 RM,任务级由 AM 到 RM。这也是 YARN 和旧式 JobTracker 最大的不同,任务调度不再集中于单一节点,而是分散到每个作业的 AM 上,彻底解决了调度瓶颈。
4.3 调度器与资源分配
RM 里有三个调度器,配置在yarn-site.xml的yarn.resourcemanager.scheduler.class里:
| 调度器 | 特点 | 适用场景 |
|---|---|---|
| FIFO(先进先出) | 按提交顺序排队,前者不跑完后者不开始 | 单用户、测试环境 |
| 容量调度器(Capacity) | 多队列按比例分配资源,队列内再 FIFO,支持弹性借用 | 多业务线共享集群的默认选择 |
| 公平调度器(Fair) | 所有作业动态平均分配资源,新的作业到来后抢占部分资源给新作业 | 多用户交互式负载,响应要求高 |
实践中企业集群几乎都用容量调度器。比如 dev、report、etl 三个队列分别占比 30%、30%、40%,互不饿死。队列之间可以弹性借用空闲资源,但一旦原队列作业来了,借出去的资源会被收回。配置队列时两个坑常踩:一是忘了给默认队列default设 ACL,导致所有人提到默认队列里挤;二是队列配置写错格式(比如少了分号),RM 直接起不来,排错时先看 RM 日志里有没有配置解析异常。
调优方面,一个常见公式是:每个 NodeManager 内存 / 容器内存 = 单节点最大容器数,乘以节点数就是集群最大并发容器数。yarn.nodemanager.resource.memory-mb设得太大或太小都会出问题:太大导致 NodeManager 预留了过多内存给 YARN,留给操作系统的内存太少容易触发 OOM;太小则浪费机器资源。一般给系统留 8-16GB 备用,剩下的交给 YARN。
5. 三件套如何串联:一条数据的完整旅程
前面对每个组件分别讲了原理,但实际工作中,一次任务往往是三个组件协同工作。我拿一个非常常见的场景来走一遍完整链路:统计某天网站日志里访问量最高的 10 个 URL。
第 1 步,数据落地。服务器上的 app 日志按小时滚动,运维脚本把日志文件收集到集群边缘节点,通过hdfs dfs -put上传到 HDFS 的/logs/2025/06/18/目录。文件落库时,HDFS 自动把大文件切块、复制三副本、建立元数据索引。这一层是 HDFS 的工作。
第 2 步,作业提交。你写好 MapReduce 程序,执行hadoop jar topN.jar /logs/2025/06/18/ /result/topn。客户端向 YARN 的 RM 发起会话,上传 jar 和配置。
第 3 步,资源调度。RM 将作业放进容量调度器的队列,等到有容器资源后,在其中一台 NM 上拉起 ApplicationMaster。AM 启动之后,向 RM 申请 30 个 Map 容器和 1 个 Reduce 容器(Map 数由文件分片数决定,比如日志目录下有 30 个 Block 就大约 30 个 Map;Reduce 数我这里显式设为 1,因为要求全局 Top10)。
第 4 步,计算执行。MapTask 逐行读取日志,每解析出一条访问记录就输出<URL, 1>;本地 Combine 先做一轮部分累加,减少网络开销。Shuffle 阶段,所有 Map 输出的<URL, 部分计数>按 URL 哈希分区,只有一个 Reduce 分区,于是全部数据的 URL 经过字典序排序后汇入那个唯一的 ReduceTask。ReduceTask 维护一个大小为 10 的小根堆做 TopN,最后把结果写回 HDFS 的/result/topn/part-r-00000。
第 5 步,收尾。AM 收到所有任务完成通知后,向 RM 注销,释放 Container,客户端 waitForCompletion 返回 true。
整个过程你看出来没有:HDFS 管数据的来和去,YARN 管谁在哪台机器用什么规格的资源跑,MapReduce 管每一条数据如何被加工计算。三层各司其职,像一条流水线上互不越界的三道工序。今后你用 Spark 跑同样的统计,第 4 步换成 Spark 的算子逻辑,剩下 HDFS 和 YARN 的部分几乎原样保留。
6. 实操中的坑与排查心得
6.1 常见问题速查表
| 问题现象 | 可能原因 | 处理思路 |
|---|---|---|
| 作业一直卡在 ACCEPTED | RM 队列资源不足,或队列 ACL 拒绝 | 检查 RM 页面活跃队列、内存使用量;检查 yarn.scheduler.capacity 队列配置 |
| Map 跑了大量任务但每个只处理几十 KB | 小文件太多,一个文件占一个 Block,一个 Block 起一个 Map | 写入前合并小文件(SequenceFile 或 Consolidator),写入后用 hive 或 Spark 做一轮小文件合并 |
| Reduce 阶段极慢,个别 Reduce 处理的数据量远超其他 | 数据倾斜:大量相同 key 进了同一个分区 | 加盐打散热点 key,二次聚合;按业务重新设计分区器 |
| 任务失败,Container 日志显示 OOM | Container 内存设置过小,或 Mapper 内部占用过高 | 调大 yarn.scheduler.minimum-allocation-mb;调 mapreduce.map.memory.mb;优化代码减少对象缓存 |
| 磁盘写满 | 溢写文件太多没清理,或本地目录规划不合理 | 检查 mapreduce.cluster.local.dir 是否分散到多块盘;清理 NM 本地 logs |
| fsck 提示副本数不足 | 某节点宕机时间过长,副本被复制但没完成 | hdfs dfsadmin -safemode leave 检查状态;确认副本策略和机架信息未错配 |
6.2 我的几条实战心得
第一,Map 数量不是越多越好。很多人一上来就把文件切成 1MB 一个小片,Map 跑几千个。每个 Map 启动有开销(JVM 启动、容器申请、任务初始化),Map 数量太多时调度和切换成本甚至超过计算本身。经验值:单个 Map 处理 128MB 到 1GB 之间比较健康,一个 300 个 Map 的作业在 100 节点集群上是正常水平,几百上千的就要警惕是不是小文件问题。
第二,Combiner 绝对是性价比最高的优化。在 WordCount 场景,没有 Combiner 的话,每个 Map 输出的每一个单词都会通过网络传给 Reduce。加了 Combiner,同一个 Map 内的单词先累加一次再传,传输量能下降到几十分之一。我见过一个生产作业,数据量 2TB,加了 Combiner 后作业时间从 3 小时降到 1 小时。付出只是 1 行代码。
第三,看日志顺序有讲究。任务失败时先看 AM 日志里有没有容器被 kill 的记录,比如“Container killed by the ApplicationMaster”,说明内存超了;再看具体任务的 stderr 里有没有 Java 堆栈。很多新手一上来就翻 Yarn 的 RM 日志,方向反了——真正的问题往往在 NodeManager 上的任务日志里。
6.3 fsck未授权风险排查实例
前面提过 fsck 未授权的问题,这里给个具体排查思路。默认情况下,hdfs fsck /path是不需要任何认证的,任何知道 NameNode RPC 地址的人都能跑,这相当于对外暴露了文件系统的块分布、副本状态、机架信息。排查步骤:
- 先确认问题是否存在:在集群外的一台机器上执行
hdfs fsck /试试,如果直接输出结果,说明当前配置确实未收紧。 - 修复方案:在
core-site.xml里配置安全认证机制,比如 Kerberos,或者至少启用hadoop.http.authentication.simple配置来控制 HTTP 接口的访问;对 RPC 层面可以设置dfs.namenode.acls.enabled=true并合理配置队列 ACL 与用户权限。 - 另外就是网络层收紧:NameNode 的 IPC 端口(默认 8020 或 9000)只对集群内部网段开放,不暴露公网;关闭 DFS 的 HTTP 端口或者限制来源 IP。
- 最后复查:配置回滚前先
hdfs dfsadmin -refreshSuperUserGroupsConfiguration让配置生效,再在外网测试一遍,确保不再支持匿名 fsck。
实际排查中,我还碰到过一种情况:fsck 命令确实执行了,但输出显示所有 Block 都是健康,而业务侧读文件却报错。后来发现是客户端拿到了一个已经停机节点的 Block 位置,重试后从其它副本读取成功,这个问题其实就是 NameNode 还没把宕机节点踢出副本提供者列表。解决办法是及时处理节点心跳超时:调整dfs.namenode.heartbeat.recheck-interval和dfs.namenode.stale.datanode.interval,让 NameNode 更快标记失联节点为 stale,客户端就不会再从它那里读。
我个人写了大半年 Hadoop 作业,最大的体会是:不要试图一次把所有参数调好,先把 HDFS 读写流程和 YARN 的任务提交链路走通,再回头调优。你把 Block、心跳、Shuffle 这一个个概念真正在自己电脑上跑一遍就会发现,这些高大上的系统,其实只是把无数工程上的细节“死磕”到了极致。最后再分享一个小技巧:学习阶段千万别开生产集群那么大的副本数,本地伪分布集群用默认配置即可,等你要测机架感知或者做性能压测时,再考虑 3 副本和跨机架部署,否则你排查的数据量和看到的报错,会让新手阶段的你瞬间失去信心。