跳转至

HDFS 小文件问题与治理

作者:JiangChong | 撰写时间:2026年08月

适用场景:HDFS 上文件数量持续增长、NameNode 内存/GC 告警、Spark/Hive 任务数膨胀导致查询变慢,或排查「为什么一个 100GB 的目录读起来比一个 10GB 的文件还慢」这类问题时。

阅读前回顾(30 秒)

系列第 1 篇 HDFS 架构原理 的核心画面是一座仓库:NameNode 是只记台账的管理员,DataNode 是搬运货物的工人,块(block)是标准集装箱。本文讲仓库管理员最头疼的病——货不多,订单却堆成山:

  • 什么是小文件:远小于一个集装箱(128MB 块)的订单,照样要占一个集装箱的「户口」;
  • 为什么有害:台账(NameNode 内存)、问路次数(RPC)、搬运任务数三层都被海量小订单放大;
  • 怎么治:归档救火(当天见效)→ 合并存量 → 改写入端(根治)的组合拳。

读完本文,你会掌握大数据架构师的一个基本功:规划任何一条落 HDFS 的数据链路,先算「每天产生多少个文件」,而不是只算数据量。

关联文章

理解全文脉络

本文按「原理 → 根源 → 诊断 → 治理」组织。如果集群已经告警、急需止血,直接读第 3 节诊断定位重灾区、再按第 4 节「快速见效到根本治理」的顺序执行;如果是要规划新表、新同步链路,重点读第 4.5 节和第 7 节最佳实践,从源头杜绝。第 5 节两个案例串联全文,可作为对照模板;对「数据库里的小文件问题」感兴趣的读者看 4.6 节;术语看不懂时查文末附录 FAQ。


1. 原理:小文件为什么是 HDFS 的「心腹大患」

1.1 什么是小文件

HDFS 把文件切成固定大小的数据块(block)存储,默认 block size 为 128MB(本库调优文章 Hadoop 调优之 HDFS 调优 中可见 dfs.block.size = 134217728,即 128MB)。小文件指远小于 block size 的文件——数据可能只有几 KB 到几 MB,却一样要占一个 block 的「户口」。

判定口径没有统一标准,实践中用以下三个指标组合判断(数值阈值优于模糊表述):

指标 健康 警戒 危险
平均文件大小 ≥ 128MB(接近 block size) < 10MB < 1MB
小于 128MB 的文件占比 < 10% > 50% > 90%
文件总数 < 100 万 100 万 – 1000 万 > 1000 万

粗体结论:只要「文件总数百万级」和「平均文件大小 10MB 以下」同时出现,就进入了需要治理的状态。 依据是业界通用的 Cloudera 容量规划口径——每 100 万块/文件约需 1GB 堆内存(与 1.2 节的 ~1KB/文件+块 估算一致),百万级文件意味着元数据已达 GB 级;10MB(约 block size 的 1/12)则是 Cloudera 对「对 NameNode 压力最大的小文件档」的分档线。注意这是「治理触发线」而非系统硬界限——更硬的信号看 3.2 节的 GC/内存告警。

打个比方:HDFS 的 DataNode 是仓库,block 是标准集装箱,文件是一个个订单。一个大文件 = 几十个装满的集装箱,装卸高效;100 万个小文件 = 100 万个只装了半箱货的集装箱,仓库管理员(NameNode)光登记这些集装箱的编号就把办公桌堆满了,实际运货效率却极低。

对比一下就知道这病是 HDFS「独有」的:普通单机文件系统里,小文件几乎没有额外成本——目录项存在磁盘上,用到才读;而 HDFS 的全部元数据集中装在一台 NameNode 的内存里,文件数越多,管理员的内存就越紧张。文件多,在单机上是小事,在 HDFS 上是全局灾难。

1.2 危害链:从元数据到查询变慢

第一步:NameNode 内存被元数据吃光。 NameNode 不存数据本身,只存两样东西:文件/目录的 inode 信息(inode 即索引节点,文件的「身份证」,登记路径、权限、block 列表)和每个 block 到各 DataNode 副本位置的映射。这些全部常驻内存,磁盘不落。业界常用的估算口径是每个文件约 150 字节、每个 block 约 150 字节的直接结构开销(Hadoop 官方文档与 Cloudera/Hortonworks 技术文章普遍引用这个量级);考虑到目录树分摊、副本映射、JVM(Java 虚拟机)对象头等,生产环境按每个文件 + 块约 1KB 的堆内存来规划更稳妥。

估算示例(按 ~1KB/文件+块 口径):100 万文件 ≈ 1GB 堆;1000 万 ≈ 10GB;1 亿 ≈ 100GB。而一个 64GB 堆的 NameNode 理论上限大约只够承载 6000 万文件——这就是为什么百万级文件量之后,NameNode 堆一路涨、Full GC 越来越频繁。

第二步:元数据寻址开销放大。 任何一次读文件,客户端都要先向 NameNode 拿 block 位置列表。本库实测结论指出,Vertica 读 HDFS 时每读一个 block 就向 NameNode 发起一次请求,交互次数与 block 数成正比(见 Vertica 读取 HDFS 文件的原理分析:1.5GB 单文件 ≈ 12 次)。小文件把「数据量」拆成了「海量小 block」:同样 100TB 数据,合并成 128MB(一个 block 大小)的文件只有约 80 万块;拆成 2 亿个 500KB 的小文件就有 2 亿块——NameNode 的请求量被放大了 250 倍。NameNode 的 RPC(Remote Procedure Call,远程过程调用——客户端与 NameNode 之间「一问一答」的通信方式)处理能力是集群读写吞吐的咽喉,dfs.namenode.handler.count(NameNode 同时处理客户端请求的线程数)默认只有 10(本库调优文章提到默认为 10),堵在这里,所有读写一起排队。

第三步:计算引擎任务数膨胀。 MapReduce 的 input split(切分给每个 Map 任务的输入片)按「文件粒度」切分:文件不会被拆开,一个文件至少产生 1 个 Map 任务——2 亿个小文件全表扫描就是 2 亿个 Map 任务。Spark 的情况好一些:spark.sql.files.maxPartitionBytes(默认 128MB)会把若干小文件装进同一个 partition,任务数不会严格等于文件数(见 4.2 节)。但有两个成本仍然按文件数计:一是扫描前逐文件列目录(FileStatus 列表,HDFS 为每个文件生成的元数据对象)的开销——案例二里 600 万文件光列清单就要 12 分钟;二是每个文件都要被打开、读取元数据、建立任务。文件数每涨一个数量级,调度与元数据成本就涨一个数量级——任务的「管理成本」超过了「干活成本」。这正是「数据不大、任务不少、查询却慢得离谱」的根源。

危害链全景图:

flowchart TD
    A["小文件泛滥<br/>文件数 × 块数暴增"] --> B["元数据吃紧<br/>每文件+块约 1KB 堆内存"]
    A --> C["寻址请求放大<br/>每块一次 NameNode RPC"]
    A --> D["任务数按文件数膨胀<br/>Map 任务 / 文件清单"]
    B --> E["Full GC 频繁<br/>RPC 停止响应"]
    C --> F["handler 线程堵死<br/>读写全部排队"]
    D --> G["管理成本 > 干活成本"]
    E --> H["集群慢得不可用"]
    F --> H
    G --> H

本节要点:小文件的危害链是——文件数 × 块数撑爆 NameNode 内存与 RPC → 每次读写按块向 NameNode 寻址 → 任务数按文件数膨胀 → 读性能全面退化。数据量相同,文件数决定一切。


2. 产生根源:小文件是从哪些链路上长出来的

小文件不是某一天突然出现的,而是写入端「文件数下限」规则被长期忽略的累积结果。

黄金公式:落地文件数下限 ≈ 写入 task 数 × 目标分区数

解释:一个写入任务在它负责的每个分区里至少写 1 个文件,数据量再小也省不掉这个「1」。所以决定文件数的不是数据量,而是两个乘数: - task 数 = 批次频率 × 每批并发(写入调度怎么起); - 分区数 = 分区层级(目录按什么切)。

任一乘数失控,小文件就成批量产生;双双失控(如案例一:每天 144 批 × 日期+来源双层目录),一年半就乘出 2 亿。

根源 典型场景 为什么产生小文件
流式小批量落地 Kafka/FTP/接口分钟级或小时级采集,每批一个文件 批次多 × 每批至少 1 文件;每批就几 MB、写完即关闭,永远攒不满一个 128M 的 block
Hive/Spark 分区过细 每日分区 + 小时分区 + 省份维度多层子分区,动态分区写入 分区数直接乘进黄金公式,每 task 每分区 1 文件
MapReduce/Spark 输出碎片 Reducer/Executor 数量多而数据量小,直接写最终目录 task 数 × 分区数,且没有二次合并
Vertica 导出/同步链路 EXPORT TO ORC/Parquet 到 HDFS 的迁移、归档、数仓交换 逐日逐表导出,单日数据量小时输出文件就小,长期积累

其中「Vertica 导出」这条链路在库存有真实案例。执行 export to orc 夯死 中,Vertica v11.1.1-22(企业模式,27+1 节点)执行 EXPORT TO ORC(directory='hdfs://.../migrate_data/vertica_wutong/<表名>') AS SELECT * FROM ... WHERE statis_day = 20250624 向 HDFS 导出,会话夯死约 20 分钟无任何错误日志,无法正常 close,只能重启初始化节点释放,且每次夯死的表不一样,最终怀疑为版本 bug(VER-97238/VER-97234,与邮储外部表任务现象类似)。注意:该案例本身是导出夯死问题而非小文件诊断案例,但它反映了两件事——(1) 按天分区、逐表导出到 HDFS 正是第 2 节表格中「分区过细 + 小批量」模式的典型落地形式,单日数据量不大时,即使 OrcExport 的目标文件上限高达 10GB(日志中 fileSizeMB = 10240),实际输出文件仍然很小;(2) 这类导出链路出问题时排障成本极高(无法 kill、需重启节点),更应在写入端就控制好文件粒度,而不是等文件堆起来再收拾。

本节要点:四种根源的共同点是同一个——没人管「黄金公式」里的两个乘数。要么 task 数失控(并发小批次),要么分区数失控(分区过细),小文件就是两者失控的乘积。


3. 诊断:先量化,再治理

治理小文件的第一步不是合并,而是量化:全库多少文件、平均多大、重灾目录在哪。没有数字支撑的治理方案无法验证效果。

3.1 fsck:一次性看清全貌

hdfs fsck(File System Check,文件系统体检)是 NameNode 自带的全文件系统健康检查工具,扫描全部元数据并输出统计。以下命令扫描指定目录并列出每个文件的 block 信息:

hdfs fsck /data -files -blocks

-files 让 fsck 输出文件清单(默认只输出损坏/缺失信息),-blocks 附带每个文件的 block 明细。输出末尾的汇总段是重点,典型字段如下:

Status: HEALTHY
 Total dirs: 1250
 Total files: 204812035          ← 文件总数(重灾信号)
 Total blocks (validated): 204812351
 Average block size: 512 KB      ← 平均 block 大小(正常应为百 MB 级)

解读要点:平均 block 大小远小于 block size(128MB)就是小文件泛滥的直接证据——文件数 2 亿、平均 block 512KB,说明几乎所有文件都只占 1 个 block。同时,fsck 本身是逐条遍历元数据的,文件越多 fsck 越慢,几十亿文件时 fsck 耗时可达数十分钟甚至更长,这本身就是 NameNode 元数据压力的一个旁证。

阈值参考:平均文件大小 < 10MB 且文件总数 > 100 万,就该启动治理;文件数过千万时通常已经伴随 NameNode 内存告警,需要尽快行动。

3.2 NameNode 内存与 GC:看元数据压力的「仪表盘」

NameNode 的堆内存使用率是衡量元数据规模最直接的指标。两个看板:

NameNode Web UI:Hadoop 3.x 默认端口 9870(Hadoop 2.x 为 50070),Overview 页的「Memory Used / Memory Total」直接显示堆使用情况,接近 100% 就是元数据逼近上限。

jstat 看 GC 频率:jstat 是 JDK(Java 开发工具包)自带的 JVM 统计工具,在 NameNode 所在主机执行(<pid> 替换为 NameNode 进程号):

jstat -gcutil <pid> 5000

每 5 秒输出一次各内存区域使用率和 GC 统计(间隔参数 5000 的单位是毫秒)。关注两个字段:FGC(Full GC 累计次数)和 FGCT(Full GC 累计耗时)。先说清名词:NameNode 是跑在 JVM(Java 虚拟机)里的 Java 程序,GC(Garbage Collection,垃圾回收)是 JVM 自动回收不再使用的内存。回收分两档:Young GC 只扫新生代(新对象区),毫秒级、几乎无感;Full GC 扫整个堆(含长期存活对象所在的老年代),秒级到分钟级,执行期间进程暂停、不响应任何请求(即 Stop-The-World)。而元数据恰恰是长期存活对象、几乎全在老年代——小文件把老年代塞满,Full GC 就被频繁触发。如果 FGC 持续增长、FGCT 涨幅明显,说明堆内元数据量过大导致频繁 Full GC——这是比「内存使用率高」更早、更致命的信号:Full GC 期间 NameNode 停止响应 RPC,客户端读写全部超时,集群表现为「慢得不可用」而不是「拒绝服务」。

配合内存使用率一起判断:使用率长期 > 70% 且 FGC/FGCT 持续上升 → 元数据规模已接近上限,光加堆只能拖延,必须削减文件数(见第 4 节)。

3.3 按目录量化文件分布:找到重灾区

fsck 给全貌,hdfs dfs -count 给目录粒度的精确数字:

hdfs dfs -count -h /data

-count 输出四列:DIR_COUNT(目录数)、FILE_COUNT(文件数)、CONTENT_SIZE(内容总大小)、路径。先对顶层目录逐个 count,内容大小 ÷ 文件数就是平均文件大小(「小文件密度」):比如某目录 600 万文件、内容只有 2TB,平均每个文件 350KB——密度异常,锁定为重灾目录。

再用 hdfs dfs -du -h(du = disk usage,磁盘占用)下钻到子目录,看文件大小分布:

hdfs dfs -du -h /data/<重灾目录>/*

-du 输出每个子目录/文件的占用大小,可以直接目测哪些子目录下是「一堆 KB 级文件」。文件总数大时 -du 输出会很长,可加 | head 或按需缩小范围查看;更精细的分布统计(如 <1MB、1-10MB、10-128MB 各占比)可以在 Spark 里用 FileSystem.listStatus(HDFS Java API 的列目录方法)批处理算出来,作为治理前后的对比基线。

诊断三步走的整体逻辑:

flowchart LR
    A["fsck 看全貌<br/>文件数 / 平均 block"] --> D["锁定贡献 80% 文件数的目录"]
    B["jstat / Web UI 看压力<br/>FGC 增长 / 堆使用率"] --> D
    C["count + du 定重灾区<br/>文件密度 / 大小分布"] --> D
    D --> E["按第 4 节选方案治理"]

本节要点:诊断三步走——fsck 看全貌(文件数、平均 block)、jstat 看压力(FGC/FGCT 增长)、count + du 定重灾目录。先知道「哪几个目录贡献了 80% 的文件数」,治理就成功了一半。


4. 治理方案:从快速见效到根本治理

方案按「见效速度 × 根治程度」分五级。原则是:能改写入端的先改写入端,改不了的先归档再合并,新表设计一律按根治原则来。

方案 见效速度 根治程度 适用条件
4.1 HAR 归档冷数据 当天见效 中(治存量) 冷数据、只读、按目录整体归档
4.2 列式格式合并重写 1-3 天 中(治存量) 活跃表、需要 SQL 查询
4.3 分区设计治理 数周 高(防新增) 新表设计期、分区过细的存量表重构
4.4 定期合并任务 1 周内上线 高(防反弹) 所有持续写入的表,长期执行
4.5 写入端控制 数周 最高(治源头) 采集/同步链路可控、可攒批

执行顺序上「先救火、再根治」:

flowchart LR
    A["4.1 HAR 归档<br/>当天见效 · 救火"] --> B["4.2 合并重写<br/>1-3 天 · 治存量"]
    B --> C["4.4 定期合并<br/>1 周 · 防反弹"]
    C --> D["4.5 写入端攒批 + 4.3 分区设计<br/>数周 · 治源头"]

4.1 归档冷数据:HAR(Hadoop Archive)打包

HAR(Hadoop Archive)是 Hadoop 自带的归档格式,把一个大目录下的小文件打包进少量 HAR 文件。打包后 NameNode 里整个目录只剩几个 HAR 文件条目,几百万个 inode 记录一次性消失——这是对 NameNode 内存最立竿见影的缓解手段。它和 tar/zip 有本质区别:tar/zip 打包完对 HDFS 只是一块「不透明的砖」,引擎没法直接读里面的某个文件,必须先整体解压回 HDFS;HAR 内部保留文件索引与边界,MapReduce/Hive 可以像读普通文件一样按需定位包内单个文件。

hadoop archive -archiveName 2023.har -p /data/raw/2023 /data/archive

命令含义:把 /data/raw/2023 目录下的所有文件打包为 /data/archive/2023.har,原目录保持不变。打包完成后即可删除原目录,之后通过 har:///data/archive/2023.har/... 路径读取归档内容(MapReduce 原生支持,Hive 表可通过外部表指到 HAR 路径)。

为什么有效:NameNode 只维护 HAR 文件自身的条目,内存占用从「每文件 1 条」降到「每 HAR 包 1 条」,降幅通常达 3-4 个数量级,且不动业务表、无停机。

适用条件:归档数据是只读的——HAR 不支持更新和删除(要改只能重新打包),Spark 等部分引擎对 har:// 支持不完整,所以适合「月度/年度冷数据一次性归档」,不适合仍在查询的活跃表。

如果是 Vertica Eon 模式的数据库文件,不要进行这个操作,数据库无法读取打包后的文件。需要在数据库上做治理,详细请见 4.6 节;Eon 公共存储侧的真实案例见 Vertica Eon Communal Storage 运维实战

4.2 合并写入:列式格式合并 + 引擎合并参数

对仍要参与分析的活跃表,把散落的小文件重写为大文件(ORC/Parquet 列式格式)。文件目标大小可参考本库 Parquet ORC 列式文件格式深度解析 的建议:小文件治理场景 128MB–256MB,交互式查询 512MB–1GB。

Hive 侧有现成的合并机制(INSERT OVERWRITE 或任务后自动合并),常用参数如下(参数名与默认值以官方文档为准):

参数 作用 备注
hive.merge.mapfiles Map-only 任务(无 reduce)结束时合并小文件 默认 true
hive.merge.mapredfiles 含 reduce 的任务结束时合并 默认 false,常需显式开启
hive.merge.size.per.task 合并后每个文件的目标大小 默认 256MB
hive.merge.smallfiles.avgsize 平均文件大小低于该值才触发合并 默认 16MB

Spark 侧则在读取阶段控制切分、在写入阶段控制输出:spark.sql.files.maxPartitionBytes(默认 128MB)决定读入时文件如何拼成 partition,小文件多时适当调大可减少 partition 数;spark.sql.adaptive(AQE,Adaptive Query Execution 自适应查询执行)开启后,spark.sql.adaptive.advisoryPartitionSizeInBytes(默认 64MB)会把过小的 shuffle(分区间的数据重新分配)输出分区自动合并,直接减少写出的文件数(参数默认值以官方文档为准,Spark 各小版本略有差异)。

为什么有效:同样数据量,文件数从「每 task 每分区 1 个」收敛为「按目标大小切分」,通常降 2-3 个数量级;列式格式还带来压缩率与谓词下推(过滤条件下推到存储层执行)收益。

适用条件:需要先确认表的读取方兼容(ORC/Parquet 是 Hive/Spark 事实标准,基本无风险);重写期间数据量会翻倍(新旧两套文件并存),需要磁盘余量。

4.3 分区设计治理

回到黄金公式,分区数是一个乘数。分区粒度规划原则:分区粒度宁粗勿细,让「单分区数据量」尽量达到目标文件大小的 10 倍以上。例如日数据 5GB、目标文件 256MB,日分区刚好(约 20 个文件);但如果再叠一层「小时 + 省份」子分区(24 × 31 = 744 个分区),单分区只剩 7MB——每个分区再小也得至少写 1 个文件,文件数被分区数带着一起失控。

设计 单分区数据量 月文件数(单 task) 评价
天分区 5GB ~600 健康
天 + 小时分区 208MB ~720 警戒
天 + 小时 + 省份 6.7MB ~22320 失控

表格怎么算:单 task 下每个分区至少落 1 个文件,所以「月文件数 ≈ 每月分区数 × 每分区文件数」。天分区 5GB 是目标 256MB 的 20 倍,1 天被切成 20 个文件(600 = 20 × 30);小时分区 208MB 已低于目标大小、也远低于「单分区 ≥ 目标 10 倍」的健康线,每天 24 个分区各落 1 个不达标的小文件(720 = 24 × 30);叠上省份后分区数按 24 × 31 = 744 倍膨胀,文件数同步失控。

为什么有效:分区粒度直接乘进文件数下限,把「数据量 / 目标文件大小」这个良性公式破坏掉,改成「task 数 × 分区数」的恶性乘积。

适用条件:新表设计期成本最低,一行 DDL(数据定义语言,即建表语句)的决定换未来几年的健康;存量表改分区需要重建表 + 迁移,成本高,更适合配合 4.4 的定期合并来「带病维持」而非推倒重来。

4.4 定期合并任务:防止反弹

写入端不一定能立刻改,存量治理完也不能保证不复发。定期合并任务 = 把合并变成流水线的一环:在每天/每周批处理任务收尾阶段,对当日新增分区执行一次合并重写(Spark coalesce(收缩分区、不搬运数据)或 repartition(重分区、可增可减但代价更高)重写,或 Hive INSERT OVERWRITE + 合并参数),把当天产生的小文件收敛到大文件。

# 示例:每日凌晨对昨日分区做合并重写(Spark)
spark-sql --conf spark.sql.adaptive.enabled=true \
  -e "INSERT OVERWRITE TABLE ods.dwd_log PARTITION(dt='2026-08-12')
      SELECT ... FROM ods.dwd_log_tmp WHERE dt='2026-08-12'"

为什么有效:小文件是「每天 1 万个、攒一年 365 万」的累积问题,定期任务把它变成「每天最多几十个」的稳态问题——治理效果不是一次性的,而是持续对冲新增量。

适用条件:表必须允许重写分区(分区级 OVERWRITE,不影响历史分区);合并任务本身消耗资源,应排在批处理低谷时段,并复用资源池/队列控制并发。

4.5 写入端控制:从源头阻断

最根本的治理在写入端,核心是攒批:把采集/同步的批次粒度提高到与目标文件大小匹配。用黄金公式反推:若目标文件 256MB、写入并发 32 个 task,那么每批数据量应达到 256MB × 32 ≈ 8GB 才写一轮;而很多链路的现状是「每 5 分钟一批、每批几十 MB」——把批次频率降下来(如小时级甚至天级攒批)、或引入内存/磁盘缓冲先聚合再落地,文件数直接回到健康区间。

为什么有效:黄金公式的两个乘数里,「task 数」直接由写入批次决定。攒批把 task 数降下来,文件数下限随之下降,且不再需要额外的合并任务来收拾残局——合并成本从「事后重读重写」变成「事前的等待」。

适用条件:链路可控(自研采集/同步程序、可配置的调度平台);对时效性有硬要求的场景(实时指标)需要折中——实时链路用小文件容忍策略 + 定期合并兜底,准实时/批量链路必须攒批。

4.6 数据库中的同构问题:以 Vertica 为例

其实在数据库中也存在小文件治理的问题——尤其是 Vertica 这类列式 MPP 数据库,它的「小文件病」与 HDFS 同构,本文的方法论可以直接平移:

HDFS 概念 Vertica 对应物 同构点
block(128MB 数据块) ROS 容器(列式存储的最小物理单元) 数据组织的「最小单元」,单元越碎,元数据与扫描开销越大
一个文件 一次 COPY 产生的一批 ROS 容器 小批量高频写入 = 海量小单元
NameNode 内存/GC 告警 每节点每投影 ROS 容器逼近 1024 上限,触发 ROS Pushback 元数据规模失控的硬警报
4.4 定期合并任务 Tuple Mover mergeout(后台合并 ROS 容器) 事后合并兜底
4.5 写入端攒批 合并 COPY 语句:一个 COPY 加载多个文件(通配符/逗号列表) 从源头减少单元数

机制也一一对应:每次 COPY 调用(哪怕只加载一个 1MB 的小文件)都会产生至少一批 ROS 容器,小批量高频 COPY 会让容器数爆炸、Tuple Mover 合并跟不上、查询扫描的容器数激增——症状同样是「数据不大、查询却慢」。治理顺序与第 4 节一致:合并 COPY 攒批(治源头)→ 合理分区/加载粒度(治乘数)→ mergeout 定期合并(防反弹)→ 归档清理(救火)。完整版见姊妹篇 Vertica COPY 小文件加载优化(含 93 节点某运营商 HDFS 加载变慢的真实案例)。

传统单机数据库也有类似现象,只是烈度低得多:PostgreSQL 每张表至少一个物理文件(1GB 一段),几万张小表就是几万个文件,备份与 checkpoint 都变慢;Oracle/MySQL 大量小表、小数据文件同理。它们没有 HDFS 的集中式 NameNode 瓶颈,所以问题不如 HDFS 和列式 MPP 库突出。

本节要点:治理优先级 = 写入端攒批(治源头)→ 分区设计(治乘数)→ 定期合并(防反弹)→ 列式合并重写(治存量)→ HAR 归档(救火)。前两项决定长期健康,后三项决定今天能否止血。


5. 案例

5.1 案例:2 亿小文件吃光 NameNode 堆

场景。 某大型数据平台的 HDFS 集群用于承载业务明细数据的日同步,同步程序每 10 分钟一批将接口数据写入 HDFS 原始目录,一年半后某日集群开始「慢性病发作」:批处理任务大面积变慢,NameNode 频繁 Full GC,最长一次 GC 停顿让全集群读写停滞近 2 分钟。

诊断。 三步定位。先看全貌:

hdfs fsck / -files -blocks

输出汇总显示 Total files: 204,812,035、Average block size: 512 KB——文件总数 2 亿、平均 block 512KB,小文件问题确认;且 fsck 全程耗时 40 分钟,与文件数成正比。再看 NameNode 压力(jstat -gcutil,FGC 每 30 秒 +1、FGCT 每小时增量超 30 秒)与 Web UI(堆 256GB 已用约 86%):按 ~1KB/文件 的实践经验口径,2 亿文件理论约 200GB 元数据,与观测值(256GB × 86% ≈ 220GB,含 JVM 自身开销)基本吻合——NameNode 堆即将耗尽,且增长是持续的。最后用 hdfs dfs -count -h /data 定位重灾区:/data/raw/biz_detail 一个目录贡献了 1.8 亿个文件(内容仅约 90TB,平均 500KB/文件),占比 88%。

根因。 同步程序每 10 分钟一批(每天 144 批)、每批约 1.2GB(两千多个 500KB 小文件),且按日期 + 来源系统双层子目录落地——黄金公式里 task 数(批次多)与分区数(日期 × 来源)双双失控,一年半乘出 2 亿。

治理。 三步执行:第一步 HAR 归档——把 1 年以前(约 0.7 亿文件,即最初半年)的只读原始数据打包为 HAR 并删除原目录,NameNode 堆使用率当天从 86% 回落到约 58%;第二步写入端改造——同步程序改为小时级攒批、按目标 512MB 聚合后再写,日新增文件从 30 万降到约 300;第三步存量合并——对近 1 年仍需查询的数据(约 1.3 亿文件、约 67TB)用 Spark 重写为 ORC(平均 512MB,共约 13 万文件),数据总量 100TB 不变。

效果对比。

指标 治理前 治理后
文件总数 2 亿 约 13 万
NameNode 堆使用率(256GB) 86% 约 15%
Full GC 频率 每 30 秒一次 罕见
fsck 全库耗时 40 分钟 约 3 分钟
MapReduce 全量扫描 Map 任务数 2 亿 约 13 万
全量扫描批任务耗时 45 分钟 12 分钟

要点。 该案例的顺序值得复制:先归档救火(当天见效)→ 再改写入端(治源头)→ 最后合并存量(治存量)。磁盘余量上,HAR 与合并都要求新旧数据并存一段时间,需确认容量预留;写入端改造的空窗期(1-2 周)由定期合并任务兜底,防止新增量继续恶化。


5.2 虚构案例:日增量表的分区文件膨胀与治理前后对比

场景。 某经营分析平台的一张日增量事实表,按 dt(天)+ 渠道维度两级分区写入 ORC。表本身设计合理,但上游调度并发过高:每日 1.5 万个文件(日数据约 5GB,平均 350KB/文件),数据保留 400 天——文件数 = 每天 1.5 万 × 400 天 = 600 万。

诊断。 hdfs dfs -count -h 显示该表目录 600 万文件、内容 2TB;NameNode 堆 16GB 使用率 78%(该库 90% 的文件来自此表及同类表)。现象是:Spark 任务启动时「获取表分区与文件列表」就要 12 分钟,全表扫描查询 38 分钟——任务还没开始干活,光数文件就数了 12 分钟。

根因。 黄金公式的第二乘数(分区数)没问题,失控的是第一乘数(task 数):日分区数据只有 5GB,上游却以 1.5 万个并发 task 写入,每个 task 只写了 350KB。

治理。 上游改为将单日数据聚合到 4 个左右并发写,落地后当日分区约 10 个文件(512MB/个);同时配置定期合并任务,把已存在的 400 天历史分区逐个重写(数据量 2TB,一个周末完成)。文件总数从 600 万降到约 4200(400 天 × 约 10 个文件/天,含渠道子分区)。

效果对比。

指标 治理前 治理后
表文件总数 600 万 约 4200
NameNode 堆使用率(16GB) 78% 约 40%
Spark 启动(列文件元数据)耗时 12 分钟 40 秒
全表扫描查询耗时 38 分钟 9 分钟
每日新增文件 1.5 万 约 10

要点。 案例二展示了「同一张表」的前后对比:数据量 2TB 没变,仅仅把文件数从 600 万降到 4200,查询就从 38 分钟降到 9 分钟——多出来的 29 分钟全是元数据寻址与任务调度成本。这印证了第 1 节的结论:对 HDFS 而言,文件数就是隐形税。


6. 快速诊断工具箱

命令 用途 关注指标
hdfs fsck / -files -blocks 全库文件与 block 统计、健康检查 Total files、Total blocks、Average block size、fsck 自身耗时
hdfs dfs -count -h <dir> 目录粒度文件数与内容大小 FILE_COUNT、CONTENT_SIZE,算出平均文件大小
hdfs dfs -du -h <dir>/* 子目录/文件大小分布 KB 级文件是否成片出现
jstat -gcutil <nn_pid> 5000 NameNode GC 压力 FGC 增量、FGCT 增量、E/S/O 区使用率(Eden 新生代 / Survivor 存活区 / Old 老年代)
NameNode Web UI(9870 / 50070) 堆内存总览 Memory Used / Memory Total
hadoop archive -archiveName a.har -p <src> <dst> 冷数据归档 归档后原目录可删除、NN 内存回落幅度

本节要点:前四行是诊断标准动作——fsck 看全貌、count 定重灾、du 看分布、jstat 看压力;最后一行不是诊断而是第一个治理动作,因为归档救火永远比重构快。


7. 最佳实践清单

按投入产出比排序,每条附「为什么」:

  1. 写入端攒批,批次数据量 ≈ 目标文件大小 × 写入并发。 为什么:黄金公式中 task 数是第一乘数,攒批直接削掉它,这是唯一不需要事后补偿的治理。
  2. 新表分区粒度宁粗勿细,单分区数据量至少为目标文件大小的 10 倍。 为什么:分区数是第二乘数,设计期的一行 DDL 决定未来几年的文件数下限,事后改分区代价高一个数量级。
  3. 列式表文件目标大小设在 128MB–512MB。 为什么:过小则元数据与行组开销占比高,过大则任务并行度下降(参考 Parquet ORC 列式文件格式深度解析 的文件大小建议)。
  4. 批处理收尾挂定期合并任务,对当日新增分区重写。 为什么:小文件是累积问题,不防反弹的治理等于没治理;每天消化增量比每季度收拾存量便宜得多。担心拖慢批处理?合并只重写当日增量(读写放大约 2 倍,通常只占每日批处理总时长的几个百分点),且放在收尾低谷执行、用独立资源池/队列隔离——真正的效率损失在「不合并」那边:下游查询会随文件数增长持续退化(案例二:38 分钟 vs 9 分钟)。
  5. 冷数据按季度/年度 HAR 归档,归档即删原目录。 为什么:HAR 让 NameNode 只记包不记文件,内存立降,且只读语义与冷数据完美匹配。
  6. 每月跑一次 hdfs fsck -files -blocks 并记录 Total files 与平均 block 大小。 为什么:小文件是渐变问题,月度基线能让你在「100 万」时就发现,而不是拖到「1 亿」才报警。
  7. NameNode 堆使用率超 70% 且 FGC 持续增长时,把「加内存」降级为临时手段,主线必须是减文件数。 为什么:加堆只延后告警不解决元数据规模,1 亿文件要 100GB 堆的账迟早要还。
  8. 合并/归档执行前确认磁盘余量与新旧数据并存空间。 为什么:合并过程新旧文件并存,磁盘翻倍需求没预留会导致任务中途失败。
  9. Vertica 与 HDFS 之间的导出/同步,按「单表单日」评估输出文件大小。 为什么:本库 执行 export to orc 夯死 显示逐日分区导出链路本身故障排障成本极高(会话无法 kill、需重启节点),文件粒度失控会进一步放大这类链路的维护成本。
  10. 治理效果以数字验收:文件数、NN 堆使用率、fsck 耗时、任务启动耗时四件套前后对比。 为什么:没有基线就没有治理,四件套都能用第 6 节工具箱直接量出来,是验证「方案是否有效」的唯一标准。

附录:新手常见疑问(FAQ)

Q1:为什么 HDFS 不把 block size 调小(比如 1MB),小文件不就「不算小」了吗?

没有用,还可能更糟。小文件问题的本质是「一个文件一份元数据、一次寻址」,调小 block size 只改变「小」的阈值,改变不了文件数。反而大文件的块数会爆炸(1TB 文件在 128MB 下 8192 块,在 1MB 下变成 100 万块),元数据总量更大;同时生态参数(MapReduce 并行度、列式文件行组 Row Group 等)都围绕 128MB 调优,贸然改动牵一发动全身。

Q2:HAR 和 tar/zip 打包有什么区别?为什么不能直接打成 zip?

tar/zip 打包后对 HDFS 是「一个不透明的文件」,引擎要读里面的某个文件必须先整体解压回 HDFS;HAR 是 Hadoop 原生归档,内部保留文件索引与边界,MapReduce/Hive 可以像读普通文件一样按需读取包内单个文件——代价是只读(见 4.1 节)。

Q3:小文件只影响读取吗?写入会不会也变慢?

写入同样受影响:客户端每写一个新文件,都要向 NameNode 发起一次元数据登记(create RPC + 分配 block),海量小文件写入时 NameNode RPC 同样被挤爆,写入变慢甚至超时。案例一里每 10 分钟一批的同步程序,既是小文件的制造者,也是受害者。

Q4:NameNode 内存不够,直接把堆加到 1TB 行不行?

短期可以拖延(先把堆加上再治理是常见节奏),长期不行:大堆的 Full GC 停顿更长(几百 GB 堆一次 GC 可达分钟级);元数据翻倍堆就要翻倍,成本线性上涨,而治理能把内存需求降几个数量级。加堆是止痛药,减文件才是手术。

Q5:为什么任务数按文件数切,而不是按数据量切?

因为文件是引擎能操作的「最小完整单元」:任务不能从文件中间借数据(每个文件都要被 open、读元数据、定位 block),一个文件至少落进一个任务。数据量再小,「打开文件」这个固定动作也省不掉——小文件把这个动作重复了几百万次。

Q6:三副本会不会让小文件问题更严重?

会按副本数线性放大:每个小文件默认 3 副本 = 3 份 block 元数据与磁盘占用,所以「文件数 × 副本数」才是真实的元数据压力。不过治理方案(HAR/合并/攒批)对副本数不敏感,治理后同样按 3 份正常生效。

Q7:fsck 跑起来会不会影响集群正常服务?

fsck 是只读的元数据遍历,不动数据;但它持续占用 NameNode 的 RPC 线程与 CPU,文件多时(几亿级别)可能让本已「慢得不可用」的集群雪上加霜。建议低峰期执行、限定目录(hdfs fsck /data 而不是全库 /),并做好「耗时随文件数线性增长」的心理预期。

Q8:Vertica 侧也有「小文件」问题吗?

有,而且是同构问题:Vertica 的 ROS 容器就像 HDFS 的 block,小文件 COPY 会让 ROS 容器爆炸、加载变慢,见姊妹篇 Vertica COPY 小文件加载优化。Eon 模式公共存储在 HDFS 上时还会双重叠加(Vertica 容器多 + HDFS 文件多),见 Vertica Eon Communal Storage 运维实战。

扩展阅读