DuckDB 存储层到执行层: Segment、DataChunk、Filter 与 Morsel-Driven Hash Join 的完整执行过程

本文以 DuckDB 的向量化执行模型为背景,详细解释三个问题:

  1. 存储层中的 ColumnSegment 如何被扫描为执行层的 DataChunk
  2. DataChunk 经过 Filter 时,是复制数据还是使用选择向量;
  3. 两个表各自的 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_SIZE2048 行,但最后一个 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 时发生什么

这里需要区分两种路径:

  1. Filter 被下推到 Table Scan:扫描 Segment 时就筛选;
  2. 独立的 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 会:

  1. 对右侧输入 chunk 计算 join key;
  2. 引用所需 payload 列;
  3. 将 join key 和 payload 写入线程本地 Hash Table。5

源码中的本地 Sink 状态明确持有一个 thread-local Hash Table5

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. 最终总结

可以用四句话记住整个过程:

  1. Segment 是存储层中一列的一段数据;扫描器将多列 Segment 的同一行号范围解码成多个 Vector,并组成 DataChunk。
  2. DataChunk 经过 Filter 时,DuckDB 通常生成 SelectionVector,并让输出 chunk 通过 Reference/Slice 引用原 Vector,而不是立即复制全部通过行。
  3. 两个 Join 输入表各自拥有扫描任务/morsel;Build 侧 morsel 产生的 chunk 先 Filter,再持续 Sink 到线程本地 Hash Table。
  4. 所有 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 官方文档及主分支源码整理,源码实现可能随版本调整。

  1. DuckDB Execution Format:Vector、DataChunk、STANDARD_VECTOR_SIZE 与 Dictionary Vector
  2. DuckDB physical_filter.cpp:SelectExpression、Reference 与 Slice
  3. DuckDB data_chunk.cpp:Reference、Slice、Copy 与 Flatten
  4. DuckDB column_segment.hpp:ColumnSegment 的 Scan、Select 与 Filter 接口
  5. DuckDB physical_hash_join.cpp:Thread-local Hash Table、Sink、Combine 与并行 Finalize
  6. DuckDB physical_hash_join.hpp:Hash Join 的并行 Sink 能力