Velox DataSource:从 TableScan 到 Hive 与 Iceberg 文件扫描
Velox 的 DataSource 是 TableScan 与存储 connector 之间的扫描接口。TableScan 从 Task 取得 split,DataSource 把计划要求的列和过滤条件绑定到这个 split,再分批返回 RowVector。文件扫描的公共实现是 FileDataSource;HiveDataSource 补充 Hive 的分桶转换、分区与行定位语义;IcebergDataSource 则接入 field ID、删除文件、默认值、row lineage 和 changelog。它们把“这次扫描需要什么”与“这个文件实际有什么”接在一起。
理解这一层,关键是同时跟踪列的身份、类型、过滤位置和对象寿命。同一个表列可以只参与 reader 内部过滤,也可以作为 remaining expression 的隐藏输入,或者因为 bucket conversion、equality delete 被临时加入 reader 输出。ScanSpec 也同时承载静态读取要求、split 常量、动态过滤和读取反馈,不能把它简单理解成一份永不改变的配置。
本文基于 2026-10-21 获取的 origin/main,commit c52ff97b4b5b0f2d0f931f989e5f9c98020927ba。所有源码链接固定到这个快照;分析范围是普通 CPU 文件扫描,重点是 Hive 与 Iceberg,DWIO 以 DWRF/ORC、Parquet 和 selective Nimble 为依据。其他 connector 的 merge-on-read、GPU 路径和宿主系统的 split 枚举不是本文的基线。这里先建立 main 的真实行为,再讨论后续 FileScanConfig / FileScanState 重构应接受什么检验。
阅读顺序沿扫描生命周期展开:前两节建立架构并走完一个小例子;第 3~8 节解释扫描输入、类型、构造、文件准备和一次 batch;第 9~14 节加入 Hive / Iceberg 语义、动态过滤与跨 split 状态;最后用场景和源码证据检验这些关系,再归纳设计取舍。
1. 文件扫描的整体架构
1.1 TableScanNode 保存扫描契约,TableScan 推进执行
TableScanNode 持有 outputType、ConnectorTableHandle 和 assignments。它是计划中的源节点,没有上游数据输入,数据从 Task 分配的 split 进入。SQL 到这些对象的转换通常由宿主引擎完成,Velox 这里不负责访问 Hive metastore、枚举整个表的目录或决定 Iceberg snapshot 的文件集合。
TableScan 构造函数 将节点的 outputType 交给 SourceOperator,保留 table handle 与列句柄,并按 tableHandle->connectorId() 从 connector registry 找到实际 Connector。DataSource 并不是在构造 TableScan 或 initialize 时就创建;getSplit 取得第一个实际 connector split 后,才创建 ConnectorQueryCtx 和 DataSource。没有文件可扫的 Task 因而不必建立 reader 或编译扫描表达式。
Connector::createDataSource(outputType, tableHandle, assignments, queryCtx) 是真正的分发边界。这个 main 中,HiveConnector::createDataSource 直接构造 HiveDataSource;IcebergConnector::createDataSource 是独立 override,构造 IcebergDataSource。不能因为 Iceberg 代码放在 hive 目录,就认为 HiveConnector 会检查 handle 再决定分发给谁。
1.2 继承关系与组合关系不能混在一起
| 层次 | 真实关系 | 承担的工作 |
|---|---|---|
| 执行层 | Task 提供 split;Driver 驱动 TableScan | 等 split、yield、计算 batch 大小、推进 split、传递动态过滤与统计 |
| Connector | HiveConnector / IcebergConnector 各自 override 工厂 | 给 DataSource 提供配置、文件句柄工厂、I/O executor、查询上下文 |
| DataSource | IcebergDataSource : HiveDataSource : FileDataSource |
读取需求、表达式、ScanSpec、splitReader 生命周期、最终输出 |
| SplitReader | HiveSplitReader : FileSplitReader;IcebergSplitReader : FileSplitReader |
绑定一个数据文件的范围,处理该 split 的常量、schema、桶过滤或删除 |
| Changelog | IcebergChangelogSplitReader : IcebergSplitReader |
底层数据行转换成 operation / ordinal / snapshotid / rowdata |
| DWIO | SplitReader 持有 Reader 与 RowReader,后者使用格式 ColumnReader 树 | 解析文件、选择 stripe / row group、过滤、解码、生成或延迟物化向量 |
这里有一个容易漏掉的不对称:IcebergDataSource 继承 HiveDataSource,但 IcebergSplitReader 不继承 HiveSplitReader。因此 Iceberg 的 next() 不会顺便执行 HiveSplitReader 的 bucket conversion,适配常量列的逻辑也有自己的 override。共同部分应追到 FileDataSource / FileSplitReader,不能从一条继承链推断另一条。
Reader 是文件视图,提供 schema、行数和统计,并创建 RowReader;RowReader 带着 range、ScanSpec、MetadataFilter 和格式 options 推进读取。一个 split 在本基线代表单个数据文件的一段字节范围,不等于一个完整文件,也不保证正好一个 Parquet row group。Iceberg 的同一 split 还可能打开多个 delete 文件,这与“一个 split 包含多个基础数据文件”是不同关系。
2. 一批数据怎样从文件走到输出
设表的非分区 schema 为 ROW(c0 BIGINT, c1 BIGINT, c2 BIGINT),分区列 p VARCHAR。计划要输出 ROW(x BIGINT, p VARCHAR),其中 x → handle(name=c0);静态下推过滤为 c2 >= 0,remaining expression 为 c0 + c1 > 10,当前 split 的分区常量是 p='east'。假设一个 batch 覆盖文件原始行 0~4:
| 文件行号 | c0 | c1 | c2 | reader 下推过滤 | remaining expression |
|---|---|---|---|---|---|
| 0 | 4 | 8 | 1 | 通过 | 12 > 10,通过 |
| 1 | 5 | 9 | -1 | 淘汰 | 不再求值 |
| 2 | 8 | 1 | 2 | 通过 | 9 > 10,淘汰 |
| 3 | 9 | 3 | 3 | 通过 | 12 > 10,通过 |
| 4 | 3 | 2 | -2 | 淘汰 | 不再求值 |
配图用 P 简记 DataSource.outputType_,R 简记 readerOutputType_,G 简记 readerProducedType_;它们的来源与变更见第 4 节。
流程按真实接口展开:
- TableScan 把计划输出和 assignments 交给 Connector。FileDataSource 将输出别名 x 换成 handle 名 c0,形成 reader 前缀
[c0,p];编译剩余表达式后发现还需要 c1,追加成[c0,p,c1]。 - makeScanSpec 为 c0、p、c1 建立可输出节点;c2 只在 ScanSpec 中出现,带 Filter,
projectOut=false,不占 reader RowVector 的输出 channel。 - addSplit 创建一个 HiveSplitReader,配置 ReaderOptions,再打开文件。p 被设置为有类型的常量;文件 schema 与请求类型完成匹配;文件统计可能先排除整个 split。
- RowReader 推进 5 个原始行位置。选择性 reader 读取 c2 并得到原始行集合
[0,2,3],其输出是 3 行ROW(c0,p,c1)。c0、c1 可在后续表达式需要时才加载。 - FileDataSource 求值
c0+c1>10,在这 3 行中的选择是[0,2],而不是文件行号[0,3]。ColumnLoader 会在延迟加载时把两层选择关联起来。 - FileDataSource 只取前两个输出 child,以 dictionary 表达行选择,并创建计划类型
ROW(x,p):结果是(4,'east')、(9,'east')。c1 留在内部,c2 从未成为 reader 输出列。 - TableScan 返回这个非空 batch。下次 next 若过滤得到零行,继续扫描;只有 splitReader 返回
rowsScanned=0,或准备时已确定 emptySplit,才结束这个 split。
因此这批的 rows scanned=5,reader 输出=3,remaining 后输出=2。FileDataSource::next 把 splitReader 返回值累加到 completedRows;TableScan::getOutput 用它更新 rawInputPositions。被 metadata 整块跳过的行没有经过这次 next 的推进,不能把这个指标解释成“文件所有行数”,更不能当成最终返回行数。Hive 桶筛选和 Iceberg 删除还会进一步减少 output_->size(),但它们保留底层的 rowsScanned 返回值。
3. TableHandle、ColumnHandle 与 Split:扫描的三类输入
第 2 节的一次读取同时接收三类信息:TableHandle 给出整次表扫描的要求,ColumnHandle 通过 assignments 解释计划列,Split 带来当前文件的事实。先分清这些输入,再看 DataSource 怎样把它们变成读取状态。
3.1 TableHandle:一次表扫描的契约
这里的 TableHandle 可以理解为某个 TableScan 的表扫描契约。同一个 catalog 表被扫描两次,两处扫描可以携带不同的 subfieldFilters、remainingFilter 和 dataColumns。它不是 metastore 中那个全局表对象,也不是已经打开文件的 I/O handle。TableScan 把它与输出类型、assignments 一起传入 Connector;DataSource 才把这些描述编译、适配成可执行的读取现场。
从计划句柄到运行状态
真实继承链是 ConnectorTableHandle → FileTableHandle → HiveTableHandle → IcebergTableHandle。ConnectorTableHandle 只提供 connector 身份、表名和通用能力接口;FileTableHandle 抽象出文件扫描所需的 schema、谓词和表参数;HiveTableHandle 保存具体的 const 成员;IcebergTableHandle 再加 changelog 标志与基础表列元数据。继承关系与工厂分发仍是两个问题:由注册的 IcebergConnector 创建 IcebergDataSource,不是基类看见 IcebergTableHandle 就自动切换实现。
FileTableHandle 是抽象接口;HiveTableHandle 的实现位于 TableHandle.h,该文件同时保存 Hive 列句柄与表句柄的定义。表句柄包含 connector ID、表名、原始 subfieldFilters、原始 remainingFilter、sampleRate、dataColumns、tableParameters、filterColumnHandles、dbName 等。Hive 句柄还提供 indexColumns 和与 dataColumns 对齐的 field IDs;后者供 Iceberg 的完整 schema 身份解析使用。
每类表参数沿调用链流向哪里
dataColumns 是表的非分区数据列 schema,允许调用方只提供所需的表 schema 范围,并不保证是 catalog 中全部列。过滤但不投影的普通列、隐藏桶列以及 equality-delete key 经常要从这里取类型,所以“输出只有 c0”不等于 dataColumns 可以省去所有其他列。FileDataSource 的普通扫描构造调用 makeScanSpec 时传入的 indexColumns 是空集合,不能把 HiveIndexSource 的 index lookup 路径直接套过来。
| TableHandle 信息 | 第一个关键消费者 | 派生结果 / 后续消费者 | 更新粒度与边界 |
|---|---|---|---|
| connectorId、name、dbName | TableScan 查 Connector;FileSplitReader 打开文件时填写操作属性 | connector 实例、文件访问/扫描事件中的表身份 | connectorId 参与执行分发;名字不是文件路径或 schema mapping 指令 |
| subfieldFilters | FileDataSource 构造复制 Subfield map | 合并提取结果后的 filters_ → ScanSpec Filter → DWIO | 原 map 保留;运行期动态条件另在 ScanSpec 中更新 |
| remainingFilter | 构造时 extractFiltersFromRemainingFilter | 可下推 Filter、采样信息、剩余 TypedExpr → ExprSet 与 MetadataFilter | 每个 DataSource 派生一次;普通 addSplit 不重新编译 |
| sampleRate | 构造时与提取出的采样率结合 | RandomSkipTracker → RowReader 的 Mutation | 有推进状态,不能把 tracker 当成 handle 中一个共享只读数值 |
| dataColumns | 构造 makeScanSpec;每 split configureReaderOptions | 仅过滤列的类型;ReaderOptions.fileSchema;缺失列/历史 schema 适配 | 表侧 schema 保持;当前文件实际 schema 每 split 解析 |
| filterColumnHandles | 构造时 processColumnHandle;Iceberg 字段解析 | 非投影列的角色、分区解析规则、ID、默认值 | handles 的列表不等于 reader 输出列列表 |
| tableParameters | FileSplitReader.configureBaseRowReaderOptions | 如 skip.header.line.count → RowReaderOptions.skipRows | 按实际消费者解释,不能假设所有参数每个格式都生效 |
| dataColumnFieldIds | Iceberg 的字段树与 equality key 解析 | 对齐 dataColumns 的字段身份 | 不等于当前文件物理 ordinal;为空表示未提供这份 ID 信息 |
| isChangelogQuery、dataColumnHandles | IcebergDataSource 构造 | 基础表 dataReaderOutputType / dataScanSpec 与 changelog 外层 schema | 属于 IcebergTableHandle;按列名保存基础列元数据 |
表参数有个适合用来检查“传参”和“生效”区别的例子:configureReaderOptions 的通用重载接收 tableParameters,但该参数在此未使用;configureRowReaderOptions 才实际解析 skip.header.line.count。配置来源还包括 FileConfig、session properties 和 split;应追到 setter 与 reader 的消费方,不能看到参数一路传递就宣布它控制所有 reader 行为。
dataColumns、filterColumnHandles 与 assignments 也不能互相替代。dataColumns 提供表列的名称和逻辑类型;ColumnHandle 还说明该列是分区值、生成值或文件数据,以及 Iceberg ID / 默认值;assignments 负责把计划输出名接到相应列句柄。只有逻辑类型时,无法知道一个 BIGINT 应从文件解码,还是应生成文件行号。
共享的是原始要求,执行时还会派生自己的对象
表句柄以 shared_ptr 共享,里面的 TypedExpr / Type 通常也是共享的只读输入。构造 DataSource 时会派生自己的过滤 map、剩余表达式编译产物和 ScanSpec,而不是把运行期修改都写回 table handle。表参数也不是全部扫描配置:实际 options 还来自 Connector 的 FileConfig、ConnectorQueryCtx 中的 session properties、QueryConfig 派生设置和 split 级覆盖。
TableScanNode、TableScan 和 DataSource 使用 shared_ptr 保持 table handle 的寿命;HiveTableHandle 的成员主要是 const 描述。TypePtr、TypedExpr 与 Filter payload 可以共享,但 FileDataSource 要新建自己的 map、ScanSpec 和 ExprSet。复制 map 时克隆 Subfield key、共享原 Filter 指针;合并谓词时替换派生 map 的引用,原 TypedExpr 也不会被原地改写成编译后的表达式。
这条边界解释了为什么 preload 不能只复制一个 table handle 就获得准备好的扫描:donor 仍需构造自己的 DataSource、ExprSet、ScanSpec 和 reader。反过来,普通连续 split 也不必重新构造 table handle、重提取全部谓词。文件常量、缺失列状态、运行期 Filter、selectivity 与 reader 游标落在后续对象中,具体迁移见第 13、14 节。
3.2 ColumnHandle:列身份、角色与输出绑定
ColumnHandle 保存单列的身份、取值来源和交付要求;assignments 则把计划输出名绑定到这个句柄。先确定读的是哪一列,再讨论它的类型、裁剪方式和运行期用途。
ColumnHandle 描述列契约,assignment 描述输出绑定
FileColumnHandle 用接口区分列角色和数据类型。HiveColumnHandle 保存其实现;IcebergColumnHandle 在此基础上增加 field-ID 树、initial-default、write-default 和 Iceberg 类型元数据。
通用 ColumnHandle 提供 name 和序列化等基础接口,并不直接要求所有 connector 都具有 Hive 的列角色。文件扫描的继承链是 ColumnHandle → FileColumnHandle → HiveColumnHandle → IcebergColumnHandle。FileDataSource 接收通用 assignments 后检查/转换成 FileColumnHandle,因此它依赖的是文件扫描接口,而不是每个地方都直接访问 HiveColumnHandle 的私有字段。
以输出 ROW(alias BIGINT, keys ARRAY(VARCHAR)) 为例,assignments 可以是 alias → handle(name=c0)、keys → handle(name=m, extraction=MapKeys)。FileDataSource 按 outputType 的名称顺序查 assignments,得到 reader 的前两个位置 c0、m;unordered_map 的迭代顺序不决定 channel。remaining expression 仍按表名引用 c0、m,最后输出包装才恢复 alias、keys。
同一普通表列不能仅靠两个 assignment key 就任意复制成两个扫描输出。构造函数按 handle.name 建索引,重复时先检查 columnType 与 dataType 的一致性,并只允许符合条件的重复分区句柄;普通列需要由上层 Project 复制。这样可以避免同一 ScanSpec 字段被两个互相矛盾的输出 channel 或 extraction 请求解释。checkColumnHandleConsistent 本身只比较 columnType 和 dataType,并不逐项比较 schemaType、requiredSubfields、extractions 或 Iceberg 元数据,不能把这个检查理解成两个句柄完整等价的证明。
名字、角色、逻辑类型与 extraction 是四个维度
“BIGINT”只回答值的 SQL/Velox 类型,无法回答它是文件中存储的普通列、分区常量还是生成的 row index。这里的 RowTypePtr / TypePtr 是 Velox 的逻辑类型描述,底层 reader 再根据 TypeKind、文件类型和 C++ 值表示分派解码。一个 kRowId 列的 SQL 类型本身可以是 ROW。
| 信息 | 例子 | 使用位置 |
|---|---|---|
| assignment key | x |
TableScanNode / DataSource 对外输出名称,用它在 assignments 中查 handle |
| handle.name | c0 |
表列身份;FileDataSource、ScanSpec、remaining expression 的字段空间 |
| schemaType | MAP(VARCHAR,BIGINT) |
表 schema 中这一列的类型;不是从当前文件 footer 读出的类型 |
| dataType | ARRAY(VARCHAR) |
计划希望这一列交付的类型,例如 map_keys 的结果 |
| columnType | kRegular / kPartitionKey / kSynthesized / kRowIndex / kRowId | 决定列来自文件、split 常量还是生成器;它不是 SQL 类型 |
| requiredSubfields | r.a、m["k"]、a[3] |
可裁剪的数据范围,不改变顶层 SQL 类型 |
| extractions | StructField、MapKeys、MapValues、MapKeyFilter、ArrayElements、Size 的链 | 在 reader 内得到不同形状的值;与 requiredSubfields 互斥 |
| postProcessor | void(VectorPtr&) |
remaining filter 和行选择之后,处理对外输出列;不得改变行数 |
schemaType 是请求侧表 schema 中的类型,dataType 是该 handle 希望交付的类型,两者都是 Velox Type。HiveColumnHandle 的 schemaType() 返回 hiveType()。特别要核对实际构造器:TableHandle.cpp 在没有 extraction 时要求 dataType 与 hiveType equivalent;有 extraction 时沿 schemaType 验证每条链的推导结果。头文件中较宽泛的历史 coercion 说明,并不意味着当前构造器接受任意不相等的两种类型。
因此,文件历史类型 INTEGER、当前请求类型 BIGINT 的读取转换,应从实际文件 schema 与请求类型之间追踪;不能直接把它写成“无 extraction 的 HiveColumnHandle 可以任意设置 schemaType=INTEGER、dataType=BIGINT”。MAP→ARRAY 则是另一种机制:schemaType 保留 MAP,NamedExtraction 说明取 keys,dataType 描述 ARRAY。多条命名 extraction 用 ROW 组织多个结果,不等于把同一普通列重复放进 assignments。
requiredSubfields 表达“这个完整逻辑值中哪些部分会被用到”,例如只用 r.a;extractions 表达“把输入值转换成哪种结果”,例如只交付 map 的 keys。前者可以把未读取的 ROW 成员置 null 而保留 ROW 类型,后者可以改变顶层类型。构造器禁止两者同时非空,以免用两套描述重复、冲突地决定同一列的读取范围。
columnType 决定值的来源。partition handle 只带名字、类型与解析规则,具体分区值到 split 才到来;synthesized handle 让 split 的 infoColumns 转成常量;row index / row ID 声明生成列;regular 才通常进入文件 column reader。IcebergColumnHandle 再用 field-ID 树识别历史字段,用 initial-default 解释旧文件缺列,write-default 则服务写入。它们不会因为顶层 SQL 类型相同而自动等价。
没有投影的列为什么仍可能需要 handle
FileDataSource 先把 assignments 中的句柄按 handle.name() 建辅助索引,并把 assignments 与 tableHandle.filterColumnHandles() 中的特殊角色加入 partitionKeys_、infoColumns_ 和 specialColumns_。重复映射不是随意允许:同名重复句柄受到一致性检查,特别处理一致的 partition handle。filterColumnHandles 给未投影的分区/合成列提供解析依据,也让 Iceberg 找到未投影列的 ID 与默认值;普通 residual field 的类型则还来自编译后 FieldReference。不能假设所有过滤列一定能在 output assignments 中找到。
| 列的用途 | 名称 / 类型从哪里获得 | 是否必须进入 readerOutputType | 谁消费值 |
|---|---|---|---|
| 计划输出列 | outputType 名称 → assignment → handle.name;输出类型来自计划 | 是,位于固定前缀 | 下游算子 |
| 普通 reader-only 下推过滤列 | Subfield 根名;dataColumns 给类型 | 通常否;ScanSpec 建 filter-only 节点 | DWIO 内部 Filter |
| 普通 remaining-expression 隐藏列 | 编译后 FieldReference 的名称与类型 | 是,追加辅助 channel | DataSource 的 ExprSet |
| 未投影分区/合成/生成列 | filterColumnHandles 提供特殊角色和解析契约 | 取决于只下推还是还需交给 ExprSet | 常量测试、生成器或表达式 |
| bucket key | split.bucketColumnHandles;必要时从表 schema 补充 | 是,需要完整 key 值 | HiveSplitReader 的桶 hash |
| Iceberg equality key / 默认列 | field ID、表 schema 与可用的 Iceberg handles | equality 比较需投影 key;仅 metadata / 下推用途另论 | Iceberg schema 适配与删除处理 |
FileDataSource 构造时那个按表名索引的 columnHandles 是局部辅助结构,并不是随后所有列需求的唯一仓库。特殊角色被保存到 partitionKeys_、infoColumns_、specialColumns_;requiredSubfields 的指针和 extraction 引用要求原 handles 继续存活;postProcessor 复制到与输出前缀对齐的 callback 数组。IcebergDataSource 还会持有一份共享的 assignments map,以便 splitReader 查询字段 ID 等元数据。
postProcessor 不参与 reader 选择哪些行通过 WHERE。它在 remaining filter 和输出行包装之后处理列,必须保持行数;内层向量和 buffer 若仍共享,不能直接覆写。若结果仍为 LazyVector,callback 如何触发或包装延迟操作要看其实现,不能把接口注释当成框架替所有 callback 安装了一次延迟执行器。第 4.3 节继续追踪它与 extraction、schema coercion 的不同位置。
3.3 Split 才带来文件事实
FileConnectorSplit 保存 filePath、fileFormat、start、length、properties、partitionKeys、columnMappingMode 等。filePath 是文件身份;若 physicalFilePath 非空,readPath() 返回替代读取位置。文件打开使用 readPath,而 row-ID、delete 匹配等仍可能使用 filePath。字节 range 由格式 reader 转换为可读的 stripe / row group 集合。
HiveConnectorSplit 增加 infoColumns(如 $path、$file_size)、serdeParameters、tableBucketNumber、bucketConversion、rowIdProperties、customSplitInfo 等。分区值以 optional string 传递,到 prepare 才根据 handle 类型、时区和日期编码规则构造 ConstantVector。$file_size 作为输出常量来自 split 的 infoColumns;不要把用于打开文件的 properties.fileSize 自动当成其输出来源。
HiveIcebergSplit 定义在 IcebergSplit.h,增加 deleteFiles、dataSequenceNumber、identityPartitionKeys、partitionEnforcedFieldIds 和 changelogSplitInfo。field-ID 树主要由 table / column handle 提供;lineage 的 $first_row_id、$data_sequence_number,以及 MERGE 的 $spec_id、partition_data 由 split infoColumns 提供。这里 dataSequenceNumber 成员用于删除文件适用性,infoColumns 的同名语义数据用于 lineage 补值,代码读取入口不同,不能遗漏其中一条。
只有绑定具体 split 后,才知道这个文件真实 schema、哪些列缺失、当前分区常量、桶目标、delete 文件集合、lineage 基值以及 range 内第一个文件绝对行号。它们不是构造 DataSource 时就能完全冻结的内容。
表级要求、列绑定和文件事实已经分开。下一步把同一列沿途使用的名字、类型和 channel 对齐,随后就能看懂构造函数为什么追加辅助列、重建 extraction 的请求类型。
4. 计划、reader 与输出:类型沿途怎样变化
4.1 先建立三个坐标系
计划名称空间描述 TableScan 给下游的列,例如 x;表名称空间描述 handle / expression / ScanSpec 的列,例如 c0;文件名称与 field-ID 空间由具体 reader 做 schema matching。普通按名读取时后两者可能相同,按位置或 field ID 映射、历史列重命名时则不能直接相等。
另一个独立坐标系是 channel:对外输出 channel、reader RowVector channel、ColumnReader 的内部 subscript 并非同一编号。ScanSpec 的 channel 决定投影到 RowVector 的哪个位置,subscript 把节点接到当前文件 reader 树的 child。过滤自适应会重排 ScanSpec children 的遍历顺序,但不能改变输出 channel 的含义。
4.2 类型演变表
下面的“辅助列”指不对下游投影、但需要返回 DataSource 或 splitReader 处理的列;“filter-only”专指只在 DWIO 内部使用、不需要返回 reader RowVector 的节点。
| 类型 / 成员 | 所属对象与创建来源 | 列名空间 | 顺序 / channel | 辅助、虚拟与过滤列 | split 准备时变化 | 变化后的消费者 / 同步点 |
|---|---|---|---|---|---|---|
| TableScanNode.outputType | 宿主计划构造 | 计划名称 | 计划输出顺序 | 可含被投影虚拟列,不自动含隐藏依赖 | 节点不改 | TableScan / DataSource 的对外契约 |
| TableScan.outputType_ | SourceOperator 从节点取得 | 计划名称 | 与节点相同 | 同节点 | 不因 split 改 | 下游算子、动态过滤 channel |
| tableHandle.dataColumns | 表 schema 输入 | 表列名 | 表顺序;与 dataColumnFieldIds 对齐时有身份含义 | 通常非分区表列;供未投影过滤 / delete 查类型 | 输入本身不改 | ReaderOptions.fileSchema、makeScanSpec、bucket / equality 解析 |
| handle.schemaType / dataType | ColumnHandle 构造 | 名字由 handle.name 给出 | 单列类型;嵌套字段有自己的顺序 | 类型不决定扫描角色 | handle 不改 | extraction、常量转换、reader 请求、输出校验 |
| FileDataSource.outputType_ | Connector 工厂的 outputType | 计划名称 | 前 N 列投影契约 | 可含 row index 等;不额外附加过滤依赖 | const,不改 | 创建最终 RowVector / 空结果 |
| FileDataSource.readerOutputType_ | 输出列按 assignment 还原名称,再追加 distinctFields | 表列名 | 前 N 个位置对应计划输出;后面是辅助列 | remaining 依赖必需;bucket / equality / filled-column 可追加;普通 filter-only 不必有 | 会,且普通连续 split 可保留已追加列 | makeScanSpec、SplitReader、输出分配与表达式字段查找 |
| FileDataSource.readerProducedType_ | configureExtractionColumns 按 ExtractionType 推导 | 表列名 | 与 readerOutputType 的位置一致 | extraction 列用结果类型,其余保留;追加辅助列要同步 | addSplit 对 splitReader 追加的尾列扩展它;takeover 移入 donor 版本 | 非空时优先用来分配 output_ |
| FileSplitReader.readerOutputType_ | 创建时接收 DataSource 类型 | 表列名 | 同上述前缀约定 | Iceberg 可追加 equality key / filled-column | 子类可改变 | addSplit 结束复制回 DataSource |
| Changelog readerOutputType() | 子类 getter 返回 changelogOutputType_;基类成员仍是基础表类型 | 外层 changelog 名;内层基础表名 | 外层与内层各自有 channel | 外层含 residual 辅助列;内层由 dataColumnHandles 决定 | 基类辅助调整与 getter 不是同一个类型 | FileDataSource 消费外层;IcebergSplitReader 消费内层 |
| 文件实际 schema | footer / 文件格式 Reader 解析 | 原始文件名或 reader 映射后的名字 | 文件物理次序 / ID | 不含凭空生成的分区或 row-ID 列 | 每次打开文件不同 | typeWithId、统计定位、ColumnReader 物理匹配 |
| ReaderOptions.fileSchema | configureReaderOptions 从 dataColumns 传入 | 请求侧表 schema | 帮助按位置 / 名称 / ID 解释文件 | Iceberg 为 lineage 可在 createReader 周围临时扩展 | options 每 split 新建,临时扩展后恢复 | 格式 Reader 的 schema matching |
| RowReaderOptions.requestedType | getAdaptedRowType 基于 Reader.rowType 的名字与顺序,替换被请求列类型 | reader 的适配名称空间 | 沿文件视图顺序,不是输出 channel 顺序 | 不必列出所有常量列;ScanSpec 另负责生成 | 每 split 重新构造 | ColumnSelector / 格式 reader 构建读取树 |
| output_ 中的 RowVector.type | 初始按 produced 或 readerOutput 分配;DWIO/子类可能替换 | 通常表名;changelog 有外层类型 | reader 投影 channel | 可含辅助列;row-number injection 曾临时多一列 | 可能改变;不能只凭成员名推断精确相等 | remaining evaluator、bucket/delete、最终投影 |
| 正常最终 RowVector.type | 非零输出列时用 outputType_ 新建 | 计划名称 | 恢复计划前缀 N 列 | 只保留计划投影 | 不改 | 下游;零输出列有特殊分支,见第 8 节 |
FileDataSource::outputType_ 是这个扫描算子的输出,上面还可能有 FilterProject、Join、Aggregation、Project 等算子,它当然不一定是整个 SQL 的最终结果。readerOutputType_ 则是一个读取接口的投影与辅助输入清单,不是完整的物理文件 schema。
前缀约定由多处代码共同维持:构造函数 按 outputType 的顺序添加列,再追加 residual fields;setupBucketConversion 只往末尾加隐藏桶列;appendProjectedColumns 给 Iceberg 新列从旧 size 开始分配 channel。最后 FileDataSource::next 只取前 outputType_->size() 个 child。这里不是依赖 unordered_map 的迭代顺序。
4.3 extraction、类型转换与 postProcessor 的位置
以 map_keys(m) 为例:表类型是 m MAP(VARCHAR,BIGINT),输出要求 keys ARRAY(VARCHAR)。初次 readerOutputType 构造沿用输出类型,extraction 分支随后将 m 的请求恢复为 handle.schemaType,即 MAP,重建 ScanSpec;configureExtractionColumns 给节点安装裁剪、ExtractionType 与 transform。若节点的 ExtractionType 非 kNone,再把 readerProducedType_ 的 m 改成 handle.dataType,即 ARRAY。
因此典型状态是:outputType=[keys:ARRAY],readerOutputType=[m:MAP],readerProducedType=[m:ARRAY]。MAP 的 keys / values / size、ROW 的 field 等可由 selective reader 原生生成:SelectiveRepeatedColumnReader 决定返回 ARRAY、标量或原形状。普通 LazyVector 且 ExtractionType=kNone 时,TransformColumnLoader 才应用完整链;支持 transform 的其他格式走各自实现。不能把所有 extraction 都画成 FileDataSource 在 next 尾部执行的一次函数调用。
多个 extraction 共用一个物理列时,代码特意保持 kNone,并用 transform 将多个结果组成命名 ROW。readerProducedType_ 为空,不代表没有 extraction 或 transform;它只表示没有满足那段“原生 ExtractionType 非 kNone”判定的独立分配类型。只含多 extraction 时,初始外层分配类型仍可能沿 readerOutputType,reader 返回的 child 则可带 transform 结果类型;最终正常投影重新建立 outputType。应分别检查外层 RowVector.type 与 child.type,不能把 produced 成员当作所有路径的运行时真值。
文件的 INTEGER 到请求 BIGINT 等 schema coercion 发生在 requested type 与 file type 的匹配、格式 column reader / conversion 路径中,不是 assignment 改名,也不是 MapKeys extraction。postProcessor 又更晚:FileDataSource 完成 remaining filter、wrapChild 后逐个调用它。它可触发加载或自行安装延迟包装,但框架没有替任意 callback 自动保证这一点;callback 必须保留行数,并按共享向量/缓冲区的可写性规则处理。过滤表达式默认看的是 postProcessor 之前的值。
有了这些类型与位置约定,构造阶段的任务就很具体了:把原始谓词拆成 reader 可以执行的条件与必须交给表达式框架的条件,并把后者依赖的列补进 reader 输出。
5. 构造 DataSource:从谓词推导读取需求
5.1 同一个 WHERE,在这里有不同表示
| 对象 | 创建与持有者 | 含义与消费方 |
|---|---|---|
| TableHandle.subfieldFilters | 表句柄输入 | 调用方已经提取的列/子字段 Filter |
| TableHandle.remainingFilter | 共享 TypedExpr 输入 | 尚未被调用方完全下推的表达式;不一定最后仍需完整求值 |
| FileDataSource.filters_ | 构造时复制 map、再合并提取结果 | 原有 Filter 与可提取谓词的交集,供 makeScanSpec 和 getFilters 使用 |
| 提取后的 TypedExpr | extractFiltersFromRemainingFilter 返回 | 真正必须留下求值的剩余树;也用于建立 MetadataFilter |
| ScanSpec.Filter | 每个相应节点中的 shared_ptr | reader 的逐行过滤、常量/统计测试;动态过滤可替换它 |
| MetadataFilter | FileDataSource shared_ptr;RowReaderOptions 共享 | 残余表达式的可统计推理部分,与 ScanSpec 的 metadata leaf 绑定 |
| ExprSet / Expr | FileDataSource unique_ptr | 编译后的残余表达式,按 batch 求值 |
| result / SelectivityVector / FilterEvalCtx | DataSource 的可复用状态 | 当前 batch 的布尔结果、通过行索引、decoded vector 与缓冲区 |
| 动态 Filter | Driver 累积,DataSource 接收 | 来自 join 等运行期生产者;普通 FileDataSource 把它存在 ScanSpec 中 |
FileDataSource 对原始 map 的复制是 filters_.emplace(k.clone(), v):Subfield key 克隆,Filter 的 shared_ptr 初始共享。复制 map 与深拷贝 Filter payload 不是一回事。提取同一 Subfield 的新 Filter 时,调用 mergeWith(oldFilter) 得到合并结果,再替换 DataSource map 中的引用;OR 合成过程中还会 clone 临时 Filter。makeScanSpec 又把这些 shared_ptr 安装到节点,后续 setFilter 替换的是节点引用。
Filter::merge 同样是有旧 Filter 则 mergeWith 并替换,否则共享新指针。在这些调用点不能推导“所有 Filter 从此永不修改”或“任何 Filter 都是独占”;能确认的是构造、合并、替换不会把原始 table handle 的 map 和原始 TypedExpr 原地改写成运行状态。ScanSpec 的 filter order、selectivity 和 reader 字典过滤缓存又是另外的可变状态。
5.2 remaining filter 怎样提取
extractFiltersFromRemainingFilter 在 FileDataSource 构造阶段执行,输入原始 TypedExpr、evaluator、DataSource 自己的 filters map 和 sampleRate 引用,返回尚未被提取的表达式;全部提取完则返回 nullptr。
leaf parser 可处理支持类型的比较、BETWEEN、IN、IS NULL、布尔字段,以及一部分可证明安全的整数 widening cast 比较。它要求字段路径能够解析成 Subfield,另一侧能求成支持的常量。列与列相加、任意 UDF、依赖运行期输入的阈值不会因此自动变成 DWIO Filter。这里还有一个细节:TypedExpr parser 的 toSubfield 主要识别 FieldAccess / Dereference / Input;不能因为后面编译后的 extractSubfields 能识别某些 subscript 函数,就声称两者能力相同。
逻辑组合保持语义的方法是:
- 非否定 AND 的子项可分别提取,未能提取的子项重新组成残余表达式;NOT 的否定状态通过递归传递,否定 OR 可按 De Morgan 的 conjunction 方向处理。
- 普通 OR(以及否定 AND 的对应情况)只有每个分支完全变成同一 Subfield 的一个 Filter、没有 residual、没有采样时,才合成 OR Filter。
c0=1 OR c1=2不能拆成两个同时执行的列 Filter,否则会错误地变成交集。 - NOT 识别还会编译候选 call 检查 vector function 的 canonical name;不是看函数名尾缀就直接认定语义。提取/merge 的 VeloxException 会被捕获,失败部分保留表达式求值。
rand() < 常量 DOUBLE的受支持模式可以把 sampleRate 相乘并移出残余树,构造 RandomSkipTracker。它是概率跳行状态,不是由列值决定的普通 Filter;不能把带采样的 OR 分支随意下推。RowReader 通过 Mutation.randomSkip 使用它,推进/消耗采样状态也不能当成 Filter clone。
原始 TypedExpr 继续由 table handle 保留;返回的残余树可能共享未改子树,也可能用 replaceInputs 新建 call。这个结果不等于“把原树删掉一些 children 后留在原对象里”。
5.3 真正的表达式编译发生在哪里
FileDataSource.cpp:170 是完整 residual expression 的 compile 调用点。非空 residual 经 ConnectorQueryCtx 的 ExpressionEvaluator 编译为 ExprSet,DataSource 独占其编译产物。
OperatorCtx::createConnectorQueryCtx 创建 SimpleExpressionEvaluator,传入 Task 的 QueryCtx 与 operator ExecCtx 的 pool。SimpleExpressionEvaluator 按需创建自己的 ExecCtx,ExprSet 构造经表达式编译器进行函数解析、常量折叠和元数据计算;运行配置来自该 QueryCtx,内存由 evaluator 的 pool 支撑。DataSource 用的 connector memory pool 与 evaluator 使用的 operator pool 应分开记录,不能统称一个“查询池”。
同一个 DataSource 的 ExprSet 跨 batch、跨普通 split 复用,既不是每个 next 编译,也不是所有 Driver 共享一个 ExprSet。addSplit 不重新编译。preload 会构造另一份 DataSource 和 evaluator,所以 donor 构造时也会编译一次;takeover 留下 receiver 的 ExprSet,不再编译,也不把 donor 的 ExprSet 移过来。
另外三种 compile 不应计为“完整 remaining filter 又编译了一次”:
| 调用点 | 目的 | 生命周期 |
|---|---|---|
| HiveConnectorUtil.isNotExpr | 确认 NOT canonical function | 提取过程临时 ExprSet |
| HiveConnectorUtil.getPrestoSampleRate | 确认 lt/rand 并识别采样模式 | 提取过程临时 ExprSet |
| ExprToSubfieldFilter.toConstant | 编译常量候选,确认 constant expression,再对一行空输入求值 | 比较阈值/IN 等构造 Filter 时使用;MetadataFilter leaf 转换也可能走到这里 |
MetadataFilter 自己保存的是条件树和 Filter,不是一份逐 batch 求值的完整 ExprSet。只搜索 compile 次数,得不出“每个 split 编译了几次完整谓词”的结论。
5.4 从编译产物反推列需求
构造函数读取 remainingFilterExpr->distinctFields(),得到编译后仍然实际依赖的输入 FieldReference。remainingFilterColumns_ 保存其名字,并传给 RowReaderOptions;不存在于 reader 前缀中的字段追加到 readerOutputType,类型取自该 FieldReference。某些格式可利用这个集合安排更早的列 I/O,但它本身不是“马上解码这些列”的命令。
remainingFilterSubfields_ 则保存 compiled Expr 的 extractSubfields() 结果:字段访问形成路径;受支持、canPushdown 的常量 subscript 也能形成路径;无法识别时递归到输入。LambdaExpr 会通过 shadowedNames 的引用计数屏蔽 lambda 参数,避免 x -> x.a 中的 x 被误认为同名表列;外层 capture 仍然需要读取。这是有作用域与函数语义的编译后遍历,不能简单替换为对原始 TypedExpr 的字段名字符串搜索。
subfields_ 合并投影 requiredSubfields 和残余表达式读取需求。一个本来要完整输出的 ROW,不会因为 residual 只引用 r.a 就裁剪掉其他字段;一个本来只需 r.a 的投影,如果 residual 还读 r.b,则至少合并出 a、b。下推 Filter 的需求还会在 makeScanSpec 内再次并入。
multiReferencedFields_ 这个名字也不能按字面理解成“出现两次的字段集合”。实际由 shouldEagerlyMaterialize 决定:当根表达式不能保证参数在不扩大的选择集上求值,或引用该字段的直接子表达式含 conditional,就提前加载相关已投影列。纯粹重复引用并不必然满足该判定。
原因是 LazyVector 通常只支持这次 reader 读取世代中的一次受限加载。若条件分支先加载部分行,另一分支或下游随后又要更多行,前一次局部加载不能被误当成完整值。evaluateRemainingFilter 在表达式求值前,按完整 filterRows 对记录的 channel 执行 ensureLoadedRows;只作为 residual 隐藏输入的列不在对外输出前缀中,调用方式与已投影列不同。每次 evaluate 创建 EvalCtx,但 ExprSet、本批结果向量和 FilterEvalCtx 等缓冲区可以复用;“缓存跨 batch 存在”并不表示上一批布尔真值可以直接用于下一批。
至此,DataSource 还没有依赖某个文件的实际值,但已经知道要读哪些字段、要计算哪个剩余表达式。ScanSpec 把这些读取要求组织起来,等待 addSplit 将它们绑定到文件。
6. ScanSpec:把列需求组织成读取树
构造函数已经得到读取类型、下推 Filter 和编译后的 remaining expression。接下来先看 ScanSpec 如何保存这些要求:有哪些字段节点、哪些值需要输出、嵌套值如何裁剪,以及统计过滤怎样绑定到树上。实际处理顺序放在 reader 准备完成后展开。
6.1 字段树、投影位置与 reader 绑定
ScanSpec 把 handles 与表达式派生出的要求组织成一棵按字段路径访问的树。根对应扫描 ROW,普通 child 对应列,嵌套 child 对应 ROW 成员或 ARRAY/MAP 的特殊节点。DWIO 依据它建立 ColumnReader 树,读取时又反过来更新它的选择率与缓存。它既不是文件 footer schema 的复制,也不是 Task / Driver 那样的线程调度器。
ScanSpec 是可执行的列需求树
makeScanSpec 先处理 readerOutputType 中的列,按位置设置 projectOut 和 channel,再处理只被 subfieldFilters 使用的列。后者从 dataColumns 查类型并创建节点,却不必成为根 RowVector 的 child。最后把 Filter 装到对应 Subfield 的节点。
例如 SELECT c0 WHERE c1>10,若条件已经是下推 Filter,则 readerOutputType 可以只有 c0,ScanSpec 却有 c0 和 c1。若条件是 c0+c1>10 并留在 ExprSet,则 c1 必须进入 readerOutputType;表达式求值拿到的是 RowVector,不能直接访问 DWIO 私有的 c1 decoder。
为突出读取次序,在第 2 节的数据中增加一列 payload VARCHAR,只输出 payload,条件仍为 c2>=0 AND c0+c1>10。R 是 [payload,c0,c1],c2 仅做 reader 过滤。下图同时画出声明顺序、处理顺序和结果位置,三者不能混用。
| 节点信息 | 谁设置 / 更新 | reader 怎样使用 |
|---|---|---|
| fieldName、嵌套 children | makeScanSpec / getOrCreateChild;Iceberg 可追加 | 按表字段需求构建读取树,随后匹配文件字段 |
| projectOut、channel | makeScanSpec 按 R;辅助列按尾部追加 | 是否把值放进结果,以及放到 RowVector 哪个位置 |
| filter、filterDisabled、hasFilter 缓存 | 静态构造、动态更新、Iceberg 特殊处理 | 判断该子树是否先过滤整行;disabled 与 payload 指针非空是不同状态 |
| subscript | 当前格式 ColumnReader 构建/绑定 | 从 ScanSpec 节点找到 children_ 中的实际 reader;不是输出 channel |
| constantValue、columnType | split 常量/缺列适配、生成列设置 | 跳过普通物理列读取,返回常量或文件行号 |
| extraction、transform、裁剪参数 | handle 需求与 configureExtractionColumns | 决定保留哪些子值、是否改变结果形状、何时变换 |
| selectivity、children 的处理顺序、numReads | 每层 struct 的 newRead 与读取反馈 | 选择更便宜的过滤顺序;给 LazyVector 标记读取世代 |
| metadata leaf 指针 | MetadataFilter 构造 | 连接统计剪枝的条件树;不是逐行 ExprSet |
children() 是可被排序的处理顺序;stableChildren() 提供按加入顺序保存的共享快照,供构建 reader 树使用。新增 child 追加到后续快照,旧快照仍保持其节点寿命。reader 的构建次序无需随着每批 filter reorder 改变,ScanSpec.subscript 可以找到原 reader,channel 则继续指向原输出位置。因此 c2 被提到第一个处理,也不意味着它突然成为输出 child 0。
这不是任意并发修改的承诺:ScanSpec 的运行期排序、Filter、常量和反馈仍由扫描拥有者按协议更新。stableChildren 为构建阶段提供稳定视图,不能据此把整棵 ScanSpec 当成线程安全共享配置。普通连续 split 复用与 preload 的另一棵树也要分开,见第 13 节。
ScanSpec 中的 channel、subscript、projectOut、constantValue、filter、metadataFilters、extractionType、transform、maxArrayElementsCount、selectivity 是不同维度。一个只过滤节点可以没有输出 channel,一个输出节点可以不从文件读取,一个 constant 节点也可以保留 Filter payload。keepValues() 当前是 projectOut || deltaUpdate,所以讨论普通路径时也不能把它在所有 connector 中硬等同于 projectOut。
6.2 ROW、ARRAY、MAP 的裁剪不是统一的“删列”
addSubfields 同时带着路径与 filterOnly 标记递归:
- ROW 保留原 ROW 类型与成员位置,不需要的成员安装 null constant。需要 r.a 和过滤用 r.b 时,a、b 都保留;其他成员可不读。
- ARRAY 保留数组类型和必要的元素子字段。固定正下标需求设置 maxArrayElementsCount,保留到最大请求位置的前缀,而不是重新拼出仅含被点名下标的新数组;非正下标不允许这类下推,
[*]表示不能按固定最大位置截短。 - MAP 保留 map 类型,但可以在 key 节点装选择 Filter,读所需 key 对应的 value 子字段;flat map 同时记录 feature selection。浮点 map key 的 pruning 还有与宿主下标取整规则匹配的范围处理,不能用整数相等替代所有 key 类型。
这里 key 的 Filter 可能是容器元素裁剪,而不是整行 WHERE 淘汰。普通 row-level hasFilter() 对 map keys / values / array elements 有专门处理,避免把“剔除 map 中无关 key”解释成“剔除这一行”。路径本身可以表示嵌套成员和下标,但实际下推能力还受 parser、makeScanSpec 和具体格式 reader 限制。
6.3 MetadataFilter 绑定的是具体树节点
MetadataFilter 构造 从提取后残余 TypedExpr 建立 AND/OR/leaf 树,并把每个 leaf 的指针与其 Filter 指针登记到传入的 ScanSpec 对应节点。leaf 的所有权在 MetadataFilter,ScanSpec 中是借用引用。这不是仅凭字段名可以自动绑定任何新 ScanSpec 的无状态表达式。
例如 c0=1 OR c1=2 不能变成两个普通逐行 Filter 的 AND,但仍可从两个列的 min/max 等统计推断某个 row group 不可能满足 OR。统计结果是“该组可否排除”,MetadataFilter 的 AND 节点对排除位图做 OR,OR 节点做 AND;未知 leaf 没有结果时,要保守地保留可能匹配的组。最终的 ExprSet 仍逐行判断,metadata pruning 从未取代精确求值。
这引出 main 的一个明确边界:FileDataSource 先建 MetadataFilter,随后 extraction 分支可能重建 ScanSpec;Hive bucket conversion 触发的重建也没有重新构造/注册 MetadataFilter。新树不会自动携带旧 metadata leaf,moveAdaptationFrom 也不搬迁它们。结果是这些组合可能失去相应 residual metadata pruning,不能因 metadataFilter_ 非空就声称过滤已经接到新 reader。普通静态 Filter 与 residual ExprSet 仍是另外两条路径,不能把 pruning 丢失直接写成查询结果必错。changelog 的内外两套 ScanSpec 还存在同类绑定边界,后面单独说明。
这棵树描述读取需求,也预留常量和 reader 绑定的位置。当前文件究竟存在哪些列、哪些节点应为分区值或缺失常量,要到 addSplit 准备具体文件时才能确定。
7. addSplit:把读取需求绑定到具体文件
FileDataSource::addSplit 要求上一 split 已结束。它保存当前 split,销毁上一 splitReader,调用虚函数 createSplitReader,再依次 configureReaderOptions、setRemainingFilterColumns、prepareSplit。最后把 splitReader 的 readerOutputType 接回;如果已有 readerProducedType 且辅助列数量增加,当前 main 会把同一尾部名称与类型追加进去。
对于 Hive,createSplitReader 先调用 HiveDataSource.prepareSplit:记录格式计数、准备 bucket conversion、更新 row-ID 常量,然后创建 HiveSplitReader。后者在普通文件准备前做 synthesized-column filter validation。对于 Iceberg,createSplitReader 同样经过继承的 Hive prepare,但返回独立的 IcebergSplitReader 或 ChangelogSplitReader,后续绑定行为由它们覆盖。
普通 FileSplitReader::prepareSplit 的顺序是:
- createReader:根据 readPath、凭证与 FileProperties 获取文件句柄,交给 BufferedInputBuilder,按文件格式 ReaderFactory 创建 Reader。配置允许忽略不存在文件时,FileNotFound 可把 split 标记为空;不满足该配置的异常仍传播。
- getAdaptedRowType:从 Reader.rowType 的列次序出发,adaptColumns 更新 ScanSpec 常量与所请求列类型。Hive 的分区值、infoColumns、缺失列在这里进入运行状态;已有普通列需要清掉上一文件留下的 null constant。
- checkIfSplitIsEmpty:文件无行、reader 不存在或 file/partition 统计 Filter 不通过时提前结束。注意 Hive 普通路径已打开 Reader 才做这些测试,并非一概在 I/O 前过滤分区。
- createRowReader:设置 range、requestedType、ScanSpec、MetadataFilter、remainingFilterColumns,以及 timestamp、lazy I/O、prefetch 等 options,再调用 Reader.createRowReader。
FileConnectorUtil::configureReaderOptions 负责公共格式选项和 column mapping,HiveConnectorUtil 的重载补 serde 参数等 Hive 细节。按名读取、按位置读取、Parquet field ID、ORC/Nimble field ID 需要分别看对应实现;“表列名就是文件列名”只在其中一部分路径成立。
Hive adaptColumns 使用文件实际列集合匹配节点:当前 split 有分区/信息值就安装常量;普通列在文件缺失就设 null;重新出现则清 constant,再将实际文件顺序中该列的请求类型替换成 readerOutputType 中的类型。返回的 requested RowType 仍按文件视图排列。scanSpec_->resetCachedValues(false) 递归清 hasFilter 缓存,但不要求此时重排。
Range 为空不一定在文件级阶段标记 emptySplit:文件本身可能有行,但 start/length 覆盖不到任何 stripe/row group,RowReader 第一次 next 仍会返回 0。文件为空、统计拒绝与 range 无可读单元是不同的判空来源。
8. 一次 next:过滤、表达式求值与输出
addSplit 已经创建 Reader / RowReader,并把当前文件的 schema、范围和常量接到 ScanSpec。现在沿一次 next 走完公共路径:先缩小 reader 的行集合,再执行 remaining expression,最后交付输出。Hive 的桶筛选和 Iceberg 的删除处理将在后面插入这条公共路径。
8.1 ScanSpec 怎样决定先处理哪些过滤列
ScanSpec::newRead 在 struct 开始读取时调用,先初始化或检查 child 顺序,再增加读取世代。排序器的第一条规则是:有逐行 Filter 的 child 放在没有 Filter 的 child 前面。一个 ROW 根节点自身没有 Filter、但 r.a 有 Filter,也会通过 hasFilter() 递归使 r 成为过滤子树;ARRAY/MAP 元素裁剪则有前述特殊判断,不能混入整行过滤优先级。
两个 child 都有 Filter 时,若已有反馈且没有禁用统计排序,compareTimeToDropValue 比较的是淘汰一个值的平均处理成本。SelectivityTimer 累加输入行数与硬件时钟,reader 读完后记录输出行数,并扣除初始化时钟。timeToDropValue = timeClocks / (numIn - numOut);没有淘汰任何值时实现返回 timeClocks,不做除零。这里记录的是时钟成本,不是直接宣称某个固定纳秒延迟。
只比较过滤率还不够。假设 A 对 1000 行耗费 10000 clocks,留下 100 行,每淘汰一行约 11.1 clocks;B 耗费 1000 clocks,留下 500 行,每淘汰一行 2 clocks。即便 A 淘汰得更多,B 也可能值得先做。实际统计来自该 Filter 在前面过滤之后收到的 activeRows,因此是随执行学习的局部策略,不是全局最优计划的证明。
没有反馈时,代码按 FilterKind 枚举建立启发式顺序,同 kind 按字段名;有直接 Filter 的简单列优先于只有嵌套 Filter 的复合列。不能把它概括成按 SQL WHERE 的书写顺序读取。newRead 会检查现有次序是否仍满足比较器,需要时才 sort;并不是固定每 N 个 batch 才调整。嵌套 struct 各自执行 newRead,因此 r 内部也可以调整 a、b 的过滤先后。
SelectiveStructColumnReaderBase::read 真正执行这个次序。它让 activeRows 依次经过各个过滤 child,后一个 reader 只接收前一个留下的行;任何一步变空即可结束该 batch 的列读取。position delete / 随机采样 Mutation 和 struct 的 null Filter 在这个循环之前还有各自处理。关键控制流可写成下面的删节伪代码,常量、生成列和 delta update 分支在正文中另述:
// 删节伪代码:处理顺序来自 ScanSpec,解码由 ColumnReader 执行。
scanSpec->newRead();
auto activeRows = rows;
for (const auto& spec : scanSpec->children()) {
auto* reader = childReaders[spec->subscript()];
if (reader->isTopLevel() && spec->projectOut() &&
!spec->hasFilter() && generateLazyChildren) {
continue; // getValues() 随后为该列创建 LazyVector。
}
reader->read(offset, activeRows, parentNulls);
if (spec->hasFilter()) {
activeRows = reader->outputRows();
if (activeRows.empty()) {
break;
}
}
}
于是同一 batch 中,下推 Filter 列先解码并缩小行集合;无 Filter 且允许 lazy 的顶层投影列在 read 中直接跳过。随后 getValues 给前者交付已读值,给后者安装带 reader/version 的 LazyVector。常量和生成列有专门分支;非顶层 reader 或关闭 generateLazyChildren 时,并不承诺同样的延迟边界。
8.2 Remaining filter 与 payload 的先后由谁接力
ScanSpec 没有一个通用的“remaining-filter 第二优先级”队列。对于排序器,c0、c1 和 payload 都可能只是没有下推 Filter 的投影列;它们谁先真正加载,由之后谁需要值决定。FileDataSource 拿到 reader 的 RowVector 后调用表达式求值框架,这才把残余条件的列放到普通 payload 之前使用。
沿增加 payload 后的五行示例追踪:
- c2 对文件行 0~4 执行
>=0,得到[0,2,3];R 中 payload、c0、c1 的逻辑长度都是 3。c2 不投影,过滤值不作为 child 交给 DataSource;其他三个 child 在允许时仍是 lazy。 - FileDataSource 把 filterRows 初始化为当前 reader 结果的三行。它先对 multiReferencedFields_ 中有扩大加载风险的已投影列执行 ensureLoadedRows,再调用 expressionEvaluator.evaluate;这一保护不是每列都做的统一物化。
- SimpleExpressionEvaluator 使用已有 ExprSet 和本批 EvalCtx。表达式
c0+c1>10需要 c0、c1,Expr::eval 的加载分支 与 EvalCtx::ensureFieldLoaded 按所需选择集触发 LazyVector 加载。ColumnLoader 把 reader 结果位置转换回文件行坐标,调用对应 ColumnReader 解码。表达式在 DataSource 层执行,却能够按需向下拉取 reader 的值。 - 表达式输出 BOOLEAN 向量,processFilterResults 只保留非 null 的 true,得到 reader 下标
[0,2]。false 和 null 都不满足 WHERE。c0、c1 的值现在已可用,而 payload 并不因参与这个 RowVector 就自动被加载。 - 输出以 dictionary 包装 payload,外层结果有两行。如果下游真的访问它,加载请求由
[0,2]映射回文件[0,3],只为必要行物化 payload。若后续算子根本不访问 payload,允许它一直不加载;若全部行被残余条件淘汰,本批 payload 可以完全不解码。
完整 residual 的编译发生在 DataSource 构造中,以上每批重复的是 ExprSet 的求值。AND、OR、IF 等条件表达式还会按分支缩小选择集;共享子表达式、重复引用和 non-null-propagating 分支又可能要求提前加载更大的集合。表达式框架参与加载调度,不能把 ExprSet 画成对所有列已经 fully-loaded 的 RowVector 再机械执行一次回调。
“过滤后才读其他列”因此有明确前提:这些列未被 reader Filter、remaining expression、完整 bucket hash、equality delete key 比较、extraction 或其他 eager 路径提前需要,并且对应 reader 支持 lazy。实际解码也可能按编码块、page、run 推进;按所需行物化不等于底层只读取这几个标量的字节。LazyVector 仍必须在 reader 前进前完成需要的加载,不能把延迟无限跨到后续 batch。
前两节说明值何时被读取与过滤;接下来回到 next 接口,检查调用方如何区分等待、空 batch 和 split 结束,以及结果如何包装和复用。
8.3 四种返回值必须分开
| DataSource.next 结果 | 含义 | TableScan 行为 |
|---|---|---|
std::nullopt |
connector 暂时无数据,future 用于等待 | kWaitForConnector,交回 Driver 调度 |
engaged optional,内含 nullptr |
当前 split 结束 | splitFinished,继续取得下一 split 或结束扫描 |
| 非 null RowVector,size=0 | 本批被过滤完,当前 split 未必结束 | getOutput 内继续循环,不把它当作 EOF |
| 非 null RowVector,size>0 | 可交付的数据 batch | 返回下游 |
本组 FileDataSource::next 没有使用 future 参数,执行的是同步 DataSource API;内部 I/O 预取和异步 split preload 不改变这条事实。TableScan 等 Task split 的 future、connector next 的 future、AsyncSource.move 等待 preload 是不同等待来源。
FileDataSource 遇 emptySplit 直接 resetSplit 返回 nullptr;普通读取 rowsScanned=0 时先把 reader runtime stats 加到累计 RuntimeStats,再 resetSplit。resetSplit 清当前 split 指针,reader 暂时保留,到下一次 addSplit 才销毁。TableScan 把这个 split 标为完成;没有更多 split 才是整个 TableScan finished。
8.4 零输出列并不总返回 ROW<>
源码的实际分支是:
if (outputType_->size() == 0) {
return exec::wrap(rowsRemaining, remainingIndices, rowVector);
}
exec::wrap 的这个重载 在 mapping 为空时直接返回原 RowVector;非空时按原类型包装全部 children。它没有用 outputType_ 重建 ROW<>。纯 count(*) 且 reader 无辅助列时,内部本来就是 ROW<>,返回预期的零列非零行向量;若 zero projection 却有 residual c1、bucket key 或 equality-delete key,非零结果路径可能仍返回带辅助列的内部 RowVector。
这和 getEmptyOutput() 又不同:零幸存行返回的缓存空向量确实用 outputType_ 构造。于是某些组合中,零行和非零行 batch 的外层类型都可能不同。这里应记录 main 的真实分支并增加对应验证,不能只凭 DataSource 接口或注释宣布它总符合零列契约,也不能在文章里悄悄把代码改成期望行为。
8.5 output_ 复用依赖哪些条件
FileDataSource 优先用非空 readerProducedType 分配,否则用 readerOutputType。next 的重新分配条件是“没有 output_,或现有 children 数少于目标类型”,不是类型、列名和数量逐项相等检查。辅助列只追加的普通路径与这个条件配合;经过 takeover,donor 类型可能比 receiver 更短或同宽但隐藏列不同,不能假定这一入口自动完整校准。
DWIO prepareResult / tryReuseResult 检查顶层引用计数:被其他对象持有就新建 RowVector;对子列、常量与缓冲区也按可写性决定复用。新建时沿用传入 result 的类型,它并不是一个自动从 ScanSpec 推断准确新类型的替代校验器。因此“底层会重新分配”与“底层会修正任意 schema 漂移”是两回事。
正常 FileDataSource 返回一个新的外层 RowVector,children 可以与 output_ 共享。下游仍持有上一批已物化结果时,下一批不能覆写那些仍被共享的缓冲区,reader/Vector 的引用计数检查与 ensureWritable 等规则负责这点。remaining filter 只选择部分行时,先 disableMemo,再对 child 做 dictionary wrapping;postProcessor 发生在这之后,必须尊重可能已经共享/包装的表示。
8.6 持有 LazyVector 不等于持有 reader
ColumnLoader 保存的是 structReader / fieldReader 指针和 read version。加载时先检查 version == structReader->numReads(),然后把当前 LazyVector 的行选择映射到创建时 reader 的 outputRows;例如图 2 的 [0,2] 映射回 [0,3]。若是稀疏加载,还用 scatter dictionary 让值回到请求位置。
未加载 LazyVector 不拥有 reader 的独立生命周期。必须在所属 reader 前进到下一批、切换读取单元/销毁之前完成需要的加载;否则版本检查会报“Loading LazyVector after the enclosing reader has moved”,reader 已销毁则更不能依赖这个检查救场。下游要跨批保存数据,需要先保证其 lazy 依赖已物化,不能只复制 shared_ptr。
这也是为什么“上一批仍被持有时不会被覆盖”和“上一批 lazy 还能随时读”必须分别回答:前者是 vector/buffer 共享与可写性,后者是执行协议、reader 世代与物化时点。残余表达式的 eager-load 分析、桶 hash 读取、equality key 比较、下游算子的加载行为,都受这条边界约束。
8.7 列处理顺序与磁盘 I/O 顺序分开看
至少有三种容易被同一个“读取”混淆的动作:把压缩字节读到内存;解码并应用列 Filter;把值交付为下游可用的 Vector。ScanSpec 和 selective ColumnReader 的上述先后,首先约束后两者;文件格式的预取、合并读取和缓存决定第一种动作是否也能推迟。
| 层次 | 实际决定者 | 当前基线可以确认的行为 |
|---|---|---|
| 下推过滤次序 | ScanSpec.newRead / reorder + struct reader 的 activeRows 循环 | 有 Filter 的子树先处理,按观测成本调整;输出 channel 不变 |
| remaining 依赖的加载 | FileDataSource + ExprSet / EvalCtx + LazyVector | 表达式先请求需要的列,可能按分支选择集加载;有扩大加载保护 |
| 普通 payload 的加载 | 下游消费者 + dictionary 选择 + ColumnLoader | 若仍 lazy,只物化最后实际请求的行 |
| 压缩流 I/O / 预取 | 各格式 Reader、BufferedInput、cache/executor 与 options | 可以提前加载多个列流、row group 或 stripe,不能从 lazy 向量推断磁盘尚未读取 |
在本快照中,通用代码把 remainingFilterColumns_ 设置到 FileSplitReader 的 RowReaderOptions,明确使用该 getter 决定 lazy I/O 的是 SelectiveNimbleRowReader::computeLazyIoColumns。开启 lazyColumnIo 时,一个非 constant、来自文件的顶层列,必须同时满足:没有下推 Filter、不在 remainingFilterColumns 中、projectOut=true,才能进入延迟 I/O 集合。
这意味着 c2 和 c0/c1 的流可以在 stripe 准备时一起作为非 lazy I/O 载入,而 payload 的流延迟到真正请求。它不意味着 c0/c1 在 DWIO 返回前一定已经解码;排除 lazy I/O 与取消 LazyVector 是两回事。最终 ColumnLoader 在解码前调用 formatData.loadLazyInputStreams,NimbleData 再触发相应延迟输入的加载。这个共同入口也覆盖 transform / delta loader,而不只是一种普通列加载器。另一个粒度细节是:同一 StripeStreams 的 lazy input 可以排入多个列流,LazyInput::load 一次加载该输入中已排队的 stream bundle;首次使用某列不保证只为这一列发起 I/O。推迟整个 bundle,与按最终行集合只物化必要值,是可以同时存在的两种粒度。
其他格式需要看自己的 I/O 路径。例如 DWRF 的 fetchStripe 在 preload 条件成立时会提前载入 stripe 范围;Parquet 的 scheduleRowGroups 会按 prefetchRowGroups 安排当前和后续读取单元。两者仍可使用 selective/lazy 解码,但不能把 Nimble 的 remainingFilterColumns 与 lazy stream 分组行为直接套过去。
所以这里的调度是几层协作:ScanSpec 让 reader 尽早用便宜的列条件减少行;表达式框架为复杂条件按需获取值;消费者最后触发 payload;格式 reader 再决定哪些 I/O 真的能省掉。评估收益要分别看读取字节、解码行数/CPU、残余过滤行数与最终输出,不能只从最终两行推断其余三行对应的所有字节都没有进过内存。
公共读取路径解释了过滤、表达式和输出怎样配合。接下来分别看 Hive 与 Iceberg 如何加入自己的语义:前者需要处理桶转换和生成列,后者还要处理字段身份、删除文件和 lineage。
9. Hive:桶转换、分区与生成列
9.1 先理解为什么需要 bucket conversion
假设表从 4 桶扩到 8 桶,历史分区仍按 4 桶写入。Hive bucket 使用列值的 Hive hash,并取 (hash & 0x7fffffff) % bucketCount。旧桶 1 包含所有 h % 4 == 1 的行;换成 8 桶后,这些行属于新桶 1 或 5。例如 h 为 1、5、9、13,旧桶号都是 1,新桶号依次是 1、5、1、5。
当查询以新表桶 5 扫描旧文件时,需要从旧桶 1 文件中筛出 h=5、13 那些行。否则这个 split 会混入另一个新桶,破坏计划依赖的分桶关系。bucket conversion 是对旧文件中的行重新选择,不是重新写文件。
| split 信息 | 含义 |
|---|---|
| tableBucketCount | 当前表的桶数,例中是 8 |
| partitionBucketCount | 该历史分区写文件时的桶数,例中是 4 |
| tableBucketNumber | 当前 split 应向查询提供的目标新桶,例中是 5 |
| bucketColumnHandles | 计算 Hive hash 所需列,列顺序参与组合 hash,可能根本没在 SQL 中投影 |
HivePartitionFunction 对配置的 channel 解码,按类型计算 HiveHash,多列按 31 等规则组合,最后得到桶号。HiveSplitReader 在 FileSplitReader.next 之后计算 bucketConversionRows,检查计算出的新桶与目标桶在旧桶数下同余,再留下 computedBucket == tableBucketNumber 的行。实现用 ranges 拷贝到一个新 RowVector,rowsScanned 仍返回底层扫描数;这不是和 residual expression 完全相同的 dictionary-only 路径。
9.2 setupBucketConversion 的持久性
每个带 conversion 的 split 都会检查参数、递增统计、确定 bucketChannels。bucket 列必须是 regular,tableHandle 必须有 dataColumns。一个未投影桶列会被追加到 readerOutputType,因为 HivePartitionFunction 消费的是 reader 输出 RowVector,无法凭空从 DWIO 的 filter-only 节点取得完整列值。
如果已有 bucket 列被 requiredSubfields 裁剪,setupBucketConversion 会删除 subfields_ 中该列的限制。对 ROW hash 只读 r.a、把 r.b 补成 null,会改变桶号;因此计算桶号需要实际完整值。这里取消的是列读取裁剪,不是移除这列上的行过滤。
只有两种变化触发 makeScanSpec:追加了读取列,或删除了已有 subfields 裁剪限制。表桶数、目标桶号变化本身不要求新树。重建后调用 newScanSpec->moveAdaptationFrom(old),然后替换 DataSource 的 scanSpec_。
连续两个 split 使用相同隐藏 bucket 列时,第一个把列加入类型、取消裁剪并可能建新树;第二个从已经扩展的 readerOutputType / subfields_ 出发,没有新的需求变化,保留同一 ScanSpec。每个 HiveSplitReader 仍创建新的 HivePartitionFunction,使用本 split 参数。把“每 split 一个桶过滤器”误写成“每 split 重建 ScanSpec”,会错误估计对象拆分的收益与代价。
重建还应检查伴随状态:main 这里只迁移根 child 的 Filter / selectivity,没有重新 configureExtractionColumns,也没有重建 MetadataFilter。如果同时存在 extraction 或复杂 metadata 条件,这些是 main 就有的组合边界,需要专门测试,不能事先把新设计当作等价实现。
9.3 Row index 是文件坐标
kRowIndex 的 SQL 类型是 BIGINT,值从 0 开始,唯一性范围是一个文件。不是 split 内重新编号,也不是过滤后输出行 0、1、2。makeScanSpec 将它标记为生成列;真正的值由 DWIO selective struct reader 的 setRowNumberField 用 currentRowNumber + selectedRows[i] 生成。
DWRF 用 stripe 前缀行数与 stripe 内位置维护 currentRowNumber,Parquet 用 row-group 前缀与组内位置,selective Nimble 也在 next 前设置当前文件行号。跨 stride / row group 跳过后,基值前进到实际文件位置;逐行过滤后只保留原始 row set 中的那些号码。例如读文件 [100,101,102,103],过滤留下第 0、3 个位置,row index 是 100、103,不能重新变成 0、1。
只读取分区列或 row index 时可以没有普通 ColumnReader。SelectiveStructColumnReaderBase::next 有 no-child 路径,仍根据行数、Mutation 与常量过滤建立 outputRows,再生成常量/行号。metadata 仍需要读取来知道文件行数和范围,不能因此说“完全不碰文件”。
直接把 row index 塞入普通 subfield pushdown 不受支持:makeScanSpec 对该特殊列有检查,generated-column 的读取分支也要求没有普通 Filter。用 row_index % 2 == 0 这样的 remaining expression 则是在生成行号后求值;rowNumberInRemainingFilter 覆盖该形状。动态过滤器也不能仅因有输出 channel 就假定任意生成列支持下推。
9.4 Hive row ID 是五字段复合值
TableScanTest.rowId 给出实际类型:
// 本基线测试使用的 Hive row-ID 类型。
auto rowIdType = ROW(
{"row_number", "row_group_id", "metadata_version", "partition_id", "table_guid"},
{BIGINT(), VARCHAR(), BIGINT(), BIGINT(), VARCHAR()});
makeScanSpec 将父节点标记 kComposite,第 0 个 child 标为 kRowIndex。每个 split 的 setupRowIdColumn 给后四个 child 安装常量:row_group_id 取 split.getFileName(),也就是 filePath 的最后一段文件名;其他三个来自 rowIdProperties。这里的 row_group_id 不等于 Parquet row group 序号,也不是 DWIO stride ID。
DWIO setCompositeField 逐行生成 row_number,把其他 child 扩成 batch 大小的常量向量。下一个 split 会重新安装四个常量;takeover 接收 donor 已准备的 ScanSpec,复合节点的常量来自 donor。moveAdaptationFrom 没有递归移动这些 child,所以不应凭“迁移 adaptation”就把 receiver 旧文件的 ID 常量覆盖过去。这个行为还有包含 preload、不同 metadataVersion / partitionId / tableGuid 的测试依据。
Hive row ID 提供这个读写协议中的物理/版本定位信息;光凭这五字段,不能推断它是跨 compaction、跨文件重写仍稳定的逻辑主键。
Hive 的桶筛选需要完整 key,生成列需要保持文件行坐标。Iceberg 延续相同的文件读取基础,但通过自己的 SplitReader 处理字段 ID、删除和 lineage,下面沿这条独立路径展开。
10. Iceberg:schema 演进、删除与行标识
10.1 field ID 与默认值改变了适配规则
IcebergSplitReader::buildFieldIds 先按 handle.name 建索引,再按 dataColumns 顺序构建请求 field-ID 树。来源包括 IcebergColumnHandle、table handle 的 dataColumnFieldIds,以及解析 equality-delete key 得到的 ID;无法对应的字段可用 sentinel。没有显式 ID 元数据时不会无条件启用 field-ID mapping。
Parquet 使用 kParquetFieldId,ORC/DWRF 使用对应 kFieldId;Nimble 路径检查请求列是否都能从句柄解析,满足条件才配置 kFieldId 与 iceberg.id 属性键。文件重命名、增删列与排序变化,要由这些匹配逻辑决定,不能只看字符串是否相等。
Iceberg 的缺失普通列优先考虑 initial-default,否则 null;有文件值时读文件值。write-default 服务写入语义,不能当成历史文件读取默认值。filterColumnHandles 和 dataColumnHandles 也参与未投影列的元数据解析,否则过滤一个新增默认列时会误按 null 判断。
分区值尤其需要证明来源。identityPartitionKeys 以源字段 ID为 key,只有 identity transform 的值等于源列值,可以直接替代文件读取;bucket、truncate、day、void 的分区值不能自动替代源字段,即使名字碰巧相同。partitionEnforcedFieldIds 又是另一信息:它证明某个静态扫描过滤已由分区约束保证,可对本 split 禁用该子树的重复测试。若后来收到动态过滤,IcebergDataSource 记录列名并重新启用;不能用“静态条件已满足”来跳过新条件。
10.2 Equality delete 必须拥有完整 key
configureEqualityDeleteColumns 在 data Reader 创建后、RowReader 创建前执行。它收集适用 delete 文件的 equalityFieldIds,优先按 table handle 的完整 ID 列表映射到 dataColumns;兼容路径在 ID 元数据缺失/不完整时回退 fieldId - 1 的 ordinal lookup。这个回退是真实 main 行为,不能把它描述成对任意不连续 ID 都正确的通用映射。
未投影 key,或本来只有 filter-only 节点的 key,会通过 appendProjectedColumns 追加到 readerOutputType,并把 ScanSpec 节点设为 projectOut、分配尾部 channel。对明确 identity partition key 可以安装分区常量;其他 transform 仍必须读取源数据。读取 key 的范围还必须满足 equality 比较,不能认为一个查询用的 extraction / subfield 裁剪自然满足全部 delete key 的语义。
EqualityDeleteFileReader 在准备阶段读取完整 equality delete 文件,保存键数据和哈希 multimap。对数据 batch,按名字解析 key 的输出 channel,计算 hash、检查实际键相等,命中则标记删除。hash 相同不等于直接判定同一行;null 等值规则也由比较实现决定。这里会读出未投影 key 并物化比较所需数据,代价不能只计一个位图。
10.3 Position delete 与 deletion vector 在读取前准备位图
IcebergSplitReader::next 先问 RowReader 的 nextReadSize 和 nextRowNumber,定位下一段文件绝对行位置。PositionalDeleteFileReader 对 delete 文件的 file_path / pos 进行匹配,保留跨 batch 尚未消费的位置,并填充本批相对删除位图。Puffin deletion vector 路径读取相应 blob 的 roaring 行位置集合;二者都作为 Mutation.deletedRows 交给 DWIO。
因此 DWIO 能在按列过滤/物化之前排除删除行。splitOffset 是当前 range 第一个可读的文件行号,baseReadOffset 跟随 nextRowNumber,而不是单纯把上次返回的输出行数相加。metadata 跳读、过滤和删除不能破坏物理位置对齐。
适用性也不同:两个 sequence number 都为正时,equality delete 只用于 deleteSeq > dataSeq,position delete / DV 用于 deleteSeq >= dataSeq;非正值走兼容路径,不按序号排除。pos 上界还可排除完全早于本 split 的 delete 文件。当前 DV 的 referencedDataFile 不匹配只记录警告,仍应用协调端发来的 DV,不要照着结构体旧注释写成 worker 会跳过它。
10.4 三种“row ID”的语义
Iceberg 的 file_path + pos 是物理行定位:pos 是数据文件中的 BIGINT 行位置,delete 文件路径列用于限定数据文件。它不等同 Hive kRowId 五字段。当前代码并没有一个看到任意 _pos 列名就通用生成的分支;应区分 delete 文件的 pos、DWIO 内部注入的行号与显式扫描列角色。
$target_table_row_id 为 MERGE 构造四字段:ROW(file_path VARCHAR, row_position BIGINT, spec_id INTEGER, partition_data VARCHAR)。file_path 与后两项来自 split(缺省 spec_id=0、partition_data 为空串);row_position 来自 reader 返回的文件绝对位置。下游 MergeProcessor / IcebergMergeSink 用它产生对应文件的 position delete,它仍不是跨文件稳定主键。
Iceberg V3 的 _row_id BIGINT 与 _last_updated_sequence_number BIGINT 才是 lineage 语义。prepare 解析 firstRowId/dataSequenceNumber 后,缺失 lineage 字段先设常量占位;next 对 null 的 _row_id 补 firstRowId + fileAbsolutePosition,保留已有非 null 值,对需要补的 last-updated 值填 data sequence number。first_row_id 不存在时,本实现按对应规则为两列提供 null,而非无条件相信文件中的 lineage 值。
为了让过滤/删除后的行位置正确,投影需补的 _row_id 或 target row ID 时,会通过 RowReaderOptions.rowNumberColumnInfo 在reader 输出末尾临时插入 BIGINT 行号列。这个列不属于 DataSource 最终 schema;填好 lineage/复合值后,IcebergSplitReader 根据实际输出 RowVector 的类型移掉它,兼容 extraction 已改变的类型。
lineage/target 的 Filter 不能在占位 null 上执行。projectFilterOnlyFilledColumns 会把仅用于过滤、尚未投影的生成字段也追加到 reader 输出;configurePostReadFilters 暂停其 reader 子树过滤和 metadata leaf,等补完值后调用 ScanSpec.applyFilter。再与 equality delete 共用 rowsToRemove 位图,最后做一次 dictionary wrapping。这里的“禁用”没有销毁 Filter,读后的 applyFilter 仍要使用它。
10.5 Changelog 有两套 schema 与 ScanSpec
IcebergTableHandle 标记 changelog 时,DataSource 构造一次 ChangelogScanContext,持有基础表的 dataColumnHandles、dataReaderOutputType、dataScanSpec。外层仍有 changelog 空间的 ScanSpec 和 residual expression。ChangelogSplitReader 从内层 IcebergSplitReader 读取基础表数据,再输出 operation VARCHAR, ordinal BIGINT, snapshotid BIGINT, rowdata ROW(...),按实际投影选择所需字段。
prepare 要求 changelogSplitInfo 存在且 deleteFiles 为空;operation / ordinal / snapshotid 的静态及已经收到的动态条件在打开基础文件之前按 split 常量测试。不支持的 rowdata subfield pushdown 会在 Connector 和提取后 DataSource 两处校验,避免直接把外层路径塞给基础表 reader。rowdata 的残余表达式在包装外层 RowVector 后求值。
main 中 changelog MetadataFilter 构造在外层 ScanSpec,底层却读 dataScanSpec;源码明确记录 这使对应 residual metadata skipping 没有接到基础 reader。next 没有重新调用 applyChangelogFilters,因此准备完之后才来的常量动态过滤不会重新检查当前 split;join 仍执行自己的精确条件。changelogDynamicFilters_ 是真实存在的 Iceberg 特殊缓存,不是 FileDataSource 的通用缓存。
现在两条 connector 路径都已展开,可以把它们的过滤阶段并排比较:哪些发生在准备时,哪些进入 DWIO,哪些必须等到值读出或生成之后。
11. Hive 与 Iceberg 的过滤阶段对照
| 路径 | 实际主要阶段 | 不能混淆的地方 |
|---|---|---|
| Hive 普通扫描 | synthesized 条件验证 → 打开 Reader / 适配常量 → 文件统计和分区 Filter → stripe/row-group/stride metadata pruning → DWIO Mutation/列 Filter → bucket conversion(若有)→ remaining ExprSet → 行包装 / postProcessor / 输出改名 | 合成列验证失败是错误;普通文件统计过滤在打开 Reader 后;桶转换是读后阶段 |
| Iceberg 普通扫描 | 清本 split 常量 → 追加必要生成列 → 建 Reader / ID 匹配 → 追加 equality key、identity/default/lineage 适配 → 设置 post-read/partition-enforced 规则 → 文件统计 → 建 RowReader、delete readers → DWIO metadata pruning 与 position/DV Mutation、列 Filter → geometry/lineage/target 补值 → post-read Filter 与 equality delete → remaining → 最终输出 | position/DV 在 DWIO 读行选择阶段,equality 需要读出的 key;没有 HiveSplitReader 的桶过滤 |
| Iceberg changelog | 外层常量 Filter 测 split → 内层基础表 Reader / RowReader → 包装 changelog 列 → 外层 remaining → 最终输出 | 禁止携带 deleteFiles;内外 schema / ScanSpec 不同;外层 metadata 不自动下推内层 |
metadata pruning 与 Mutation 的具体交错由 RowReader 决定:nextRowNumber/nextReadSize 可能先前进到下一个通过 metadata 的读取单元,Iceberg 再为该行范围准备删除位图。DWIO selective struct read 在 prepareRead 后先建立 Mutation 生存行,再依 ScanSpec 顺序处理有过滤的列;无过滤的顶层投影列可留成 LazyVector。应把这理解成不同粒度的选择,而不是把所有操作都想成一个固定的串行 filter 函数。
Hive 的 validateSynthesizedColumnFilters 读取静态/提取后的 filters_,匹配 split infoColumns 并按 BIGINT、INTEGER、VARCHAR 验证;失败触发检查错误。其契约是调用方已经保证这些 split 信息满足条件,并非正常的“返回空 batch”分区裁剪接口。
前面的阶段描述了条件已知时的扫描。查询执行中还会产生新的动态条件,接下来追踪它们如何从 Driver 传回已有 DataSource,并改变后续读取。
12. 动态过滤:更新正在进行的扫描
12.1 从生产者回到 TableScan
Driver::pushdownFilters 从生产算子沿 pipeline 向前走,穿过中间算子的 identityProjections,将生产者 channel 逐级映射成扫描输出 channel。遇到无法按 identity 映射的表达式,不能继续直接传递该过滤器。找到可接收过滤的算子后,Driver 在共享 PushdownFilters 中把该 channel 的新旧 Filter 合并,记录 dynamicFilteredColumns,再调用目标算子的 addDynamicFilterLocked。
TableScan 的 helper createDataSource 还承担一次静态初始化:从 DataSource.getFilters 取静态/提取后的过滤 map,按输出 assignment 对应的根 Subfield导入共享 PushdownFilters;不是把所有嵌套过滤都压成输出 channel 的一个根 Filter。新建普通 receiver 或 preload donor 时,都把当前累计动态条件重新注入。
TableScan::addDynamicFilterLocked 对涉及的 output channel 调 DataSource.addDynamicFilter。FileDataSource 的实现只有三步:
// 源码连续片段:传入条件的合并已经在上层完成。
auto& fieldSpec = scanSpec_->getChildByChannel(outputChannel);
fieldSpec.setFilter(filter);
scanSpec_->resetCachedValues(true);
if (splitReader_) {
splitReader_->resetFilterCaches();
}
所以“动态过滤如何累积”必须回答 Driver/shared PushdownFilters 做合并,DataSource/ScanSpec 做替换。直接调用 DataSource 两次,并不自动得到交集。普通 FileDataSource 没有另一个 dynamicFilters_ map;它的 filters_ 仍是构造时静态/提取后的 map,运行条件主要存在当前 ScanSpec 节点。
普通 next 与 addSplit 的相关调用持有 shared PushdownFilters 的读锁,生产/更新使用相应锁来协调;ScanSpec 内部 stableChildren 的锁只保护稳定 child 快照,不是整棵树任意并发读写的万能锁。
12.2 缓存刷新不等于所有格式重新做全部 pruning
resetCachedValues(true) 递归清 hasFilter 缓存并请求重排。splitReader.resetFilterCaches 向 RowReader 传递,再清 column reader 的字典 FilterResult 缓存,使之前按旧条件算出的 dictionary entry 结果变为 Unknown。
DWRF 还把当前 stride skipping 标记为需要重算;selective Nimble 会重算当前 stripe 的 skip ranges;该 ParquetRowReader 入口主要清 column reader 缓存,并没有从这个函数重新构造全部 row-group 候选列表。新条件至少可以影响后续逐行过滤,但不能泛化成“所有格式收到动态 filter 都立刻撤销预读并重跑整个文件的 metadata pruning”。
12.3 普通列、缺失列和常量列的差异
普通文件列的 Filter 从 ScanSpec 获取,后续读批使用新条件。缺失列是 null constant:若测试不接受 null,适配后的 file-level testFilters 可以跳 split;已开始读取后,selective struct 的 testFilterOnConstant/testNull 路径也能将其行淘汰。
非空常量更特殊:testFilterOnConstant 对已知非 null 常量直接返回 true,其假设是 split 准备时已做常量测试。main 的 FileConnectorUtil.testFilters 确实在 prepare 阶段检查分区值和非 null 常量,但 addDynamicFilter 不会重新调用它。因此一个在 prepare 之后才到达、与本 split 非 null 常量冲突的动态 Filter,不应被描述成“reader 必定立即删光当前 split”;可能只是错过一次 scan reduction,下游 join 仍按自己的条件验证结果。
如果动态条件在下一个 addSplit 前已经装到 ScanSpec,下一 split 的常量和文件统计测试可以看到它。若经 bucket 重建或 takeover,是否还在则取决于 moveAdaptationFrom 的常量判断;不能笼统说“动态条件全部自动跨文件继承”。Iceberg 对 partition-enforced Filter 的启用状态有额外修复路径,对填充后字段也有专门 post-read Filter,不能与普通非 null 常量规则混为一谈。
动态过滤会改变一棵正在使用的 ScanSpec。它能否影响下一个文件,取决于哪些对象继续保留、哪些在 split 切换时重建,因此需要进一步梳理状态生命周期。
13. 连续 split:哪些状态复用,哪些重新绑定
13.1 所有权与生命周期表
| 状态 | 持有 / 借用关系 | 普通下一个 split | preload / takeover |
|---|---|---|---|
| outputType_ / 计划 handles | DS 共享类型;TableScan/plan 持有 table 与 assignments;DS 共享 table | 保留扫描契约 | receiver 保留自身契约 |
| readerOutputType_ / readerProducedType_ | DS 与 splitReader 共享不可变 Type 对象,成员可换指针 | 保留累计辅助列;准备阶段可追加 | 移入 donor 版本 |
| ScanSpec / nested nodes | DS shared_ptr;splitReader/options 持有共享引用;column readers 借节点,stableChildren 保活其快照 | 通常同一树,更新 constant / subscript / caches | 采用 donor 树,部分 receiver adaptation 移到 donor |
| MetadataFilter | DS 与 options 共享拥有;ScanSpec 借 leaf/filter 指针 | 树不换时绑定持续;重建树不会自动重绑 | 与 donor ScanSpec 一起移动 |
| 静态 Filter | table map、DS filters_、ScanSpec 可共享 payload | filters_ 保留;节点可被运行过滤替换 | receiver filters_ 保留,donor ScanSpec 移入 |
| 动态 Filter | Driver shared state 累积;普通 DS 存于节点 | 同树自然保留;重建受迁移逻辑限制 | 非常量根 child 倾向 receiver;常量/嵌套受限 |
| ExprSet / expression cache | DS unique_ptr;evaluator/ExecCtx 借用其上层环境 | 同 DS 复用,按新 batch 求值 | receiver 留存;donor 产物销毁 |
| subfields_ / extractionColumns_ | subfields 指向 handle 内或 DS 自有 vector;extractionColumns 指向 handles | subfields 可被桶转换放宽 | extractionColumns 移入,subfields 保留 receiver;底层 handle 寿命仍靠计划等所有者 |
| 分区、缺失列、row-ID 常量 | 在共享 ScanSpec 节点里保存 VectorPtr | 按新 split 更新,真实列重新出现时清除缺失常量 | donor 已准备的常量跟树走 |
| selectivity / filter order / numReads | ScanSpec 的可变状态;每层 struct 有自己的读取反馈 | 同树保留;新 reader 绑定新的 subscript | moveAdaptationFrom 仅搬根 child 的部分状态 |
| reader 字典过滤缓存 / I/O 游标 | 当前 RowReader / ColumnReader 独占 | 新 reader 新建;不能当作查询缓存移过去 | donor reader 带自己的已准备状态,条件变化需刷新 |
| output_ / 空结果 / residual 缓冲区 | DS 拥有,输出 children 可共享,lazy 借 reader | 可复用;实际类型/引用安全有额外约束 | receiver output_ 与 residual 缓冲区保留 |
| QueryCtx / pool / factory / executor | TableScan/Connector/Task 等拥有;DS/splitReader 多为 raw pointer 借用 | 相同扫描上下文 | 新 donor ctx 准备;接管仅显式重绑顶层 splitReader ctx |
| RuntimeStats / I/O stats | DS 累计值;多个 reader 共享 I/O 统计对象 | EOF 汇总 reader stats,I/O 对象持续累计 | 移 donor I/O 对象并合并 receiver 余额;只显式合并部分准备阶段 RuntimeStats |
| IcebergScanSpecState | DS 值成员;reader 借指针;独立 reader 有 own fallback | 跟踪需清理常量与动态列名 | 移常量集合、并动态列名、重绑指针 |
生命周期表并不等于这些对象可以线程间任意共享。Type/TypedExpr 的只读共享、Filter 引用共享、ScanSpec 的可变状态共享、reader 对节点的借用,是四种不同关系。
13.2 ScanSpec 的实际创建/重建点
| 时点 | main 实际动作 |
|---|---|
| FileDataSource 构造 | makeScanSpec;若有 residual,建立 MetadataFilter |
| 构造中的 extraction 分支 | 用 schemaType 重建 readerOutputType 和 ScanSpec,配置 extraction;不重建 MetadataFilter |
| 普通连续 addSplit | 创建新 SplitReader / Reader / RowReader,复用 DS ScanSpec |
| Hive bucket 需求增加或取消子字段裁剪 | makeScanSpec 新树,moveAdaptationFrom,替换旧树 |
| 同桶列、不同桶号或桶数的后续 split | 新 HivePartitionFunction;需求不变则不重建树 |
| Iceberg equality / 读后填充字段 | 在原 ScanSpec 上追加/更新节点与 channel,扩展类型;不是整棵新建 |
| Iceberg schema / 常量适配 | 改当前节点 constant、过滤启用状态、subscript/cache 等 |
| ChangelogDataSource 构造 | 另建一次基础表 dataScanSpec,跨该 DS 的 changelog splits 共享 |
| preload | donor 是另一份 DS,有自己的构造和 ScanSpec;不能与普通下一个 split 混称 |
| takeover | 接过 donor 树和已建 reader,不在此调用 makeScanSpec |
所以答案很明确:main 不是每个文件一棵全新 ScanSpec。每个 addSplit 新建的是 splitReader 及其文件读取对象,而通常复用同一棵扫描树。反过来,preload 的确会额外构造一棵,bucket/extraction 也可能触发重建;“永远只建一次”同样错误。
13.3 moveAdaptationFrom 的实际范围
实现 的关键循环如下,省略了前置检查与注释:
// 源码节选:只遍历当前节点的直接 children。
for (auto& child : children_) {
auto it = other.childByFieldName_.find(child->fieldName_);
if (it == other.childByFieldName_.end()) {
continue;
}
auto* otherChild = it->second;
if (!child->isConstant() && !otherChild->isConstant()) {
child->filter_ = std::move(otherChild->filter_);
child->selectivity_ = otherChild->selectivity_;
}
}
这段代码不递归。匹配字段名,只有双方该 child 都非 constant,才移动 child 的 Filter shared_ptr、复制其 selectivity;没有递归转移嵌套 Filter/selectivity,也没有复制 constant、channel、subscript、metadataFilters、extraction/transform。它不直接按旧 children 顺序重排,也不在此函数清 hasFilter 或字典缓存。
因此注释中的“filter order”要按实现理解:迁移了可用于后续排序的部分 selectivity,不是完整搬迁排序数组。普通连续 split 原本就复用树,所以嵌套反馈自然保留;重建之后再调用这个函数,嵌套静态 Filter 是由 makeScanSpec 重建的,嵌套反馈不会被这个函数补回。
对于文件 A 缺 c1、文件 B 有 c1,Hive adaptColumns 在 A 把节点变 constant,在 B 清 constant;这个变化不需要新树。但如果从 A 的树向预加载 B 的树 moveAdaptationFrom,A 的 c1 是 constant,于是不会把其根 Filter 移过去。必须结合 donor 在创建时收到哪些动态条件判断,不能把常量分支忽略。
普通连续 split 主要在同一个 DataSource 内更新文件状态。preload 则先构造另一份 DataSource 和 reader,随后接管它们;状态迁移因而成为一条需要单独分析的执行路径。
14. Preload 与 takeover:接管预加载的读取现场
14.1 donor 如何创建
TableScan 在条件允许、当前 reader 的 prefetch 等状态满足要求时,给后续 split 建立 AsyncSource,并由 executor 调 prepare。闭包捕获 outputType、table handle、assignments、Connector、新建 ConnectorQueryCtx、Task、共享 PushdownFilters 和 split。它先检查取消,构造另一份 DataSource,再检查取消,调用 addSplit,返回 donor。
所以 preload 不只是打开文件:它可能再次进行过滤提取、辅助 compile、完整 residual compile、建 ScanSpec、解析 footer、适配 schema、建立 RowReader、读取 equality delete key 集合。实际预加载成本与这些步骤相关,不能只从“是否重复 new 一个配置类”估计。
AsyncSource.move 若准备已完成则取得结果;若未开始可在调用线程制作,若正在制作则等待其 promise。当前 TableScan 的这一段使用 move 的同步等待,并记录 waitForPreload 时间,没有把它包装成 FileDataSource.next 的 nullopt/future 返回。制作抛出的 std::exception 被保存为 exception_ptr,move 重新抛出;不能把失败 donor 当成正常空 split。取消时的 nullptr 由 TableScan 验证 Task 已取消。
14.2 谁的状态保留
FileDataSource::setFromDataSource 的顺序是:移入 donor split、readerOutputType、readerProducedType、extractionColumns;合并 donor 的 skippedSplits / processedSplits / skippedSplitBytes;让 donor ScanSpec 接收 receiver 的有限 adaptation;然后移入 donor ScanSpec、MetadataFilter 和 splitReader,更新 splitReader 的 ConnectorQueryCtx 指针;最后把 receiver 原 I/O 余额加进 donor I/O 对象,采用 donor 统计对象。
receiver 的 outputType、ExprSet、evaluator、filters_、subfields_、output_、residual 结果缓冲与累计 completedRows 不在这个函数中移动。donor preload 只做准备,没有普通 next,completedRows 没有需要迁移的基础数据扫描进度。I/O 对象必须采用 donor 的,因为准备好的 reader 已经引用它们;如果只把计数拷进 receiver 旧对象,后续 I/O 可能还写到另一份统计。
原始 dwio RuntimeStats 在 split EOF 累计,getRuntimeStats 再转成 RuntimeMetric;不能把每个 split 的 RuntimeMetric 分别 merge 当作完全等价,它会改变 count/min/max 的统计语义。本 main 的公共 FileDataSource 仍然是先累计 counters,再 toRuntimeMetricMap。
14.3 借用关系只部分重绑
FileSplitReader::setConnectorQueryCtx 只赋值顶层的 context 指针。其 pool、partitionKeys 指针、Hive infoColumns 指针、subfieldFiltersForValidation 指针并未全量重绑。preload 通过同一 TableScan 的 operator/connector pools 建上下文,因此 pool 指针通常仍有 Task/Operator 支撑;prepare 阶段使用的验证 map 不会在普通 next 再重新验证,不能看见 raw pointer 就直接宣布当前路径每批必然悬空访问。
但也不能从顶层 setter 推断“所有子 reader 的借用已安全重定向”。例如 PositionalDeleteFileReader 保留 context 字段,实际打开/检查用在构造阶段;EqualityDeleteFileReader 的 RuntimeStats 引用也是构造时使用,并非存成每 batch 更新的引用。新增懒初始化、错误日志或重新 prepare 时必须重新检查这些寿命。preload callback 的上下文由其捕获保持,donor DS 自身只借用;制作结束与接管之间不能任意调用依赖旧上下文的新方法。 还要检查销毁路径:AsyncSource::prepare 把 maker 移到局部变量,制作完成后释放其捕获;donor 的 ExprSet 直到接管后才销毁,而 ExprSet 析构 在存在 listener 时会通过 execCtx 读取 queryId。结合 evaluator 对 ExecCtx 的所有权,这是需要用 listener 开启的 preload 用例验证的寿命风险,顶层 reader 的 ctx 重绑并不覆盖它;本文未运行该触发组合。
Iceberg takeover 另移 splitConstantColumns,合并 dynamicFilterColumns,采用 donor reader 后把 scanSpecState 指针改指向 receiver,并按动态列重新启用 partition-enforced Filter、清缓存。普通 Hive 基类 takeover 没有同样的显式缓存刷新收尾。
14.4 接管动态过滤的时间窗
设 donor 在 T0 prepare,动态 Filter 在 T1 到达 receiver,T2 才 takeover。donor 创建时收到的是 T0 共享状态,T1 不会因为持有 shared_ptr 就自动改写其 ScanSpec 引用。T2 的 moveAdaptationFrom 对双方都非 constant 的根字段移动 receiver 最新 Filter;若一方为 constant,则保留 donor 节点的 Filter,不能保证带上 T1 条件。嵌套反馈也不递归转移。
这可能使某些动态过滤优化没能应用到预加载 split。评估正确性要看下游是否继续验证精确 join 条件,评估性能则要测漏掉了多少剪枝,不能把两个结论合成一句“takeover 完全正确/完全错误”。
changelog 还更特殊:outer scanSpec 与实际 reader 的 dataScanSpec 不同;changelogScanContext_、changelogDynamicFilters_ 不在 base takeover 的移动列表中,ChangelogSplitReader 借用的过滤 map 指针也没有专门重绑。当前 map 的主要读取发生在 prepare,next 不再次检查,因此不能凭这些指针直接声称每批 UAF;但后续 split 的 dataScanSpec 适配是否连续保留、晚到动态条件是否漏过、将来增加再检查是否引入寿命问题,都应作为main 的既有边界单独验证。
主体调用链到这里已经闭合。下面用具体 schema、条件与连续文件验证这些关系,再汇总 main 的行为边界和现有测试,最后讨论设计取舍。
15. 十三个场景:串起类型、过滤与状态
以下为固定 main 源码的逐步推演。P 表示 FileDataSource.outputType,R 表示 readerOutputType,G 表示 readerProducedType,G=∅ 表示该成员为空、分配回退到 R。ScanSpec 中 P(ch=i) 表示向 reader 投影至 channel i;F 表示 Filter;C 表示常量;I 表示生成的文件行号。它们是本文的图表记号,不是新增的 Velox 类。
15.1 输出名称与表/文件列名不同
输入文件 ROW(c0 BIGINT,c1 VARCHAR),输出 P=ROW(alias BIGINT),assignments 为 alias → regular(name=c0,schemaType=BIGINT,dataType=BIGINT)。R 构造为 [c0:BIGINT],G=∅;ScanSpec 有 c0 P(ch=0),c1 无需求时不建读取节点。
按名 mapping 时,reader 从文件 c0 产生 child 0。FileDataSource 最后用原 P 包装 child,所以输出列名为 alias。这个过程没有改写文件 schema,也没有把 remaining expression 中所有字符串自动替换为 SQL alias。若表列已改名但历史文件仍叫 old_c0,需另靠 field-ID / position mapping;assignment alias 只负责计划层映射,不能替代 schema evolution。
无过滤时输出行数等于该 batch reader 行数。行号(若另外投影)仍是原文件坐标,与输出 alias 无关。
15.2 输出 c0,remaining filter 用未投影 c1
输入 [c0 BIGINT,c1 BIGINT],P=[c0],remaining 为 c0+c1>10,普通 c0 handle 被投影。编译后 distinctFields 包含 c0、c1,R=[c0,c1],G=∅;ScanSpec 二者均投影,channel 0 对应最终列,channel 1 只是 residual 输入。
假设 reader 输出 (4,8),(8,1),(9,3),ExprSet 对三行求值,selectedIndices=[0,2]。最终只有 ROW(c0 BIGINT) 的 [4,9],不是把 c1 也返回下游。c1 可由 compiled FieldReference 确定类型;若它是分区、row index、Hive row ID 等特殊角色,还必须有相应 handle 使节点按正确方式构造,不能仅凭 BIGINT 类型猜角色。
15.3 某列仅做 reader 下推过滤
同一文件,P=[c0],原 subfieldFilters 中 c1 > 10,dataColumns 包含 c1 类型,没有 residual。R=[c0]、G=∅;ScanSpec 为 c0 P(0) 与 c1 F(>10), projectOut=false。c1 的 column reader 在 DWIO 内缩小 row set,不为 c1 分配 DataSource 输出 child。
若原行 c0=[1,2,3]、c1=[9,12,20],rowsScanned=3、reader 输出 c0=[2,3] 两行,最终仍两行。以后若 Iceberg equality delete 也需要 c1,它才会从 filter-only 升为有 channel 的投影辅助列;不能把本场景的“隐藏”理解成永远无需返回。
15.4 ROW / ARRAY / MAP 子字段裁剪
输入 r ROW(a BIGINT,b BIGINT,c VARCHAR)、a ARRAY(ROW(x BIGINT,y BIGINT))、m MAP(VARCHAR,ROW(x BIGINT,y BIGINT))。P/R 保留这三个完整逻辑类型,G=∅;handles 要求 r.a、a[3].x、m["k"].x,同时下推 Filter 依赖 r.b。
ScanSpec 中 r 的 a、b 都需要读,c 为 null constant;a 最多保留到第 3 个元素,每个元素只需 x、y 可置 null;m 只需 key k,其 value.x 被读取、y 可置 null。r.b 的 Filter 缩小整行集合,m.keys 的 key selection 则裁剪 map entries,不能把它当成同一层行过滤。
最终向量仍有 r、a、m 三列,顶层行数由行过滤决定,r/元素 ROW 的类型与字段位置不缩减。数组元素数量、map entries 可能减少。这种“不完整值”只有在调用方保证未读部分不会被使用时才成立:如果随后还要输出完整 map、计算整个 ROW 的 bucket hash 或作为完整 equality key,就必须扩大读取需求。
15.5 extraction 把 MAP 变成 ARRAY
输入 m MAP(VARCHAR,BIGINT),输出 P=[keys ARRAY(VARCHAR)],handle.name=m、schemaType=MAP、dataType=ARRAY,extractions=[MapKeys]。构造完成后 R=[m MAP],G=[m ARRAY];节点 m P(0), extraction=kKeys,并带完整链 transform。常规 selective reader 可以不读 value stream,直接返回 keys ARRAY。
文件两行 {a:1,b:2}、{c:3},输出是 [a,b]、[c] 两个数组,仍是两行,不是三行展开。最终列名恢复 keys。如果 Iceberg 另追加 _row_id 过滤辅助列,R 与 G 同步在尾部加 BIGINT,而 m 这一列仍分别是 MAP/ARRAY;main 已有该同步代码与测试,不能把它写成待重构才有的能力。
多链 extraction 则可能 G 为空,读后 transform 组装 ROW;它与单个 kKeys 原生输出的实现不同。extraction 与 bucket conversion 重建组合时还要检查 transform 是否留在新树,不能由这个单场景的正确性外推。
15.6 只输出分区列、row index 或 Hive row ID
文件有普通列 c0,但 P 只要 p VARCHAR, idx BIGINT, rid ROW(五字段),对应 handles 为 partition、kRowIndex、kRowId。R 名称来自三个 handle,G=∅;ScanSpec 是 p 的 C、idx 的 I、rid 的 composite(child 0 I、其余 C),没有必须读出的普通数据列。
假设可读文件行号为 100~103,p='east'。没有筛选时输出 4 行,idx=[100,101,102,103],rid.row_number 同它,rid.row_group_id 在四行上都是该文件名。若 residual idx % 2=0,最终两行行号是 100、102。DWIO no-child 分支仍用行数和 selected row set 生成正确向量;只输出 rid 的路径也有测试覆盖。
这里“没有物理数据列解码”不等于省掉 footer、range 处理、行计数和可能的 delete/随机抽样工作。直接把 idx 的比较交给普通 pushdown Filter 与 residual 求值是两种支持范围,不能随意互换。
15.7 文件缺列后又重新出现
表/P 为 [c0 BIGINT,c1 VARCHAR],文件 A 只有 c0,文件 B 同时有 c0、c1。R 在两个 split 都是 [c0,c1],G=∅,同一 ScanSpec 保留两个投影 channel。
A.prepare 把 c1 设置为 null constant;next 输出真实 c0 与全 null c1。A EOF 后,B 创建新 reader,但使用同一树;Hive adaptColumns 看到文件 c1 重新存在,执行 setConstantValue(nullptr),再创建对应 column reader,输出 B 的真实字符串。若忘记清这个常量,B 就会继续返回 null,这正是状态隔离/复用都必须检验的地方。
已有动态 c1 IS NOT NULL 时,A 可以被过滤,B 必须重新按真实数据判断。Iceberg 对 projected 缺列也有默认值/重新读文件的路径;不过 filter-only 节点、已安装 split 常量与 identity 元数据清理具有额外条件,不能只引用 Hive 的 else 分支就声称 Iceberg 全部形状等同。相关边界列在第 16 节。
15.8 相同隐藏桶列的连续 split,同时有嵌套 Filter
表为 [c0 BIGINT,k ROW(a BIGINT,b BIGINT)],只输出 c0;静态嵌套 Filter 为 k.a>0;桶 hash 依赖整个 k。开始 R=[c0],ScanSpec 中 k 仅过滤。第一个 conversion split 到来后,setupBucketConversion 把 k 的完整类型追加到 R=[c0,k],新树有 c0 P(0)、k P(1) 及其 a 的 Filter;读 k 必须保留 b,不能只保留查询用的 a。
第二个 split 仍使用 k,只改变目标桶号:k 已在 R 中,相关裁剪已解除,没有新读取需求,因此不再重建 ScanSpec。嵌套 k.a 节点的 Filter 和已经学习到的 selectivity 都随同一树保留,新建的只是文件 reader 和 HivePartitionFunction。若是另一个 split 引入新的 bucket 列触发重建,嵌套静态 Filter 由 makeScanSpec 重新安装,但旧嵌套 selectivity 不会由 moveAdaptationFrom 递归恢复。
每批先通过 k.a Filter,再按完整 k 的 Hive hash 挑目标桶,最后只返回 c0。R 中隐藏 k 不对外输出;rowsScanned 计读过的行,桶后 RowVector 行数另计。现有 bucketConversionWithSubfieldPruning 测试验证了完整 key 需求,但“隐藏 key + 嵌套反馈跨相同连续 split”的精确组合仍应作为独立重构回归用例。
15.9 Hive row ID 常量在连续 split 与 takeover 后变化
P/R=[rid 五字段 ROW]、G=∅。A 的 rowIdProperties=(version=1,partition=10,guid=A),B=(2,20,B),文件名也不同。普通连续 split 的 setupRowIdColumn 在原树上重装后四个常量,row_number 每个文件从自己的绝对位置生成;不能累加上一文件输出行数。
preload 时 donor 为 B 创建另一棵 composite 节点并填好 B 的常量。takeover 采用 donor ScanSpec;moveAdaptationFrom 对根 rid 不递归处理其常量 children,所以 B 应输出 B 的文件名、version=2、partition=20、guid=B。最终 rid 的 SQL 类型不变,变的是 VectorPtr 常量值及逐行 row_number。已有 rowId preload 测试明确用多个不同 rowIdProperties 检查这类变化,而不是只测同一个文件常量。
15.10 Equality delete 需要未投影 key
Iceberg 表 [c0 BIGINT,key BIGINT],完整 field IDs=[10,20],P=[c0];data split 带 equality delete 文件,其 equalityFieldIds=[20],删除 key=7。数据为 (100,7),(200,8),(300,7)。初始 R=[c0],prepare 解析 ID=20 为 key,追加 R=[c0,key],G=∅,ScanSpec key 为 P(1)。若此前 key 是 filter-only,原节点被升级,原有 Filter 仍需保留。
EqualityDeleteFileReader 准备 hash 集合 {7},DWIO 返回需要的 key 值后,delete 比较标记输出下标 0、2;removeRows 用字典保留下标 1。最终 P 只有 c0=[200] 一行,rowsScanned 仍为3。若同时原生 extraction 使 G 非空,尾部 key 也必须加进 G;否则输出分配列数不足。
若 key 是明确 identity partition field,split 常量可以替代其物理读取。若只是 bucket(16,key) 的分区值,即便名字叫 key,也不能拿变换结果与 equality delete 的原 key 比较。完整 field ID 元数据缺失时存在 ordinal 兼容回退,不能据此承诺 ID=20 会自动映射第二列。
15.11 Position delete、lineage 与 changelog
数据文件绝对位置100~104,_row_id 在需要继承的行上为 null,firstRowId=1000;position delete 删除101,reader Filter 再排除103。R 包含计划列及 _row_id,G 由是否 extraction 决定。RowReader 临时附加行号,生存位置为100、102、104;next 补 lineage 后得到1100、1102、1104,不能用压缩后位置算成1100、1101、1102。临时行号 child 随后移除。
若原102的 _row_id 已有非 null 值9000,则保留9000。last-updated 的 null 补成 data sequence number;target row ID 则包含各行物理位置100、102、104以及相同 file_path/spec_id/partition_data。post-read Filter 再检查这些补完的值,最终 RowVector 的行数按剩余选择决定。
changelog 是另一条流程:输入 split 标记 operation=DELETE、ordinal=3、snapshotid=42,deleteFiles 必须为空。内层读取基础行,外层 P/R 是所请求的 changelog schema,例如 [operation VARCHAR,rowdata ROW(c0 BIGINT)];dataScanSpec 仍按基础表 c0。输出 operation 常量与 rowdata,不会因为 operation=DELETE 就再调用 position/equality delete 去删光这些“删除事件行”。
15.12 动态过滤遇到真实列、缺失列、常量与 preload
输入 P=[c0 BIGINT,p INTEGER],assignments 分别为 regular / partition,R=[c0,p]。上游 join 在 output channel0 产生 c0>10:Driver 回溯并合并到 shared PushdownFilters,FileDataSource 找 c0 节点替换 Filter,清树/reader 缓存;下批真实列按它筛选。若当前文件缺 c0,null constant 在不接受 null 的条件下不应产出有效行。
若动态条件针对 p,且 p=1:在 prepare 前收到 p=2,文件/分区测试可跳 split;prepare 后才收到,普通 selective reader 对非 null 常量的快速路径不会保证重新检查该冲突。最终 join 仍按 p 验证,scan 返回行数可能多于能够提前剪掉的行数。
再加入 preload:donor T0 只有旧条件,receiver T1 收到新条件。takeover 时真实非 constant 根 c0 的 Filter 可从 receiver 移到 donor;p 或缺失 c0 的 constant 条件使迁移跳过,donor 并不自动拥有 T1 条件。Iceberg 的 partition-enforced re-enable 与 lineage post-read Filter 是专门分支,需另外验证,不可宣称通用动态缓存已解决所有常量时序。
15.13 零输出列、零 batch、空 split
第一种输入:P=ROW<>,无过滤,无辅助列,文件有5行。R=ROW<>、G=∅、根没有投影数据节点,no-child reader 仍产出 size=5 的 ROW<>;count(*) 可消费这个行数。
第二种:P=ROW<>,residual 为 c1 % 2=0,c1 BIGINT。R=[c1]、G=∅,ScanSpec c1 为 P(0),ExprSet 需要它。若文件值[0,1,2],过滤后两行,zero-projection 分支返回的是包着 c1 的内部 RowVector,而不是重建空 schema;若全部过滤掉,getEmptyOutput 却返回 size=0 的 ROW<>。这是主线行为边界,不应抹平。
第三种:普通 P=[c0],rowsScanned>0 但 reader/bucket/delete/residual 淘汰全部行,返回 size=0 的计划类型 RowVector,split 继续;真正空文件、统计拒绝、允许忽略的缺文件或 range 没有读取单元最终返回 engaged nullptr,TableScan 才切换 split。nullopt 属于等待协议,在这里的 FileDataSource.next 主实现中并未返回。
16. main 的行为边界与待验证组合
源码中的“不直观”不等于所有输入都会出错;一个可疑分支也不应在没有触发用例时被描述成已复现故障。下面把事实、影响范围和下一步证据分开。本文没有修改 Velox 实现,也没有把这些行为归因于某个尚未比较的重构 PR。
| main 中可直接定位的事实 | 能确定的影响 / 需要检验的组合 | 源码依据 |
|---|---|---|
| extraction / bucket 重建 ScanSpec,没有重新登记 MetadataFilter leaf | 新树缺相应 metadata 绑定;可能损失 residual 统计剪枝,不能直接等同结果错误 | FileDataSource 构造、HiveDataSource.setupBucketConversion、MetadataFilter.LeafNode |
| bucket 重建只调用 makeScanSpec 和 moveAdaptationFrom,不重装 extraction | extraction + bucket 需求变化是重点组合:类型/transform 是否一致不能由单独测试保证 | HiveDataSource.cpp:80;FileDataSource.configureExtractionColumns |
| moveAdaptationFrom 非递归,跳 constant,没有清缓存 | 不能承诺所有嵌套反馈和晚到动态条件完整接管 | ScanSpec.cpp:206;FileDataSource.setFromDataSource |
| 非 null 常量在 selective reader 中直接通过常量过滤快路 | prepare 后新动态条件可能不减少当前 scan;需与 join 最终语义分开测 | SelectiveStructColumnReader.cpp:748;FileConnectorUtil.testFilters |
| zero projection 返回 wrap(内部 RowVector) | 有辅助列且有生存行时可能泄露内部 schema;零行路径又用计划类型 | FileDataSource.cpp:516;OperatorUtils.cpp:392 |
| output_ 只按“列数不足”重新分配 | takeover 后同宽异名/异型、旧辅助列较多时不能假定自动校准;检查实际外层与 children | FileDataSource.cpp:469;SelectiveStructColumnReader.prepareResult |
| Iceberg projected regular 列重新出现会清 constant,但 filter-only 的“文件存在且未投影”并不走同一分支 | “前文件缺失/default,后文件存在,列只用于下推”的 stale-constant 组合需要专门运行验证 | IcebergSplitReader.adaptColumns:1321~1447 |
| equality-augmented identity 常量由本 reader 集合保护,跨 split 清理另依赖 ScanSpecState/句柄覆盖 | 隐藏 equality key 没有 handle、分区值/identity 状态变化时,应核对上次常量是否清除及重新安装 | configureEqualityDeleteColumns、clearSplitConstants、configureIdentityPartitionColumns |
| Iceberg checkEqualityDeleteColumnsAreReadable 允许 constant 或 subscript>=0,源码注明旧正 subscript 可漏检 | 这个检查不是 reader 树实际存在的完整证明 | IcebergSplitReader.cpp:1000 |
| donor 上下文由 maker 捕获,ExprSet 析构在 listener 分支访问 ExecCtx | 检查 maker 已释放后销毁 donor 的寿命;不能把 reader setter 当成表达式上下文重绑 | AsyncSource.prepare;ExprSet::~ExprSet |
| changelog 内外 ScanSpec 分离、metadata 不重绑;动态常量只在 prepare 测 | metadata 减少工作不生效;late filter / takeover 需独立时序用例 | IcebergChangelogSplitReader.cpp:79、199;IcebergDataSource.setFromDataSource |
其中已经能从代码直接推出的分支行为,与表中标为“需要运行验证”的组合,要保持措辞区别。没有执行这些组合,不能把一次 source review 写成主线 regression test 已通过;同样,未来 PR 如果保留这些行为,也不能把它们算成 PR 新增回归。
17. 测试证据与重构验证
17.1 现有测试提供哪些锚点
本次核对了调用实现和下列测试的输入/断言,未编译或运行整个 Velox 测试套件。文中的小型行选择与桶号算例单独校验,不能代替引擎测试。后续改实现时,应在同一 baseline 先跑 old/new 对照,再判断行为或统计差异。
这些入口以及主调用链可以从源码核对清单继续阅读。所有链接使用同一 main commit,避免后来代码移动后把另一版行为混进来。
17.2 FileScanConfig / FileScanState 需要证明什么
拆分是否有价值,应从这些已识别的不变量出发,而不是先把成员分成“看起来像配置”和“看起来像状态”:
- 名字与 channel 契约。 计划名、表名、file mapping 分开;前 N 个 reader channel 保证对应对外输出;辅助列增加、消失、takeover 同宽异名都不能误读。
- 类型与执行表示一致。 schemaType / dataType / R / G / 实际 output child 全链路一致;单链、多链、TEXT/selective、extraction 加 delete 辅助列分别验证。
- 过滤语义不变。 静态和动态 Filter 的 ownership/merge 边界清楚,residual compile 结果不误共享,metadata 与节点同时建立/销毁,常量晚到动态条件单独测试。
- 连续文件状态转换完整。 缺失→存在、partition A→B、同桶列→新桶列、identity→非 identity、delete key 集合变化、lineage 有/无继承值都覆盖。
- 接管不是普通拷贝。 donor reader 依赖的 context、stats、ScanSpec、metadata、辅助类型和 reader 私有缓存作为关联对象审计;receiver ExprSet/output_ 保留策略有证据。
- 延迟加载与统计不可隐式改变。 lazy 的 read generation、共享 buffer 可写性、nullopt/empty/EOF 协议不变;rowsScanned 与 RuntimeMetric count/min/max 不因换接口无意改义。
可以把第 15 节扩展成测试矩阵:每个场景同时检查最终值、外层/子列类型、行数/物理行号、ScanSpec 是否重建,以及必要 runtime stats。再分别在 preload 开/关、dynamic filter 在 prepare 前/后、不同文件格式上运行。无需给每一种组合机械做笛卡尔积,优先覆盖状态变化与语义边界。
这些测试与不变量给出了评估重构的依据。回到设计本身,可以从扫描契约、选择性读取和状态复用三个方面看清现有实现的收益与代价。
18. 设计取舍:扫描契约、选择性读取与状态复用
DataSource 最有价值的抽象,是把执行层的 split/batch 协议与文件格式解码分开。TableScan 不需要认识 Parquet levels,DWIO 不需要知道 SQL alias;一个前缀投影约定就能让 residual、桶 key 和 delete key 共用内部 batch,再在出口统一恢复计划类型。它减少重复列读取,也让新增扫描语义能接在已有 reader 上。
代价是内部 batch 已经不是一个静态等同于计划输出的对象。辅助列会增长,extraction 改变值形状,lineage 临时注入行号,changelog 甚至有内外两套 schema。只把 RowTypePtr 放进一个 const struct 并不自动获得清晰边界;更重要的是标明谁决定它、谁可以替换它、哪些 channel 必须同步。
ScanSpec 的共享复用也有真实收益:静态 Filter 不必每个普通 split 重建,嵌套 selectivity 和自适应信息可以延续,动态条件能影响后续读取。但它把读取需求、文件常量、子 reader 绑定、metadata 指针和运行反馈放到一起,使“重建一棵等价树”成为比复制字段更严格的操作。moveAdaptationFrom 的局部实现与 metadata 绑定问题正说明:状态迁移需要逐项定义,不能靠函数名完成证明。
选择性读取的工程技巧同样来自边界:尽早用 metadata 排除大单元,再用低成本列 Filter 缩小行集合,最后延迟物化 payload;用 row set 维持物理坐标,用 dictionary 表达剩余行选择,用引用计数保护已经交付的向量。它们能少解码、少拷贝,却要求读者与调用方遵守 lazy 的寿命协议,也要求 generated column 不能在未生成时被误过滤。
preload 把昂贵准备工作移到另一个执行线程,但收益伴随另一份表达式/ScanSpec 构造以及一次状态交接。好的重构应让这些依赖更显式、更容易验证,同时保留现有有价值的复用;它不应把每个文件强行变成完全独立状态,也不应把本该按 split 刷新的常量藏进共享配置。是否采用 FileScanConfig / FileScanState,应由这些边界能否变得清楚、旧行为能否被准确对照来决定。
TableHandle、ColumnHandle 与 ScanSpec 的分工也体现了这套设计的层次:前两者描述扫描要求与列契约,ScanSpec 把它们转成可调整的读取树;真正的读取执行还由 ColumnReader、表达式求值框架与 I/O 实现接力。保持这些职责明确,才能既保留跨文件的反馈和延迟物化,又避免把所有调度状态塞进计划句柄,或误认为复制一棵树就等价于复制整个扫描现场。