首页 / 资讯中心 / 文章详情

ETL存储优化实战:压缩算法、列式存储与小文件治理

ETL存储优化实战:压缩算法、列式存储与小文件治理 ★ FEATURED ARTICLE
最近这段时间连续帮两个业务团队优化ETL链路发现一个很有意思的现象大家聊起ETL就是调度、数据质量、迟到数据很少有人真正关心“数据写完之后到底占了多大地方”。但只要账单出来存储成本往往是最先让人肉疼的那一项。有一次某张日增量几百GB的明细表因为用了行式存储且没有压缩半年跑下来占掉的存储比原始日志还多出好几倍而下游查询反而越来越慢。问题就出在一个经常被忽视的组合上——数据压缩与存储优化。这篇文章我会从ETL场景出发把压缩算法怎么选、列式存储和压缩的搭配逻辑、分区与小文件治理、以及落地时需要盯的监控指标完整过一遍。所有内容都是我在真实任务里验证过的做法不是教科书式罗列。如果你正在负责离线数仓或实时链路的结果表存储治理读完之后至少能把“存储为什么爆炸”“查询为什么变慢”“该从哪里下手”这三个问题想清楚。1. ETL存储成本与压缩优化的核心思路1.1 为什么ETL做完数据反而越来越大不少人对ETL有个直觉原始数据经过清洗、过滤、聚合之后量肯定变小了。但现实往往相反。我见过一个典型的案例原始日志1TB经过ETL之后生成了三份数据——一份用于即席查询的明细宽表一份用于报表的轻度汇总表还有一份为了兼容旧接口而保留的冗余副本。三份数据加起来超过了3TB而且因为创建时没指定压缩格式单副本膨胀率还很高。这里面的矛盾在于ETL的目标是让数据“更好用”但“更好用”往往等于“更多冗余”。宽表把多张表的字段合并到一起重复存储了公共维度汇总表把明细结果按多个粒度重复加工为了容灾或跨集群同步又额外存了一份副本。每一份都合情合理叠加起来就成了存储黑洞。所以做存储优化第一步不是着急换压缩算法而是先问一个问题这份数据真的需要存在吗我通常建议业务团队对每张ETL结果表做一次“存在性审计”——记录表的最后访问时间、每日查询次数、下游依赖数量。连续30天无访问且无下游依赖的表直接进入回收流程。这一步做完好多集群的存储立刻就能省下20%以上比调压缩参数见效快得多。1.2 压缩优化和存储优化的边界很多人把“压缩优化”和“存储优化”混为一谈其实两者的边界很清楚。压缩优化是技术手段关注的是“怎么用更少的比特存同样的数据”。它解决的是编码层面的浪费比如文本字段的重复模式、数值列的高位冗余、JSON日志里的重复Key。存储优化是系统设计关注的是“在满足查询需求的前提下让数据的总物理占用和访问开销最小”。它包含压缩但更包含表结构设计、生命周期管理、副本策略、分区和文件大小治理。我见过一个团队把存储成本高完全归咎于“没有用Snappy”结果换了Zstd之后存储确实降了一些但小文件问题没解决查询依然慢。换压缩只是把马车换成了更好的马但路还是那条坑坑洼洼的路。小文件治理、分区裁剪、列裁剪这些“路况”问题不解决存储和查询的综合成本依然降不下来。1.3 从压缩率转向“综合成本”视角衡量压缩做得好不好不能只看压缩率一个指标。压缩率高的算法往往CPU开销大如果ETL任务本身已经CPU密集换用高压缩率算法反而会让任务耗时变长挤占集群资源。同样一个压缩率稍低但解压速度极快的算法可能让下游查询快出一倍综合算下来反而“更省钱”。我在评估一个存储方案时通常用一个简化的成本模型总成本约等于存储占用成本加上写入压缩CPU成本加上下游查询解压IO成本。三个变量往往此消彼长。比如Gzip压缩率比Snappy高但写入慢、解压也慢LZ4解压极快但压缩率偏低Zstd则能在两个方向上都做得不错。所以不存在“哪个压缩算法最好”只存在“哪个压缩算法最适合你的读写比”。关于具体选型下一节会展开讲。2. 压缩算法选型读多写少场景下怎么选2.1 主流压缩算法速览ETL场景里常见的压缩算法有这么几个Snappy、LZ4、Zstd、Gzip、Bzip2以及Parquet列式存储里会遇到的Brotli。它们的核心差异体现在压缩率、压缩速度、解压速度三者的取舍上。我在实际测试里把它们拉到一个对比框架里看大致是这样的结果算法压缩比压缩速度解压速度CPU开销典型适用场景LZ4低极快极快低实时写入、高频查询Snappy低很快很快低大多数离线ETL默认选择Zstd中高快快中存储成本敏感、读写均衡Gzip高慢中高冷数据归档、极少访问的表Bzip2极高极慢慢极高几乎不用除非对压缩率极端敏感这里要特别说明这些“直觉”只对普通行式数据成立。一旦配合列式存储情况会变化很多。列式存储自带编码层比如字典编码、RLE编码、Delta编码先做一层编码再做一层压缩。编码会把数据变成更规整的形态压缩算法在这上面的表现和压缩原始文本完全不同。所以选算法的时候一定要在真实的数据形态上测试不要拿网上普适的benchmark直接套。2.2 按数据特征和读写模式选算法读多写少的场景比如数仓里的明细层和汇总层下游查询非常频繁解压速度的重要性要排在压缩率前面。Snappy和LZ4是这一类场景的常客解压都在几百MB/s甚至更高查询侧几乎感知不到解压开销。Zstd在压缩率上比它们强不少解压速度也不差是目前性价比很高的选项。有一次我把一张日增量300GB的日志明细表从Snappy换成了Zstd压缩级别设为3存储占用下降了差不多35%查询耗时反而没有明显变差。原因在于这张表的存储格式是Parquet读取时大部分时间花在IO和后续计算上Zstd的解压开销被计算时间掩盖了。但如果你的任务本身就是CPU密集的比如每天跑大量的复杂Join和窗口函数压缩算法的CPU开销就要谨慎评估。我建议这种情况下先在测试环境做一个小时级的压测同一份数据用不同压缩算法各跑一次ETL对比总耗时和集群CPU水位不要只看存储节省。2.3 实测方法论两小时快速选型压缩算法选型不能拍脑袋我给团队定的测试流程非常简单两小时就能出结论准备一张典型ETL产出表的真实数据样本不要用随机生成数据。随机数据的分布和真实数据差异很大压缩结果完全不具备参考价值。样本量建议在5GB到20GB之间太小则压缩比的统计波动大太大则测试耗时太久。第一步在Parquet格式下分别用Snappy、Zstd(level1)、Zstd(level3)、Zstd(level6)、Gzip各写一遍数据记录文件大小。第二步对每个版本跑一条典型的聚合查询比如按天分组求和记录查询耗时。第三步对比两者的乘积——文件大小乘以查询耗时谁低选谁。这个“文件大小乘查询耗时”可以理解为综合成本文件大小代表存储成本查询耗时代表计算成本。多数情况下Zstd(level3)在这个乘积里表现最好。如果查询以超高并发点查为主Snappy或LZ4更稳。如果是纯归档表几乎不查Gzip或Zstd(level9)直接上能把存储压到极致。注意不要在ETL结果表上默认套用“压缩率越高越好”的逻辑。压缩率高通常意味着写入侧CPU开销大而ETL的写入往往是周期性批量任务如果写入时间被拉长会影响整个链路的SLA。3. 列式存储与压缩协同一个被低估的组合3.1 行式存储为什么压缩率上不去ETL里有很多表是直接从业务库同步过来的默认格式是行式存储。行式存储把一行里的所有字段挨个排在磁盘上包括姓名、地址、备注、状态码这些不同类型的数据。压缩算法在行式存储上的工作方式是“整块字节流一起压”但一行的数据往往比较短重复模式不够明显压缩算法能榨出的水分很有限。更重要的是行式存储里同一列的值散落在不同的磁盘块中。比如“城市”列在10000行里可能只有50个不同值但行式存储里这10000个值被10000行记录的其他字段隔开压缩算法处理时很难识别出这种高重复性。列式存储的思路完全相反把整张表按列拆开每一列的所有值连续存放。这样一来“城市”列就是10000个值连续排在一起再加上字典编码50个不同值各映射成一个短ID压缩率自然大幅提升。这也是Parquet和ORC在ETL结果表场景里能大幅降低存储的关键原因。3.2 Parquet与ORC的压缩机制差异Parquet和ORC是目前最主流的两种列式存储格式。它们都采用“先列式存储再块级压缩”的层次结构但内部机制有差异。Parquet的逻辑单位是Row Group每个Row Group内部按列划分成Column Chunk每个Column Chunk再根据大小细分为Page。数据先按列写入PagePage内部做编码字典、RLE、Delta等再由压缩算法对整个Page做二次压缩。读取时按需加载Page配合Page级别的统计信息最大值、最小值、空值数可以直接跳过不满足查询条件的Page。ORC的逻辑单位是Stripe每个Stripe默认256MBStripe内部同样按列存储并且为每列建立了Index Data包含行组级别的统计信息。ORC对谓词下推的支持很成熟读取时可以跳过范围外的行组。这两种格式在压缩这件事上的本质逻辑是相通的编码层先把数据变成“规整形态”压缩层再对规整形态做二次压缩。但有一个容易被忽略的差异——Parquet在嵌套数据(Struct、Array、Map)上的压缩效率通常优于ORC而ORC在某些简单列类型和大扫描场景下统计信息更精准。选型时不要只看压缩率还要看你的数据里嵌套字段的占比以及查询引擎对两种格式的支持成熟度。3.3 和查询引擎配合的选型建议如果你用的是Hive或SparkParquet和ORC都支持良好如果主要用Presto/Trino做交互查询两者也都能跑但Parquet的生态兼容性是公认最稳的。还要看点查场景下要不要Bloom Filter——Parquet支持Bloom FilterORC的索引机制本身也能达到类似效果但实现方式不同。我在实际项目里的选型建议很简单三条数据来源复杂、字段经常变、有较多嵌套结构优先Parquet。数据以简单列为主、查询模式非常固定、希望最大化谓词下推效果ORC值得考虑。下游同时接多种查询引擎Spark、Presto、HiveParquet是最不会出错的选择。但无论是Parquet还是ORC都需要在ETL写入时显式指定压缩算法否则默认可能是无压缩或特定的默认值导致存储优化效果打折。提示同一个Parquet文件里不同Column Chunk的压缩算法理论上可以不同但大多数引擎不支持这么细的配置。实践中保持整表统一压缩算法即可别把复杂度堆在这种细节上。4. 小文件与分区策略存储和查询的平衡点4.1 小文件才是存储优化的最大敌人小文件问题在大数据ETL里比压缩问题更隐蔽破坏力却更强。一张表的数据量只有几GB但因为每天一个分区、每个分区又按小时或者按业务线拆成几百个文件结果文件数上万每个文件只有几百KB到几MB。小文件带来的问题有三种第一文件系统元数据压力大海量小文件会让NameNode内存占用暴涨第二任务调度开销大一个Spark任务读1万个小文件每个文件都要启动一个Task或至少做一次分区扫描启动开销远大于计算开销第三查询性能变差即使文件内部压缩率很高被大量小文件拖慢的IO调度也会抵消压缩带来的收益。更麻烦的是压缩算法和文件分割之间存在冲突。有些压缩格式比如Gzip压缩的文本文件是不可分割的一个200MB的Gzip文件只能被一个Task处理没法并行读取。这在小文件场景下尤其尴尬——你为了压缩省了存储结果查询并发上不去。4.2 分区粒度的设计经验分区数量要匹配两个维度查询模式和写入频率。查询经常带日期过滤条件分区字段就必须有日期写入频率是小时级分区粒度就不能比小时更细否则每个分区里的数据量太小文件就会变得又碎又多。我的个人经验是离线数仓里日增量在10GB以上的表按天分区是相对稳妥的起点如果日增量在100GB以上查询又有小时过滤需求可以按天分区再加小时字段作为普通过滤条件不一定非要把小时也建成分区。分区的意义是裁剪文件但如果分区太细导致文件过小裁剪带来的收益会被文件调度成本抵消。这里有一个可以量化的参考标准每个分区目录下的总数据量最好在1GB以上单个文件大小最好在256MB到1GB之间。按这个标准反推分区粒度比凭感觉定要靠谱得多。4.3 分桶、排序与Salting分区解决的是“按某个维度裁剪”的问题分桶解决的是“文件数量不可控”的问题。对ETL结果表我强烈建议在写入时按高频Join或过滤字段做分桶。分桶之后每个桶内的数据会稳定落成固定数量的文件文件大小比较均匀查询时还可以做Bucket Pruning减少扫描的数据量。分桶的同时通常还要配合排序。排序不仅让查询条件下的数据局部性更好还能显著提升压缩率——相同值靠在一起RLE和字典编码的效果会明显增强。我做过一个测试同一份订单数据按“用户ID”排序后再用Zstd压缩文件大小比无序写入时又小了将近20%。但分桶和排序也有风险如果分桶字段的基数值很小会导致数据倾斜某些桶特别大某些桶特别小。这种情况下可以用Salting技巧把分桶字段加一个随机后缀将数据打散到更多桶中写入后再根据业务需要决定是否合并。这个方法在ETL写入时很实用能有效避免桶间数据量差距过大的问题。5. 落地实操从测试到监控的完整闭环5.1 新表设计阶段的强制规范压缩和存储优化最好的介入时机是建表阶段。一旦表已经跑了好几个月再改存储格式就要重写全量数据成本高很多。我建议在每个ETL任务里加一个“表设计登记”环节包含存储格式、压缩算法、分区字段、分桶字段、排序键、生命周期。这些信息记录在数据字典里而不是让开发凭经验自由选择。一张Hive表的建表语句可以参考这样的写法CREATE TABLE dws_order_detail_daily ( order_id STRING, user_id STRING, city_id INT, product_id STRING, amount DECIMAL(12,2), status INT, create_time TIMESTAMP ) PARTITIONED BY (dt STRING) CLUSTERED BY (user_id) SORTED BY (create_time) INTO 64 BUCKETS STORED AS PARQUET TBLPROPERTIES ( parquet.compression zstd, parquet.compression.level 3 );这张表的设计逻辑是按天分区保持裁剪能力按用户ID分桶方便与用户维表进行Join时的数据局部性优化按create_time排序提升查询范围扫描效率Parquet加Zstd是平衡压缩率和查询性能的选择。你可能会问为什么步把压缩级别写到3而不是更高因为级别越高写入压缩时的CPU开销越大而收益增长会递减。Zstd设置为3到6是性价比最高的区间9以上基本只适合归档场景。这个参数我不是拍脑袋定的是在一次全量重写任务上分别测试多个级别后确定的后面会讲具体方法。5.2 存量任务的改造流程存量表改造要讲策略不能一口气全改。一是因为全量重写的资源消耗可能把集群打满影响正在运行的线上任务二是因为没有对比数据无法评估改造效果。我处理存量表时通常会按四步走先列出所有ETL结果表按存储大小降序排列找出Top 20的大表。大表对存储成本影响最明显优先处理。再筛选出“高频查询表”和“低频归档表”分别采取不同策略。高频查询表优先考虑查询性能选择合适的压缩算法低频归档表直接上高压缩率算法比如Zstd级别9或Gzip把存储压到最低。然后对每张表做增量验证从最近的分区里抽取一部分数据用新格式写到一个临时表或测试目录对比旧格式的存储大小、查询耗时、写入耗时三个指标。验证通过后再对全表做重写。最后是灰度发布先改最近30天分区观察一周确认没有异常再改全量。灰度期间同时关注两个指标——查询任务的平均耗时是否下降ETL任务是否出现超时。如果这两个指标没有恶化说明改造是安全的。这个流程看起来很繁琐但能避免很多坑。我有过一次惨痛教训某张表全量改完格式之后一个下游数仓任务读取时出现类型不兼容问题只能连夜回滚。所以现在我做存量改造一定会先用一个新表名写一个周期数据让下游任务完整跑一遍确认无误后再切正式表名。5.3 监控指标与效果验证改造做完不等于结束需要持续观测数据。我建议至少盯住四个指标第一个是压缩比也就是逻辑数据量除以物理存储量这个值直接反映压缩效果。一般行式未压缩的表压缩比是1.0换成列式加Zstd之后能到3到5具体取决于数据重复度。第二个是平均文件大小分区内总数据量除以文件数。低于128MB就要警惕小文件问题高于2GB则要确认是否影响并行度。第三个是查询扫描字节数在Spark或Presto的查询日志里能看到。这个指标反映的是查询实际读取的数据量而不是表总大小。压缩和分区做得好不好最终都会体现在扫描字节数的变化上。第四个是ETL写入耗时。如果压缩算法导致写入时间明显增加可能需要降低压缩级别或者在业务低峰期运行。有了这些指标之后建议建一个定期巡检脚本把每张表的物理大小、文件数、分区数、最近访问时间拉出来对比上周同期的变化趋势。哪个指标异常了就去查对应的表而不是等到存储报警了再补救。6. 常见问题与排查技巧实录6.1 问题速查表这些年在ETL存储优化上遇到过的典型问题整理成一张速查表方便直接对号入座现象可能原因排查思路与解决方向换成高压缩算法后文件反而变大数据已做过编码压缩收益很小或压缩级别设置不合理检查是否对编码后的数据再做压缩降低压缩级别对比原始数据分布文件变小了但查询变慢了小文件问题掩盖了压缩收益或压缩算法解压开销大查看分区下文件数量检查平均文件大小尝试调大文件目标大小分区很多但查询依然扫描全表分区字段没有参与过滤条件或动态分区裁剪失效确认过滤谓词是否包含分区字段检查查询计划是否命中分区裁剪写入后文件数量暴增写入任务并行度过高每个Task产出多个小文件使用合并或重分区操作降低写入并行度设置文件大小目标某个分区的文件大小特别大数据倾斜导致某个分桶或分区数据量异常检查分桶字段的基数值分布使用Salting打散数据Gzip压缩文件无法并行读取Gzip格式不可分割改用Zstd或Snappy或在ETL时预先做文件内部的格式转换排序后压缩率没有提升排序键选择不当相同值没有靠在一起改为按重复度高的维度字段排序使用多级排序键6.2 场景复盘一次查询变慢的排查这里分享一个实际排查案例有参考价值。某个团队反馈一张ETL结果表从Hive迁移到Spark SQL之后查询经常扫全表凌晨的定时报表任务从原来的20分钟涨到了50分钟。第一步查表结构发现这张表是Parquet分区表按天分区分区字段是dt没有问题。第二步跑了一个典型查询并看执行计划发现Filter条件里带的是substr(create_time, 1, 10) 2025-01-01而不是直接的dt字段。Spark无法把这个表达式推导成分区过滤条件结果就是全表扫描。解决方案很简单在ETL脚本里增加一个dt字段直接把日期字符串写入查询时把过滤条件改成dt 2025-01-01。改造之后扫描量从全表降到了单分区查询耗时从50分钟回到10分钟以内。这个案例很典型它说明存储优化不只是“用什么格式压缩”的问题还包括“查询能不能正确裁剪数据”。如果分区字段设计好了但过滤条件写得不规范所有的压缩和存储优化都白做了。再分享一个文件大小治理的案例。某张订单表每天写入后会产生上千个小文件每个只有几MB。排查发现写入任务开了200个并行度每个Spark Task处理的数据量很小但各自独立写入产生了大量文件。解决方法是写入前做一次repartition(64)把数据落到64个文件里再配置合适的文件大小目标。改完后文件数从上千降到了64个查询时间缩短了一半以上。6.3 几个值得坚持的实操习惯最后说几个我踩过坑之后总结出的实操习惯可能对大家有参考价值。第一临时表尽量不用高压缩率。ETL过程中间表通常是写一次读一次生命周期又短用Snappy或LZ4就够了。给临时表上Zstd级别9纯粹是浪费CPU对最终存储也没有正向影响。第二压缩算法变更必须带版本记录。ETL脚本里的存储格式和压缩参数应该像代码版本一样可以追溯。哪天发现某张表的存储异常增长能快速定位是哪个版本改了什么导致的。第三不要对排序列使用过高压缩级别。排序后的列相同值连续RLE编码已经能省很多空间再配很高压缩级别投入产出比很低。建议排序列所在表使用Zstd级别3即可把高压缩级别留给那些重复度低、无法通过编码大幅压缩的列。第四定期做“压缩重写”Compaction。即使设计再好日积月累的增量写入也会让文件大小分化小文件越来越多。建议离线表每月做一次Compaction将近期分区的小文件合并成合理大小的文件重新按排序键排序然后再重写一次。这个操作对存储和查询性能都有正向作用。压缩和存储优化这件事没有哪一套参数是放之四海而皆准的。我在不同团队、不同数据特征的任务上反复验证过最后发现最适用的原则反而最简单永远基于真实数据样本来测试永远盯着综合成本而非单一指标永远把文件大小和查询扫描量当作最需要盯住的两个仪表盘。只要这两套仪表盘的数据是健康的存储和查询通常都不会出大问题。希望这篇文章能让你少走一些我走过的弯路。
阅读完成 · 觉得有帮助?
咨询建站