我们平时维护大数据集群,最绕不开的一件事就是存储成本。尤其是HDFS上堆了几百TB甚至PB级的数据,每次看到告警说“磁盘空间不足”,第一反应都是赶紧清理临时目录,但治标不治本。真正能长期见效的手段之一,就是在数据落盘时选择合适的压缩算法。不过压缩算法选型这个事,网上资料虽然多,多数都停留在“Gzip压缩率高、Snappy速度快”这类笼统结论,真要落到生产环境,要考虑的可不只是这两个维度。
这篇文章我从HDFS实际存储和计算的角度,把主流的几个压缩算法放在一起做个对比,结合我这边运维和调优的实践经历,聊聊选型思路、踩过的坑,以及一些可以直接参考的参数配置。
1. 为什么HDFS上的数据压缩不是简单套个格式
1.1 存储、网络、CPU三笔账要一起算
很多刚接触大数据的同学以为压缩就是为了省磁盘,其实不全对。在HDFS上做压缩,本质是在用CPU换存储空间和网络带宽。写入数据时先压缩再落盘,读取数据时先解压再计算,整个过程CPU多承担了一些工作量,但磁盘IO和网络传输的压力会明显降下来。
我举个实际场景。某个业务线每天产生几十亿条日志,原始文本格式大约占300GB空间。如果直接存储不动,一周下来就是2TB出头,再加上副本机制(默认3副本),实际占用的物理存储接近6TB。如果改用Snappy压缩,压缩比大约在2到3倍之间,可能降到1TB多,配合副本后约3TB多;如果换成Gzip或Bzip2,压缩比能达到4到5倍,物理占用会更低。
但注意,压缩比高不代表一定划算。如果这个目录的数据后续经常要被Spark或Hive动态查询,那每次查询都要耗费大量时间解压。Gzip虽然能把几百GB压成几十GB,解压时CPU开销也不小,尤其在高并发查询场景下,压缩带来的CPU开销可能会成为新的瓶颈。
所以在HDFS上做压缩,第一原则是:看你的数据是“写一次读多次”还是“写入频繁读也频繁”,前者可以偏向压缩率,后者必须偏向解压速度和并发能力。
1.2 压缩算法与数据切片的爱恨纠葛
HDFS的核心设计思想是把大文件切分成128MB或256MB的块,分布式存储、并行计算。MapReduce或Spark读数据时,一个split对应一个块,理想情况是一个块一个任务并行处理。但这里有个特别关键的细节:有些压缩格式不支持“从中间开始解压”。
什么意思呢?比如Gzip,它的压缩流是一个整体,要解压第2个数据块的起始位置,必须从头开始把前面的数据全部解压。因此当Gzip文件被切分到多个HDFS块时,虽然存储上分开了,逻辑上却没法并行读取——每个任务都得从这个文件的最开始去解压,相当于只用一个任务串行读整个文件。这在MapReduce里是最典型的“数据本地性失效”,并行动能全被浪费。
相比之下,Snappy和LZO如果不做特殊处理,其实也类似,不天然可分片。但LZO有配套的索引文件工具,生成一个.lzo.index后就可以按块切分;Snappy在SequenceFile或Parquet这类容器格式中,由于内部按块组织压缩数据,天然支持从块边界开始解压,所以也不存在“不能切片”的问题。
Bzip2则很特别,它本身就支持块级独立压缩,每个块大约900KB,压缩数据内部带有块边界标记,所以Bzip2压缩文件天生可切片。
这一条基本决定了:如果你的数据是纯文本存储(比如TextFile格式),又希望通过Hive或MapReduce直接做并行查询,压缩算法最好选Bzip2或者带索引的LZO;如果用Parquet、ORC这类列式存储,Snappy和LZO都是好选择,因为文件格式已经保证了内部块的可分割性。
2. 主流压缩算法横向对比与选型思路
2.1 各算法核心指标一览
我在测试环境分别用一份真实的业务日志文本(约10GB)、一份数值型表数据(约2.5亿行),以及一组JSON格式的事件数据做了压测。这里不贴全部测试数字了,直接整理成对比表会更直观。
| 算法 | 压缩比(越高越好) | 压缩速度 | 解压速度 | 是否原生可切片 | 综合CPU开销 |
|---|---|---|---|---|---|
| Gzip | 4.1 | 中等 | 中等 | 否 | 中高 |
| Bzip2 | 5.5 | 慢 | 慢 | 是 | 高 |
| LZO | 2.5 | 快 | 快 | 需建索引 | 低 |
| Snappy | 2.2 | 很快 | 很快 | 容器格式内支持 | 低 |
| Zstandard | 3.5 | 可配置 | 快 | 否 | 中 |
| LZ4 | 2.0 | 极快 | 极快 | 容器格式内支持 | 极低 |
表格里压缩比是文本类数据的典型值,如果是数值型数据或压缩率很低的结构化数据,压缩比会小一些。但这张表足够帮我们建立直觉:压缩比和速度基本是跷跷板,但在具体应用场景下,我们会更看重解压速度和可切片性。
2.2 从使用场景反推算法选型
场景一:冷数据归档,很少被读取,存储空间紧张。这类数据的特点是“写入一次,基本不读,万一要读也能等”。选Bzip2通常是最优解,它能给出最好的压缩率。缺点就一个字:慢。无论是压缩还是解压,速度都不理想,比如压缩10GB文本,Gzip大约两到三分钟,Bzip2可能要十五分钟以上。但如果这个数据三个月才被拉出来审计一次,这点时间完全能接受。
场景二:热数据,频繁被查询分析。比如数仓里的ODS层明细表,每天被大量任务读取。这时候选Snappy的性价比最高。压缩速度极快,解压速度也快,虽然压缩比只有2倍左右,但CPU开销很低,能让查询引擎的瓶颈重新回到磁盘扫描和计算上,而不是解压上。对于Spark这类内存计算框架,Snappy几乎可以做到解压透明化。
场景三:需要精确控制切片粒度的非容器格式数据。如果你还在用TextFile,又希望文件可以分割并行处理,LZO配索引是我用得最多的方案。Hadoop生态对LZO的支持比较成熟,压缩速度比Snappy稍慢,压缩比稍高,配一个索引文件后,MapReduce和Spark都能按块并行读。缺点是部署稍麻烦,需要在集群上安装native的LZO库,生产环境初始化时要多费点功夫。
场景四:数据要跨集群传输,或写入对象存储做长期留存。这类场景我偏向Zstandard。Zstandard是较新的算法,压缩率接近Gzip,但速度快得多,而且支持压缩级别动态调节。比如传输前用Zstd级别3快速压缩,能减少不少网络IO;如果留给后续分析,解压也很快。唯一麻烦的是老版本Hadoop生态原生库不一定带Zstd,需要确认版本兼容性。
2.3 关于Snappy,多说几句
Snappy是Google开源的无损压缩算法,设计目标就是“极速而非最小体积”。它不追求极限压缩率,而是把解压速度做到亚毫秒级别,内部实现大量使用SIMD指令优化,每个字节的处理开销极小。
在Hadoop生态里,Snappy几乎成了默认选择。查一下Hive或Spark的默认配置就会发现,很多压缩参数默认值就是Snappy。一个很直观的原因:Hadoop上的数据访问频率极高,用户希望查询任务的大部分时间花在真正处理数据上,而不是在解压上。Snappy在这方面的表现非常稳定,CPU占用低、响应快。
弱点也存在。Snappy的压缩文件如果不经过容器格式整理,HDFS照样无法并行切片。我见过不少同学直接往HDFS上写.snappy后缀的纯文本文件,结果跑MapReduce时只能一个任务串行读,数据量一大就慢得离谱。所以Snappy的正确用法,要么是配合SequenceFile,要么配合Parquet/ORC等列式存储。
3. 压缩和存储格式:两者密不可分
3.1 行式存储与列式存储对压缩的影响
很多初学阶段的认知里,压缩算法和数据格式是两回事。但在选型时,这两者必须联动考虑。
行式存储,比如TextFile或SequenceFile,数据按行排列存储。压缩时是对整块字节流做处理,算法选择的自由度大,但可切片性是个大问题。如果处理不好,就容易出现“文件不能并行读”的性能灾难。
列式存储,比如Parquet和ORC,压缩的对象从“整块字节流”变成了“一列的同类型数据”。这个差别很关键:同一列的数据类型一致,数值、枚举、时间字段在编码后往往有很好的重复度,压缩率天然比整行混合数据高。
以Parquet为例,它内部按行组(Row Group)组织数据,每个列块可以独立编码和压缩。由于每一列的数据模式更单一,即使是用Snappy这种高速度低压缩率的算法,实际压缩效果也比直接Snappy压缩整个文本好很多。ORC类似,它还支持额外的字典编码和Run-Length编码,在低基数枚举值字段上压缩效果特别明显。
所以现在的实践趋势是:用Parquet/ORC这类列式存储,内部配Snappy压缩,压缩率介于“纯文本Gzip”和“纯文本Snappy”之间,但压缩和解压速度远快于Gzip,且天然支持切片。这个组合对于大多数数仓分析场景,几乎是性价比最优解。
3.2 表存储参数配置示例
如果用的是Hive,一张Parquet表加上Snappy压缩,建表语句长这样:
CREATE TABLE user_event_log ( user_id STRING, event_type STRING, event_time TIMESTAMP, event_detail MAP<STRING, STRING> ) PARTITIONED BY (dt STRING) STORED AS PARQUET TBLPROPERTIES ( 'parquet.compression' = 'SNAPPY' );如果是ORC表,可以显式指定压缩格式:
CREATE TABLE user_event_log_orc ( user_id STRING, event_type STRING, event_time TIMESTAMP, event_detail MAP<STRING, STRING> ) PARTITIONED BY (dt STRING) STORED AS ORC TBLPROPERTIES ( 'orc.compress' = 'SNAPPY' );注意这里有个细节:Parquet的配置参数在不同的计算引擎里名字可能不同。Hive里允许在TBLPROPERTIES里指定parquet.compression,Impala里可能用PARQUET_COMPRESSION_CODEC的会话选项。如果在Spark里用DataFrame写入Parquet,你需要这样配置:
df.write.option("compression", "snappy").mode("overwrite").parquet("/path/to/table")我踩过的坑是:有时候明明代码里指定了压缩格式,写完以后去HDFS上看文件后缀,看不出任何信息。这时不要怀疑配置没生效,Parquet文件在文件末尾会有一段metadata,记录了压缩算法,直接检查文件footer才是准确的做法。可以用parquet-tools查看:
parquet-tools meta hdfs://nameservice/data/table/dt=2024-01-01/part-00000.parquet输出里会列出各列的压缩方式,确认是Snappy即可。
3.3 压缩级别参数的灵活使用
Zstandard还有个特色是compression level可调,从1到22,级别越高压缩率越好,但速度越慢。Hadoop生态里配置Zstd后,可以通过参数dfs.datanode.compression.codec.zstd.level去控制级别。通常建议是3到9之间,线上我一般用3,追求速度;如果对压缩率更敏感,可以调到9,性能损失也能接受。
Gzip其实也有类似的权衡,但它的级别调节范围有限,默认的6级别是综合考虑后的平衡点。一般不建议为了压缩率把级别调到9,压缩时间会成倍增加,压缩率提升却只有3%到5%,不划算。
4. 生产环境压缩配置的完整落地指南
4.1 从HDFS、MapReduce到Spark的逐层配置
理论聊了不少,实际配置到底怎么做?我按数据链路往下游一层一层看。
首先是HDFS本身是否启用压缩。其实HDFS作为一个分布式文件系统,默认是不对文件做任何自动压缩的,它只负责存块。压缩行为发生在写入数据的计算引擎那一层。不过可以确认一下集群是否安装了对应的Codec库,用下面的命令检查:
hadoop checknative输出会列出gzip、bzip2、snappy、lzo、zstd等是否available。这一步排查问题常会用到。比如有大任务提交后报“Compression codec SnappyCodec not found”的错误,十有八九就是native库没装好。
然后是MapReduce层面。可以在mapred-site.xml里配置中间结果压缩和输出压缩。中间结果压缩很多人会忽略,但Shuffle阶段数据量巨大,启用压缩能减少网络传输,效果很明显:
<property> <name>mapreduce.map.output.compress</name> <value>true</value> </property> <property> <name>mapreduce.map.output.compress.codec</name> <value>org.apache.hadoop.io.compress.SnappyCodec</value> </property> <property> <name>mapreduce.output.fileoutputformat.compress</name> <value>true</value> </property> <property> <name>mapreduce.output.fileoutputformat.compress.codec</name> <value>org.apache.hadoop.io.compress.SnappyCodec</value> </property>这里建议Map输出用Snappy,因为它是中间数据,硬压缩没必要,快才是关键。最终输出格式再看需求选择。
Spark层面,如果是Spark SQL写Parquet,在spark-defaults.conf里配置:
spark.sql.parquet.compression.codec=snappy spark.sql.orc.compression.codec=snappy如果Spark作业是用TextFile写文本文件,可以设置:
spark.hadoop.mapreduce.output.fileoutputformat.compress=true spark.hadoop.mapreduce.output.fileoutputformat.compress.codec=org.apache.hadoop.io.compress.SnappyCodec spark.hadoop.mapreduce.output.fileoutputformat.compress.type=BLOCKcompress.type=BLOCK也很重要。它表示对HDFS块级别做压缩,而不是Record级别或None级别。BLOCK类型配合容器格式,很多时候才能正常发挥切片能力。
4.2 压缩相关的参数计算与实际效果验证
为了让大家有直观感受,我举一个实际调优的案例。某离线任务每天处理大约3亿条PV日志,原始文本每个小时约120GB,一天下来接近1.5TB,按3副本存储,物理占用4.5TB。
优化前:直接存TextFile不压缩,HDFS占用4.5TB,每天跑全量统计任务需要扫描4.5TB数据,耗时约2小时20分钟。
优化方案:数据落地改成Parquet存储,压缩算法Snappy,分区按小时。优化后,单日数据压缩到约600GB,物理占用1.8TB,存储成本直接降了60%。因为Parquet按列存储,查询只扫需要的列,实际扫描数据量从1.5TB降到大约200GB,统计任务耗时降到35分钟左右。
这个案例中,压缩算法选Snappy其实不算最优,因为Parquet文件如果换Zstandard,压缩率更高,但解压速度稍慢。经过压测,在这个查询模式偏简单的场景里,Snappy和Zstandard在最终任务耗时上差距不超过5%,而Snappy的压缩阶段耗时更短。考虑到后续还有其他任务频繁读同一份数据,最终保留Snappy。
4.3 数据写入压缩时要注意的写入放大
压缩本身不产生额外数据,但要注意的是,压缩格式和处理框架如果选得不匹配,可能会产生写入放大的问题。例如向一个原本压缩率很高的字段做小文件大量写入,文件太小导致无法合并压缩块,元数据开销相对增大,实际有效内容占比反而低。
这里建议开启Hive或Spark的小文件合并机制,把碎片化写入合并成大文件再压缩。否则即便压缩算法再好,文件数量一多,NameNode内存就报警了,性能一样上不去。
5. 实际操作中常见的问题与排查经验
5.1 Codec找不到或ClassNotFound
这是最常遇到的问题。报错信息类似:
java.io.IOException: Compression codec org.apache.hadoop.io.compress.SnappyCodec not found.这种绝大多数不是Jar包缺失,而是native库缺失。Hadoop的压缩Codec依赖本地C/C++库,比如Snappy、LZO、Zstd都是。解决方法是先在每台节点上安装对应的native库,比如在CentOS上通过yum install snappy snappy-devel,然后重新编译或更新Hadoop的native库目录。安装完以后,用前面提到的hadoop checknative验证。
LZO稍微特殊,它的Hadoop支持包通常需要单独下载或编译,里面包含了hadoop-lzo的Jar以及native库。配好后还要在core-site.xml里显式注册Codec:
<property> <name>io.compression.codecs</name> <value>org.apache.hadoop.io.compress.GzipCodec,org.apache.hadoop.io.compress.DefaultCodec,org.apache.hadoop.io.compress.BZip2Codec,org.apache.hadoop.io.compress.SnappyCodec,com.hadoop.compression.lzo.LzopCodec</value> </property>而且LZO建索引的步骤很容易被忽略。写完.lzo文件后,需要跑一下索引命令,否则文件不可分片:
hadoop jar /path/to/hadoop-lzo.jar com.hadoop.compression.lzo.DistributedLzoIndexer /path/to/data/file.lzo建议这一步在日常ETL脚本的末尾自动执行,不然下游Spark作业读到没有索引的LZO文件就抓瞎了。
5.2 SequenceFile里的压缩类型选择
如果还在用SequenceFile,它有三种压缩模式:无压缩、记录级压缩(Record Compress)、块级压缩(Block Compress)。块级压缩对一大块记录统一压缩,压缩率高且CPU开销相对合理,是生产环境唯一推荐的选择。
记录级压缩是指对每一条记录的value单独压缩。数据量一大,每条记录都带压缩头部,开销高、压缩率差。我曾经见过一个同学把SequenceFile设为Record压缩,一个20GB的文件实际压缩后还有16GB,明显没起到省空间的效果。改成Block压缩后,立刻降到6GB。选型时优先保证Block压缩。
5.3 ORC与Parquet在压缩上的天然差异
虽然现在很多团队主推Parquet,但ORC在Hive场景下,尤其是大量聚合类SQL,有它的独到之处。ORC内置了更丰富的编码方式,比如对字符串列做字典编码,对数字列做增量编码,这些编码发生在压缩之前,会大幅提高后续压缩算法的效率。
所以在相同的Snappy压缩条件下,ORC的压缩率通常略好于Parquet。但Parquet在Spark生态里的兼容性和谓词下推能力更强。我的看法是:如果在以Hive为重心的数仓,ORC可以优先;如果Spark SQL为主,Parquet用起来更顺手。两者都支持Snappy,选型前做好小规模压测才是稳的。
5.4 文件格式不变动时,临时切换压缩算法
有人会问:历史数据已经用Gzip存了好几个月的文件,能不能不重新写数据就换算法?答案是不能。压缩算法是在文件字节流层面做的,想换算法必须重写数据。
不过如果只做临时验证,可以考虑在建临时表时改变压缩设置:
SET hive.exec.compress.output=true; SET mapreduce.output.fileoutputformat.compress.codec=org.apache.hadoop.io.compress.SnappyCodec; INSERT OVERWRITE TABLE temp_table SELECT * FROM old_gzip_table;但注意,这种方式的成本就是全量读、再全量写,对集群IO是一次考验。大表建议放在低峰期执行,并且控制并发度。
5.5 压缩对数据倾斜和查询计划的隐性影响
压缩文件不可分片这个坑,带来的不只是性能损失,有时候还会引发数据倾斜。一个大的Gzip文件在小集群上跑任务,所有数据只能由一个Map处理完,即便集群有几千个CPU核心,这个任务也只会用到一个CPU去解压。我曾遇到一个跑了几小时的任务,后来发现瓶颈就在一个1.8GB的Gzip压缩文件上。
排查方法很简单,到HDFS上看文件的存储情况和任务日志。如果发现一个split的Input Size特别大,而其它split很小,多半就是文件不可分片。尽早发现尽早重写。
5.6 压缩算法对比压测的几个建议
最后说一下如何做一次靠谱的压测。不要只看官方的benchmark,那通常是在标准测试集上的结果,和你真实数据差别可能很大。我建议这样操作:
- 从生产环境抽1GB到10GB真实数据,覆盖业务内的典型数据形态。
- 分别用Gzip、Snappy、LZO、Zstd、Bzip2压缩,记录压缩后文件大小。
- 用Spark或Hive对压缩后的数据跑同一套SQL(包含扫描、过滤、聚合),记录总耗时。
- 观察集群CPU、磁盘IO和网络IO,确认瓶颈不在资源冲突。
- 连续压测三天,取平均值,避免瞬时状况干扰结论。
压测时还要注意,YARN的资源调度会影响测试结果,最好在低峰期进行。我用这种方式帮不同的业务线选过多次压缩方案,每次结论都不同,但过程是一致的:用真实数据说话,别信经验主义。
6. 小结与实践体会
聊了这么多,整理一下我的选型逻辑:如果数据要长期归档、极少读取,选Bzip2或高等级Zstd;如果数据是分析热数据,用Parquet/ORC配Snappy;如果还在用文本文件做数据湖且要求文件可切分,选LZO配索引;如果数据要跨集群传输,Zstd是个好选择。
我个人在实际维护集群过程中的体会是,压缩算法没有绝对的好坏,每次选型都要回到数据形态、访问频率、计算引擎、集群资源这四个维度去权衡。而且一旦定了方案,尽量写进ETL模板和表规范里,避免后面的人随意覆盖。加了一个parquet.compression参数就能省下60%存储的案例,我这边发生过不止一次,收益远比想象中明显。
最后再分享一个小技巧。上线新的压缩方案前,先拿过去30天的元数据统计一下哪些表读取最频繁、哪些表占存储最大。读取频繁的表优先保证解压速度,占存储最大的表重点看压缩率。集中优化Top 10的表,集群整体压力往往立刻就能降下来,这也是投入产出比最高的优化路径。