HDFS 跑得慢,很多时候不是硬件不行,而是默认参数和你的数据特征不匹配。我在一线维护过不少大数据集群,被问到最多的问题基本集中在几类:写入吞吐上不去、小文件太多把 NameNode 内存吃光、读大文件时明明网络带宽很足却还是慢吞吞。下面要聊的这 10 个技巧,全部围绕 HDFS 性能优化和存储效率展开,从块大小、纠删码、短路读,到小文件治理、NameNode 内存调优,覆盖写入链路、读取链路、文件治理、集群运维四个方向。正在维护 HDFS 的工程师可以直接跟着调,刚接触大数据存储的朋友也能通过这篇文章搞懂性能瓶颈到底出在哪里。1. 写入链路优化:让数据落地又快又省1.1 技巧1:按文件规模选块大小,别让 128MB 成为默认答案HDFS 默认块大小是 128MB,但真的不是所有场景都适合。块越大,NameNode 需要维护的 block 对象越少,顺序读时寻道次数也越少;但块太大会导致计算端的并行度下降,因为 Spark 或 MapReduce 的 split 数量通常取决于块数量。换句话说,块大小是"存储管理开销"和"计算并行度"之间的一个权衡。我一般按两个维度来定块大小。第一,文件平均大小。如果单文件普遍在 GB 级以上,比如 Parquet/ORC 分区表、训练样本集,完全可以调到 256MB 甚至 512MB;如果目录里全是几十 MB 的文件,调大块大小意义不大。第二,下游计算引擎的读取方式。用 Spark 读 HDFS 时,一个 block 通常对应一个 partition,块越大分区越少,太大的块在数据倾斜时会让单 task 处理量变得难以接受。改起来很简单,在 hdfs-site.xml 中设置:<property> <name>dfs.blocksize</name> <value>268435456</value> </property>如果只是想对某一次写入生效,也可以不重启集群,直接用命令:hdfs dfs -Ddfs.blocksize=268435456 -put bigfile.bin /data/warehouse/一个容易踩的坑是 NameNode 侧的dfs.namenode.fs-limits.max-blocksize,默认限制最大 1GB。如果想把块调到 1GB 以上,必须连同这个参数一起改,否则客户端配置了也写不进去。另外,块大小只影响新写入的文件,已经落盘的老文件不会自动按新块大小重切,不要以为改完配置存量数据就变了。我自己的实测场景里,把一批单表分区 1TB+ 的 Parquet 数据从 128MB 调到 256MB 后,NameNode 堆内存使用率以肉眼可见的速度下降,同一批读任务的执行时间也有缩短。但如果你数据本身偏小,调大块大小只会让“喂给任务的粒度”变得粗糙,反而可能出现资源空转。1.2 技巧2:冷数据用 EC 纠删码,三副本不是唯一选择三副本机制是 HDFS 可靠性的基石,但代价是 3 倍的物理存储。对很多离线数仓来说,动辄几百 TB 的冷数据、日志归档区还在用三副本,成本非常夸张。HDFS 3.x 原生支持了 EC(纠删码),可以理解成“用 CPU 换存储空间”:数据被拆成多个数据块和校验块,任何一个或几个块丢了,都能靠剩下的块算回来。常用的 EC 策略是 RS-6-3-1024k,相当于把数据切成 6 份、再生成 3 份校验块,容忍任意 3 个块丢失,空间利用率达到 6/9≈66.7%。对比三副本同一份数据要占 3 份物理空间,EC 只需要 1.5 份,存储成本直接降一半。如果容忍度要求低一些,还可以选 RS-3-2-1024k,空间利用率更高,但容错能力也相应变弱。对目录设置 EC 策略:hdfs ec -setPolicy -path /data/cold -policy RS-6-3-1024k hdfs ec -getPolicy -path /data/cold设置之后,该目录下新写入的文件会按 EC 策略存储,老文件不受影响。要把已经存在的三副本数据迁过去,我常用的办法是先建好带 EC 策略的目标目录,再用 distcp 从旧目录拷过去:hdfs distcp hdfs://nameservice/old/cold_data /data/cold但 EC 不是银弹,使用前有几个限制必须心里有数。EC 文件不支持追加写,这对日志类追加型任务很不友好;EC 的随机读性能也差一些,因为某些场景需要读多个块做解码;EC 对 CPU 有额外开销,写入密集或实时查询重的数据不适合用。经验上,最值得先切 EC 的是三类数据:离线备份、历史明细、日志归档。我曾经把 100TB 的冷备区切成 RS-6-3,存储占用下降了接近一半,后台跑 distcp 时 DataNode CPU 确实上去了一些,但完全在可接受范围内。对在线业务数据,我不建议无脑开 EC,尤其是偏向小文件的场景,EC 因为需要额外读取和计算,反而可能更慢。1.3 技巧3:调大写入 Packet,降低 ACK 开销HDFS 写入数据不是一次性把整个 block 发给 DataNode,而是切成很多个小 packet,沿着 DataNode 组成的 pipeline 一级一级转发。默认一个 packet 是 64KB(dfs.client.write.packet.size默认 65536 字节)。每写完一个 packet,客户端要等下游 ACK,packet 越小、数量越多,ACK 的交互次数就越频繁,网络往返和 CPU 处理都会增加。如果网络条件好,比如万兆网卡、延迟低,把 packet 调大到 256KB 通常能提升写入吞吐:<property> <name>dfs.client.write.packet.size</name> <value>262144</value> </property> <property> <name>io.file.buffer.size</name> <value>65536</value> </property>io.file.buffer.size是读写缓冲区的通用配置,默认只有 4KB,对现代硬件来说偏小,调到 64KB 属于低成本高收益的操作。但调大 packet 之前一定要先判断瓶颈。如果集群网络是千兆,或者有交换机丢包、流控问题,大 packet 会让重试成本更高,效果反而更差。我曾经遇到一个“写入慢”的集群,改了 packet 大小毫无起色,最后排查出来是物理交换机的流控策略导致大量丢包重传,问题根本不在 HDFS 参数。另外几个和写入相关的参数也可以一起看:dfs.datanode.synconclose控制在文件 close 的时候是否做 fsync,默认 false,追求高可靠性可以打开,但写入延迟会明显上升;dfs.client.block.write.replace-datanode-on-failure.policy建议保留 DEFAULT,不要轻易改成 NEVER,否则 pipeline 中某个节点出问题时整个写入流程容易失败。2. 读取链路优化:让计算就近拿数据2.1 技巧4:开启短路读,减少本地读的重复拷贝很多 Spark/Hive 任务是和 DataNode 混部在同一批物理机上的。按照 HDFS 普通读路径,一个进程即使跑在数据所在的那台机器上,也要通过 TCP 回环走一遍“本地磁盘 → 内核缓冲区 → 回环 socket → 用户态”的路径,白白浪费一次拷贝和上下文切换。短路读(Short Circuit Read)就是让客户端进程通过 UNIX domain socket 直接访问本地 block 文件的文件描述符,绕开 TCP 回环。开启短路读需要三个配置配合:<property> <name>dfs.client.read.shortcircuit</name> <value>true</value> </property> <property> <name>dfs.domain.socket.path</name> <value>/var/run/hadoop-hdfs/dn.sock</value> </property> <property> <name>dfs.client.read.shortcircuit.skip.checksum</name> <value>false</value> </property>这里skip.checksum我一般建议保持 false。跳过校验确实能省一点 CPU,但如果磁盘出现静默损坏,数据损坏往往要到最后计算阶段才暴露,排查成本极高。这个功能生效的前提是“读本地数据”。如果任务根本没有调度到数据所在节点,短路读完全不发挥作用。另外,/var/run 等目录在系统重启后会被清空,需要确保 socket 文件能被自动创建。如果跑在容器里,还要把宿主机的 socket 路径挂载到容器中,权限也是很容易踩的坑。实际效果方面,我曾经在跑 Spark 离线任务的混部集群上开启短路读,配合数据本地性调度,DataNode 的网络流量明显下降,同一批读任务的 P50 延迟也有改善。对纯存储节点和计算节点分离的架构来说,这个优化基本没意义,配置前先想清楚自己的部署形态。2.2 技巧5:预读和 OS 层 IO 调度,别忽略内核HDFS 参数优化得再漂亮,最终数据还是要通过操作系统去磁盘读取。很多人只盯着 Hadoop 配置,忽略了 Linux 内核层的 IO 设置,结果问题始终解决不了。先看 HDFS 自身的预读。DataNode 有一个dfs.datanode.readahead.bytes参数,默认大约是 4MB。顺序读大文件时,把预读加大到 8MB 甚至 16MB,可以减少磁盘寻道次数,明显提升吞吐;但如果集群里有大量随机读,比如点查某个 row group,预读反而会把宝贵的磁盘带宽浪费在不会用到的数据上,这种时候保持默认甚至调小更合适。再看操作系统层。Linux 的 IO 调度器对机械盘和 SSD 的影响差别很大。老内核常见的 cfq 调度器在多任务并发读写时容易造成 IO 排队,改为 deadline 或 mq-deadline 往往立竿见影。SSD 和 NVMe 盘直接用 none 就好:echo mq-deadline > /sys/block/sda/queue/scheduler文件系统挂载参数也值得检查。确保数据盘使用了 noatime,否则每次读文件都会触发 atime 元数据更新,产生大量额外小写:mount -o remount,noatime /data还有一个容易被忽视的点是脏页回写。sysctl 里的vm.dirty_ratio和vm.dirty_background_ratio如果设置不合适,数据会长时间堆积在 Page Cache,之后集中刷盘时产生写入尖峰,拖慢同一块磁盘上的读请求。经验是,当“HDFS 读慢”问题出现时,我会先看磁盘层面的监控,把 OS 调度策略和 mount 参数排除掉再动 Hadoop 配置。很多所谓 HDFS 读性能差,根子其实在内核 IO 调度,压根不是 HDFS 的锅。2.3 技巧6:数据本地性调度,让任务搬到数据身边这个技巧严格说不是改 HDFS 本身,但对 HDFS 读取性能的影响非常大。HDFS 上数据分块分布在不同节点和机架,计算引擎如果能优先把任务调度到数据所在的节点,读请求就是纯本地读;否则数据就要通过网络跨机架传输,延迟和带宽压力立刻暴露。Spark 的本地性等待时间由spark.locality.wait控制,默认是 3s。如果任务在 3 秒内等不到完全本地的资源,就直接退化为机架本地或任意节点。离线任务如果对延迟不敏感,可以适当调大等待时间:spark.locality.wait 10s spark.locality.wait.node 10s spark.locality.wait.rack 10sYARN 侧也有对应的本地性延迟参数,Fair Scheduler 可以设置yarn.scheduler.fair.locality-delay-node-ms,Capacity Scheduler 则是yarn.scheduler.capacity.node-locality-delay。但需要注意的是,集群繁忙时本地等待并不是越长越好——等待资源的时间也是一种浪费,要结合集群的利用率来权衡。本地性真正生效的前提是机架拓扑正确。HDFS 的副本放置策略依赖机架信息,比如第一副本落在客户端所在节点,第二副本在同机架不同节点,第三副本跨机架。如果集群里所有机器都被识别成同一个 default-rack,NameNode 就失去了做合理放置的依据,调度器也拿不到准确的机架距离。配置机架感知的常规做法是准备一个 topology 脚本:#!/usr/bin/env bash host=$1 case "$host" in node1|node2|node3) echo "/rack1" ;; node4|node5|node6) echo "/rack2" ;; *) echo "/default-rack" ;; esac然后在 core-site.xml 里指定脚本路径。我遇到过 Spark UI 上大量任务处于 RACK_LOCAL 甚至 ANY,跨机架流量把交换机打得喘不过气,最后查下来就是 topology 脚本里对新扩容节点返回了异常机架。处理好机架感知之后,很多任务回到 NODE_LOCAL,读性能和稳定性同时提升。3. 文件与存储治理:从根源减少浪费3.1 技巧7:小文件治理,解决 NameNode 内存危机小文件是 HDFS 存储效率的头号杀手。HDFS 的元数据全部常驻在 NameNode 内存里,每个 block 对象差不多要占 150 字节左右,每个文件对应的 inode 还会额外占用几百字节。粗算一下,100 万个小文件就可能吃掉 NameNode 1GB 以上的堆内存,要知道这些内存是可以容纳上亿 block 的元数据的。小文件的影响不只是内存。每个文件都是一份独立的元数据记录,扫描和读取时 RPC 次数会爆炸式增长;下游计算引擎读一堆小文件时,task 数量也会跟着膨胀,调度和启动开销甚至超过实际计算时间。治理要分两条线。第一条线是写入端预防。Hive、Spark 写结果时打开合并开关:set hive.merge.mapfiles=true; set hive.merge.mapredfiles=true; set hive.merge.size.smallfiles.avgsize=268435456; set hive.merge.smallfiles.avgsize=268435456;Spark 流式任务可以在输出时做 partition 数控制,避免每次微批都生成一堆几十 KB 的文件。第二条线是存量治理。写一个定时任务,把一天内新增的小文件按分区读出来重新 coalesce 成 256MB 左右的大文件。注意只处理“最后修改时间足够久、没有进程正在写入”的文件,比如用 mtime 超过 2 小时作为过滤条件,避免把正在写入的数据打断。HDFS Archive(HAR)也可以把多个小文件打包成一个 har 文件,减少 NameNode 内存占用,但读路径上多了一层转换,很多生态工具不原生支持 har 协议。我在实际项目中不太推荐把 HAR 作为第一选择,直接用 Spark 批量合并大文件更干净。真正解决问题的是让写入端不再持续产生小文件,否则合并的速度永远赶不上产生的速度。3.2 技巧8:压缩与列式存储,存储效率立竿见影HDFS 本身不负责压缩,但它存储的数据如果选对了压缩算法和文件格式,磁盘占用和读取效率能同时提升一个档次。先看压缩算法选型,没有绝对的最佳,只有场景匹配:算法压缩比CPU 开销典型场景Snappy中低在线查询、热数据LZ4较低很低高吞吐、日志实时处理ZSTD中高中日志、大规模离线分析Gzip高高冷归档、很少读取的数据Bzip2很高很高极少使用,压缩太慢单独压缩文本文件时,Snappy、ZSTD 这类“不可切分”算法会影响 MapReduce 的并行度,所以更推荐把数据先转成 Parquet 或 ORC 这种列式存储格式。列式格式本身内部按 block/row group 组织,压缩后的文件依然可以被多任务并行读取,压缩和并行度不冲突。对 Spark 任务,可以这样设置输出压缩:spark.sql.parquet.compression.codec=zstdHive 建表时指定:CREATE TABLE log_analyze (...) STORED AS ORC tblproperties("orc.compress"="ZSTD");我处理过一批按天分区的 JSON 日志表,改成 ORC + ZSTD 后磁盘占用直接下降 65% 左右,离线查询速度也有明显提升。但有一点要提前确认:下游消费组件如果对压缩格式支持不好,可能读不了数据,尤其是自研系统或者老版本的组件,改格式前先做一轮兼容性验证,别只盯着磁盘节省。3.3 技巧9:热温冷分层存储,给数据安排合适的家HDFS 3.x 引入了存储策略,可以把不同目录的数据放在不同的存储介质上。物理层面,DataNode 需要在dfs.datanode.data.dir里标明每个目录的存储类型:<property> <name>dfs.datanode.data.dir</name> <value>[SSD]file:///data/ssd1,[DISK]file:///data/disk1,[ARCHIVE]file:///data/archive1</value> </property>然后对业务目录设置策略:hdfs storagepolicies -setStoragePolicy -path /data/hot -policy ALL_SSD hdfs storagepolicies -setStoragePolicy -path /data/cold -policy COLD hdfs mover -p /data/hotCOLD 通常对应 ARCHIVE 存储类型,适合配合 EC 一起使用,把冷数据既做纠删码又放到低成本磁盘上。后台的 mover 进程负责把数据从一种存储类型迁移到另一种。但从我个人经验看,分层存储适合异构硬件明显的集群,比如既有便宜的机械盘、又有少量 SSD。如果集群本身就是全 SSD 环境,硬要搞分层收益不大,反而增加运维复杂度。更值得优先做的是把 NameNode 的 edit log 和 JournalNode 放到 SSD 上,这部分元数据操作延迟对整个集群的稳定性和响应速度影响更大,先把这笔投入花出去,往往比给业务数据分层更划算。4. 集群级参数与运维调优:NameNode 之外也要看均衡4.1 技巧10:NameNode 内存、GC 与 RPC 参数NameNode 是 HDFS 的“大脑”,所有文件路径、block 映射、副本位置都保存在它的堆内存里。元数据规模直接决定 NameNode 需要多大堆,经验上可以按“100 万 block 大约需要 1GB 堆内存”来粗估,再加上文件 inode 和其他内部结构,实际建议预留 50% 的余量。如果发现 NameNode 堆经常逼近上限、Full GC 频繁,不要只想着加内存。JVM GC 调优更关键。在 hadoop-env.sh 中给 NameNode 进程加 G1 参数是一个比较稳的操作:export HADOOP_NAMENODE_OPTS="-Xms32g -Xmx32g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"用jstat -gcutil <pid>可以实时观察 GC 情况,看 FGCT 是否持续上涨。如果 Full GC 频率高、单次停顿时间长,集群的 RPC 必然会跟着超时,因为 NameNode 在 GC 期间无法响应任何请求。另一个常被误调的参数是dfs.namenode.handler.count。默认值是 10,大集群通常会调大到几十。但这不代表越大越好,handler 线程多了之后,线程上下文切换和锁竞争会成为新的瓶颈。我见过一个环境把 handler 从 20 调到 128,RPC 超时更严重了,退回到 40 才稳定。正确的做法是先看 NameNode 的 RPC 队列指标,如果CallQueueLength一直很高,再逐步往上加,同时观察 GC 和 CPU,不要一上来就拍一个很大的数。NameNode 堆内存真正的下限是整个集群能管理多少 block,这也是为什么技巧 1 的块大小和技巧 7 的小文件治理都直接关系到 NameNode 容量。调参数之前先用量化数据说话,不要凭感觉。4.2 数据倾斜与均衡实操集群新加了节点之后,新的 DataNode 磁盘是空的,存量节点的数据不会自动搬过去,整体负载必然倾斜。这种场景最直接的优化不是调某个参数,而是把数据重新均衡。Balancer 是 HDFS 自带的均衡工具。但默认的迁移带宽很低,只有 1MB/s,不调的话大集群的均衡能跑一两个星期。先提高带宽:hdfs dfsadmin -setBalancerBandwidth 104857600再启动 balancer:hdfs balancer -threshold 10-threshold 10表示节点磁盘使用率与集群平均值的偏差控制在 10% 以内。阈值不要设太低,否则 balancer 会陷入无意义的“搬过去、搬回来”,一直在后台空转。5% 到 10% 是一个比较合理的区间。HDFS 3.x 还提供了 DiskBalancer,解决的是单个 DataNode 内部多个磁盘之间数据分布不均的问题。可以生成迁移计划再执行:hdfs diskbalancer -plan datanode01 hdfs diskbalancer -execute plan.json均衡操作本质上是在集群内部搬数据,一定占用网络和磁盘 IO。我把这类任务都安排在业务低峰期执行,并且根据集群繁忙程度动态调整带宽,避免为了均衡数据把在线任务拖垮。5. 常见问题与排查实录5.1 写入慢、block 迟迟没有 ACK先看是不是某个 DataNode 成为瓶颈。写入是 pipeline 模式,最慢的节点决定整条链路的速度。如果日志里大量出现Slow BlockReceiver,八九不离十是磁盘或网络问题。处理顺序建议:先看该 DataNode 的磁盘 util 是否持续打满,排除坏道或磁盘老化。再看网络指标,确认没有丢包重传。最后才轮到调 packet 大小、synconclose 等 HDFS 参数。如果真的定位到某块磁盘 IO 异常,优先把该盘上的 block 迁移走,而不是重启节点。5.2 读取延迟高,本地读与远端读差异大先判断读请求到底走的是本地还是远端。如果大部分是远端读,看机架感知拓扑配置是否正确、Spark 任务的本地性比例是否偏低;如果本地读也慢,考虑短路读是否生效、OS 层预读和 IO 调度器是否合适。有一个快速验证手段:用hdfs dfs -cat直接读一个文件并观察耗时,再对比把客户端显式绑定到本地节点的耗时差异。如果差异巨大,说明问题基本集中在网络路径或者本地读优化没有生效。5.3 小文件总反弹?给写入端加约束小文件治理最怕“今天合并、明天再生”。只要写入端还在产生小文件,存量治理就永远跟不上。我见过最典型的情况是某些流式任务为了追求低延迟,每个微批都写几个小文件,日积月累目录里全是 KB 级文件。根治方法是在写入端加约束:Spark 流任务控制输出 partition 数,Hive 开 merge,Kafka 落 HDFS 前做攒批。做不到这些的话,至少要靠脚本监控目录下的文件量,设置告警,别等 NameNode 告警了才意识到问题。下面是一个简单的问题速查表,方便日常排查时对照:现象优先排查点常用手段写入吞吐低DataNode 磁盘 IO、网络丢包、packet 大小调整 dfs.client.write.packet.size、检查网卡流控读大文件很慢OS 层预读、IO 调度器、短路读调整 dfs.datanode.readahead.bytes、改 mq-deadline/noatimeNameNode 内存飙升block 数量、小文件比例、堆内存设置小文件合并、调整块大小、G1 GCRPC 超时频发NameNode GC、handler 数量看 FGCT、调 dfs.namenode.handler.count节点磁盘使用不均新节点扩容、数据分布balancer、diskbalancer跨机架流量大机架感知配置、本地性比例检查 topology 脚本、调本地性等待时间我自己最深刻的体会是:HDFS 调优不是一个孤立的参数游戏,而是把写入链路、读取链路、文件治理、NameNode 容量这四件事放到一起看。每改一个参数之前,先记录当前监控数据,改完再对比,否则你根本分不清是优化起了作用还是业务负载本身在波动。如果你手头也有集群遇到类似问题,建议先从块大小和 NameNode GC 这两个方向入手,它们门槛低、见效快,也是最能帮你建立“调优手感”的切入点。
HDFS 跑得慢,很多时候不是硬件不行,而是默认参数和你的数据特征不匹配。我在一线维护过不少大数据集群,被问到最多的问题基本集中在几类:写入吞吐上不去、小文件太多把 NameNode 内存吃光、读大文件时明明网络带宽很足却还是慢吞吞。下面要聊的这 10 个技巧,全部围绕 HDFS 性能优化和存储效率展开,从块大小、纠删码、短路读,到小文件治理、NameNode 内存调优,覆盖写入链路、读取链路、文件治理、集群运维四个方向。正在维护 HDFS 的工程师可以直接跟着调,刚接触大数据存储的朋友也能通过这篇文章搞懂性能瓶颈到底出在哪里。1. 写入链路优化:让数据落地又快又省1.1 技巧1:按文件规模选块大小,别让 128MB 成为默认答案HDFS 默认块大小是 128MB,但真的不是所有场景都适合。块越大,NameNode 需要维护的 block 对象越少,顺序读时寻道次数也越少;但块太大会导致计算端的并行度下降,因为 Spark 或 MapReduce 的 split 数量通常取决于块数量。换句话说,块大小是"存储管理开销"和"计算并行度"之间的一个权衡。我一般按两个维度来定块大小。第一,文件平均大小。如果单文件普遍在 GB 级以上,比如 Parquet/ORC 分区表、训练样本集,完全可以调到 256MB 甚至 512MB;如果目录里全是几十 MB 的文件,调大块大小意义不大。第二,下游计算引擎的读取方式。用 Spark 读 HDFS 时,一个 block 通常对应一个 partition,块越大分区越少,太大的块在数据倾斜时会让单 task 处理量变得难以接受。改起来很简单,在 hdfs-site.xml 中设置:<property> <name>dfs.blocksize</name> <value>268435456</value> </property>如果只是想对某一次写入生效,也可以不重启集群,直接用命令:hdfs dfs -Ddfs.blocksize=268435456 -put bigfile.bin /data/warehouse/一个容易踩的坑是 NameNode 侧的dfs.namenode.fs-limits.max-blocksize,默认限制最大 1GB。如果想把块调到 1GB 以上,必须连同这个参数一起改,否则客户端配置了也写不进去。另外,块大小只影响新写入的文件,已经落盘的老文件不会自动按新块大小重切,不要以为改完配置存量数据就变了。我自己的实测场景里,把一批单表分区 1TB+ 的 Parquet 数据从 128MB 调到 256MB 后,NameNode 堆内存使用率以肉眼可见的速度下降,同一批读任务的执行时间也有缩短。但如果你数据本身偏小,调大块大小只会让“喂给任务的粒度”变得粗糙,反而可能出现资源空转。1.2 技巧2:冷数据用 EC 纠删码,三副本不是唯一选择三副本机制是 HDFS 可靠性的基石,但代价是 3 倍的物理存储。对很多离线数仓来说,动辄几百 TB 的冷数据、日志归档区还在用三副本,成本非常夸张。HDFS 3.x 原生支持了 EC(纠删码),可以理解成“用 CPU 换存储空间”:数据被拆成多个数据块和校验块,任何一个或几个块丢了,都能靠剩下的块算回来。常用的 EC 策略是 RS-6-3-1024k,相当于把数据切成 6 份、再生成 3 份校验块,容忍任意 3 个块丢失,空间利用率达到 6/9≈66.7%。对比三副本同一份数据要占 3 份物理空间,EC 只需要 1.5 份,存储成本直接降一半。如果容忍度要求低一些,还可以选 RS-3-2-1024k,空间利用率更高,但容错能力也相应变弱。对目录设置 EC 策略:hdfs ec -setPolicy -path /data/cold -policy RS-6-3-1024k hdfs ec -getPolicy -path /data/cold设置之后,该目录下新写入的文件会按 EC 策略存储,老文件不受影响。要把已经存在的三副本数据迁过去,我常用的办法是先建好带 EC 策略的目标目录,再用 distcp 从旧目录拷过去:hdfs distcp hdfs://nameservice/old/cold_data /data/cold但 EC 不是银弹,使用前有几个限制必须心里有数。EC 文件不支持追加写,这对日志类追加型任务很不友好;EC 的随机读性能也差一些,因为某些场景需要读多个块做解码;EC 对 CPU 有额外开销,写入密集或实时查询重的数据不适合用。经验上,最值得先切 EC 的是三类数据:离线备份、历史明细、日志归档。我曾经把 100TB 的冷备区切成 RS-6-3,存储占用下降了接近一半,后台跑 distcp 时 DataNode CPU 确实上去了一些,但完全在可接受范围内。对在线业务数据,我不建议无脑开 EC,尤其是偏向小文件的场景,EC 因为需要额外读取和计算,反而可能更慢。1.3 技巧3:调大写入 Packet,降低 ACK 开销HDFS 写入数据不是一次性把整个 block 发给 DataNode,而是切成很多个小 packet,沿着 DataNode 组成的 pipeline 一级一级转发。默认一个 packet 是 64KB(dfs.client.write.packet.size默认 65536 字节)。每写完一个 packet,客户端要等下游 ACK,packet 越小、数量越多,ACK 的交互次数就越频繁,网络往返和 CPU 处理都会增加。如果网络条件好,比如万兆网卡、延迟低,把 packet 调大到 256KB 通常能提升写入吞吐:<property> <name>dfs.client.write.packet.size</name> <value>262144</value> </property> <property> <name>io.file.buffer.size</name> <value>65536</value> </property>io.file.buffer.size是读写缓冲区的通用配置,默认只有 4KB,对现代硬件来说偏小,调到 64KB 属于低成本高收益的操作。但调大 packet 之前一定要先判断瓶颈。如果集群网络是千兆,或者有交换机丢包、流控问题,大 packet 会让重试成本更高,效果反而更差。我曾经遇到一个“写入慢”的集群,改了 packet 大小毫无起色,最后排查出来是物理交换机的流控策略导致大量丢包重传,问题根本不在 HDFS 参数。另外几个和写入相关的参数也可以一起看:dfs.datanode.synconclose控制在文件 close 的时候是否做 fsync,默认 false,追求高可靠性可以打开,但写入延迟会明显上升;dfs.client.block.write.replace-datanode-on-failure.policy建议保留 DEFAULT,不要轻易改成 NEVER,否则 pipeline 中某个节点出问题时整个写入流程容易失败。2. 读取链路优化:让计算就近拿数据2.1 技巧4:开启短路读,减少本地读的重复拷贝很多 Spark/Hive 任务是和 DataNode 混部在同一批物理机上的。按照 HDFS 普通读路径,一个进程即使跑在数据所在的那台机器上,也要通过 TCP 回环走一遍“本地磁盘 → 内核缓冲区 → 回环 socket → 用户态”的路径,白白浪费一次拷贝和上下文切换。短路读(Short Circuit Read)就是让客户端进程通过 UNIX domain socket 直接访问本地 block 文件的文件描述符,绕开 TCP 回环。开启短路读需要三个配置配合:<property> <name>dfs.client.read.shortcircuit</name> <value>true</value> </property> <property> <name>dfs.domain.socket.path</name> <value>/var/run/hadoop-hdfs/dn.sock</value> </property> <property> <name>dfs.client.read.shortcircuit.skip.checksum</name> <value>false</value> </property>这里skip.checksum我一般建议保持 false。跳过校验确实能省一点 CPU,但如果磁盘出现静默损坏,数据损坏往往要到最后计算阶段才暴露,排查成本极高。这个功能生效的前提是“读本地数据”。如果任务根本没有调度到数据所在节点,短路读完全不发挥作用。另外,/var/run 等目录在系统重启后会被清空,需要确保 socket 文件能被自动创建。如果跑在容器里,还要把宿主机的 socket 路径挂载到容器中,权限也是很容易踩的坑。实际效果方面,我曾经在跑 Spark 离线任务的混部集群上开启短路读,配合数据本地性调度,DataNode 的网络流量明显下降,同一批读任务的 P50 延迟也有改善。对纯存储节点和计算节点分离的架构来说,这个优化基本没意义,配置前先想清楚自己的部署形态。2.2 技巧5:预读和 OS 层 IO 调度,别忽略内核HDFS 参数优化得再漂亮,最终数据还是要通过操作系统去磁盘读取。很多人只盯着 Hadoop 配置,忽略了 Linux 内核层的 IO 设置,结果问题始终解决不了。先看 HDFS 自身的预读。DataNode 有一个dfs.datanode.readahead.bytes参数,默认大约是 4MB。顺序读大文件时,把预读加大到 8MB 甚至 16MB,可以减少磁盘寻道次数,明显提升吞吐;但如果集群里有大量随机读,比如点查某个 row group,预读反而会把宝贵的磁盘带宽浪费在不会用到的数据上,这种时候保持默认甚至调小更合适。再看操作系统层。Linux 的 IO 调度器对机械盘和 SSD 的影响差别很大。老内核常见的 cfq 调度器在多任务并发读写时容易造成 IO 排队,改为 deadline 或 mq-deadline 往往立竿见影。SSD 和 NVMe 盘直接用 none 就好:echo mq-deadline > /sys/block/sda/queue/scheduler文件系统挂载参数也值得检查。确保数据盘使用了 noatime,否则每次读文件都会触发 atime 元数据更新,产生大量额外小写:mount -o remount,noatime /data还有一个容易被忽视的点是脏页回写。sysctl 里的vm.dirty_ratio和vm.dirty_background_ratio如果设置不合适,数据会长时间堆积在 Page Cache,之后集中刷盘时产生写入尖峰,拖慢同一块磁盘上的读请求。经验是,当“HDFS 读慢”问题出现时,我会先看磁盘层面的监控,把 OS 调度策略和 mount 参数排除掉再动 Hadoop 配置。很多所谓 HDFS 读性能差,根子其实在内核 IO 调度,压根不是 HDFS 的锅。2.3 技巧6:数据本地性调度,让任务搬到数据身边这个技巧严格说不是改 HDFS 本身,但对 HDFS 读取性能的影响非常大。HDFS 上数据分块分布在不同节点和机架,计算引擎如果能优先把任务调度到数据所在的节点,读请求就是纯本地读;否则数据就要通过网络跨机架传输,延迟和带宽压力立刻暴露。Spark 的本地性等待时间由spark.locality.wait控制,默认是 3s。如果任务在 3 秒内等不到完全本地的资源,就直接退化为机架本地或任意节点。离线任务如果对延迟不敏感,可以适当调大等待时间:spark.locality.wait 10s spark.locality.wait.node 10s spark.locality.wait.rack 10sYARN 侧也有对应的本地性延迟参数,Fair Scheduler 可以设置yarn.scheduler.fair.locality-delay-node-ms,Capacity Scheduler 则是yarn.scheduler.capacity.node-locality-delay。但需要注意的是,集群繁忙时本地等待并不是越长越好——等待资源的时间也是一种浪费,要结合集群的利用率来权衡。本地性真正生效的前提是机架拓扑正确。HDFS 的副本放置策略依赖机架信息,比如第一副本落在客户端所在节点,第二副本在同机架不同节点,第三副本跨机架。如果集群里所有机器都被识别成同一个 default-rack,NameNode 就失去了做合理放置的依据,调度器也拿不到准确的机架距离。配置机架感知的常规做法是准备一个 topology 脚本:#!/usr/bin/env bash host=$1 case "$host" in node1|node2|node3) echo "/rack1" ;; node4|node5|node6) echo "/rack2" ;; *) echo "/default-rack" ;; esac然后在 core-site.xml 里指定脚本路径。我遇到过 Spark UI 上大量任务处于 RACK_LOCAL 甚至 ANY,跨机架流量把交换机打得喘不过气,最后查下来就是 topology 脚本里对新扩容节点返回了异常机架。处理好机架感知之后,很多任务回到 NODE_LOCAL,读性能和稳定性同时提升。3. 文件与存储治理:从根源减少浪费3.1 技巧7:小文件治理,解决 NameNode 内存危机小文件是 HDFS 存储效率的头号杀手。HDFS 的元数据全部常驻在 NameNode 内存里,每个 block 对象差不多要占 150 字节左右,每个文件对应的 inode 还会额外占用几百字节。粗算一下,100 万个小文件就可能吃掉 NameNode 1GB 以上的堆内存,要知道这些内存是可以容纳上亿 block 的元数据的。小文件的影响不只是内存。每个文件都是一份独立的元数据记录,扫描和读取时 RPC 次数会爆炸式增长;下游计算引擎读一堆小文件时,task 数量也会跟着膨胀,调度和启动开销甚至超过实际计算时间。治理要分两条线。第一条线是写入端预防。Hive、Spark 写结果时打开合并开关:set hive.merge.mapfiles=true; set hive.merge.mapredfiles=true; set hive.merge.size.smallfiles.avgsize=268435456; set hive.merge.smallfiles.avgsize=268435456;Spark 流式任务可以在输出时做 partition 数控制,避免每次微批都生成一堆几十 KB 的文件。第二条线是存量治理。写一个定时任务,把一天内新增的小文件按分区读出来重新 coalesce 成 256MB 左右的大文件。注意只处理“最后修改时间足够久、没有进程正在写入”的文件,比如用 mtime 超过 2 小时作为过滤条件,避免把正在写入的数据打断。HDFS Archive(HAR)也可以把多个小文件打包成一个 har 文件,减少 NameNode 内存占用,但读路径上多了一层转换,很多生态工具不原生支持 har 协议。我在实际项目中不太推荐把 HAR 作为第一选择,直接用 Spark 批量合并大文件更干净。真正解决问题的是让写入端不再持续产生小文件,否则合并的速度永远赶不上产生的速度。3.2 技巧8:压缩与列式存储,存储效率立竿见影HDFS 本身不负责压缩,但它存储的数据如果选对了压缩算法和文件格式,磁盘占用和读取效率能同时提升一个档次。先看压缩算法选型,没有绝对的最佳,只有场景匹配:算法压缩比CPU 开销典型场景Snappy中低在线查询、热数据LZ4较低很低高吞吐、日志实时处理ZSTD中高中日志、大规模离线分析Gzip高高冷归档、很少读取的数据Bzip2很高很高极少使用,压缩太慢单独压缩文本文件时,Snappy、ZSTD 这类“不可切分”算法会影响 MapReduce 的并行度,所以更推荐把数据先转成 Parquet 或 ORC 这种列式存储格式。列式格式本身内部按 block/row group 组织,压缩后的文件依然可以被多任务并行读取,压缩和并行度不冲突。对 Spark 任务,可以这样设置输出压缩:spark.sql.parquet.compression.codec=zstdHive 建表时指定:CREATE TABLE log_analyze (...) STORED AS ORC tblproperties("orc.compress"="ZSTD");我处理过一批按天分区的 JSON 日志表,改成 ORC + ZSTD 后磁盘占用直接下降 65% 左右,离线查询速度也有明显提升。但有一点要提前确认:下游消费组件如果对压缩格式支持不好,可能读不了数据,尤其是自研系统或者老版本的组件,改格式前先做一轮兼容性验证,别只盯着磁盘节省。3.3 技巧9:热温冷分层存储,给数据安排合适的家HDFS 3.x 引入了存储策略,可以把不同目录的数据放在不同的存储介质上。物理层面,DataNode 需要在dfs.datanode.data.dir里标明每个目录的存储类型:<property> <name>dfs.datanode.data.dir</name> <value>[SSD]file:///data/ssd1,[DISK]file:///data/disk1,[ARCHIVE]file:///data/archive1</value> </property>然后对业务目录设置策略:hdfs storagepolicies -setStoragePolicy -path /data/hot -policy ALL_SSD hdfs storagepolicies -setStoragePolicy -path /data/cold -policy COLD hdfs mover -p /data/hotCOLD 通常对应 ARCHIVE 存储类型,适合配合 EC 一起使用,把冷数据既做纠删码又放到低成本磁盘上。后台的 mover 进程负责把数据从一种存储类型迁移到另一种。但从我个人经验看,分层存储适合异构硬件明显的集群,比如既有便宜的机械盘、又有少量 SSD。如果集群本身就是全 SSD 环境,硬要搞分层收益不大,反而增加运维复杂度。更值得优先做的是把 NameNode 的 edit log 和 JournalNode 放到 SSD 上,这部分元数据操作延迟对整个集群的稳定性和响应速度影响更大,先把这笔投入花出去,往往比给业务数据分层更划算。4. 集群级参数与运维调优:NameNode 之外也要看均衡4.1 技巧10:NameNode 内存、GC 与 RPC 参数NameNode 是 HDFS 的“大脑”,所有文件路径、block 映射、副本位置都保存在它的堆内存里。元数据规模直接决定 NameNode 需要多大堆,经验上可以按“100 万 block 大约需要 1GB 堆内存”来粗估,再加上文件 inode 和其他内部结构,实际建议预留 50% 的余量。如果发现 NameNode 堆经常逼近上限、Full GC 频繁,不要只想着加内存。JVM GC 调优更关键。在 hadoop-env.sh 中给 NameNode 进程加 G1 参数是一个比较稳的操作:export HADOOP_NAMENODE_OPTS="-Xms32g -Xmx32g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"用jstat -gcutil <pid>可以实时观察 GC 情况,看 FGCT 是否持续上涨。如果 Full GC 频率高、单次停顿时间长,集群的 RPC 必然会跟着超时,因为 NameNode 在 GC 期间无法响应任何请求。另一个常被误调的参数是dfs.namenode.handler.count。默认值是 10,大集群通常会调大到几十。但这不代表越大越好,handler 线程多了之后,线程上下文切换和锁竞争会成为新的瓶颈。我见过一个环境把 handler 从 20 调到 128,RPC 超时更严重了,退回到 40 才稳定。正确的做法是先看 NameNode 的 RPC 队列指标,如果CallQueueLength一直很高,再逐步往上加,同时观察 GC 和 CPU,不要一上来就拍一个很大的数。NameNode 堆内存真正的下限是整个集群能管理多少 block,这也是为什么技巧 1 的块大小和技巧 7 的小文件治理都直接关系到 NameNode 容量。调参数之前先用量化数据说话,不要凭感觉。4.2 数据倾斜与均衡实操集群新加了节点之后,新的 DataNode 磁盘是空的,存量节点的数据不会自动搬过去,整体负载必然倾斜。这种场景最直接的优化不是调某个参数,而是把数据重新均衡。Balancer 是 HDFS 自带的均衡工具。但默认的迁移带宽很低,只有 1MB/s,不调的话大集群的均衡能跑一两个星期。先提高带宽:hdfs dfsadmin -setBalancerBandwidth 104857600再启动 balancer:hdfs balancer -threshold 10-threshold 10表示节点磁盘使用率与集群平均值的偏差控制在 10% 以内。阈值不要设太低,否则 balancer 会陷入无意义的“搬过去、搬回来”,一直在后台空转。5% 到 10% 是一个比较合理的区间。HDFS 3.x 还提供了 DiskBalancer,解决的是单个 DataNode 内部多个磁盘之间数据分布不均的问题。可以生成迁移计划再执行:hdfs diskbalancer -plan datanode01 hdfs diskbalancer -execute plan.json均衡操作本质上是在集群内部搬数据,一定占用网络和磁盘 IO。我把这类任务都安排在业务低峰期执行,并且根据集群繁忙程度动态调整带宽,避免为了均衡数据把在线任务拖垮。5. 常见问题与排查实录5.1 写入慢、block 迟迟没有 ACK先看是不是某个 DataNode 成为瓶颈。写入是 pipeline 模式,最慢的节点决定整条链路的速度。如果日志里大量出现Slow BlockReceiver,八九不离十是磁盘或网络问题。处理顺序建议:先看该 DataNode 的磁盘 util 是否持续打满,排除坏道或磁盘老化。再看网络指标,确认没有丢包重传。最后才轮到调 packet 大小、synconclose 等 HDFS 参数。如果真的定位到某块磁盘 IO 异常,优先把该盘上的 block 迁移走,而不是重启节点。5.2 读取延迟高,本地读与远端读差异大先判断读请求到底走的是本地还是远端。如果大部分是远端读,看机架感知拓扑配置是否正确、Spark 任务的本地性比例是否偏低;如果本地读也慢,考虑短路读是否生效、OS 层预读和 IO 调度器是否合适。有一个快速验证手段:用hdfs dfs -cat直接读一个文件并观察耗时,再对比把客户端显式绑定到本地节点的耗时差异。如果差异巨大,说明问题基本集中在网络路径或者本地读优化没有生效。5.3 小文件总反弹?给写入端加约束小文件治理最怕“今天合并、明天再生”。只要写入端还在产生小文件,存量治理就永远跟不上。我见过最典型的情况是某些流式任务为了追求低延迟,每个微批都写几个小文件,日积月累目录里全是 KB 级文件。根治方法是在写入端加约束:Spark 流任务控制输出 partition 数,Hive 开 merge,Kafka 落 HDFS 前做攒批。做不到这些的话,至少要靠脚本监控目录下的文件量,设置告警,别等 NameNode 告警了才意识到问题。下面是一个简单的问题速查表,方便日常排查时对照:现象优先排查点常用手段写入吞吐低DataNode 磁盘 IO、网络丢包、packet 大小调整 dfs.client.write.packet.size、检查网卡流控读大文件很慢OS 层预读、IO 调度器、短路读调整 dfs.datanode.readahead.bytes、改 mq-deadline/noatimeNameNode 内存飙升block 数量、小文件比例、堆内存设置小文件合并、调整块大小、G1 GCRPC 超时频发NameNode GC、handler 数量看 FGCT、调 dfs.namenode.handler.count节点磁盘使用不均新节点扩容、数据分布balancer、diskbalancer跨机架流量大机架感知配置、本地性比例检查 topology 脚本、调本地性等待时间我自己最深刻的体会是:HDFS 调优不是一个孤立的参数游戏,而是把写入链路、读取链路、文件治理、NameNode 容量这四件事放到一起看。每改一个参数之前,先记录当前监控数据,改完再对比,否则你根本分不清是优化起了作用还是业务负载本身在波动。如果你手头也有集群遇到类似问题,建议先从块大小和 NameNode GC 这两个方向入手,它们门槛低、见效快,也是最能帮你建立“调优手感”的切入点。