上周有个同事跟我倒苦水同样的查询在测试环境跑只要几秒到生产环境就要十几分钟明明加了那么多节点为什么还是慢我说你先别急着加机器把数据文件打开看一眼问题多半出在存储格式上。这类现象在数据工程里太常见了。列式存储现在已经是数据湖和数据仓库的默认底牌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 GroupRow 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默认64MBIndex 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.compressZSTD orc.stripe.size268435456 orc.row.index.stride10000 orc.dictionary.key.threshold0.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.sizeORC里用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默认参数直接写生产表跑完发现文件数爆炸、压缩率拉胯再回头做优化。与其事后补救不如在写入时顺手把参数设好。主要监控几个点压缩算法统一用ZSTDRow 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.2TB350GB单次查询扫描量780GB35GB查询耗时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.enableVectorizedReadertrue spark.sql.parquet.vectorizedReader.enabledtrue向量化读取会批量解码数据减少对象创建开销对内存和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、同一批数据每次变更前后都跑一次对比。有了量化对比你才能知道每一步到底带来了多大收益也才能说服自己时间到底是花在了刀刃上还是又白忙了一场。