☰
Presto Release 0.91 深度解析:清除 LazyBlockLoader 引用与懒加载块的内存释放优化
2026/10/11 1:22:21 网站建设 项目流程
  • 大数据
  • 数据库
  • 后端

【免费下载链接】presto

The official home of the Presto distributed SQL query engine for big data

项目地址:https://gitcode.com/gh_mirrors/pre/presto
点击查看免费下载

导读:本文围绕 Presto Release 0.91 中唯一的一条 General Changes——"清除LazyBlockLoader引用以提前释放内存"——展开。它对应着 Presto 列式执行引擎中一项核心内存优化:LazyBlock(懒加载块)在被真正读取后才物化数据,而加载完成后及时断开对 loader 的强引用,可让底层 PageSource 持有的文件句柄、缓冲区等资源尽早被 GC 回收。读完本文,你将理解LazyBlock/LazyBlockLoader的完整实现机制、在各连接器(Hive ORC/Parquet/RCFile、Druid 等)中的落地方式,以及 ScanFilterAndProjectOperator 如何通过"记录型 loader"精确统计懒加载块的输入字节数,并了解对应测试用例的验证思路。

版本背景:一条变更与一个必须记住的警告

在 Presto 官方发布说明 release-0.91.rst 中,0.91 版本的发布记录极其克制,只包含一条变更,并附带一条醒目的警告:

Warning:此版本存在内存泄漏(memory leak),不应在生产环境使用。

General Changes:在加载完成后清除LazyBlockLoader引用(ClearLazyBlockLoaderreference after load to free memory earlier),以便更早释放内存。

这条变更的落点在LazyBlockLoader:当LazyBlock完成数据加载后,立即置空其内部对 loader 的引用。这样做有两个直接收益:

  1. 更早释放内存:loader 实现类往往持有 PageSource、文件读取器、位置数组等重资源引用,尽早断开引用可以让这些对象随LazyBlock一起被 GC 回收;
  2. 更早触发底层资源释放:例如 ORC/Parquet 的流读取器在close()后才能真正归还文件缓冲区。

需要特别强调的是:0.91 版本本身因内存泄漏被官方明确警告不可使用,这条变更属于"修一处、漏一处"的历史版本中的一页。它更值得被当作理解 Presto 懒加载块内存模型的一把钥匙,而不是作为可部署版本被引用。下文将以当前仓库中的源码(即修复后的演进形态)为准进行讲解。

LazyBlock 与 LazyBlockLoader:延迟物化的最小单元

Presto 以Page(若干Block的列式集合)为数据交换单位。在扫描大表时,若把每列的原始数据都立即解码成Block,会占用大量内存。LazyBlock的职责就是"占位":它只记录positionCount(行数),真正的数据块Block block在首次被访问时才由 loader 填充。

接口定义

LazyBlockLoader.java 是整个机制的契约,它极其精简:

package com.facebook.presto.common.block; public interface LazyBlockLoader<T extends Block> { void load(T block); }

实现方通过load(T block)把真实数据写入传入的LazyBlock(通常调用lazyBlock.setBlock(...))。

LazyBlock 的核心状态机

LazyBlock.java 内部持有三个关键字段:

  • positionCount:预先可知的行数;
  • loader:延迟加载器,构造时强制非空(requireNonNull(loader, "loader is null"));
  • block:真实数据块,初始为null,加载后非空。

所有数据访问方法(getLong、getSlice、isNull、getSizeInBytes等)的第一步都是调用私有方法assureLoaded():

private void assureLoaded() { if (block != null) { return; } loader.load(this); if (block == null) { throw new IllegalArgumentException("Lazy block loader did not load this block"); } // clear reference to loader to free resources, since load was successful loader = null; }

这段代码正是 0.91 变更的最终形态:

  1. 首次访问时调用loader.load(this)触发物化;
  2. 校验 loader 确实调用了setBlock,否则抛出IllegalArgumentException防止静默丢失数据;
  3. 加载成功后立即将loader置空——注释原话是"clear reference to loader to free resources, since load was successful",即清除 loader 引用以便释放资源。这正是 Release 0.91 那条变更的直接实现。

此外LazyBlock提供isLoaded()(block != null)和getLoadedBlock()供上层判断是否已物化;setBlock只允许调用一次,重复设置会抛出IllegalStateException("block already set"),保证了状态机的单向性。

为什么 loader 引用必须被清除:引用链与 GC 分析

从内存模型看,loader 清除的意义远超"少一个引用"这么简单。以 ORC 为例,loader 实现(见下文OrcBlockLoader)通常持有:

  • 对应的SelectiveStreamReader(内部持有 ORC 数据源、解压缓冲);
  • 待读取的positions数组;
  • 可选的类型转换函数coercer。

只要LazyBlock.loader字段还指向这些对象,即便 Page 已经离开算子树、block已被上层使用,整套读取设施也无法被回收。0.91 的修复把引用生命周期收敛为"加载完成即失效",让 loader 及其持有的文件资源可以在物化后的第一个 GC 周期就被回收,这正是"free memory earlier"的底层含义。

同样的引用释放手法也出现在算子层:在 ScanFilterAndProjectOperator.java 的RecordingLazyBlockLoader.load()中:

public void load(LazyBlock block) { checkState(delegateLazyBlock != null, "delegateLazyBlock already loaded"); Block loadedBlock = delegateLazyBlock.getLoadedBlock(); delegateLazyBlock = null; // 立即断开对委托 LazyBlock 的引用 recordInputStats(); block.setBlock(loadedBlock); }

加载完成后delegateLazyBlock = null与LazyBlock.assureLoaded()中loader = null互为呼应,构成"加载即断链"的统一约定。

连接器中的落地:从 ORC、Parquet 到 RCFile

LazyBlock是 Presto 各存储连接器扫描路径上的通用机制。当前仓库中,以下位置均可见new LazyBlock(...)+ 自定义LazyBlockLoader的经典组合:

连接器/模块实现类文件
Hive ORCOrcBatchPageSourcepresto-hive/src/main/java/com/facebook/presto/hive/orc/OrcBatchPageSource.java
Hive ParquetParquetPageSource.ParquetBlockLoaderpresto-hive/src/main/java/com/facebook/presto/hive/parquet/ParquetPageSource.java
Hive RCFileRcFilePageSourcepresto-hive/src/main/java/com/facebook/presto/hive/rcfile/RcFilePageSource.java
Hive 通用HivePageSource(类型强制转换场景)presto-hive/src/main/java/com/facebook/presto/hive/HivePageSource.java
DruidDruidSegmentPageSourcepresto-druid/src/main/java/com/facebook/presto/druid/DruidSegmentPageSource.java
ORC 选择性读取OrcSelectiveRecordReader.OrcBlockLoaderpresto-orc/src/main/java/com/facebook/presto/orc/OrcSelectiveRecordReader.java

以 OrcSelectiveRecordReader.java 中的OrcBlockLoader为例,其load()展示了 loader 的典型工作流:

public void load(LazyBlock lazyBlock) { if (loaded) { return; } try { reader.read(offset, positions, positionCount); } catch (IOException e) { OrcSelectiveRecordReader.this.getOrcDataSourceId().attachToException(e); throw new UncheckedIOException(e); } Block block = reader.getBlock(positions, positionCount); if (coercer != null) { block = coercer.apply(block); } lazyBlock.setBlock(block); loaded = true; }

要点:

  • loader 内部有loaded标志位,保证同一块数据只解码一次(LazyBlock本身也有block != null短路,双重防护);
  • 解码异常会被绑定到 ORC 数据源 ID(attachToException),便于把 I/O 错误关联回具体文件;
  • 通过coercer(类型强制转换函数)在物化阶段顺便完成类型适配,避免另一次全列拷贝。

类似地,ParquetPageSource.java 的ParquetBlockLoader用checkState(batchId == expectedBatchId)校验批次号一致性,防止跨批次误用缓存,并将ParquetCorruptionException映射为HIVE_BAD_DATA、IOException映射为HIVE_CURSOR_ERROR的PrestoException,体现了连接器对懒加载异常的规范处理。

算子层的字节统计:RecordingLazyBlockLoader 的设计

引入LazyBlock后产生一个统计难题:未加载的块无法知道自己的真实字节数(getSizeInBytes()会触发加载)。如果每次算子输入都统计所有列的字节,就会强制物化所有懒加载列,抵消延迟加载的意义。

ScanFilterAndProjectOperator.java 的recordProcessedInput给出了精妙解法:

private Page recordProcessedInput(Page page) { long blockSizeSum = 0L; Block[] blocks = null; for (int i = 0; i < page.getChannelCount(); ++i) { Block block = page.getBlock(i); // account processed bytes from lazy blocks only when they are loaded if (block instanceof LazyBlock && !((LazyBlock) block).isLoaded()) { if (blocks == null) { blocks = copyOfPageBlocks(page); } blocks[i] = new LazyBlock(page.getPositionCount(), new RecordingLazyBlockLoader((LazyBlock) block)); } else { blockSizeSum += block.getSizeInBytes(); } } return (blocks == null) ? page : new Page(page.getPositionCount(), blocks); }

思路是"以懒对懒":对未加载的LazyBlock,不计算其字节数,而是用一个新的RecordingLazyBlockLoader把它包起来。当该块在后续PageProcessor中被真正访问、触发加载时,RecordingLazyBlockLoader.load()会先调用recordInputStats()把延迟统计的输入字节补上,再透传真实数据块。这样:

  • 从未被消费的列永远不会被物化,也永远不会被统计字节(零成本);
  • 被消费的列在物化瞬间精确记账,统计不丢失也不重复。

延迟投影:DictionaryBlock 的懒加载链

LazyBlock的价值还体现在表达式执行中的"懒加载传递"。在 DictionaryBlock.java 的createProjection中,如果新字典本身是LazyBlock,投影操作不会立即物化它,而是生成一个嵌套的LazyBlock:

if (newDictionary instanceof LazyBlock) { return new LazyBlock(positionCount, (block) -> { Block newDictionaryBlock = newDictionary.getBlock(0); Block newBlock = createProjection(newDictionaryBlock); block.setBlock(newBlock); }); }

注释明确写着"be careful to not materialize it"。也就是说,从文件扫描到投影计算,懒加载状态可以沿表达式树逐层传递,直到真正需要数据的那一层才统一物化——这正是 Release 0.91 所保护的机制之所以重要的原因:链越长、loader 越晚清除,被拖住的资源就越多。

测试验证:懒加载语义与字节统计的正确性

TestScanFilterAndProjectOperator.java 用两个用例锁定了上述行为:

  • testPageSourceLazyLoad:构造new LazyBlock(100, lazyBlock -> { throw new AssertionError("Lazy block should not be loaded"); }),如果第 1 列(懒加载列)被物化,测试直接失败。这验证了"未被消费的懒加载列绝不物化"的语义;
  • testPageSourceLazyBlock(注释为 "Tests that a page containing a LazyBlock is loaded and its bytes are counted by the operator"):通过CountingLazyPageSource+CountingLazyBlockLoader,验证包含LazyBlock的 Page 被加载后,其字节数会被算子正确统计,即RecordingLazyBlockLoader的补记账逻辑成立。

这两个测试一正一反,分别守护了"延迟"与"精确记账"两条约束,是理解这条内存优化变更最直观的对照实验。

小结:一行变更背后的内存治理哲学

Release 0.91 的这条 General Changes 只有一句话,但它揭示的是 Presto 内存治理的一条重要原则:懒加载不仅要"延迟物化",还要在物化后"及时断链"。从 LazyBlock.java 的loader = null,到 ScanFilterAndProjectOperator.java 的delegateLazyBlock = null,再到各连接器 loader 内部的loaded标志位,整个体系围绕"资源生命周期与数据生命周期解耦"展开。也正因如此,官方才会对 0.91 的内存泄漏问题给出如此郑重的警告——在内存优化密集的引擎里,任何一个引用周期的疏漏都可能演变成线上事故。理解这段历史,比记住某个版本号本身更有价值。

  • 大数据
  • 数据库
  • 后端

【免费下载链接】presto

The official home of the Presto distributed SQL query engine for big data

项目地址:https://gitcode.com/gh_mirrors/pre/presto
点击查看免费下载

相关推荐

上一篇:Tape命令行工具深度解析:如何高效运行和管理测试的完整指南
下一篇:Chinese-STD-GB-T-7714测试与验证:确保引用格式准确性的完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询