继前三篇之后,我觉得Hadoop还有个很严重的小问题,需要特别列出来,所以有了本篇文章。
大数据之Hadoop从入门到放弃之hdfs大数据之Hadoop从入门到放弃之MapReduce大数据之Hadoop从入门到放弃之hadoop的小伙伴们
前言:一个关于"小"的大问题
如果你问我,Hadoop最让人头疼的问题是什么?
不是集群搭建,那有文档;不是参数调优,那有经验;不是数据倾斜,那有套路。
是小文件。
让我先讲一个真实的故事。
那是某大数据工程师入职新公司的第一周。老板说他说:"小伙子,我们的Hadoop集群最近有点慢,你看看怎么回事。"
他信心满满地登上集群,敲下hdfs dfs -ls /user/hive/warehouse/,然后——
屏幕开始疯狂滚动......
滚了整整5分钟......
他数了一下,一个表的分区目录下,有47万个文件,平均每个文件3KB。
当时他的表情是这样的:😱
然后我看了一眼NameNode的内存使用:98%。
那个时候他终于明白了,为什么上一个大数据工程师离职了。
今天,让我们一起来彻底搞懂这个"小"问题。
一、什么是小文件问题?
1.1 小文件的定义
在HDFS的世界里,什么叫"小文件"?
通常 < 128MB"] B[正常文件] --> B1["≈ Block大小
128MB - 1GB"] C[大文件] --> C1["> 1GB
会被切成多个Block"] end subgraph 危险程度["⚠️ 危险程度"] D1["< 1MB 的文件"] --> D2["极度危险 🔴"] D3["1MB - 10MB"] --> D4["比较危险 🟡"] D5["10MB - 128MB"] --> D6["还好 🟢"] end style A fill:#ff6b6b style D1 fill:#ff6b6b style D2 fill:#ff6b6b
简单来说:任何远小于HDFS Block大小(默认128MB)的文件,都可以被认为是"小文件"。
但真正可怕的是那些KB级别的文件——几KB、几十KB的文件,成千上万地堆在HDFS上,就像蚂蚁一样,单个不起眼,成群结队就能把大象(Hadoop)啃死。
1.2 小文件问题的本质
为什么小文件会成为问题?让我们从HDFS的设计说起。
≈ 150字节元数据
8个Block"] P2["1000个1MB文件
≈ 150KB元数据
1000个Block"] P3["存储相同数据量
小文件元数据是大文件的125倍!"] end end NN --> 元数据 元数据 --> 问题 style P3 fill:#ff6b6b
核心问题:不管文件多大多小,每个文件在NameNode中都要占用约150字节的元数据空间。
让我们算一笔账:
| 场景 | 文件数量 | 元数据占用 | 说明 |
|---|---|---|---|
| 1亿个小文件 | 100,000,000 | 约15GB | NameNode内存爆炸💥 |
| 100个大文件存相同数据 | 100 | 约15KB | 毫无压力✅ |
这就是为什么我们说:HDFS天生不适合存储大量小文件。
二、小文件问题的影响
小文件不只是占用NameNode内存那么简单,它会带来一系列连锁反应。
2.1 影响全景图
内存不够用] A --> A2[GC频繁
服务不稳定] A --> A3[严重时OOM
集群挂掉] B --> B1[Map Task过多
调度开销大] B --> B2[启动时间 > 处理时间] B --> B3[资源利用率低] C --> C1[频繁寻址
吞吐下降] C --> C2[NameNode成为瓶颈] D --> D1[加载元数据慢] D --> D2[安全模式时间长] E --> E1[数据分散
网络传输多] end style Root fill:#ff6b6b style A fill:#ff6b6b style B fill:#ff6b6b style A3 fill:#ff0000,color:#fff
2.2 对NameNode的影响
真实案例中的恐怖数字:
| 指标 | 正常集群 | 小文件灾难集群 |
|---|---|---|
| 文件数量 | 500万 | 5亿 |
| NameNode内存 | 8GB | 80GB+ |
| 启动时间 | 2分钟 | 30分钟+ |
| ls命令响应 | 毫秒级 | 分钟级 |
| Full GC频率 | 每天1-2次 | 每小时多次 |
2.3 对MapReduce的影响
还记得MapReduce的原则吗?一个InputSplit对应一个Map Task。
并行处理
效率高] end subgraph 小文件情况["❌ 小文件:10000个128KB文件"] S1[文件1: 128KB] --> SM1[Map Task 1] S2[文件2: 128KB] --> SM2[Map Task 2] S3[...] --> SM3[...] S10000[文件10000: 128KB] --> SM10000[Map Task 10000] SM1 --> R2[10000个Map Task!
调度开销巨大
效率极低] end style R1 fill:#4ecdc4 style R2 fill:#ff6b6b
Map Task的开销分析:
结论:处理小文件时,90%的时间都浪费在了启动和调度上,真正处理数据的时间只有10%。这就像你开车去买瓶酱油,路上花了1小时,买酱油只花了1分钟。
三、小文件是怎么产生的?
知己知彼,百战不殆。让我们看看小文件是怎么来的。
3.1 小文件的来源
每分钟一个文件] C2[Kafka消费写入
每个分区一个文件] C3[日志采集
每个服务一个文件] end subgraph 数据处理["⚙️ 数据处理层"] P1[Spark/MR输出
每个Task一个文件] P2[动态分区写入
每个分区一个文件] P3[数据倾斜
部分Task数据少] end subgraph 业务原因["📋 业务原因"] B1[增量导入
每次导入一批小文件] B2[实时写入
频繁小批量写入] B3[分区过细
按小时/分钟分区] end end style C1 fill:#ff6b6b style P1 fill:#ff6b6b style P2 fill:#ff6b6b style B3 fill:#ff6b6b
3.2 典型场景分析
场景1:Flume采集日志
一年产生 52万个文件
如果是100个服务... 💀
场景2:Hive动态分区
-- 危险操作!
INSERT OVERWRITE TABLE user_behavior
PARTITION (dt, hour, province) -- 三级分区
SELECT * FROM tmp_data;文件1: 50KB"] Partition --> P2["dt=20240101/hour=00/province=上海
文件2: 30KB"] Partition --> P3["dt=20240101/hour=00/province=广州
文件3: 20KB"] Partition --> P4["...
每个Task每个分区一个文件"] Result["假设:
365天 × 24小时 × 34省 × 100个Reduce
= 2977万个文件 💀"] end style Result fill:#ff0000,color:#fff
场景3:Spark任务输出
# 危险代码!
df.repartition(1000).write.parquet("/output/path")
# 产生1000个文件,如果数据量小,每个文件可能只有几MB四、解决方案大全
好了,问题说清楚了,现在让我们来解决它!
4.1 解决方案全景图
读取时合并] Mid2[Hive合并小文件参数] Mid3[Spark coalesce/repartition] end subgraph 事后治理["🔧 事后治理"] Post1[HAR归档] Post2[SequenceFile合并] Post3[定期合并任务] Post4[转换为列式存储] end end 事前预防 --> |"最佳"|Result[小文件问题解决] 事中处理 --> Result 事后治理 --> |"补救"|Result style 事前预防 fill:#4ecdc4 style 事中处理 fill:#ffe66d style 事后治理 fill:#ff6b6b
4.2 方案一:HAR文件(Hadoop Archive)
HAR是Hadoop自带的归档工具,可以把多个小文件打包成一个归档文件。
占用1000个元数据"] end subgraph HAR["📦 HAR归档"] HAR1[archive.har] subgraph 内部结构["HAR内部"] Index[_index 索引文件] Master[_masterindex 主索引] Part[part-0 数据文件] end end subgraph 归档后["✅ 归档后"] Note2["3个文件
占用3个元数据
节省99.7%"] end 归档前 --> |"hadoop archive"|HAR HAR --> 归档后 style Note1 fill:#ff6b6b style Note2 fill:#4ecdc4
HAR使用方法:
# 创建HAR归档
hadoop archive -archiveName myfiles.har -p /input/small_files /output/
# 查看HAR内容
hdfs dfs -ls har:///output/myfiles.har
# 读取HAR中的文件
hdfs dfs -cat har:///output/myfiles.har/file1.txt
# 在MapReduce/Hive中使用
# 直接用har://协议访问HAR的优缺点:
| 优点 | 缺点 |
|---|---|
| ✅ 减少NameNode元数据 | ❌ 创建后不可修改 |
| ✅ 透明访问,不影响使用 | ❌ 查询需要访问索引,有额外开销 |
| ✅ Hadoop原生支持 | ❌ 不减少存储空间 |
| ❌ 底层还是多个文件,MapReduce效率提升有限 |
4.3 方案二:SequenceFile
SequenceFile是Hadoop的一种二进制文件格式,可以把多个小文件合并成一个大文件。
文件头] subgraph Records["记录区"] R1["Record1
Key: 文件名
Value: 文件内容"] R2["Record2
Key: 文件名
Value: 文件内容"] R3["Record3
Key: 文件名
Value: 文件内容"] Sync1[Sync Marker
同步标记] R4["Record4..."] end Header --> Records end subgraph 特点["💡 特点"] T1[二进制格式,紧凑] T2[支持压缩] T3[支持分片,可并行处理] T4[Key-Value结构] end style Header fill:#4ecdc4 style Sync1 fill:#ffe66d
合并小文件为SequenceFile的代码:
public class SmallFilesToSequenceFile {
public static void main(String[] args) throws Exception {
Configuration conf = new Configuration();
FileSystem fs = FileSystem.get(conf);
// 输出SequenceFile
Path outputPath = new Path("/output/merged.seq");
SequenceFile.Writer writer = SequenceFile.createWriter(
conf,
SequenceFile.Writer.file(outputPath),
SequenceFile.Writer.keyClass(Text.class),
SequenceFile.Writer.valueClass(BytesWritable.class),
SequenceFile.Writer.compression(CompressionType.BLOCK, new GzipCodec())
);
// 遍历小文件目录
Path inputDir = new Path("/input/small_files");
FileStatus[] files = fs.listStatus(inputDir);
for (FileStatus file : files) {
// Key: 文件名
Text key = new Text(file.getPath().getName());
// Value: 文件内容
byte[] content = readFile(fs, file.getPath());
BytesWritable value = new BytesWritable(content);
writer.append(key, value);
}
writer.close();
System.out.println("合并完成!" + files.length + " 个文件 -> 1 个SequenceFile");
}
private static byte[] readFile(FileSystem fs, Path path) throws IOException {
FSDataInputStream in = fs.open(path);
byte[] content = IOUtils.toByteArray(in);
in.close();
return content;
}
}SequenceFile的优缺点:
| 优点 | 缺点 |
|---|---|
| ✅ 大幅减少文件数量 | ❌ 需要编写代码转换 |
| ✅ 支持压缩,节省空间 | ❌ 不方便单独访问某个文件 |
| ✅ 支持分片,MapReduce友好 | ❌ 二进制格式,不可直接查看 |
| ✅ 支持排序 |
4.4 方案三:CombineFileInputFormat
这是MapReduce层面的解决方案,在读取时把多个小文件合并成一个InputSplit。
包含file1+file2+file3+file4] CF2 --> CS1 CF3 --> CS1 CF4 --> CS1 CS1 --> CM1[Map1] Note2["4个文件 = 1个Map Task"] end style Note1 fill:#ff6b6b style Note2 fill:#4ecdc4
使用方法:
// 方法1:在代码中设置
job.setInputFormatClass(CombineTextInputFormat.class);
CombineTextInputFormat.setMaxInputSplitSize(job, 128 * 1024 * 1024); // 128MB
CombineTextInputFormat.setMinInputSplitSize(job, 64 * 1024 * 1024); // 64MB
// 方法2:在配置中设置
conf.set("mapreduce.input.fileinputformat.split.maxsize", "134217728");
conf.set("mapreduce.input.fileinputformat.split.minsize", "67108864");在Hive中使用:
-- 设置输入合并
SET hive.input.format=org.apache.hadoop.hive.ql.io.CombineHiveInputFormat;
SET mapreduce.input.fileinputformat.split.maxsize=134217728;
SET mapreduce.input.fileinputformat.split.minsize=67108864;4.5 方案四:Hive小文件处理
Hive提供了多种处理小文件的参数和方法。
4.5.1 Map端输入合并
-- 开启Map端输入合并
SET hive.input.format=org.apache.hadoop.hive.ql.io.CombineHiveInputFormat;
-- 每个Map处理的最大数据量
SET mapreduce.input.fileinputformat.split.maxsize=256000000;
-- 每个节点处理的最小数据量
SET mapreduce.input.fileinputformat.split.minsize.per.node=128000000;
-- 每个机架处理的最小数据量
SET mapreduce.input.fileinputformat.split.minsize.per.rack=128000000;4.5.2 输出端合并小文件
-- 开启输出合并
SET hive.merge.mapfiles=true; -- Map-only任务结束时合并
SET hive.merge.mapredfiles=true; -- MapReduce任务结束时合并
SET hive.merge.size.per.task=256000000; -- 合并后文件大小目标
SET hive.merge.smallfiles.avgsize=128000000; -- 小于此值触发合并4.5.3 手动合并已有小文件
-- 方法1:INSERT OVERWRITE(会重写整个表/分区)
INSERT OVERWRITE TABLE your_table PARTITION(dt='2024-01-01')
SELECT * FROM your_table WHERE dt='2024-01-01';
-- 方法2:使用CONCATENATE(ORC表专用,不重写数据)
ALTER TABLE your_table PARTITION(dt='2024-01-01') CONCATENATE;
-- 方法3:使用distribute by控制输出文件数
INSERT OVERWRITE TABLE your_table PARTITION(dt='2024-01-01')
SELECT * FROM your_table WHERE dt='2024-01-01'
DISTRIBUTE BY rand(); -- 随机分布,控制Reducer数量4.5.4 Hive小文件处理流程图
快速合并,推荐"] O1 --> |"其他"|Other["INSERT OVERWRITE
重写数据"] N1 --> Verify[验证文件数量] N2 --> Verify N3 --> Verify ORC --> Verify Other --> Verify Verify --> Q2{文件数量合理?} Q2 --> |"是"|Done[完成 ✅] Q2 --> |"否"|Adjust[调整参数重试] Adjust --> New end style Start fill:#ff6b6b style Done fill:#4ecdc4 style ORC fill:#4ecdc4
4.6 方案五:Spark小文件处理
4.6.1 控制输出文件数量
# 方法1:使用coalesce减少分区(窄依赖,效率高)
df.coalesce(10).write.parquet("/output/path")
# 方法2:使用repartition重新分区(会shuffle)
df.repartition(10).write.parquet("/output/path")
# 方法3:根据数据量动态计算分区数
data_size_mb = df.rdd.map(lambda x: len(str(x))).sum() / 1024 / 1024
num_partitions = max(1, int(data_size_mb / 128)) # 每个文件约128MB
df.coalesce(num_partitions).write.parquet("/output/path")4.6.2 Spark 3.x 自适应执行(AQE)
# 开启自适应执行
spark.conf.set("spark.sql.adaptive.enabled", "true")
# 开启自动合并小分区
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
# 合并后的目标大小
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB")
# 最小分区数
spark.conf.set("spark.sql.adaptive.coalescePartitions.minPartitionNum", "1")4.6.3 读取时合并小文件
# 设置每个分区的最大字节数
spark.conf.set("spark.sql.files.maxPartitionBytes", "134217728") # 128MB
# 设置打开文件的开销(用于计算是否合并)
spark.conf.set("spark.sql.files.openCostInBytes", "4194304") # 4MB
# 小文件合并阈值
spark.conf.set("spark.sql.files.minPartitionNum", "1")4.7 方案六:列式存储格式
使用ORC或Parquet等列式存储格式,天然支持高压缩比,同样数据量产生更少的文件。
转换为ORC格式:
-- 创建ORC表
CREATE TABLE user_behavior_orc (
user_id STRING,
action STRING,
timestamp BIGINT
)
PARTITIONED BY (dt STRING)
STORED AS ORC
TBLPROPERTIES (
"orc.compress"="SNAPPY",
"orc.stripe.size"="67108864" -- 64MB
);
-- 从原表导入数据
INSERT OVERWRITE TABLE user_behavior_orc PARTITION(dt)
SELECT user_id, action, timestamp, dt
FROM user_behavior_text;4.8 方案七:源头治理
最好的解决方案是:从源头避免产生小文件。
rollInterval=3600"] FL2["增大滚动文件大小
rollSize=134217728"] FL3["使用批量写入"] end subgraph Kafka消费优化["Kafka消费优化"] KF1["增大批次大小"] KF2["增大消费间隔"] KF3["使用合并写入"] end subgraph 分区策略["分区策略优化"] PT1["避免过细分区
按天而非按小时"] PT2["合理评估分区数"] PT3["使用分桶代替过细分区"] end subgraph 任务输出["任务输出优化"] TK1["控制Reducer/Task数量"] TK2["使用coalesce合并输出"] TK3["开启自动合并"] end end style FL1 fill:#4ecdc4 style FL2 fill:#4ecdc4 style PT1 fill:#4ecdc4 style TK2 fill:#4ecdc4
Flume配置优化示例:
# 原来的配置(产生大量小文件)
agent.sinks.hdfs-sink.hdfs.rollInterval = 60
agent.sinks.hdfs-sink.hdfs.rollSize = 0
agent.sinks.hdfs-sink.hdfs.rollCount = 1000
# 优化后的配置
agent.sinks.hdfs-sink.hdfs.rollInterval = 3600 # 1小时滚动
agent.sinks.hdfs-sink.hdfs.rollSize = 134217728 # 128MB滚动
agent.sinks.hdfs-sink.hdfs.rollCount = 0 # 禁用按条数滚动
agent.sinks.hdfs-sink.hdfs.batchSize = 10000 # 批量写入五、解决方案对比
5.1 各方案对比表
5.2 详细对比表
| 方案 | 实现难度 | 效果 | 适用场景 | 是否推荐 |
|---|---|---|---|---|
| HAR归档 | ⭐⭐ | ⭐⭐ | 冷数据归档 | 一般 |
| SequenceFile | ⭐⭐⭐ | ⭐⭐⭐ | 需要合并且保留原文件结构 | 一般 |
| CombineFileInputFormat | ⭐ | ⭐⭐⭐ | MapReduce/Hive读取 | 推荐 |
| Hive合并参数 | ⭐ | ⭐⭐⭐⭐ | Hive任务 | 强烈推荐 |
| Spark coalesce | ⭐ | ⭐⭐⭐⭐ | Spark任务 | 强烈推荐 |
| ORC/Parquet | ⭐⭐ | ⭐⭐⭐⭐⭐ | 新建表 | 强烈推荐 |
| 源头治理 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 所有场景 | 最佳实践 |
六、实战案例:一次完整的小文件治理
让我分享一个真实的小文件治理案例。
6.1 问题背景
dt/hour/province"] B --> C["文件数量:4700万个"] C --> D["平均文件大小:2.3KB"] D --> E["NameNode内存:95%"] E --> F["查询性能:一个简单查询要5分钟"] end style C fill:#ff6b6b style D fill:#ff6b6b style E fill:#ff0000,color:#fff
6.2 治理方案
dt/hour/province → dt"] Step2 --> Step3["Step 3: 转换存储格式
TextFile → ORC"] Step3 --> Step4["Step 4: 数据迁移+合并"] Step4 --> Step5["Step 5: 设置输出合并参数"] Step5 --> Step6["Step 6: 验证效果"] end
6.3 实施步骤
Step 1: 分析现状
# 统计文件数量
hdfs dfs -count /user/hive/warehouse/user_behavior | awk '{print $2}'
# 结果:47,382,156
# 统计平均文件大小
hdfs dfs -du -s /user/hive/warehouse/user_behavior
# 总大小:109GB,平均每个文件:2.3KB
# 查看分区分布
hdfs dfs -ls /user/hive/warehouse/user_behavior | head -20Step 2: 创建新表
-- 新表:简化分区,使用ORC格式
CREATE TABLE user_behavior_optimized (
user_id STRING,
action STRING,
hour INT,
province STRING,
timestamp BIGINT
)
PARTITIONED BY (dt STRING)
STORED AS ORC
TBLPROPERTIES (
"orc.compress"="SNAPPY",
"orc.stripe.size"="67108864"
);Step 3: 数据迁移(带合并)
-- 设置合并参数
SET hive.exec.dynamic.partition=true;
SET hive.exec.dynamic.partition.mode=nonstrict;
SET hive.merge.mapredfiles=true;
SET hive.merge.size.per.task=268435456; -- 256MB
SET hive.merge.smallfiles.avgsize=134217728; -- 128MB
-- 迁移数据
INSERT OVERWRITE TABLE user_behavior_optimized PARTITION(dt)
SELECT
user_id,
action,
hour,
province,
timestamp,
dt
FROM user_behavior;Step 4: 验证效果
# 统计新表文件数量
hdfs dfs -count /user/hive/warehouse/user_behavior_optimized | awk '{print $2}'
# 结果:1,247
# 统计平均文件大小
hdfs dfs -du -s /user/hive/warehouse/user_behavior_optimized
# 总大小:31GB(ORC压缩后),平均每个文件:25MB6.4 治理效果
| 指标 | 治理前 | 治理后 | 提升 |
|---|---|---|---|
| 文件数量 | 4700万 | 1247 | 减少99.997% |
| 平均文件大小 | 2.3KB | 25MB | 增大10000倍 |
| 存储空间 | 109GB | 31GB | 减少72% |
| 查询耗时 | 5分钟 | 15秒 | 提升20倍 |
| NameNode内存 | 95% | 45% | 减少50% |
七、最佳实践总结
7.1 黄金法则
- 文件大小目标:单个文件128MB-1GB之间最佳
- 文件数量控制:单个分区文件数不超过1000
- 分区粒度:优先按天分区,避免按小时/分钟
- 存储格式:优先使用ORC/Parquet
- 定期巡检:每周检查文件数量,及时治理
7.2 监控脚本
#!/bin/bash
# 小文件监控脚本
THRESHOLD=10000 # 告警阈值
PATHS=(
"/user/hive/warehouse/db1.db"
"/user/hive/warehouse/db2.db"
)
for path in "${PATHS[@]}"; do
echo "检查路径: $path"
# 统计文件数量
file_count=$(hdfs dfs -count $path | awk '{print $2}')
# 统计小于1MB的文件数
small_files=$(hdfs dfs -ls -R $path | awk '$5 < 1048576 {count++} END {print count}')
echo " 总文件数: $file_count"
echo " 小文件数(<1MB): $small_files"
if [ "$small_files" -gt "$THRESHOLD" ]; then
echo " ⚠️ 警告:小文件数量超过阈值!"
# 发送告警...
fi
echo ""
done八、写在最后
小文件问题,是每个大数据工程师都会遇到的"成长之痛"。
它看起来不起眼——不就是文件小点嘛,能有什么问题?但当你的集群因为几千万个KB级别的小文件而濒临崩溃时,你就会明白:在大数据的世界里,"小"问题往往是"大"麻烦。
解决小文件问题的核心思想就一句话:
"合久必分,分久必合"——该拆的拆,该合的合,保持平衡。
记住几个关键数字:
- 文件大小:128MB-1GB
- 单分区文件数:< 1000
- NameNode内存占用:< 70%
如果你正在被小文件困扰,希望这篇文章能帮到你。如果你还没遇到这个问题——别担心,迟早会遇到的😄。
最后,送你一句话:
"治理小文件最好的时间是在它产生之前,其次是现在。"
祝你的集群永远健康!🐘
本文作者:一个曾经被4700万小文件支配的程序员
最惨经历:ls一个目录,等了20分钟还没出结果
系列番外完结,感谢阅读!🎉
附录:小文件问题面试题
什么是HDFS小文件问题?为什么会产生这个问题?
小文件指远小于Block大小的文件。问题根源是每个文件无论大小都要占用约150字节的NameNode内存,大量小文件会导致NameNode内存耗尽。
小文件问题有哪些影响?
NameNode内存压力、MapReduce效率下降(Task过多)、集群启动慢、查询性能差。
如何解决小文件问题?
事前:合理分区、控制输出文件数、采集端优化
事中:CombineFileInputFormat、Hive/Spark合并参数
事后:HAR归档、定期合并、转ORC/ParquetHive中如何处理小文件?
输入端:使用CombineHiveInputFormat
输出端:设置hive.merge.mapredfiles=true
存量数据:ORC表用CONCATENATE,其他用INSERT OVERWRITECombineFileInputFormat的原理是什么?
在生成InputSplit时,把多个小文件合并到一个Split中,从而减少Map Task数量。它是逻辑合并,不改变实际文件。
为什么推荐使用ORC/Parquet格式?
列式存储、高压缩比、支持谓词下推、ORC支持CONCATENATE快速合并。相同数据量,文件更少更小。