本文以 DuckDB 的向量化执行模型为背景,详细解释三个问题:
- 存储层中的
ColumnSegment如何被扫描为执行层的DataChunk;DataChunk经过 Filter 时,是复制数据还是使用选择向量;- 两个表各自的 morsel/DataChunk 如何先 Filter,再分 Build、Probe 两个 pipeline 完成多线程 Hash Join。
文中统一使用 DuckDB 的正式类型名
DataChunk。口语中的 "chunk data""chunkdate" 均指DataChunk。
1. 先建立整体概念
DuckDB 的数据从磁盘进入查询执行引擎,大致经历以下层次:
flowchart LR A磁盘或内存中的表 --> BRow Group B --> C1id 列的 ColumnSegment B --> C2age 列的 ColumnSegment B --> C3city 列的 ColumnSegment C1 -->|扫描 / 解码| V1id Vector C2 -->|扫描 / 解码| V2age Vector C3 -->|扫描 / 解码| V3city Vector V1 --> DDataChunk V2 --> D V3 --> D D --> FFilter F --> GFiltered DataChunk G --> HHash Join Build 或 Probe
其中:
| 概念 | 所属层次 | 含义 |
|---|---|---|
ColumnSegment |
存储层 | 某一列的一段数据,可能压缩,带有统计信息和磁盘块位置 |
Vector |
执行层 | 一列的一批值,可以是 Flat、Constant、Dictionary 等物理格式 |
DataChunk |
执行层 | 多个等长 Vector 的集合,表示一批完整的行 |
SelectionVector |
执行层 | 记录哪些行通过过滤,例如 [1, 4, 9] |
| morsel | 调度层 | 线程可以领取的一段扫描工作;一个 morsel 通常产生多个 DataChunk |
| pipeline | 执行调度层 | 可以连续执行的一串算子,例如 Scan → Filter → Hash Join Build |
DuckDB 官方执行格式文档将 Vector 定义为执行期间的内存数据容器,将 DataChunk 定义为多个 Vector 的集合。当前默认 STANDARD_VECTOR_SIZE 是 2048 行,但最后一个 chunk 或过滤后的 chunk 可以少于 2048 行。1
2. Segment 和 DataChunk 的根本区别

2.1 Segment 是按列切分的存储单位
假设有一张表:
sql
CREATE TABLE users (
id INTEGER,
age INTEGER,
city VARCHAR
);
存储层是列式组织的,可以抽象成:
text
id 列 age 列 city 列
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ ColumnSegment id-0 │ │ ColumnSegment age-0│ │ColumnSegment city-0│
│ 一段连续行的数据 │ │ 一段连续行的数据 │ │ 一段连续行的数据 │
│ 压缩 + 统计 + 位置 │ │ 压缩 + 统计 + 位置 │ │ 压缩 + 统计 + 位置 │
└────────────────────┘ └────────────────────┘ └────────────────────┘
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ ColumnSegment id-1 │ │ ColumnSegment age-1│ │ColumnSegment city-1│
└────────────────────┘ └────────────────────┘ └────────────────────┘
... ... ...
一个 ColumnSegment 只属于一列。其内部可能包含:
- 该段的数据数量;
- 压缩函数和压缩状态;
- Segment 统计信息;
- 对应的磁盘 Block、Block 内偏移;
- 扫描状态和解码状态。
DuckDB 的 ColumnSegment 接口提供扫描一个 Vector、按 SelectionVector 选择,以及在扫描时执行 Filter 的入口。4
2.2 DataChunk 是按行批次组织的执行单位
执行引擎不是把数据重新组装成行式结构:
text
[id1, age1, city1]
[id2, age2, city2]
而是继续保持列式:
text
DataChunk,cardinality = 2048
┌──────────────────┬──────────────────┬──────────────────┐
│ id Vector │ age Vector │ city Vector │
│ 2048 个逻辑值 │ 2048 个逻辑值 │ 2048 个逻辑值 │
└──────────────────┴──────────────────┴──────────────────┘
可以将 DataChunk 理解为:
text
DataChunk
├── Vector 0:id
├── Vector 1:age
├── Vector 2:city
└── cardinality:这些 Vector 共同拥有的有效行数
因此:
Segment 是"某一列的一段存储";DataChunk 是"多列在同一行号范围内组成的一批执行数据"。
二者没有固定的一一对应关系。
3. Segment 如何扫描成 DataChunk
3.1 第一步:查询先确定需要哪些列
例如:
sql
SELECT id, city
FROM users
WHERE age >= 30;
执行计划至少需要读取:
age:用于过滤;id:用于输出;city:用于输出。
没有被查询引用的其他列可以被列裁剪,不必进入 DataChunk。
3.2 第二步:从各列 Segment 读取相同的行号范围
假设本次扫描目标是逻辑行 0~2047:
flowchart TB subgraph Storage存储层 S1id ColumnSegment S2age ColumnSegment S3city ColumnSegment end subgraph Scan扫描和解码 R1读取 id 的 0\~2047 R2读取 age 的 0\~2047 R3读取 city 的 0\~2047 end subgraph Execution执行层 V1id Vector: 2048 V2age Vector: 2048 V3city Vector: 2048 CDataChunk: 2048 行 end S1 --> R1 --> V1 --> C S2 --> R2 --> V2 --> C S3 --> R3 --> V3 --> C
扫描器会让多个列 Vector 对齐到同一批逻辑行。只有这样,id[i]、age[i]、city[i] 才属于同一条记录。
3.3 第三步:解码不等于全部展开成 Flat Vector
常见误解是:
text
压缩 Segment → 完全解压成连续数组 → 再复制进 DataChunk
实际并不一定如此。DuckDB 的执行 Vector 支持多种物理格式:1
Flat Vector
text
逻辑值:[10, 20, 30, 40]
物理值:[10, 20, 30, 40]
数据以连续数组保存。
Constant Vector
text
逻辑值:[北京, 北京, 北京, 北京]
物理值:北京 × 1
存储层如果使用常量压缩,可以直接产生 Constant Vector,不必重复写入 2048 次。
Dictionary Vector
text
child vector = [北京, 上海, 深圳]
selection = [0, 1, 0, 2, 1, ...]
Dictionary Vector 由一个 child vector 和一个 SelectionVector 构成。DuckDB 可以在读取字典压缩 Segment 时继续保留这种形式,让压缩表示进入执行阶段。1
因此,更准确的描述是:
Segment 被扫描和解码为执行层可以消费的 Vector;Vector 可能是 Flat,也可能继续保持 Constant、Dictionary 等压缩表示。
3.4 一个 Segment 可以产生多个 DataChunk
假设某个 Segment 含有 10,000 个值,默认每个 DataChunk 最大处理 2048 行,则它可能依次贡献给:
text
DataChunk 1:行 0~2047
DataChunk 2:行 2048~4095
DataChunk 3:行 4096~6143
DataChunk 4:行 6144~8191
DataChunk 5:行 8192~9999,共 1808 行
反过来,如果一个执行批次的读取范围跨越了 Segment 边界,扫描器需要结束当前 Segment 的读取,再切换到下一 Segment。具体扫描路径可能填充同一个 Vector,也可能在边界处返回较短批次;关键结论是:
Segment 边界和 DataChunk 边界是两个不同层次的边界,不能假设它们完全重合。
3.5 Segment 统计信息可以让整个 Segment 不被读取
对于:
sql
WHERE age >= 30
如果某个 age Segment 的统计信息表明:
text
min(age) = 10
max(age) = 25
那么该 Segment 不可能产生满足条件的行,扫描器可能直接跳过它。这属于过滤下推和 Segment 级裁剪,可以减少解码和 DataChunk 构造工作。
4. DataChunk 经过 Filter 时发生什么
这里需要区分两种路径:
- Filter 被下推到 Table Scan:扫描 Segment 时就筛选;
- 独立的
PhysicalFilter算子:Scan 已产生 DataChunk,Filter 再对整个 DataChunk 计算条件。
两种路径的目标相同:得到"通过条件的行位置",尽量避免复制所有列。
5. PhysicalFilter 的核心:SelectionVector
假设输入 DataChunk 有 2048 行、3 列:
text
Input DataChunk
cardinality = 2048
┌──────────────┬──────────────┬──────────────┐
│ id Vector │ age Vector │ city Vector │
│ 2048 行 │ 2048 行 │ 2048 行 │
└──────────────┴──────────────┴──────────────┘
条件是:
sql
WHERE age >= 30
表达式执行器会批量计算条件,并生成类似:
text
SelectionVector = [1, 4, 7, 10, ..., 2046]
result_count = 600
这表示:输入 chunk 中第 1、4、7、10......行通过过滤。
DuckDB 当前 PhysicalFilter 的关键路径是:2
cpp
idx_t result_count = executor.SelectExpression(input, sel);
if (result_count == input.size()) {
output.Reference(input);
} else if (result_count > 0) {
output.Slice(input, sel, result_count);
}
5.1 全部通过
text
result_count = 2048
输出直接引用输入:
text
output.Reference(input)
没有必要增加选择层。
5.2 部分通过
text
result_count = 600
输出执行:
text
output.Slice(input, selection, 600)
DataChunk::Slice 会对每一列 Vector 应用相同的 SelectionVector;如果输入本身已经是 Dictionary Vector,DuckDB 会合并选择关系,避免无限叠加 Dictionary 层。3
逻辑上,下游看到:
text
Filtered DataChunk
cardinality = 600
┌──────────────┬──────────────┬──────────────┐
│ id │ age │ city │
│ 600 个逻辑值 │ 600 个逻辑值 │ 600 个逻辑值 │
└──────────────┴──────────────┴──────────────┘
物理上通常更接近:
text
原始 id Vector ─┐
原始 age Vector ─┼─ 被输出 Vector 引用
原始 city Vector ─┘
共同的选择关系:sel = [1, 4, 7, 10, ...]
输出 cardinality:600
5.3 全部不通过
text
result_count = 0
本次不会产生有效输出行。pipeline 会继续向 Scan 请求下一批输入。
6. Filter 后究竟是不是"原来的 Chunk"
需要区分三个对象:
text
1. DataChunk 容器对象
2. DataChunk 内的 Vector 对象
3. Vector 引用的底层数据 Buffer
部分过滤时,通常是:
text
输入 DataChunk 对象 A
│
│ Filter 产生 sel
▼
输出 DataChunk 对象 B
│
├── Vector 引用 A 的底层数据
└── 通过 SelectionVector 改变逻辑行映射
因此最准确的说法是:
Filter 通常产生一个新的输出 DataChunk 视图;DataChunk 容器不是原对象,但底层列数据通常仍是原 Buffer,没有立即复制成新的连续数组。
它不是在原始 chunk 上保存一个简单的布尔数组:
text
false, true, false, true, ...
而是更像保存通过行的索引:
text
1, 3, 8, 12, ...
6.1 什么时候会真正复制或物化
后续发生以下操作时,数据可能被复制到新的连续存储:
- 写入 Hash Join 的 Build 存储;
- 写入 Sort 的排序缓冲;
- 写入聚合状态或临时结果;
- 算子要求 Flat Vector;
- 跨越需要持久化的数据生命周期边界;
- 输出到文件、客户端或序列化格式。
DataChunk::Copy 会把选中数据复制到 Flat Vector,而 DataChunk::Flatten 会将各列 Vector 转成 Flat 表示。3
7. Table Scan 内部的过滤下推
如果条件足够简单,DuckDB 可以将 Filter 推入扫描算子:
text
没有下推:
Segment → Scan 2048 行 → DataChunk → PhysicalFilter → 600 行
过滤下推:
Segment → Scan + Filter → 直接得到符合条件的行/Vector
ColumnSegment 提供:
cpp
Scan(...)
Select(..., SelectionVector ...)
Filter(..., SelectionVector ..., TableFilter ...)
这说明存储扫描路径本身也可以使用 SelectionVector,在扫描和解码阶段缩小结果。4
不过从逻辑模型看,仍然可以理解为:
text
Scan → Filter → 下游算子
区别只是过滤被融合进了 Scan,而不是作为单独的 PhysicalFilter 节点执行。
8. Morsel 和 DataChunk 的关系
这两个概念不能混为一谈:
text
表
└── 多个 morsel / 扫描任务范围 调度单位
└── 多个 DataChunk 算子处理单位
└── 多个 Vector 列数据
└── 底层 Buffer / 压缩表示
假设一个 morsel 对应约 8192 行扫描工作,而默认 DataChunk 最大 2048 行,则:
text
morsel M1
├── DataChunk M1-C1:2048 行
├── DataChunk M1-C2:2048 行
├── DataChunk M1-C3:2048 行
└── DataChunk M1-C4:2048 行
这里的 8192 只是示意。DuckDB 的实际任务划分会根据数据源、Row Group、线程数和算子特性变化。应当记住:
morsel 是"线程领取多少扫描工作";DataChunk 是"算子一次处理多少行"。
9. 两个表 Join 时,两边都有自己的 morsel
考虑下面的查询:
sql
SELECT
o.order_id,
o.amount,
c.name
FROM orders AS o
JOIN customers AS c
ON o.customer_id = c.id
WHERE c.active = TRUE
AND o.amount > 100;
假设优化器选择:
customers为 Build 侧;orders为 Probe 侧。
两个表各自拥有独立的扫描任务:
text
customers Build 侧
├── morsel C1
├── morsel C2
├── morsel C3
└── ...
orders Probe 侧
├── morsel O1
├── morsel O2
├── morsel O3
├── morsel O4
└── ...
但两个表的 morsel 不是同时进入同一个 Join 算子并"相遇"。Hash Join 被拆成两个有依赖关系的 pipeline:
flowchart LR subgraph BuildPipelinePipeline 1:Build CBcustomers morsels --> CSScan CS --> CFFilter: active = true CF --> HBHash Join Build Sink HB --> HTHash Table end HT -->|Finalize 完成后解除依赖| DEP同步 / Pipeline 依赖 subgraph ProbePipelinePipeline 2:Probe OBorders morsels --> OSScan OS --> OFFilter: amount \> 100 OF --> HPHash Join Probe HP --> OUTResult DataChunks end DEP --> HP
10. Build 侧:morsel 的 DataChunk 先 Filter,再进入 Hash Join Sink

10.1 三线程领取 Build morsel
text
Thread 1 领取 customers morsel C1
Thread 2 领取 customers morsel C2
Thread 3 领取 customers morsel C3
每个线程都沿同一条 Build pipeline 连续执行:
text
Scan customers
↓
产生 DataChunk
↓
Filter active = true
↓
产生 Filtered DataChunk 视图
↓
计算 join key:customers.id
↓
Hash Join Build Sink
10.2 一个线程内部的具体过程
假设 Thread 1 从 C1 中取得一个 2048 行 DataChunk:
text
customers Chunk C1-1:2048 行
↓ Filter active = true
SelectionVector = [0, 2, 5, 9, ...]
↓
Filtered Chunk:900 行
↓
join key Vector = id 列的 900 个逻辑值
payload = name 等 Build 侧列
↓
插入 Thread 1 的本地 Hash Table
当前 DuckDB Hash Join 的 Build Sink 会:
- 对右侧输入 chunk 计算 join key;
- 引用所需 payload 列;
- 将 join key 和 payload 写入线程本地 Hash Table。5
源码中的本地 Sink 状态明确持有一个 thread-local Hash Table。5
10.3 三线程 Build 示意
flowchart TB C1customers morsel C1 --> T1SThread 1: Scan T1S --> T1FFilter T1F --> L1Local Hash Table 1 C2customers morsel C2 --> T2SThread 2: Scan T2S --> T2FFilter T2F --> L2Local Hash Table 2 C3customers morsel C3 --> T3SThread 3: Scan T3S --> T3FFilter T3F --> L3Local Hash Table 3 L1 --> COMBCombine / 收集本地状态 L2 --> COMB L3 --> COMB COMB --> FINFinalize Hash Table
重要的是:
Build 侧不是先把所有 DataChunk 收集完,再进入 Hash Join;每个 Filtered DataChunk 一产生,就立即 Sink 到本地 Hash Table。
阻塞发生在 Build 与 Probe 之间,不是发生在 Scan/Filter 与 Build 之间。
11. Combine 和 Finalize:阻塞点真正在哪里
当某线程处理完自己领取的 Build 输入后,会刷新本地追加状态,并把本地 Hash Table 注册到全局状态。当前实现的 Combine 会收集各线程的本地 Hash Table。5
text
Thread 1 Local HT ─┐
Thread 2 Local HT ─┼── Combine ──► Global Build State
Thread 3 Local HT ─┘
接下来执行 Finalize:
- 统计全局数据规模;
- 初始化全局指针表或 bucket 相关结构;
- 建立可供 Probe 使用的链接;
- 可能按 radix partition 并行 Finalize;
- 如果数据量小或键严重倾斜,也可能选择单线程 Finalize。
当前源码中,分区场景的并行 Finalize 会让不同线程分别处理不同 partition。5
text
Finalize task 1 → Partition 0
Finalize task 2 → Partition 1
Finalize task 3 → Partition 2
只有 Finalize 完成,Probe pipeline 的依赖才满足。
因此正确顺序是:
text
Build morsel 持续产生 chunk
↓
每个 chunk:Scan → Filter → Sink
↓
所有 Build 输入被消费完
↓
所有 Local State 完成 Combine
↓
Finalize Hash Table
↓
Probe pipeline 可以开始
12. Probe 侧:另一组 morsel 的 DataChunk 先 Filter,再查询 Hash Table
Build 完成后,三个线程可以开始领取 orders 表的 Probe morsel:
text
Thread 1 领取 orders morsel O1
Thread 2 领取 orders morsel O2
Thread 3 领取 orders morsel O3
每个线程沿 Probe pipeline 执行:
text
Scan orders
↓
产生 DataChunk
↓
Filter amount > 100
↓
产生 Filtered DataChunk
↓
计算 customer_id join key
↓
Probe 已完成的 Hash Table
↓
输出 Join Result DataChunk
例如:
text
orders Chunk O1-1:2048 行
↓ Filter amount > 100
SelectionVector = [1, 3, 4, 10, ...]
↓
Filtered Chunk:1300 行
↓
用 customer_id 批量查询 Hash Table
↓
匹配 customers.id
↓
输出 order_id、amount、customer name
12.1 Probe 输入 Chunk 和输出 Chunk 不一定一一对应
一个输入 chunk 可能:
- 没有任何匹配,输出 0 行;
- 每行最多匹配一次,输出少于或等于输入行数;
- 遇到重复 key,一行匹配多行,输出可能超过一个标准 DataChunk;
- 因为输出超过 2048 行,需要分多次返回结果 chunk。
因此不能假设:
text
一个 Probe DataChunk = 一个 Result DataChunk
Join 算子可能为同一个 Probe 输入保留局部状态,分多批产生输出。
13. 三线程的完整时间线
下面把 Build、阻塞点和 Probe 放在同一条时间线上:
sequenceDiagram participant T1 as Thread 1 participant T2 as Thread 2 participant T3 as Thread 3 participant G as Global Hash Join State Note over T1,T3: Build Pipeline:customers T1->>T1: C1: Scan → Filter T1->>G: Sink 到 Local HT 1 T2->>T2: C2: Scan → Filter T2->>G: Sink 到 Local HT 2 T3->>T3: C3: Scan → Filter T3->>G: Sink 到 Local HT 3 T1->>T1: 继续领取 C4 T2->>T2: 继续领取 C5 T3->>T3: 继续领取 C6 T1->>G: Combine Local HT 1 T2->>G: Combine Local HT 2 T3->>G: Combine Local HT 3 Note over T1,T3: 阻塞点:Probe 依赖尚未满足 G->>G: Finalize 全局 Hash Table Note over T1,T3: Probe Pipeline:orders T1->>T1: O1: Scan → Filter T1->>G: Probe Hash Table G-->>T1: Result Chunk T2->>T2: O2: Scan → Filter T2->>G: Probe Hash Table G-->>T2: Result Chunk T3->>T3: O3: Scan → Filter T3->>G: Probe Hash Table G-->>T3: Result Chunk
实际调度并不要求固定"Thread 1 永远处理 morsel 1"。线程处理完一个任务后可以领取新的任务,因此更加准确的是:
text
共享任务队列:C1 C2 C3 C4 C5 C6 ...
↓ ↓ ↓
T1 T2 T3
某线程先完成后,再领取下一个未处理 morsel。
这有利于负载均衡:如果某个 morsel 过滤后数据少,线程会更快完成并领取新工作。
14. Build 侧和 Probe 侧的 Filter 是否使用同一个 SelectionVector
不是。
两个表拥有不同的 DataChunk,也有不同的过滤条件:
text
customers Chunk
Filter: active = true
SelectionVector C = [0, 2, 5, ...]
orders Chunk
Filter: amount > 100
SelectionVector O = [1, 3, 4, ...]
甚至同一个表的不同 DataChunk 也有自己的 SelectionVector:
text
customers chunk 1 → sel C1
customers chunk 2 → sel C2
customers chunk 3 → sel C3
SelectionVector 的索引只对当前输入 Vector/Chunk 的逻辑行映射有意义,不能跨 chunk 直接复用。
15. Filtered Chunk 进入 Hash Join Build 时会不会一直保持引用
Filter 输出通常可以继续以 Dictionary/Selection 形式进入 Hash Join 的 key 计算阶段,DuckDB 的向量操作会通过统一的 Vector 接口读取逻辑值。
但是 Build Hash Table 必须让数据跨越输入 chunk 的生命周期:
text
输入 chunk 处理完成后可能被复用
但 Hash Table 在整个 Probe 阶段都必须存在
因此 Build 阶段最终必须把需要长期保存的 join key 和 payload 写入 Hash Join 自己管理的存储结构。
可以理解为:
text
Scan Buffer
↓ 引用
Filtered Chunk 视图
↓ 读取选择后的逻辑值
Hash Table Build Storage ← 在这里形成长期保存的数据
所以:
Filter 通常不复制整批数据;Hash Join Build 为了持久保存 Build 侧内容,会将需要的数据写入自己的 Hash Table/tuple storage。
16. 完整数值示例
假设:
text
customers:12,000 行
orders:100,000 行
线程数:3
标准 DataChunk 最大:2048 行
查询:
sql
SELECT o.order_id, c.name
FROM orders o
JOIN customers c
ON o.customer_id = c.id
WHERE c.active
AND o.amount > 100;
16.1 Build customers
假设 customers 被划分为 3 个调度范围:
text
C1:约 4,000 行
C2:约 4,000 行
C3:约 4,000 行
每个范围产生约 2 个 DataChunk:
text
C1 → 2048 + 1952
C2 → 2048 + 1952
C3 → 2048 + 1952
假设 active = true 的比例是 40%:
text
12,000 行
↓ Filter
约 4,800 行进入 Build
三个线程分别构建 Local Hash Table:
text
Local HT 1:约 1,600 行
Local HT 2:约 1,600 行
Local HT 3:约 1,600 行
然后:
text
Combine → Finalize → 可 Probe 的 Hash Table
16.2 Probe orders
假设 orders 有多个 morsel,三个线程动态领取。每个 DataChunk 最多 2048 行。
如果 amount > 100 的通过率为 60%:
text
一个 2048 行 orders chunk
↓ Filter
约 1229 行
↓ Hash Join Probe
根据 customer_id 查询 Build Hash Table
如果其中 70% 能匹配 active customer:
text
约 1229 行 Probe 输入
↓ Join
约 860 行结果
这些结果进入 Result DataChunk,再送给 Projection、Aggregate、Order By 或最终输出等下游算子。
17. 一张总图
flowchart TB subgraph StorageAcustomers 存储层 ACS各列 ColumnSegments end subgraph StorageBorders 存储层 AOS各列 ColumnSegments end subgraph BuildBuild Pipeline:多线程 CMcustomers morsels CSCANScan Segment → DataChunks CFILTERFilter active=true\
SelectionVector LHT各线程 Local Hash Table COMBINECombine FINALIZEFinalize Global Hash Table end subgraph ProbeProbe Pipeline:多线程 OMorders morsels OSCANScan Segment → DataChunks OFILTERFilter amount\>100\
SelectionVector HPROBEHash Join Probe RESULTResult DataChunks end ACS --> CM --> CSCAN --> CFILTER --> LHT --> COMBINE --> FINALIZE AOS --> OM --> OSCAN --> OFILTER --> HPROBE --> RESULT FINALIZE -->|依赖完成后 Probe 才启动| HPROBE
18. 最容易混淆的几个问题
18.1 一个 DataChunk 是否只有一列?
不一定。DataChunk 可以有一列,也可以有很多列;每列是一个 Vector。它包含多少列取决于执行计划需要哪些列。
18.2 一个 Segment 是否等于一个 DataChunk?
不等于。Segment 是存储层的一列片段,DataChunk 是执行层的多列批次。
18.3 Segment 是否先完整解压,再组装 DataChunk?
不一定。存储层可以产生 Flat、Constant、Dictionary 等 Vector,压缩表示可能延续到执行阶段。
18.4 Filter 是否复制出一个新的连续 Chunk?
通常不立即复制。部分通过时,输出 chunk 使用 SelectionVector/Slice 引用原数据;需要物化时才复制。
18.5 两个表的 morsel 是否同时进入 Hash Join?
Hash Join 通常不是。Build 侧 morsel 先通过 Build pipeline 构建并 Finalize 哈希表,之后 Probe 侧 morsel 才进入 Probe pipeline。
18.6 遇到 Hash Join 阻塞算子,是不是先处理完数据再进入 Build?
不是。Build DataChunk 会持续进入 Hash Join Sink;等待发生在 Build 完成和 Probe 开始之间。
18.7 morsel 是否等于 DataChunk?
不等于。一个 morsel 通常产生多个 DataChunk。
19. 最终总结
可以用四句话记住整个过程:
- Segment 是存储层中一列的一段数据;扫描器将多列 Segment 的同一行号范围解码成多个 Vector,并组成 DataChunk。
- DataChunk 经过 Filter 时,DuckDB 通常生成 SelectionVector,并让输出 chunk 通过 Reference/Slice 引用原 Vector,而不是立即复制全部通过行。
- 两个 Join 输入表各自拥有扫描任务/morsel;Build 侧 morsel 产生的 chunk 先 Filter,再持续 Sink 到线程本地 Hash Table。
- 所有 Build 输入完成并经过 Combine/Finalize 后,Probe 侧 morsel 的 chunk 才开始 Scan、Filter 和 Hash Table Probe,最后输出结果 DataChunk。
最精炼的执行链如下:
text
Build 表:
ColumnSegments
→ Build morsels
→ Scan DataChunks
→ Filter / SelectionVector
→ Local Hash Tables
→ Combine
→ Finalize
Probe 表:
ColumnSegments
→ Probe morsels
→ Scan DataChunks
→ Filter / SelectionVector
→ Probe Finalized Hash Table
→ Result DataChunks
参考资料
本文基于 DuckDB 官方文档及主分支源码整理,源码实现可能随版本调整。
- DuckDB Execution Format:Vector、DataChunk、STANDARD_VECTOR_SIZE 与 Dictionary Vector
- DuckDB
physical_filter.cpp:SelectExpression、Reference 与 Slice - DuckDB
data_chunk.cpp:Reference、Slice、Copy 与 Flatten - DuckDB
column_segment.hpp:ColumnSegment 的 Scan、Select 与 Filter 接口 - DuckDB
physical_hash_join.cpp:Thread-local Hash Table、Sink、Combine 与并行 Finalize - DuckDB
physical_hash_join.hpp:Hash Join 的并行 Sink 能力