上周有个同事跟我倒苦水:同样的查询,在测试环境跑只要几秒,到生产环境就要十几分钟,明明加了那么多节点,为什么还是慢?我说你先别急着加机器,把数据文件打开看一眼,问题多半出在存储格式上。
这类现象在数据工程里太常见了。列式存储现在已经是数据湖和数据仓库的默认底牌,Parquet、ORC这些词大家都会念,但真正把列式存储的优化技巧用到位的人,真不多。很多人以为用了Parquet就等于优化,实际只是把CSV换了个马甲,文件结构、压缩编码、排序策略全都交给默认值,等于没优化。这篇文章就把我在实际项目中打磨出来的列式存储优化经验完整梳理一遍,从存储结构、压缩编码、排序键、Schema设计到写入链路,挨个讲清楚原理和实操参数,适合数据工程师、数仓开发,以及用Spark、Hive、Trino、Doris但始终被查询性能困扰的读者。
你会发现,数据工程里很多“慢”根本不是计算慢,而是存储层不配合。列式存储优化技巧这东西,听起来跟以前折腾系统优化一样,翻翻帖子能列出一堆参数,但真要落地时,很少有人说得清为什么这么改。这篇文章,我就是来把“为什么”补上的。
1. 列式存储的底层逻辑:为什么存储格式决定查询速度
1.1 你存数据的方式,就是查询时的扫描路径
先聊聊最基础的东西。行式存储比如CSV、JSON,甚至传统关系型数据库的堆表,是把同一行的所有字段放在一起。你要查一列,必须把整个文件或者整页数据都读进来,然后在内存里把不需要的列丢掉。这个动作在数据量小的时候无感,一旦上了十亿行、几百个字段,代价就非常恐怖。
列式存储的做法完全反过来,它把同一列的数据连续存放在一起。查询的时候,引擎只需要读取目标列对应的数据块,这个机制叫列裁剪,也叫Projection。举个直观的例子:一张200列的用户行为日志表,分析UV只需要device_id、event_name、event_date三列,列存只需要读这三个字段的数据块,行存要把全部200列都扫一遍。扫描量的差距不是百分之几十,而是几十倍。
列式存储的第二个优势藏在“同质化”里。同一列的数据类型一致、语义相近,压缩算法更容易发挥。比如channel_id这一列的值基本在0到100之间波动,压缩后可能只有原始大小的十分之一。而行存文件里每行都是各种类型混在一起,压缩时找不到规律,压缩率自然上不去。
理解到这一步,你就能明白为什么数据仓库领域的核心表几乎都倒向了列存。查询模式是读多写少、按列聚合、范围扫描,列存把这个路径优化到了极致。
1.2 理解Parquet/ORC的数据组织结构:Page、Row Group、Footer
光知道列存好还不够,真要调优,得看懂文件是怎么组织的。我以Parquet为例拆一下,ORC结构类似,只是叫法不同。
Parquet文件从外到内分四层:
- 文件最外层是多个Row Group,Row Group把整张表的数据按行切成一段一段的逻辑单元,每段包含一定数量的行,比如默认128MB对应一个Row Group。
- Row Group内部,每一列对应一个Column Chunk,也就是说一个Row Group内有几个字段,就有几个Column Chunk。
- Column Chunk内部又切成多个Page。Page是Parquet最小的编码单元,默认大小1MB左右。写入时数据是一页一页编码的,读取也是一页一页解码的。
- 文件末尾是Footer区,保存整个文件的Schema、每个Row Group的每列统计信息,包括min/max、null数量、字典信息等。
这四层结构你必须刻在脑子里,因为后面所有优化技巧,本质上都是在跟这四层打交道。查数引擎在读取Parquet文件时,先读Footer,根据查询条件判断哪些Row Group可以直接跳过。比如查询条件要求event_date在某个范围内,而某个Row Group里event_date的min/max区间完全落在范围外,这个Row Group就不用读了。
ORC的对应关系:Stripe对应Row Group,默认64MB,Index Data里保存每列的行索引和统计信息,Row Data则切成多个Row Group。读起来同样是先用统计信息裁剪,再访问需要的数据段。
很多优化问题,归根结底就是三个字:剪得少。查询扫描了大量本不该扫描的数据,性能自然上不去。
2. 压缩与编码的优化技巧:存储缩水60%,查询还能变快
2.1 压缩算法怎么选:Snappy、Gzip、ZSTD、Brotli实测对照
压缩算法是列式存储优化里最常被改的参数,但很多人选得很随意。其实选压缩算法要权衡三件事:压缩比、压缩速度和查询时的解压速度。
我把常用的四种算法放在一张表里:
| 算法 | 压缩比 | 压缩速度 | 解压速度 | 适合场景 |
|---|---|---|---|---|
| Snappy | 低 | 极快 | 极快 | 高吞吐写入、对延迟敏感 |
| Gzip | 高 | 慢 | 中 | 归档型数据,读很少、省空间优先 |
| ZSTD | 高 | 较快 | 较快 | 数据工程场景的默认首选 |
| Brotli | 很高 | 很慢 | 中 | 写一次读多次、压缩率优先级最高 |
大多数时候,我推荐直接用ZSTD。它比Snappy多花一点CPU时间,但通常能把存储再压缩30%到50%,解压速度又不拖后腿。过去大家抱着“Snappy是Spark默认值”不敢动,实际上在Spark 3.3之后,官方已经在很多场景把ZSTD作为更推荐的选项。
有一个容易忽略的点:压缩算法在write端和read端都要被支持。如果你的数仓管道用Spark写Parquet,下游用Trino查询,要确保Trino内核支持对应压缩格式,否则会遇到解码失败或者性能回退。跨引擎数据湖里,选算法之前先确认上下游引擎的压缩支持矩阵。
2.2 编码层才是大头:字典编码、RLE、Delta的使用前提
如果说压缩算法是表面功夫,编码就是内功。Parquet和ORC都支持在Page内部做一层编码,编码效果直接决定数据膨胀还是缩水。常用编码有三种。
字典编码。核心思路是先把列里出现过的不同值收集到一个字典中,每个值只存一份,数据主体部分用字典ID替代,ID一般用较小的整数表示。适合低基数或者重复度很高的列,比如城市ID、渠道ID、状态码。在Parquet中这个能力默认开启,但有个阈值:当字典大小超过Page的限制,或者字典比例过大时,引擎会退化成普通编码,效果直接跳水。
RLE,即行程编码。它极擅长处理连续重复的值。如果列里相同值连续出现很多次,比如按日期排序后的日期字段、按分区写入的渠道字段,RLE可以把连续的相同值压缩成“值+重复次数”,存储成本变成常量级。
Delta编码。适用于相邻值差值较小的数值列,比如自增ID、时间戳、计数器。它存的是相邻值的差值,差值一般远小于原值,再用变长编码或者Bit-Packing存储,效果非常明显。
这三种编码不是互斥的,引擎通常会把它们组合使用。比如字典编码后,字典ID再用RLE或Bit-Packing进一步压缩。你在配置层面能控制的开关主要是Parquet的字典编码开关和字典Page大小。
2.3 实战参数与案例:低基数和高基数字段的差异化处理
我在实际项目里最常见的错误,是拿同一套压缩配置对付所有字段。正确的做法是先给字段做分类。
拿一个广告点击日志表举例。channel_id取值只有50个,属于低基数字段,字典编码和RLE都适用。user_id这种每天几千万去重的字段,属于高基数,字典编码大概率失效,因为字典条目太多,反而浪费空间。时间戳是递增的,适合Delta编码。页面URL虽然基数高,但很多前缀重复,分区或排序后也能有不错的效果。
在Spark里写Parquet时,我可以这样设置:
df.write .option("compression", "zstd") .option("parquet.enable.dictionary", "true") .option("parquet.dictionary.page.size", "1048576") .option("parquet.page.size", "1048576") .mode("overwrite") .parquet("/data/ads/click_log")其中parquet.enable.dictionary控制全局字典编码开关,一般保持默认就好。如果发现某些低基数枚举列压缩不理想,优先检查是不是字典条目太多导致退化了,这时候可以适当调大parquet.dictionary.page.size,让字典多容纳一些条目。
ORC侧对应的常见参数是这样:
orc.compress=ZSTD orc.stripe.size=268435456 orc.row.index.stride=10000 orc.dictionary.key.threshold=0.8orc.dictionary.key.threshold表示字典编码的比例阈值,默认0.8,对于低基数字段可以适当调高。
3. 行组大小、排序键与统计裁剪:查询命中率才是隐藏大招
3.1 Row Group尺寸的工程取舍
很多人在列式存储优化里改来改去,就是不动Row Group大小,其实这个参数对查询性能影响非常直接。
Row Group是读取和裁剪的基本单位。Row Group越大,每个Column Chunk里连续数据越多,压缩率更高,统计信息覆盖的行数也越多,有利于减少元数据开销。但Row Group太大也有问题:读取一个Column Chunk需要更大的内存和解码开销,并行度会被削弱。Row Group太小,Footer里统计信息膨胀,每个文件碎块很多,裁剪时频繁跳来跳去,读取效率低。
推荐的工程区间是128MB到512MB。OLAP点查和并发高的场景取小值,批量全表扫描取大值。Spark里对应的是parquet.block.size参数,Hive里是parquet.block.size,ORC里用orc.stripe.size,默认64MB,我一般在数仓大表上直接调到256MB。
3.2 排序键设计:让min/max索引真正开始工作
这一节我认为是全文含金量最高的一部分。列式存储的统计裁剪依赖Row Group里的min/max信息,但如果你写入数据时没有排序,那么这个min/max区间几乎覆盖全表所有可能的值,查询条件根本剪不掉任何Row Group。
举个例子。一张订单表按日期随机写入,每个Row Group里都同时存在1月和12月的订单。查询只想要当月的订单,引擎检查min/max时发现每个Row Group的日期区间都包含当月,于是只能全扫。可如果写入前按日期做一次排序,数据自然聚集,每个Row Group的日期区间变得非常窄,查询时只需要读取那一个或少数几个Row Group,扫描量减少百分之八十。
排序键的设计有一些原则:第一,把过滤最频繁、区分度又高的字段放最前面,比如日期、租户ID;第二,排序键数量不要贪多,一般2到3个字段就够,太多会让排序成本大涨,收益递减;第三,低基数字段排序后对压缩率帮助很大,因为相同的值挤在一起,RLE效果非常好。
如果有多维过滤条件同时存在,单字段排序不够用,可以考虑Z-order等空间填充曲线。它的思路是把多维坐标映射到一维值,让相近的数据尽量落到同一个Row Group里,这样多个维度上的查询都能受益。Delta Lake和Hudi都有现成的Z-order优化能力,需要做多维查询裁剪时值得一试。
3.3 分区裁剪与文件切分的配合
排序键和Row Group都在文件内部做文章,分区则是文件外部的第一道裁剪。数据入湖时按日期、地区这类低基数字段做分区目录,查询时通过分区裁剪直接跳过大量无关目录。
分区粒度需要仔细权衡。粒度过细,比如按小时分区,每天产生24个目录,每个目录里又有多个文件,整体文件数量爆炸;粒度过粗,比如只按年分区,查询单月数据也要扫描整个目录。我一般建议明细大表按天或者按小时分区,并根据查询模式决定。离线数仓按天最常见,实时链路按小时是常态。
写完分区后还要关注文件数量。理想状态是每个分区内文件大小在128MB到1GB之间,太小的文件会带来NameNode或对象存储元数据压力,也削弱列存裁剪优势。Spark里可以用repartition或coalesce控制输出文件数,启用AQE之后批量写经常会出现大量小文件,建议在写表前做一次分组聚合或加一个coalesce操作:
df.repartition(col("event_date")) .write .partitionBy("event_date") .option("compression", "zstd") .parquet("/data/warehouse/event_log")4. Schema设计与写入链路:把优化前置到数据落盘之前
4.1 字段类型选型:类型越胖,代价越重
很多优化是在数据落盘后做的,但Schema设计上的失误,落盘后就很难补救。列式存储优化真正的性价比,来自写入前的类型选择。
先说整数字段。能用int32就别用int64。一个字段少4个字节,看起来不起眼,但上千亿行乘起来就是几百GB的差距。所有引擎里int8、int16、int32、int64的存储成本是递增的,类型越胖,扫描代价越重。实际建表时我看到很多人习惯性把所有ID都定义成bigint,其实大部分业务ID用int32就够。
Decimal精度要克制。decimal(38,18)在列存里占的空间比decimal(18,6)大得多,而大多数业务数据并不需要38位精度。设计字段之前先想清楚:金额最大到多少,需要几位小数,刚刚好就行,别为了“万一将来用得着”浪费存储和IO。
字符串字段要格外小心。能用数值字典映射的枚举字段就不要存长字符串,比如城市名可以映射成城市ID;固定长度的编码比如国家代码、货币代码,明确声明类型长度。高基数字符串比如用户昵称,存储开销很难压,只能依赖压缩兜底。
4.2 Schema演进和列裁剪的最佳实践
列式存储对schema的宽容度和对存储成本的敏感度是两回事。Parquet支持嵌套结构,允许你在一个字段里塞一堆子字段,但嵌套会显著影响读取效率和压缩效果。
嵌套字段在Parquet里的物理存储依赖repeat和definition级别来还原结构,逻辑上还是一列,但展开和编码都更复杂。能用扁平Schema就用扁平的,实在拆不开的,也要控制嵌套层数,嵌套三层以上的结构在查询时很难优化。
列裁剪有一个实践细节可以分享:查询时尽量只取需要的列,不要动不动select *。有些下游任务图省事,每天跑全表,把200个字段全读出来,再用的时候只要5个。这种情况就算列存再强也扛不住。把列裁剪从SQL层面做好,对查询引擎的收益立竿见影。
另外字段顺序不是重点。列式存储里读取特定列不需要管列在文件里的物理顺序,没必要为了“前几列是常用列”而重新排列Schema。
4.3 写入端参数:从源头减少后续调优成本
我见过太多团队用Spark默认参数直接写生产表,跑完发现文件数爆炸、压缩率拉胯,再回头做优化。与其事后补救,不如在写入时顺手把参数设好。
主要监控几个点:压缩算法统一用ZSTD;Row Group大小调到256MB;开启字典编码;低基数字段排序后再写;分区内文件数控制好;写入前用repartition把数据分布均衡一下,避免数据倾斜导致某些分区文件巨大、某些分区几乎为空。
如果你的查询有大量的点查需求,比如按主键查某条记录,Parquet从1.12开始支持Bloom Filter,写入时可以根据高基数字段开启,点查时能跳过更多文件。但Bloom Filter对范围查询没有帮助,不要盲目全开,否则写入成本和元数据开销都会变大。
5. 实战调优记录:一次用户行为分析查询从18分钟到4分钟
5.1 基线场景:一张300GB的用户行为日志表
去年我们接手了一个用户行为分析项目。业务方每天要分析一段时间的用户点击行为,主要计算UV、PV、点击率等指标。业务表有180多个字段,按天分区存储,单日新增2亿行左右,Parquet格式,但一直用默认参数写的,没做过排序,也没有按列裁剪的习惯。
一个持续三天的UV查询,在Spark集群上跑一次要18分钟。看监控,扫描的数据量是780GB,但实际上业务需要的字段只有6个。也就是说,绝大部分IO都在搬运用不上的字段。
5.2 从存储到查询的优化链路
我们按五步做了优化。
第一步,压缩算法从Snappy换成ZSTD。存储占用直接降了四成,查询需要的IO和网络传输随之减少。
第二步,根据查询模式定义常用字段,写入时把常规分析要用的字段抽出来建了一个宽表子集,同时要求下游SQL只select业务字段,不再全表扫描。
第三步,给表加排序键,按event_date和channel_id做一次全局排序。这一步做完,每个Row Group的channel_id区间被压缩得极窄,统计裁剪终于开始工作。
第四步,把parquet.block.size从默认的128MB调到256MB。Row Group变大之后,单次读取的数据更连续,编码效率更高。
第五步,合并小文件。之前实时管道写入会产生大量几十MB的小文件,我们用定时任务做了一次重写合并,把分区内单文件大小拉到300MB以上。
5.3 优化前后的数据对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 存储占用 | 1.2TB | 350GB |
| 单次查询扫描量 | 780GB | 35GB |
| 查询耗时 | 18分钟 | 4分钟 |
| CPU使用率 | 高,任务经常排队 | 明显下降,集群有空余 |
这个案例最能说明一件事:列式存储优化不是一个单点操作,而是一整套环环相扣的组合拳。压缩减少IO,排序让裁剪生效,裁剪让扫描量骤降,扫描量少了查询自然快。每步单独拎出来都有提升,但合在一起才是质变。
6. 常见问题与排查技巧实录
6.1 压缩率远低于预期
这是最常见的问题。数据写出来比想象中大很多,第一反应是压缩算法没生效,其实多数原因是数据本身太“碎”。常见情况有几种:列里随机值太多,字典编码和RLE都发挥不出效果;文件太小,每个文件都有独立的字典和Footer,元数据占比太高;排序字段没设计好,同值数据没有聚在一起。
排查思路很简单:看单文件大小,低于64MB的先合并;看每列的基数和分布,高随机、高基数字段多的表压缩率天然低;看压缩算法是否被引擎默认值覆盖。
6.2 查询还是慢,min/max裁剪似乎没生效
统计裁剪失效的原因通常有两个。一是写入时没排序,Row Group的min/max区间覆盖整个表,裁剪无从谈起。二是查询条件的过滤字段不在排序键或分区键上。举个例子,表按日期排序,但查询经常按user_id过滤,这个字段的min/max在Row Group里依然是全量范围,效果自然差。
解决方法是分析真实的过滤模式,把高频过滤字段挪到排序键前面。多字段过滤场景考虑Z-order。
6.3 读文件时内存吃紧
大Row Group和大的Page会提升解码时的内存开销。如果查询端经常出现OOM,可以适当调小Row Group或者Page大小,同时开启向量化读取。
Spark里确认这几个参数:
spark.sql.parquet.enableVectorizedReader=true spark.sql.parquet.vectorizedReader.enabled=true向量化读取会批量解码数据,减少对象创建开销,对内存和CPU都更友好。
6.4 小文件堆出大隐患
小文件是数据湖性能杀手。大量几十KB、几MB的文件会让统计信息形同虚设,读一个文件的开销比数据本身还大。出现小文件一般是实时写或者分区太细导致的。
解决手段是定期合并,用批量任务把分区内小文件重写成大文件;更治本的方法是在写入端控制并发,写之前做一次coalesce或者repartition到合理分区数。Iceberg、Hudi这类表格式支持自动的Compaction和小文件整理,数据量大的团队可以考虑引入。
6.5 字典编码突然失效
使用Parquet时,如果你明明设置了字典编码,压缩率却不理想,很可能是字典膨胀超过了Page大小阈值,引擎自动退化成普通编码。此时可以调大parquet.dictionary.page.size,或者对这个字段改用更合适的类型。比如长字符串改成整数枚举后,字典条目变少,问题会自动消失。
ORC那边,字典编码阈值是orc.dictionary.key.threshold,默认0.8,数据分布变了也会导致字典失效,可以按需调高。
最后再分享一点个人经验。拿一张新表做优化时,不要急着调一堆参数。先花十分钟做三件事:看字段基数和重复度,看查询的过滤字段,看现在的数据文件大小分布。想清楚优化重点在哪,再动手改参数。数据工程里的列式存储优化,方向对了,参数拧一点就见效;方向错了,参数调得再热闹,查询该慢还是慢。优化迭代的时候,也别忘了保留一份基线数据,同一个SQL、同一批数据,每次变更前后都跑一次对比。有了量化对比,你才能知道每一步到底带来了多大收益,也才能说服自己,时间到底是花在了刀刃上,还是又白忙了一场。