去年排查某一个内部OLAP引擎的慢查询时,我盯着监控面板上飙到40GB的内存占用,第一次认真思考了一个问题:查询执行过程中,算子之间那些只活一瞬间的中间结果,为什么不能先压一压再往下传?
这里的“压一压”,后来成了团队里常说的“压缩物化”(Compressed Materialization)。先解释清楚概念:物化(materialization)指查询执行时把某个算子的完整输出写入内存或磁盘,形成可以被后续算子反复读取的中间结果;压缩物化就是在此之上对中间结果做编码压缩,减少它占用的内存、磁盘带宽和缓存空间。注意,这不是把最终查询结果压缩后返回给客户端,而是查询引擎内部的一种执行期优化,跟物化视图(Materialized View)也不是一回事——物化视图是持久化的预计算数据,压缩物化处理的是查询过程中转瞬即逝的临时数据。
如果你在维护OLAP引擎、写执行器,或者单纯对数据库原理的工程实现感兴趣,这篇文章值得往下看。我会把物化为什么需要压缩、核心实现路径、实测数据、边界情况以及工程落地经验一次讲透。
1. 物化的岔路口:为什么中间结果值得压一压
1.1 两种执行模型的边界
经典的火山迭代模型里,查询计划是一棵算子树,每个算子通过 next() 接口从上游拉一条数据,处理完再吐给下游。这种流水线方式下数据不会整体停留,内存占用很小,但有两个绕不开的问题:一是每次 next() 调用都要走一层算子状态机,虚函数开销不小;二是某些算子天生需要看到全部输入才能产出第一个输出。
典型的就是排序算子——必须拿全所有行才能开始输出;哈希连接中构建端要先读完一整张表把哈希表建好;窗口函数要按分区排序后才能算排名;去重操作同样要等完整输入。这些算子所在的位置,就是执行计划里的物化点。到了物化点上,上游数据会被完整写出来,再触发下游算子。
现代列式向量化引擎里这种模型更常见,因为它可以把数据按批批量处理,而不是一行一行地 next()。批处理让物化变得非常自然:一个 batch 写完,直接作为后续操作的输入。代价就是中间结果可能变得很大——一张千万行事实表和维度表做哈希连接,光构建端哈希表就可能吃掉几个GB。
1.2 被忽略的中间结果
很多做查询优化的同学会把精力放在统计信息、代价模型、Join 顺序上,容易忽略一个事实:查询执行过程中物化出来的中间结果,往往比最终返回给用户的结果大一个数量级。
比如一个 TPCH 风格的大查询,先对订单表过滤,再和客户表连接,然后做分组聚合,最后排序返回100行。中间那步连接产生的数据可能高达几千万行,宽表场景下一行几百字节很常见,物化出去就是好几个GB。这些临时数据占用内存,一旦超过内存阈值就要落盘,落盘就要走磁盘IO,磁盘IO一慢整个查询就卡住。
我在某个模拟项目X上统计过,执行复杂分析查询时,中间物化数据量能占到峰值内存的60%以上。也就是说,优化执行器的注意力如果只盯着最终结果集,其实是在管一个很小的局部——大头在中途。
1.3 压缩的时机:中间数据天生比持久化数据更适合压缩
为什么存储引擎早就在做压缩,执行器中间结果却很少这么做?因为过去CPU资源太贵、压缩解压的开销抵不过省下的内存,而且执行器代码路径复杂,在物化点上加一层压缩会让内存管理变得很麻烦。
但现在情况变了。CPU 算力增长远超内存带宽和磁盘带宽,压缩一个列可能只要几十毫秒,而省下来的数据量在内存里少跑几个GB的带宽,往往更划算。更关键的是,中间物化数据有三个持久化数据不具备的特征:
第一,schema 固定。物化时列数、类型、长度都是已知的,可以在 batch 头部写清楚每列的编码类型和偏移量,解压时做到按列精确访问。
第二,生命周期短。一个查询跑完,这批数据立刻释放。所以压缩策略可以激进,不需要像存储引擎那样考虑长期读写均衡、增量更新、随机读等问题,只需要为“写一次、批量读一次或几次”服务。
第三,访问模式集中。后续算子几乎总是全量批量扫描这批数据,比如哈希连接探测端会一行行过,聚合会一块块过。批量扫描意味着压缩和解压单位可以做得很大,摊薄下来的每行开销极低。
这三条加在一起,让物化点成了压缩的天然黄金位置。这也是压缩物化这个优化方向近几年在不少新引擎里被认真对待的根本原因。
2. 核心实现路径:从算法选型到编码组合拳
2.1 压缩算法选型逻辑
先明确一点:物化压缩的算法选型,跟文件压缩完全是两个思路。文件压缩追求极限压缩率,哪怕多花几倍时间都行;物化压缩发生在查询热路径上,每多花1毫秒CPU,都可能直接体现在查询延迟上。
我常用的选型判断,大致按这张表来:
| 算法 | 压缩速度 | 压缩率 | 典型延迟敏感度 | 适用场景 |
|---|---|---|---|---|
| LZ4 | 极快 | 较低 | 低 | 内存物化默认首选 |
| Snappy | 快 | 较低 | 低 | 兼容旧系统、流式场景 |
| Zstd | 较快 | 高 | 中 | 磁盘物化、大中间结果 |
| Deflate/Gzip | 慢 | 高 | 高 | 几乎不用于中间物化 |
实测下来,纯内存物化场景我默认选 LZ4 级别的高吞吐算法,原因很简单:内存带宽通常不是瓶颈,CPU 才是,LZ4 每GB解压耗时比 Zstd(-3) 低一大截,压缩率虽然低一些,但已经能把内存占用砍掉一半。真正需要落盘时再用 Zstd 的中低档位,因为磁盘IO的代价远高于CPU解压。
2.2 先做语义编码,再做通用压缩
只靠 LZ4/Zstd 对原始字节流做压缩,其实浪费了列式数据里最明显的机会。真正的高压缩率来自理解数据分布的编码,通用算法只是最后的兜底。我一般把编码分为三类,可以叠加使用:
- Delta 编码:针对递增ID、时间戳这类整数列。相邻值差异很小,把原始值转成与前一行差值的序列,再做 VarInt 变长编码,一个几千万行的自增ID列能压到不到原有体积的十分之一。
- 字典编码:针对低基数字符串列,比如地区名、订单状态。把字符串映射成整数ID,再对ID流做位打包。字典通常很小,挂在 batch 头部即可。
- RLE(Run-Length Encoding):针对重复值多的列。连续相同的值只记录“值+重复次数”,比如布尔标记列、分区键列,压完往往只剩几十字节。
举一个具体的例子。假设有一列整数,原始数据是这样的:
1002, 1003, 1005, 1008, 1012, 1013, 1015先做 Delta,变成:
1002, +1, +2, +3, +4, +1, +2再做 VarInt 变长编码,每个差值只占1字节,最后一列4字节一个的原始整数变成大约2字节等价存储,压缩率逼近70%。如果这列后面还叠加通用压缩,最终压缩率能到85%以上。
这一层叫“语义编码”,不是可选的加分项,而是压缩物化拉开和普通压缩差距的关键。实际测试中,“语义编码 + LZ4”的组合,压缩率通常比单纯 LZ4 高30%到80%,而解压速度几乎不掉。
2.3 批次结构与元数据设计
物化通常以 batch 为单位,batch 头部的设计直接决定了解压的灵活性。一个典型的压缩 batch 头部至少包含:行数、列数、每列的编码类型、每列压缩后的偏移量和长度、null bitmap 的偏移量。
null 值很重要,因为大多数压缩算法处理不了 null,必须先抽出 null bitmap,把有效值按非空序列紧凑排列再编码。等解压时,先恢复有效值,再按 null bitmap 还原到正确位置。
这里有个容易踩的坑:不要为了省几行头部空间,把元数据写得太紧凑。每列偏移量和编码类型是后续按列解压的前提,宁可多花几十字节,也要保证任意列能在不解压全批次的情况下独立定位。按列解压是压缩物化能跟向量化执行配合的关键——实际查询里很多算子只需要读两三个列,如果每次都要整批解压才能拿到其中一列,内存峰值反而会反弹。
2.4 压缩态上的算子下推
压缩物化最有趣的部分,是很多算子其实不需要先解压再干活。
过滤条件里的确定性函数,比如字符串转小写、哈希、截断,可以直接在压缩块上逐段计算;聚合里的 count、sum(如果列是定长整数且没做复杂编码)、min/max(配合每块统计信息)都可以在压缩态完成,甚至跳过大部分数据块。哈希连接构建哈希表时,如果键列是定长编码,可以在压缩态直接算哈希,省一次解压。
真正必须解压的情况是:列要展示给用户,或者列要做随机位置访问,以及排序这类需要全值比较的算子。所以实现压缩物化时,一定要给每个算子标的输入列清点“是否必须在解压态才能消费”。这一步做得好,大部分数据可能在压缩态就被消耗掉,解压总量只是未压缩方案的一个零头。
我见过一个实际查询,开启压缩物化后解压的数据量只占中间结果总量的大约15%,剩下的都是在压缩态上直接跑完的。这就是压缩物化真正的威力来源——不只是省内存,还省了后续算子的数据搬运。
3. 实际运行分析:一组基准测试的拆解
3.1 测试环境与数据集构造
为了验证压缩物化在真实查询里的表现,我在模拟项目X里构造了一组测试。硬件是某台16核服务器,128GB内存,数据全部放在内存里,避免磁盘因素干扰。数据集模拟了一个简化版的数据分析场景:一张2000万行的订单事实表和一张120万行的客户维度表,两表做哈希连接,连接后再按地区分组聚合,最后排序取出前100条。
对比三档配置:完全关闭压缩的原始版本、启用 LZ4 内存物化、启用 Zstd(-1) 内存物化。所有查询都强制走同一套执行计划,只隔离压缩物化这一个变量。
3.2 峰值内存与物化耗时对比
结果如下,单位是GB和秒:
| 配置 | 峰值内存 | 物化阶段耗时 | 中间结果压缩率 | 端到端查询耗时 |
|---|---|---|---|---|
| 无压缩 | 11.2GB | 1.8s | - | 5.1s |
| LZ4 | 6.4GB | 2.3s | 42.8% | 5.4s |
| Zstd(-1) | 5.1GB | 3.1s | 55.3% | 6.7s |
第一眼看过去:LZ4 把峰值内存从11.2GB压到6.4GB,降幅超过40%,但物化阶段多花了0.5秒,端到端查询反而慢了0.3秒。看起来“用CPU换内存”的买卖在纯内存环境里并不划算。
但这个结论有个前提:内存充足。一旦中间结果的大小接近甚至超过内存边界,事情就完全不一样了。我在另一组测试里把物化数据量人为放大到超过内存上限,无压缩版本的查询因为大量落盘,端到端耗时飙到47秒;LZ4 版本因为数据被压缩到一半以内,还能全部留在内存里,端到端耗时只增加到9秒。在内存受限的环境里,压缩物化不是一个优化项,而是一根救命稻草。
3.3 反直觉的现象:某些查询压缩后反而更快
更反直觉的是另一组查询。一个纯分组聚合查询,中间只有两个列,开启 LZ4 后竟然比无压缩版本快了12%。按理说多加了压缩解压环节,不应该更快,但实测就是这个结果。
原因在缓存。压缩后的中间结果体积变小,同样大小的 L2/L3 缓存能容纳更多行。聚合算子需要反复扫描同一批数据做状态更新,未压缩版本每轮扫描都可能触发大量 cache miss;压缩版本数据变小后,cache miss 数量显著下降。一次 cache miss 在当代CPU上可能要付出几百个周期的代价,而批量解压摊到每行上只有几个周期。这等于用一点点解压开销,换掉了数量级更大的缓存缺失开销。
我用 perf 做过一次CPU事件分解:压缩和解压合计占执行CPU的约17%,但省下来的 cache miss 和内存带宽等待时间约占执行CPU的24%。两相对冲,压缩版本总CPU时间反而更低。这个比例跟查询模式强相关:凡是扫描密度高、单行处理轻的算子,压缩版本赢面都很大;凡是重计算、少扫描的算子,压缩的收益就会被稀释。
3.4 CPU时间分解与带宽权衡
拆开来看,压缩物化的CPU成本主要在三个地方:压缩时采样统计、语义编码计算、通用压缩;解压时位图恢复、逆编码、通用解压;以及因为格式变化带来的额外分支判断。
其中通用解压是大头。LZ4 解压1GB数据大约耗CPU 0.3到0.5秒,Zstd 则要慢3到6倍。所以我在实际调优时有个习惯:只要能通过语义编码达到目标压缩率,就尽量不升级到更高档位的通用压缩算法。语义编码的解压代价几乎可以忽略,而通用压缩的解压代价是实打实的CPU时间。
另外还有一个经常被忽略的收益方向:内存带宽。未压缩的中间结果在算子之间搬运时,每多1GB数据都要从内存读一遍再写一遍。压缩之后,搬运量直接减少一半以上,这对内存带宽敏感的多并发场景尤其重要。同一个服务器上同时跑多个大查询时,压缩物化带来的带宽节省往往比对单查询的延迟收益更明显。
4. 边界情况与陷阱:压缩物化不适合哪些场景
4.1 高基数列与熵的问题
压缩物化有一个天然克星:高熵数据。随机生成的UUID列、加密哈希值列、已经做过压缩或混淆处理的列,压缩率经常不到5%。这种列上做压缩,纯粹是浪费CPU,省下的内存几乎可以忽略不计。
某次压测里,一个开启 LZ4 后的查询反而慢了3倍。用火焰图定位,发现90%的时间花在解压一个无索引UUID列上。这列的高基数和随机性决定了它压不动,但压缩和解压还是被完整执行了一遍,纯属白干。
解决办法是在压缩前对每列做极轻量的采样估算。常见做法是采样前1024行的前缀差异、去重计数或者熵估算,如果预测压缩收益低于10%,这列直接标记为“不压缩”,按原始格式存储。这个采样过程开销很小,一次大概几十微秒,却能避免最典型的压缩浪费。
4.2 小批量的固定成本问题
压缩物化有个难以回避的固定开销:维护压缩上下文、写 batch 头部、分配压缩缓冲区、记录列偏移。这些开销跟数据量关系不大,但当物化批次本身很小时,它们就成了不可忽略的比例。
一个只有200行、3列的物化批次,可能压缩一下就省了几十KB,但压缩上下文和头部占用的却差不多是等效的固定成本。换句话说,压了个寂寞。
工程上的处理方式很直接:给压缩物化设下开启阈值,行数不超过10000行或者原始大小不超过1MB的批次不压缩。这个阈值不用太精细,按经验设成“至少存满一个压缩块大小”就行。真正的大物化百万行起步,小物化让它保持原样,收益反而更干净。
4.3 压缩与随机访问的矛盾
压缩物化在批量扫描场景里表现很好,但遇到随机访问型算子就可能反转。把数据整页压缩后,单行访问变成整页解压——对列式扫描没问题,但对那些按行跳跃访问的算子(比如某些 outer join 的探测端、rowid 回表)就很不友好。
我一个实际项目里就碰到过:用压缩物化优化哈希连接,结果连接本身快了,但连接后紧跟着一个按行号回原表取列的算子,这种算子对物化后的压缩列做随机访问,把缓存彻底打乱,整体反而慢了近一倍。
解决思路是“按列访问、按需解压”。不要因为一个 batch 被压缩了就必须整批解压,而是设计成只解压该算子真正需要的列。回表算子需要哪些行号的哪些列,就只对那几个列做局部解压。这要求物化格式支持列级别的独立寻址,也就是前面章节说的头部偏移量设计。没有这一层,压缩物化的适用场景会窄很多。
4.4 延迟敏感查询的尾巴延迟
压缩物化带来的峰值内存收益是确定的,但压缩时间是不确定的。CPU繁忙期、高基数数据、突发的大宽表物化,都会让压缩耗时产生较大波动,进而拉长尾巴延迟。
对延迟敏感型查询,比如在线分析里的点查或小查询,压缩物化并不是默认项。我的经验是:设一个阈值,物化预估超过1GB时才启用压缩;小查询保持不压缩。这样内存风险最大的查询拿到了确定性收益,而低延迟查询完全不受影响。
4.5 一个真实的定位过程
回到开头说的那个慢查询。排查时我先关掉压缩物化,查询从40GB内存变成不落盘也勉强跑完,但还是慢;再开压缩后内存降到20GB以下,结果查询又慢回去。后来抓住关键点,把物化点逐个打点,发现慢的根源不在压缩本身,而是一个宽表中间结果里混着两个高熵列,压缩阶段花了1.2秒,解压阶段又花了0.8秒,压完内存只省了5%。把这几个列标记为不压缩后,查询总体快了一倍。这段经历让我确定了一件事:压缩物化必须设计成“分列决策、自适应开关”,全局一刀切的开或关都是偷懒做法。
5. 工程落地经验:从开关设计到监控埋点
5.1 分档开关与自适应阈值
工程实现上,压缩物化不应简单做成“开/关”二值状态。我通常分三档:off、fast、dense,默认 fast。
- off:完全关闭,用于排查问题和对比基线。
- fast:LZ4 级别 + 语义编码自动检测,适用于绝大多数内存物化。
- dense:Zstd 级别,适用于大物化、即将落盘的场景。
自适应逻辑可以叠加一层:物化批次写入前,根据预估结果大小、当前内存池压力、查询配置的延迟等级,动态决定用 fast 还是 dense。这样既保证普通查询的延迟可控,又能在内存紧张时自动切到更高压缩率档位,甚至提前触发磁盘物化的降级路径。
5.2 压缩直达,避免二次拷贝
压缩物化最容易被忽略的工程坑,是内存拷贝放大。很多初版实现是:先物化成未压缩的 batch,再分配一块新内存做压缩,把数据拷过去,最后释放原来的 batch。这一来一回,内存峰值等于未压版本加上压缩后版本,内存优势直接被打折。
正确的做法是“压缩直达”:算子写数据时,直接按压缩格式写入目标缓冲区,不经过未压缩中间态。比如哈希连接构建端,一边算哈希一边把键列编码成 delta 或字典格式,直接写进压缩页。实测这样改之后,CPU开销能减少约30%,内存峰值也明显更低。
实现压缩直达的复杂度确实高一些,因为要事先知道每个列用什么编码,所以通常配合“先采样、定编码、再物化”的三段式流程来做。采样阶段只扫前几批数据,确定编码方案,后续物化直接按方案写入。这个流程多了一些前期开销,但换来的是后面每一步都不必再碰未压缩数据。我在这类实现上踩过坑,晚知道不如早知道,先把压缩直达的缓冲区管理设计好,后续所有算子都能受益。
5.3 监控指标与算子级定位
没有监控的压缩物化等于盲人摸象。至少要埋这么几个指标:物化次数、物化批次总字节数、压缩率、压缩耗时、解压耗时、内存节省量、跳过压缩的列占比。
特别是“解压耗时”和“内存节省量”这两个指标,一定要按查询ID关联起来。否则你只知道系统跑得慢,但不知道是哪个算子因为压缩策略不合理在烧CPU。
一个实用的定位方法:在物化点埋一个计数器,记录每个算子的输出行数、列数、编码组合、压缩率、压缩耗时。查询跑完后把这些日志导出,按耗时排序,立刻能看出哪些物化点收益高、哪些不值当。这是在多轮调优中我用的最顺手的决策工具。
5.4 跟存储层压缩区分开
最后说一个很多人会搞混的点:存储引擎的列压缩和压缩物化不是一回事,不要直接复用存储层的压缩配置。
存储压缩追求极限压缩率和长期稳定性,数据要存很久、多次读写、可能需要随机访问,所以会毫不犹豫上高压缩档位。物化压缩恰好相反,数据生命周期只有毫秒到秒,访问模式单一,追求的是低延迟下的吞吐。如果把存储层的 -T19 档位 Zstd 配置直接搬到物化路径上,CPU 很快会被烧光,而压缩率只比低档位好一点点。
我建议物化压缩最多用到 Zstd(-3) 这种轻档位,默认 LZ4,核心思路永远是“能省CPU就省CPU,能省内存也省,但CPU优先”。内存不够可以靠压缩补,CPU不够就真没办法了。
我在实际压测中的体会是,压缩物化从来不是一条免费的捷径,它本质上是“以CPU换空间、空间再换缓存与IO”的折中。做了几轮调优后,我慢慢形成了自己的判断标准:中间结果超过总内存四分之一时,压缩物化收益稳定为正;结果又小、数据熵又高时,不如直接关掉;存在落盘风险时,它往往就是整个查询的生命线。最后再分享一个小技巧——前期先不要开压缩,把原始执行计划的物化点全部打出来,逐个评估压缩收益,再决定哪些点启用、用什么档位。这比全局一个开关盲调要高效得多,也更容易让团队在代码评审时理解这个优化到底在解决什么问题。