Duckdb存储结构
DuckDB 存储结构:从逻辑表到磁盘 Block
1. 核心结论
DuckDB 原生表的主线只有一条:
Table
→ Row Group
→ ColumnData
→ Column Segment
→ DuckDB Block
→ Buffer Manager
→ Vector / DataChunk
表按行划分为 Row Group;Row Group 内按列组织;每列拆成可独立压缩的 Column Segment;Segment 映射到固定大小的 DuckDB Block;查询时由 Buffer Manager 把 Block 读入内存并解码成 Vector。
这里最重要的两个边界是:
- Column Segment:逻辑压缩与解码单元。
- DuckDB Block:物理存储、I/O 和缓存单元。



2. 示例表
全文使用同一张表说明:
CREATE TABLE events (
tenant_id INTEGER,
ts TIMESTAMP,
payload VARCHAR
);
3. DuckDB 原生存储层
DuckDB 存储层可以用一句话概括:
表先按行切成 Row Group;每个 Row Group 内按列组织成 ColumnData;每列再切成若干 Column Segment;Segment 压缩后放进数据库文件的 Block;Block 由 Buffer Manager 读入内存。
Table
└── Row Group
├── ColumnData A
│ ├── Column Segment A0
│ └── Column Segment A1
└── ColumnData B
├── Column Segment B0
├── Column Segment B1
└── Column Segment B2
↓
Block ID + Block Offset
↓
.duckdb 数据库文件
↓
Buffer Manager
Parquet:
逻辑表(Table)
└── 映射为 Parquet File
└── Row Group
├── Column Chunk A
│ ├── Dictionary Page(可选)
│ ├── Data Page A0
│ └── Data Page A1
└── Column Chunk B
├── Dictionary Page(可选)
├── Data Page B0
├── Data Page B1
└── Data Page B2
↓
文件偏移(file offsets)
↓
.parquet 文件
↓
Reader / 文件系统 / OS Page Cache
需要特别注意:DuckDB 原生存储中通常不把物理单位称为传统数据库的“Page”,它的核心物理 I/O 单位叫 Block。如果你说的 page 是操作系统的 4 KiB 内存页,那么一个 DuckDB Block 通常会覆盖多个 OS page。在常见的 4 KiB OS Page 环境中,DuckDB 的 Block 边界是对齐的;但这不是对任意操作系统 Page 大小都成立的通用保证。
但parquet它也可能:
一个 Parquet Page 跨多个 OS Page;
一个 OS Page 同时包含两个相邻 Parquet Page 的字节;
Parquet Page 的开始位置不和 OS Page 对齐。
因为 Parquet Page 是可变长度的文件格式对象,而不是固定大小的物理 I/O 页。Parquet 的目标是紧凑存储和跨系统交换,因此 Page 通常在 Column Chunk 中连续排列,不按 4 KiB 等操作系统页面边界补齐。
Parquet 格式不要求 Page 与操作系统页面对齐。
3.1 Row Group:一批连续行
Row Group 是表的水平分区。例如:
events
├── Row Group 0:行 0 ~ 122879
├── Row Group 1:行 122880 ~ 245759
└── ...
它的作用是:
- 把大表分成可管理的行范围;
- 保存统计信息,用于跳过不可能命中的数据;
- 提供原生表并行扫描的重要边界;
- 为列压缩提供组织范围。
Row Group 不是行存。 它只规定一批行号;内部仍然按列存储。
3.2 ColumnData:Row Group 中的一列
在一个 Row Group 内,每列对应一个 ColumnData:
Row Group 0
├── tenant_id ColumnData
├── ts ColumnData
└── payload ColumnData
ColumnData 负责管理该列的:
- Segment 集合;
- 扫描位置;
Scan / Select / Skip;- 统计信息和更新状态。
3.3 Column Segment:一列中的连续压缩片段
一列可以包含多个 Segment:
payload ColumnData
├── Segment P0
├── Segment P1
└── Segment P2
一个 Segment 通常包含:
start / count
compression
statistics
block_id
block_offset
segment_size
Segment 的行数并不固定。它取决于数据类型、数据宽度、压缩率和 Block 可用空间。因此宽字符串列通常比整数列产生更多 Segment。
4. Segment 与 Block 的对应关系
4.1 多个小 Segment 可以共享一个 Block
Block 42
┌──────────────────────────────┐
│ Segment T0 offset = 0 │
├──────────────────────────────┤
│ Segment T1 offset = 55 KB │
├──────────────────────────────┤
│ Segment TS0 offset = 105KB │
├──────────────────────────────┤
│ 剩余空间 │
└──────────────────────────────┘
定位依靠:
block_id + block_offset
因此:
- 相同
block_id、不同block_offset:多个 Segment 共享一个 Block; - 大 Segment 可能接近独占一个 Block;
- Constant Segment 可能无需普通数据 Block;
- 长字符串或复杂类型可能另外引用溢出或子存储。
4.2 读取粒度与解码粒度不同
假设查询只需要 Block 42 中的 Segment T1:
逻辑解码:只解码 Segment T1
物理读取:通常加载整个 Block 42
因此:
DuckDB 可以只解码需要的 Segment,但当 Block 尚未在内存中时,物理上通常仍需读取该 Segment 所在的完整 Block。
这也是优化数据搬运时必须明确的边界。
5. Block 与 Buffer Manager
DuckDB Block 是 .duckdb 文件内部的固定大小物理单元,常见默认分配大小约为 256 KiB。
.duckdb 文件
├── 文件头 / 数据库元数据
├── Block 0
├── Block 1
├── Block 2
└── ...
Buffer Manager 负责:
- 把 Block 从文件读入内存;
- 缓存和淘汰 Block;
- Pin 当前正在使用的 Block;
- 执行软件预取;
- 协调数据库页和查询内存。
不要混淆:
| 概念 | 含义 |
|---|---|
| DuckDB Block | DuckDB 自己的物理 I/O 与缓存单元 |
| OS Page | 操作系统内存页,常见为 4 KiB |
| DataChunk | 查询执行中的内存批次,不是磁盘块 |
一个 256 KiB DuckDB Block 在常见 4 KiB 页系统上覆盖约 64 个 OS Page,但 Segment 在 Block 内未必按 OS Page 对齐。
6. 一次查询如何读取数据
查询:
SELECT payload
FROM events
WHERE tenant_id = 42;
读取路径:
关键点:
- 各列拥有独立扫描状态;
- 过滤列可以先读取;
- 没有幸存行时,载荷列可以
Skip; - 有幸存行时,再对载荷列执行
Select或Scan; - 但物理读取的基本单位仍然是 Block。
DuckDB 原生存储与 Parquet 的区别
7. 两套层级
DuckDB 原生表
Table
→ Row Group
→ ColumnData
→ Column Segment
→ DuckDB Block
→ Buffer Manager
Parquet 文件
File
→ Row Group
→ Column Chunk
→ Dictionary Page / Data Page
→ 文件字节区间
→ Reader / OS Cache
8. 概念对应
| DuckDB | Parquet | 说明 |
|---|---|---|
| Table | Parquet File 或文件集合 | 顶层数据对象,近似对应 |
| Row Group | Row Group | 都表示一批行,最接近 |
| ColumnData | Column Chunk | 都表示某 Row Group 中的一列 |
| Column Segment | Page | 都是细粒度列数据单元,但不完全等价 |
| DuckDB Block | 无直接对应物 | Parquet 格式内部没有固定 Block 层 |
| Buffer Manager | Reader + OS/对象存储缓存 | 属于读取实现,而非 Parquet 格式本身 |
最重要的一点是:
Parquet Page 不是 DuckDB Block。
Parquet 的 Page 是文件中的编码和压缩字节区间;它最终落在哪些文件系统块或 OS Page 上,由底层存储系统决定。
9. 压缩方式的区别
DuckDB:Segment 自适应压缩
Column Segment
→ Analyze 数据分布
→ 选择 Compression Function
→ Compress
→ 写入 DuckDB Block
特点:
- 每个 Segment 可以选择不同算法;
- 偏向快速解码和查询执行;
- 与固定大小 Block 和 Buffer Manager 深度结合;
- 读取时支持
Scan / Select / Skip。
示例列可能采用:
| 列 | 可能的编码 |
|---|---|
tenant_id |
Bit Packing、FOR、RLE |
ts |
FOR、Bit Packing |
payload |
Dictionary、FSST、Zstd |
Parquet:Page Encoding + Compression Codec
Column Chunk
→ 切分 Page
→ Page Encoding
→ 通用压缩 Codec
→ 顺序写入文件
特点:
- Page 是主要编码与压缩边界;
- Encoding 和通用 Codec 是两层;
- 强调跨系统兼容、批量扫描和文件可移植性;
- Footer 记录 Row Group 与 Column Chunk 的位置;
- 可选 Page Index 支持更细粒度跳过。
10. 核心差异表
| 维度 | DuckDB 原生存储 | Parquet |
|---|---|---|
| 定位 | 数据库内部格式 | 通用列式文件格式 |
| 核心层级 | Row Group → ColumnData → Segment → Block | Row Group → Column Chunk → Page |
| 压缩单元 | Column Segment | Page |
| 物理 I/O 单元 | 固定大小 DuckDB Block | 由文件系统、对象存储和读取器决定 |
| 元数据入口 | 数据库内部元数据 | 文件尾 Footer |
| 读取管理 | Buffer Manager | Parquet Reader + 底层缓存 |
| 主要目标 | 查询执行、更新、空间复用 | 交换、归档、共享、批量扫描 |
11. 最终心智模型
【DuckDB】
一批行:Row Group
一列:ColumnData
一段压缩数据:Column Segment
物理读写:Block
内存管理:Buffer Manager
执行输出:Vector / DataChunk
【Parquet】
一批行:Row Group
一列:Column Chunk
一段编码压缩数据:Page
定位入口:Footer
物理读写:由读取器和底层文件系统决定
DuckDB 是数据库内部存储:强调 Segment、固定 Block 和 Buffer Manager。Parquet 是交换型列式文件:强调 Column Chunk、Page 和 Footer。
未经作者同意请勿转载
本文来自博客园作者:aixueforever,原文链接:https://www.cnblogs.com/aslanvon/p/21648465

浙公网安备 33010602011771号