资讯详情

资讯详情

压缩物化原理与调优:破解查询执行中间结果内存瓶颈

上周在调一个跑批任务的时候发现有个查询的执行计划看起来并不复杂但内存就是顶不住。两个大表做哈希连接之后的结果集还要做窗口排序中间结果直接把容器内存干到告警线。后来把中间结果物化方式从默认的行式改成压缩物化同样的查询内存峰值降了将近六成。这让我决定把压缩物化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_id8字节、total_amount8字节、region_code平均16字节、order_date4字节原始大小约360MB。如果region_code用字典编码能压到10MBorder_date用RLE或Delta压到20MBcustomer_id用ZSTD压到25MBtotal_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到12ZSTD有级别1到22。但我发现在压缩物化场景里越高的压缩级别往往越不值得。因为中间结果的生命周期短压缩和解压可能只相隔几个算子如果压缩花了很长时间那节省的空间优势会被压缩时延吃掉。我的经验是通用压缩用LZ4的默认级别或者ZSTD的级别1到3编码压缩字典、RLE、Delta则不存在“级别”的概念收益主要来自列特征判断的准确度而不是压缩器的强度。第三个是压缩收益判定阈值和采样行数。采样行数太少估算的基数不准会把高基数列误判成低基数选择了字典编码后字节数反而变大采样行数太多又拖慢决策时间。我建议采样行数在1万到10万之间并且要在数据中来几个不同的数据块位置采样避免只采到一个重复值非常集中的区域。收益阈值就按前面说的1.5倍压缩率来起步再根据你的CPU型号和工作负载做微调。4.2 一组实测数据示例与调优思路我用自己的分析引擎在标准测试集上做过一组对照实验这里把参数和结果都列出来供你参考。测试环境是双路CPU32核心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打架。写在最后的小体会压缩物化这个概念听起来像是论文里的词但真正把它落地到查询执行链路中之后我发现它更像是一种工程哲学不要默认让中间结果“舒展”地存在内存里而是先问一句能不能让它更紧凑一点。很多分析型查询的内存瓶颈根源并不是引擎能力不够而是物化方式太奢侈。我自己在几次调优中体会到压缩物化不是银弹它需要配合编码感知的下游算子、合理的触发阈值和正确的压缩级别才能真正发挥价值。如果你正准备在项目里尝试压缩物化我建议从最简单的场景入手找一个中间结果集大且内存紧张的查询打开物化决策日志先看每一列走的是什么编码、压缩率是多少、每次解压发生在哪里。把这几个数据点摸清楚再谈调优。很多时候光是“看清楚中间结果长什么样”这件事就足以让你找到比压缩物化更值得先做的优化了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →