☰
压缩物化原理与调优:破解查询执行中间结果内存瓶颈
2026/10/9 11:02:59 网站建设 项目流程

上周在调一个跑批任务的时候,发现有个查询的执行计划看起来并不复杂,但内存就是顶不住。两个大表做哈希连接之后的结果集还要做窗口排序,中间结果直接把容器内存干到告警线。后来把中间结果物化方式从默认的行式改成压缩物化,同样的查询内存峰值降了将近六成。这让我决定把压缩物化(Compressed Materialization)这件事彻底讲透。它不是什么遥不可及的黑科技,就是查询执行引擎在处理中间结果时,不直接把数据原样铺在内存里,而是带着压缩格式“躺平”,等下游算子真正要消费数据时再按需解压。这篇文章就从原理、设计拆解、实际运行机制、参数调节到常见问题,把我自己的实测和分析过程完整写出来,希望能给正在跟中间结果内存较劲的同行一些参考。

1. 先讲清楚“物化”为什么是查询执行中的隐形瓶颈

1.1 一个查询跑得慢,恰恰不是算子慢,而是中间结果没地方放

很多人排查慢查询时,第一反应是看算子本身的执行效率,比如哈希连接有没有走错路、排序有没有选对算法。但我在实际运维和优化中,踩得最多的坑反而是中间结果物化。所谓物化,就是查询执行引擎把某个算子产出的中间结果写到内存(或者临时文件)里的过程。比如哈希连接构建哈希表、排序操作产生有序序列、聚合函数产生分组状态,这些数据都需要一个“落地”的动作。如果一个查询有七八个算子,每个算子都可能产生一份中间结果,那么内存占用就是多个临时结果集的叠加,而不是最终结果的大小。

我见过一个典型的TPC-H分析查询,原始表数据加起来不到50GB,但跑起来时内存峰值能冲到120GB。为什么?因为两张大表先做了过滤和聚合,聚合后的结果集虽然变小了,但接下来的连接操作又要为探测侧建立哈希表,这个哈希表加上连接输出的中间结果,再进入窗口函数时还需要一份排序缓冲区。每一层都存了一份“完整”的中间数据,而且没有压缩。内存就是这么被一层层吃掉的。更麻烦的是,内存不够的时候引擎会把中间结果spill到磁盘,然后磁盘IO又成了下一个瓶颈。所以,物化方式的选择,往往比单个算子的算法优化影响更大。

1.2 压缩物化的核心定义与它解决的本质问题

压缩物化,说白了就是在物化中间结果时,不是把tuple按原始布局直接存进内存缓冲区,而是先做一次列级别或者块级别的压缩编码,让数据以更紧凑的形态留在内存里,等到下游算子真正读取这些数据时,再按需解压或者直接对压缩数据进行部分计算。

这个思路解决的是三个层面的问题。第一,内存占用。同样的中间结果,压缩后可能只有原来的四分之一到五分之一,形态小,能装进更快的缓存层级,也能减少OOM概率。第二,内存带宽。数据量小了,从内存往CPU cache搬运的字节数就少了,特别是在做列式扫描和聚合时,带宽往往比计算更容易饱和。第三,磁盘spill的概率。当内存中有压缩中间结果,触发溢写的概率也会下降,减少了一整轮序列化和反序列化的损耗。

不过要注意,压缩物化不是“免费的午餐”。它引入了压缩和解压的CPU成本,而且如果压缩率上不去,或者下游算子对数据访问模式不适合压缩格式,性能反而会恶化。我在后面会专门讲这些边界条件。但先说结论:对于中间结果集较大、内存紧张、IO密集的分析型负载,压缩物化是一个非常值得投入的优化方向。

2. 压缩物化的设计拆解:内存里的“列车编排”

2.1 三种物化形态的同台对比:行式、列式与压缩式

要理解压缩物化的设计,最好把它跟另外两种物化方式放在一起对比。第一种是行式物化,这是最常见也最直观的方式。每个tuple连续存储,字段按定义顺序排好,一行紧挨着一行。优点是实现简单,下游算子要取整行数据时特别快。缺点是像customer_id这种重复值很多的高频字段,会一而再再而三地完整存储,浪费了大量空间;而且内存访问效率对cache不友好,扫描一列数据时等于把整行其他列也拖出来。

第二种是列式物化。列式物化把同一列的数据连续存放,不同列分到不同的缓冲区。这个设计的最大好处是支持向量化执行和只访问必要列——做聚合时只要把分组列和聚合列搬进cache即可。但列式物化不等于压缩物化,它只是“更利于压缩”的布局。很多引擎在列式物化时仍然使用原始编码,只是换了个存放方式,空间节省有限。

压缩物化则是把“压缩”当成物化的一等公民。它在列式布局的基础上,根据列的特征选择编码策略,让数据在内存中就是以压缩形态存在。比如一个region_code列可能只有少数几个枚举值,在压缩物化中它不会老老实实地每行存一个字符串,而是会被转成字典索引,每行只存一个数字。下游算子如果只需要根据region_code做等值过滤,甚至可以不解压列本身,直接对数字索引做过滤,只有最后向上返回字符串值时再做一次字典翻译。这种“能不解压就不解压”的思路,是压缩物化区别于普通列式物化的最大分水岭。

2.2 压缩编码怎么选:字典、RLE还是Delta

压缩物化的核心在于选择合适的编码,而不是简单地套一个ZSTD。我在实际使用中会重点考察四类编码。

第一类是字典编码(Dictionary Encoding),也是最常用、收益最明显的一种。思路是给列里每个唯一值编一个数字ID,内存中只存放ID数组和一份字典表。适用场景是低基数列,比如部门编号、地区代码、状态码。一个1000万行的region_code列,原始每个字符串平均16字节,一共160MB,但区域可能只有50个,字典编码后每行只需1字节(因为50个值用1个字节就够了),内存直接从160MB降到10MB加几百字节的字典表。这就是我前面提到的“四分之一”是怎么来的。

第二类是游程编码(RLE)。它把连续相同的值合并成“值+重复次数”。适用场景是排序后或者按某列聚簇后、相邻行重复值很多的列。比如按日期分区存储的order_date列,如果物化时数据已经按日期排序,那每个日期的重复次数就很大,RLE压缩比会非常漂亮。但需要注意,如果数据分布完全随机,RLE会退化成逐行存储,甚至因为多存储一个计数导致负优化,所以使用时一般要配合局部性检测。

第三类是增量编码(Delta Encoding),它存储相邻两个值之间的差值而不是原值。适用场景是时间戳、流水号这种单调递增且数值接近的列。差值往往可以用更少的字节表示,再配合位压缩(Bit-Packing)进一步按需分配位数。比如原来的时间戳都是8字节,差值集中在几千以内,那就只需2字节,直接省掉75%的空间。

第四类是通用压缩(如LZ4、ZSTD、Snappy)。这里踩过一个误区:并不是所有数据都值得用通用压缩,尤其是高基数、无序、长度很短的字符串列。通用压缩需要建立匹配窗口,数据越随机,匹配越少,压缩率就越差,CPU开销却一点不少。所以我现在的习惯是,先对每一列做基数统计、排序度检测和重复率统计,再决定用哪一种编码,而不是一律上ZSTD。

2.3 代价模型:省下来的内存如何抵消CPU成本

压缩物化不是“存小一点”这么简单,它是一个CPU换内存和IO的交易。因此必须看清代价模型,否则很容易把查询调得更慢。

我习惯用一个简化公式来估算收益。

原始物化开销包括:写入内存缓冲区的耗时、后续算子读取原始数据的耗时、还有可能发生的spill和重新读回的耗时。压缩物化开销则等于:压缩消耗的CPU时间、压缩后写入缓冲区的耗时、下游算子读取时部分或全部解压的CPU时间。只要压缩节省的内存带宽和IO时间大于额外付出的CPU时间,这个交易就是划算的。

举个例子。一个中间结果集有1000万行,四列:customer_id(8字节)、total_amount(8字节)、region_code(平均16字节)、order_date(4字节),原始大小约360MB。如果region_code用字典编码能压到10MB,order_date用RLE或Delta压到20MB,customer_id用ZSTD压到25MB,total_amount压到22MB,总压缩后约77MB,压缩比4.7。假设原始物化时,因为内存太紧导致其中200MB被spill到磁盘,而磁盘吞吐只有200MB/s,那么光spill写和读就各浪费1秒,总共2秒。压缩物化虽然多花压缩CPU约0.2秒(用LZ4级别),但省掉了这2秒的磁盘往返,还让后续扫描直接从内存里读77MB而不是360MB。这种场景下,压缩物化几乎就是白赚。

但反过来,如果中间结果集只有50MB,压缩后变成20MB,而机器内存很充足、数据也在page cache里,那压缩和解压的CPU开销很可能大于节省的IO,反而变慢。所以压缩物化一定要设置阈值,我一般建议小于1MB的结果集直接走无压缩路径,这部分内容会在后面参数部分细说。

3. 实际运行机制与关键算子的配合

3.1 物化时机与触发条件:什么时候该压缩

压缩物化的触发条件不是“有中间结果就压”,而是由引擎根据结果集大小、列特征和集群内存压力动态决定。在工程实现上,通常会在算子输出端设置一个Materialization决策点。这个决策点会做三件事:统计输出行数和字节数、检查当前内存水位、抽样估算各列的压缩收益。

我在一个执行引擎里实测过这样的决策流程:当物化缓冲区写入量累计达到512KB时,暂停输入,对已写数据进行一次快速采样压缩测试。如果压缩率低于1.5倍,就不启用压缩物化;如果高于1.5倍且内存水位超过70%,则切换为压缩物化模式,并且后续写入的数据直接送入压缩器。这里有两点值得说明。

第一,为什么是1.5倍这个阈值?因为低于1.5倍时,省下的空间还不足以抵消压缩器和解压器带来的CPU开销以及额外的引路数据结构。我曾经在一组高基数字符串列上测过,压缩率只有1.2倍,但压缩和解压时间让整个查询慢了18%。第二,为什么做采样而不是全量压缩?是因为压缩本身需要消耗CPU,如果先全量压缩再做决定,那就等于让所有成本都白付了。采样式预判可以只花少量CPU就得到压缩率估计,减少误判。

另外,触发压缩物化的另一个重要条件是结果集的后续消费方式。如果下游算子是阻塞型的,比如排序、窗口函数,它们通常需要完整物化后才开始工作,那么压缩物化的价值就很大,因为数据要在内存里待很久。如果下游算子是流式的,比如简单的流水线聚合,数据随到随消费,压缩物化反而增加延迟,这时一般会跳过压缩。

3.2 压缩数据如何被下游算子消费

压缩物化最容易被低估的环节,是下游算子怎么读取压缩数据。如果每个算子都要先完整解压再计算,那压缩省下的内存空间在计算阶段很快就“还回去”了。所以真正设计得好的压缩物化,一定会琢磨怎么让“部分解压”和“不下推解压”成为常态。

我遇到过三种下游消费模式,按代价从低到高排列。

第一种是“零解压消费”。典型场景是等值过滤和聚合分组。如果过滤条件落在字典编码的列上,引擎可以把过滤值直接翻译成字典ID,然后在ID数组上做整数比较和位图操作,完全不碰原始字符串。分组聚合同理,可以先按ID分组,最后再映射回原始值。这种模式是压缩物化的理想态,几乎零额外成本。

第二种是“块级解压”。当消费者需要对某个列做表达式计算或复杂过滤时,不必把整个列全解压,可以按块(Block)解压。压缩物化在物化时会把数据切成固定大小的块(比如64KB或65536行),每块独立压缩,并记录块边界和块内统计信息,比如min/max、sum、count。下游算子可以Only加载满足条件的块。举例来说,一个时间范围过滤能利用块的min/max索引跳过大量不需要解压的块,这就把压缩物化和数据跳过(Data Skipping)结合了起来。

第三种是“全列解压”。必要时候我们也不得不承认,某些算子确实需要看到完整原始值。比如复杂字符串匹配、涉及多列运算的表达式,或者需要随机访问特定行的场景。这种情况下,引擎会在算子边界做一个物理解压,把整列恢复到原始编码,再交给算子处理。但即便如此,压缩物化仍然有价值:因为在“等待算子开始”的阶段,中间结果是以压缩形态躺在内存里的,这大幅降低了内存峰值和可能的spill;只是计算阶段需要多付出一次解压成本。所以压缩物化带来的收益峰值在前半段,后面那个解压开销,是在买“不被内存打垮”的保险。

从查询引擎的实际运行来看,压缩格式在内存中不是静止的。它会跟算子之间的数据流转互相绑定。不少现代执行引擎会把物化后的压缩数据再注入到向量化执行流程中,每次处理一批解压后的列向量,处理完即释放,避免解压后的数据长期驻留。我在分析一个内存敏感的聚合查询时,看到引擎把聚合算子输出的压缩中间结果传给排序算子,排序算子只保留了指向压缩块的指针数组,真正排序时才按需解压排序键列,这就非常高效。

3.3 与延迟物化、向量化执行的协同

压缩物化经常跟另外两个概念混在一起,一个是延迟物化(Late Materialization),一个是向量化执行。我在理解它们的关系上花了不少时间,这里也说清楚。

延迟物化指的是:在列存引擎中,先不要急着把各列拼接成完整的行,而是在过滤和投影阶段只保留必要的列,等连接或者最终输出阶段再组合成tuple。延迟物化天然适合压缩物化,因为它让中间结果在很长一段时间里都是以“列”的形式存在。列的形态更容易做字典编码、RLE、Delta,也更容易在下游做列式跳过。而一旦提前物化成行式tuple,压缩收益会大打折扣,因为行式布局破坏了列内数据的局部性和重复性。

向量化执行则是执行引擎的“CPU使用方式”。它批量处理数据,一次处理1024行或者4096行,避免逐行的解释开销。压缩物化与向量化执行协作的关键点在于“按块解压,按块计算”。引擎可以把一个压缩块直接解压成一个列向量,然后对该向量整体执行过滤、计算、聚合,一批处理完再处理下一批。这比逐行解压、逐行判定的方式高效得多。我这里有一组之前的微基准测试数据:

在同样的硬件上跑一个十亿行的计数查询,数据以字典编码压缩物化后直接做ID计数,耗时是无压缩列式物化的约0.8倍;如果做的是对原始字符串计数的查询,则需要先解压字符串列,耗时反而达到无压缩的1.15倍。这组数字说明了一个道理:压缩物化不是永远更快,而是“能充分利用编码语义”时更快。这也是为什么我始终强调,下游算子要尽量做“编码感知”的计算,而不是遇到压缩数据就本能地原地解压。

4. 实操配置与参数调优经验

4.1 在工程落地时需要盯住的三个参数

如果你打算在自己的引擎、或者使用支持类似特性的系统时启用压缩物化,有三个参数必须烂熟于心。

第一个是物化缓冲区阈值。太小的话,还没等到采样判断,缓冲区就满了,引擎会按默认模式处理,压缩物化形同虚设;太大的话,决策延迟增加,内存峰值也会提前飙升。我一般把初始阈值定在512KB到1MB之间,具体看L2 cache大小。如果L2 cache有1MB以上,阈值可以设在512KB,让采样数据尽量留在cache里。

第二个是压缩级别。很多压缩器提供从快到强的等级。LZ4有级别1到12,ZSTD有级别1到22。但我发现,在压缩物化场景里,越高的压缩级别往往越不值得。因为中间结果的生命周期短,压缩和解压可能只相隔几个算子,如果压缩花了很长时间,那节省的空间优势会被压缩时延吃掉。我的经验是:通用压缩用LZ4的默认级别或者ZSTD的级别1到3,编码压缩(字典、RLE、Delta)则不存在“级别”的概念,收益主要来自列特征判断的准确度,而不是压缩器的强度。

第三个是压缩收益判定阈值和采样行数。采样行数太少,估算的基数不准,会把高基数列误判成低基数,选择了字典编码后字节数反而变大;采样行数太多,又拖慢决策时间。我建议采样行数在1万到10万之间,并且要在数据中来几个不同的数据块位置采样,避免只采到一个重复值非常集中的区域。收益阈值就按前面说的1.5倍压缩率来起步,再根据你的CPU型号和工作负载做微调。

4.2 一组实测数据示例与调优思路

我用自己的分析引擎在标准测试集上做过一组对照实验,这里把参数和结果都列出来,供你参考。测试环境是双路CPU,32核心,256GB内存,测试查询包含一次大表哈希连接、一次分组聚合和一次排序。

中间结果集的结构正好是我前面说的“360MB四列”情况。第一轮跑默认无压缩物化,内存峰值是3.2GB,原因是哈希连接输出结果后被排序算子完整缓冲,然后排序过程又建立了一份排序键索引。第二轮开启压缩物化,字典编码识别出region_code低基数,RLE识别出order_date按序排列,customer_id和total_amount走LZ4,最终内存峰值降到1.4GB,下降了56%,查询总耗时也缩短了22%。主要收益来自于内存压力下降后,排序算子没有再触发spill。

第三轮我把ZSTD级别调高到10,内存峰值确实继续降了一点,从1.4GB降到1.28GB,但查询总耗时反而比第二轮多了11%。因为中间结果压缩时间太长,CPU浪费严重。这再次印证了我前面说的,压缩级别不是越高越好。

第四轮我特意把采样行数从1万改到1000,结果region_code被误判为高基数列,走了ZSTD而非字典编码,压缩率从4.7掉到2.1,内存峰值回升到2.5GB。这就是采样不足的代价,所以后来我把采样逻辑改成分区随机采样,保证低基数列不会被漏掉。

5. 常见问题与排查技巧实录

5.1 压缩率上不去的三个典型原因

我踩过太多压缩率翻车的场景,总结下来主要是三个原因。

原因一:高基数列强制编码压缩。有些列几乎每行都是唯一值,比如订单详情里的商品备注、随机数生成的ID,这类数据既不适合字典也不适合RLE,强行上ZSTD后压缩率往往只有1.1到1.3倍。解决办法是直接在物化决策时判定为“不可压缩列”,保持原始编码。判定方法就是采样基数除以采样行数,如果比值超过0.8,就不走压缩路径。

原因二:行式布局破坏了局部性。如果物化时数据是行式排列的,那么压缩器看到的数据流是“第1行所有列、第2行所有列”,同一列的数据会被隔断,这样RLE和列式编码根本无从下手。解决办法是尽量在压缩物化前按列重排,至少也要按列切分成独立缓冲区。这也是为什么压缩物化最好跟列式物化捆绑,而不是跟行式物化捆绑。

原因三:数据顺序随机,RLE和Delta被击穿。RLE需要连续重复,Delta需要差值很小,但如果数据的顺序完全是乱的,这两种编码都会失效。我在排查时发现,某个中间结果列按order_date做RLE,压缩率只有1.0,几乎没压。后来检查发现,这个结果集在物化前的并行扫描阶段被多个线程分片写入,每个线程负责的片段都按时间排序,但合并时没有做全局归并,导致相邻行的日期看起来很乱。解决办法是在物化时对数据块做轻度重排,或者改用字典编码和通用压缩组合。

5.2 压缩后CPU反而成为瓶颈怎么办

这是压缩物化上线后最容易被业务侧反馈的问题。现象一般是这样:内存峰值确实降下来了,但CPU使用率明显升高,查询耗时反而更长。出现这个问题时,我一般按顺序排查四件事。

第一,确认是否每次都被迫全量解压。如果下游算子没有做编码感知,每次都对整列解压再计算,那等于把压缩物化的红利全部舍弃,还要白交压缩成本。排查方法是在执行计划里看有没有RLE、DictionaryDecode这类算子,如果出现频率很高,就要优化算子逻辑,让它尽量在ID空间计算,或者只解压需要的块。

第二,确认压缩器选择是否过强。前面说到ZSTD level过高的情况,应换LZ4或者ZSTD level 1。很多线上环境的机器CPU主频并不高,多一次强大的压缩循环,时间差是肉眼可见的。

第三,确认物化决策是否过度触发。如果小结果集也走了压缩物化,那压缩和解压的固定成本占比太高。解决方法就是设阈值,小于1MB的结果集直接走无压缩路径。

第四,确认是否存在“重复压缩”。有些引擎会在一个中间结果被使用多次时反复压和解压。比如先做了一次压缩物化,下游做聚合后又对聚合结果再做一次压缩物化,这个过程中可能会把已经压缩的数据解压后计算,再把计算结果重新压缩。这种情况可以考虑让中间结果持有“已压缩”的标记,下游算子如果仍需要接近原始语义,可以直接引用之前的压缩块,避免重复劳动。

5.3 生命周期管理的坑

压缩物化还有一个很少有人会提前意识到的坑,就是中间结果的缓存和过期。压缩格式的内存对象比原始格式多了一张“编码元数据表”,比如字典表、RLE的运行长度表、Delta的基准值。这些元数据本身也要占用内存,而且如果同一份中间结果被多个下游算子共享,元数据是复用还是复制,会直接影响内存使用。

我遇到过一个案例:一份压缩物化的中间结果被三个下游游标并发读取,如果不做特殊处理,每个游标为了独立迭代都要持有一份“迭代状态”。这些迭代状态本身很小,但关联的解压缓冲区和块索引会膨胀。最后我改用“共享压缩块 + 独立游标位置”的机制,把所有下游游标的迭代器上层共用同一份压缩块索引,每层只维护自己的读取位置,内存占用才回归正常。

另外,压缩物化的中间结果在spill到磁盘和重新加载时,有点需要留意:最好直接保存压缩块格式,而不是先解压再spill。这样从内存到磁盘再到内存,整个过程数据形态度保持不变,避免两次编码转换的开销。如果引擎不支持这种“压缩态spill”,我就建议临时降低压缩物化的启用概率,因为它会和磁盘IO打架。

写在最后的小体会

压缩物化这个概念听起来像是论文里的词,但真正把它落地到查询执行链路中之后,我发现它更像是一种工程哲学:不要默认让中间结果“舒展”地存在内存里,而是先问一句,能不能让它更紧凑一点。很多分析型查询的内存瓶颈,根源并不是引擎能力不够,而是物化方式太奢侈。我自己在几次调优中体会到,压缩物化不是银弹,它需要配合编码感知的下游算子、合理的触发阈值和正确的压缩级别,才能真正发挥价值。

如果你正准备在项目里尝试压缩物化,我建议从最简单的场景入手:找一个中间结果集大且内存紧张的查询,打开物化决策日志,先看每一列走的是什么编码、压缩率是多少、每次解压发生在哪里。把这几个数据点摸清楚,再谈调优。很多时候,光是“看清楚中间结果长什么样”这件事,就足以让你找到比压缩物化更值得先做的优化了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询