第2章:存储模型与文件组织
Paimon如何在文件系统上组织数据
Paimon通过三层设计组织数据:
- 分区(Partition) - 逻辑分割,按时间或业务维度划分
- 桶(Bucket) - 物理分割,确保同一主键的数据聚合
- 文件(File) - 实际数据存储,Parquet/ORC等格式
这三层形成了金字塔式的数据组织结构,层层递进,既保证了查询效率,又支持并行写入。
深入理解存储模型各组件
2.1 分区(Partition)与桶(Bucket)机制
什么(What):什么是分区和桶?
分区(Partition):将大表逻辑上分割成多个较小的部分
ini
表 /db/users/
├── dt=2024-01-01/ ← 分区:日期维度
│ └── ...
├── dt=2024-01-02/ ← 分区:日期维度
│ └── ...
└── dt=2024-01-03/ ← 分区:日期维度
└── ...
桶(Bucket):在同一分区内,进一步按主键或指定字段分割
ini
分区 /db/users/dt=2024-01-01/
├── bucket-0/ ← 桶:存储hash(user_id) % bucket_num == 0的数据
│ └── data-*.parquet
├── bucket-1/ ← 桶:存储hash(user_id) % bucket_num == 1的数据
│ └── data-*.parquet
└── bucket-2/ ← 桶:存储hash(user_id) % bucket_num == 2的数据
└── data-*.parquet
为什么(Why):为什么需要分区和桶?
| 目标 | 解决方案 | 好处 |
|---|---|---|
| 快速查询特定日期的数据 | 分区 | 不需要扫描整个表,直接定位到日期目录 |
| 避免同一主键散落各处 | 桶 | 同一user_id的所有版本都在同一桶中,便于合并/查询 |
| 支持并行写入 | 分桶 | 多个writer可以并发写到不同桶,互不干扰 |
| 减少小文件数量 | 分桶 | 将相关数据集中,减少文件碎片 |
怎样(How):Paimon如何实现分区和桶?
分区配置:
java
// 创建按日期分区的表
Schema schema = new Schema(
fields(...),
partitionKeys("dt", "hour"), // 按dt和hour分区
primaryKeys("user_id"),
...
);
// 物理结构
/warehouse/db/users/
└── dt=2024-01-01/hour=00/ ← 分区路径
├── bucket-0/
│ └── 20250112-1.parquet
├── bucket-1/
│ └── 20250112-1.parquet
└── bucket-2/
└── 20250112-1.parquet
桶配置:
java
// 固定桶模式 - 表结构定义时指定桶数
Schema schema = new Schema(
fields(...),
primaryKeys("user_id"),
options(
"bucket", "4" // 固定4个桶
)
);
// hash(user_id) % 4 决定数据进入哪个桶
// 动态桶模式 - 桶数动态调整
options(
"bucket", "-1" // -1表示动态桶
)
// 根据负载自动增加桶数
三种桶模式详解
1. 固定桶模式(Fixed Bucket)
scss
特点:
- 桶数量不变
- hash(user_id) % bucket_num 决定数据位置
- 写入性能稳定
工作流程:
INSERT (user_id=1, ...) → hash(1) % 4 = 1 → 写入bucket-1
INSERT (user_id=5, ...) → hash(5) % 4 = 1 → 写入bucket-1
INSERT (user_id=9, ...) → hash(9) % 4 = 1 → 写入bucket-1
缺点:
- 桶之间可能数据量不均衡
- 如果需要增加桶数,需要重新分布所有数据
2. 动态桶模式(Dynamic Bucket)
markdown
特点:
- 桶数量可以动态增加
- 初始采用固定桶,当负载过高自动分裂
工作流程:
初始状态:4个桶
bucket-0
bucket-1
bucket-2
bucket-3
当bucket-1负载过高:
bucket-0
bucket-1 → 分裂为 bucket-4, bucket-5
bucket-2
bucket-3
优点:
- 自动平衡负载
- 支持无限扩展
3. 无感知桶模式(Unaware Bucket)
markdown
特点:
- 追加表专用
- 不需要考虑主键分布
- 文件直接追加到分区
适用场景:
- 日志表、事件表(无需去重)
- 简单高效,无须计算hash
物理结构:
/warehouse/db/logs/
└── dt=2024-01-01/
└── 20250112-1.parquet
└── 20250112-2.parquet
└── 20250112-3.parquet
(直接在分区目录下,不分桶)
场景应用示例
场景1:用户维度表 - 需要实时更新用户属性
java
// 使用固定桶 + 主键表
Schema schema = new Schema(
fields(
field("user_id", INT, false),
field("name", VARCHAR, true),
field("age", INT, true),
field("dt", VARCHAR, true)
),
partitionKeys("dt"), // 按日期分区(方便日期查询)
primaryKeys("user_id"), // user_id是主键
options(
"bucket", "8" // 固定8个桶(假设日均100万用户)
)
);
// 桶的分配:
// user_id=1 → hash(1) % 8 = 1 → bucket-1
// user_id=2 → hash(2) % 8 = 2 → bucket-2
// ...
// user_id=8 → hash(8) % 8 = 0 → bucket-0
// user_id=9 → hash(9) % 8 = 1 → bucket-1
// 实时更新用户名
INSERT INTO users VALUES (1, 'Alice', 25, '2024-01-15');
// 更新:如果user_id=1已存在,则覆盖旧值(保留最新版本)
// 查询特定日期的用户
SELECT * FROM users WHERE dt='2024-01-15' AND user_id=100;
// 执行计划:
// 1. 定位分区:dt=2024-01-15/
// 2. 定位桶:hash(100) % 8 = 4 → 只扫描bucket-4
// 3. 快速找到user_id=100的最新记录
场景2:日志表 - 大量追加,无需去重
java
// 使用无感知桶 + 追加表
Schema schema = new Schema(
fields(
field("event_id", LONG, false),
field("user_id", INT, false),
field("event_type", VARCHAR, false),
field("timestamp", TIMESTAMP, false),
field("dt", VARCHAR, true)
),
partitionKeys("dt", "hour"), // 按日期和小时分区
primaryKeys(), // 无主键 = 追加表
options(
"bucket", "-1" // 动态/无感知桶
)
);
// Flink实时写入日志
DataStream<LogEvent> events = ...;
events.sinkTo(new PaimonSink(logTable));
// 物理文件结构:
// /logs/dt=2024-01-15/hour=10/
// ├── data-1.parquet (第1个Flink并行度)
// ├── data-2.parquet (第2个Flink并行度)
// ├── data-3.parquet (第3个Flink并行度)
// └── ...
// 离线分析:统计某小时的事件量
SELECT COUNT(*) FROM logs
WHERE dt='2024-01-15' AND hour='10';
// 执行计划:直接扫描hour=10分区的所有文件,快速统计
场景3:订单表 - 跨分区更新
java
// 固定桶 + 主键表 + 跨分区更新
Schema schema = new Schema(
fields(
field("order_id", LONG, false),
field("status", VARCHAR, false),
field("amount", DECIMAL, false),
field("dt", VARCHAR, true)
),
partitionKeys("dt"),
primaryKeys("order_id"),
options(
"bucket", "16",
"cross-partition-update", "true" // 支持跨分区更新
)
);
// 订单初始创建
INSERT INTO orders
VALUES (1001, 'PENDING', 99.99, '2024-01-15');
// 路由到:dt=2024-01-15/bucket-X/
// 订单状态更新(可能在不同日期)
UPDATE orders
SET status='SHIPPED', dt='2024-01-16'
WHERE order_id=1001;
// 旧记录删除:dt=2024-01-15/bucket-X/ 的order_id=1001
// 新记录插入:dt=2024-01-16/bucket-X/ 的order_id=1001
2.2 文件布局与目录结构
Paimon的目录树
ini
warehouse/
└── db_name/ # 数据库目录
├── table_name/ # 表目录
│ ├── schema/ # Schema管理目录
│ │ ├── schema-0.orc # 初始Schema版本
│ │ └── schema-1.orc # Schema演化版本
│ ├── manifest/ # 元数据目录
│ │ ├── manifest-1.orc # Manifest文件(包含数据文件列表)
│ │ ├── manifest-list-1.orc # Manifest List(包含所有manifest文件列表)
│ │ └── ...
│ ├── snapshot/ # 快照目录
│ │ ├── 1 # 快照ID=1 (JSON格式)
│ │ ├── 2 # 快照ID=2
│ │ └── ...
│ ├── changelog/ # 变更日志目录
│ │ └── ...
│ ├── index/ # 索引文件目录
│ │ └── ...
│ ├── stats/ # 统计信息目录
│ │ └── ...
│ │
│ └── pt=2024-01-01/ # 分区目录
│ ├── bucket-0/ # 桶目录
│ │ ├── data-1.parquet # 数据文件
│ │ ├── data-2.parquet
│ │ └── ...
│ ├── bucket-1/
│ │ ├── data-1.parquet
│ │ └── ...
│ └── bucket-2/
│ └── ...
│
└── ...其他表目录
文件命名规则
数据文件命名:
kotlin
data-{creation-time}-{index}.{file-format}
例如:
data-20250112142530-1.parquet
└─┬─┘ └──────┬──────┘ └┬┘ └────┬─────┘
│ 创建时间 索引 文件格式
└── 固定前缀
Manifest文件命名:
python
manifest-{manifest-id}.{format}
例如:
manifest-1.orc
manifest-2.orc
快照文件命名:
javascript
{snapshot-id} (无扩展名,JSON格式)
例如:
1
2
3
数据文件的两种组织模式
模式1:标准模式(Standard Layout)
bash
/pt=2024-01-01/bucket-0/
├── data-20250112142530-1.parquet
└── data-20250112142531-2.parquet
特点:
- 每个文件包含完整的行数据
- 适合主键表(需要合并多个版本)
模式2:BLOB模式(Blob Layout) - 用于大二进制字段
sql
/pt=2024-01-01/bucket-0/
├── data-20250112142530-1.parquet # 普通列(id, name等)
├── blob-20250112142530-1.blob # BLOB列(图片、视频等)
├── data-20250112142531-2.parquet
└── blob-20250112142531-2.blob
特点:
- BLOB数据单独存储
- 避免大文件扫描时的内存压力
- 适合包含媒体内容的表
2.3 Snapshot快照管理
什么是Snapshot?
Snapshot是Paimon的时间点视图,它记录了某一时刻表的完整状态。
核心概念:
diff
Snapshot = 元数据指针 + 时间戳 + 统计信息
一个Snapshot记录:
- 这一刻的所有数据文件
- 这一刻的Schema版本
- 这一刻的统计信息(行数、文件数等)
- 提交者和提交时间
Snapshot文件结构(JSON)
json
{
"version": 3, // Snapshot格式版本
"id": 5, // 快照ID(从1开始递增)
"schemaId": 0, // 使用的Schema版本
"baseManifestList": "manifest-list-5-0", // 基础清单列表
"deltaManifestList": "manifest-list-5-1", // 增量清单列表
"changelogManifestList": null, // 变更日志(仅主键表)
"commitUser": "flink-application", // 提交者
"commitIdentifier": 1234567890, // 提交标识符(用于去重)
"commitKind": "APPEND", // 提交类型(APPEND/OVERWRITE/...)
"timeMillis": 1673088000000, // 提交时间戳
"totalRecordCount": 1000000, // 总记录数
"deltaRecordCount": 5000, // 本次增加的记录数
"changelogRecordCount": 3000, // 变更日志记录数
"watermark": 1673087999999, // 水位线(流处理)
"statistics": "stats-5", // 统计信息文件
"indexManifest": "index-5" // 索引清单
}
Snapshot的生命周期
创建过程:
markdown
1. 写入阶段
用户写入1000条记录
↓
2. 预提交阶段(Prepare)
生成临时元数据文件
将新增文件信息写入Manifest
↓
3. 提交阶段(Commit)
检查冲突
创建ManifestList指向所有Manifest
原子写入Snapshot JSON文件 ← 提交完成!
↓
4. 快照完成
snapshot-5文件已写入
所有reader都可以读取这个快照
查询Snapshot的流程:
javascript
用户查询
↓
读取最新Snapshot JSON文件
↓
获得ManifestList路径
↓
读取ManifestList → 所有Manifest文件列表
↓
读取每个Manifest → 所有数据文件列表
↓
扫描数据文件
时间旅行查询(Time Travel)
Paimon支持查询历史快照的数据:
sql
-- 查询最新数据
SELECT * FROM users;
-- 查询特定快照的数据
SELECT * FROM users /*+ SNAPSHOT_ID(3) */;
-- 查询特定时间点的数据
SELECT * FROM users /*+ TIMESTAMP(1673088000000) */;
-- 使用Tag查询(命名快照)
SELECT * FROM users /*+ TAG(release-v1.0) */;
物理执行:
markdown
SELECT * FROM users /*+ SNAPSHOT_ID(3) */
1. 读取snapshot-3文件
2. 获得当时的ManifestList → manifest-list-3-0
3. 解析manifest-list-3-0 → [manifest-1, manifest-2, manifest-3]
4. 解析每个manifest → 所有数据文件
5. 扫描这些文件 → 得到历史快照的数据
多个快照的并存
bash
/snapshot/
├── 1 # 第一个提交
├── 2 # 第二个提交
├── 3 # 第三个提交(当前)
└── 4 # 第四个提交(当前最新)
特点:
- 多个快照可以共享相同的数据文件
- 仅Manifest和Snapshot元数据增长
- 旧快照的数据文件被新快照引用时不会删除
- 只有当所有引用快照都被清理时,文件才会真正删除
2.4 Manifest清单文件详解
什么是Manifest?
Manifest是数据文件的目录表,它列出了表中所有的数据文件及其元数据。
scss
┌─────────────────────────────────────┐
│ Snapshot(快照) │
│ 指向→ ManifestList │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ ManifestList(清单列表) │
│ 包含指向→ [manifest-1, manifest-2] │
└──────────────┬──────────────────────┘
│
┌──────────┴──────────┐
│ │
┌───▼──────────┐ ┌──────▼────────┐
│ Manifest-1 │ │ Manifest-2 │
│ (数据文件表) │ │ (数据文件表) │
│ ├─ file-1 │ │ ├─ file-4 │
│ ├─ file-2 │ │ ├─ file-5 │
│ └─ file-3 │ │ └─ file-6 │
└──────────────┘ └───────────────┘
Manifest的内容
Manifest是一个ORC或Parquet文件,包含数据文件的列表:
sql
╔════════════════════════════════════════════════════════════════╗
║ Manifest文件内容 ║
╠════════════════════════════════════════════════════════════════╣
║ 字段 │ 类型 │ 说明 ║
╠─────────────────┼─────────┼──────────────────────────────────┤
║ partition │ Row │ 分区值 {dt: '2024-01-01'} ║
║ bucket │ INT │ 桶号 (0, 1, 2, ...) ║
║ totalBuckets │ INT │ 总桶数 (用于分布式读取) ║
║ file_name │ STRING │ 文件名 data-xxx-1.parquet ║
║ file_size │ LONG │ 文件大小(字节) ║
║ row_count │ LONG │ 文件中的行数 ║
║ min_key │ Row │ 文件中主键的最小值 ║
║ max_key │ Row │ 文件中主键的最大值 ║
║ min_seq_num │ LONG │ 最小序列号(LSM版本号) ║
║ max_seq_num │ LONG │ 最大序列号 ║
║ schema_id │ LONG │ 该文件使用的Schema版本 ║
║ stats │ MAP │ 统计信息(min/max/null_count等) ║
║ file_format │ STRING │ 文件格式(parquet/orc/avro等) ║
║ kind │ INT │ 文件类型(ADD/DELETE/...) ║
╚════════════════════════════════════════════════════════════════╝
Manifest的作用
1. 文件索引 - 快速定位数据文件
sql
查询:SELECT * FROM users WHERE user_id = 100
执行步骤:
1. 读Snapshot获得Manifest清单
2. 扫描Manifest中的min_key和max_key
3. 过滤:如果min_key <= 100 <= max_key,则该文件可能包含目标数据
4. 只读取满足条件的文件
效果:
- 100个文件中,可能只需读取5-10个
- 大幅减少I/O,提升查询性能
2. 统计信息 - 优化执行计划
sql
Manifest中的stats包含:
- 每列的min值、max值
- 每列的null count
- 每列的distinct count
用途:
SELECT COUNT(*) FROM users;
→ 直接读Manifest中的row_count字段,无须扫描文件
3. 版本管理 - 支持时间旅行查询
每个Snapshot指向一个特定版本的Manifest
通过保留多个Manifest,支持查询历史版本的表
snapshot-1 → manifest-1 → {file-1, file-2}
snapshot-2 → manifest-2 → {file-1, file-2, file-3}
snapshot-3 → manifest-3 → {file-1, file-3, file-4}
Manifest的演化
Paimon会为每次提交创建新的Manifest:
kotlin
时刻1:插入100条记录
→ 创建manifest-1.orc
→ manifest-1包含 [data-file-1, data-file-2]
→ 创建snapshot-1指向manifest-1
时刻2:插入200条记录
→ 创建manifest-2.orc
→ manifest-2包含 [data-file-1, data-file-2, data-file-3]
→ 创建snapshot-2指向manifest-2
时刻3:压缩(删除过期文件)
→ 创建manifest-3.orc
→ manifest-3包含 [data-file-4, data-file-5] (压缩后)
→ 创建snapshot-3指向manifest-3
总结:存储模型的整体设计
核心设计原则
1. 分层存储 - 易于并行和查询
scss
分区层 → 按业务维度(时间/地域)分割
↓
桶层 → 按数据特征(hash)分割,实现数据聚合
↓
文件层 → 实际数据存储,支持多种格式
↓
元数据层 → Manifest/Snapshot管理,支持高效查询
2. 元数据驱动 - 快速定位文件
Snapshot → ManifestList → Manifest → DataFile
每一层都是索引关系,支持快速定位
3. 不可变设计 - 保证一致性
diff
文件一旦写入就不改变
每次修改都是新增文件
通过元数据层控制可见性
好处:
- 支持并发读写
- 支持时间旅行查询
- 支持快照隔离
性能优化技巧
1. 充分利用分区
sql
-- 好的查询:直接定位分区
SELECT * FROM orders WHERE dt='2024-01-15';
-- 不好的查询:需要扫描所有分区
SELECT * FROM orders WHERE year=2024;
2. 选择合适的桶数
java
// 桶数过少:竞争激烈,热点问题
options("bucket", "1") // ✗
// 桶数过多:文件数过多,元数据膨胀
options("bucket", "1000") // ✗
// 合理范围:10-100通常是好的选择
options("bucket", "32") // ✓
3. 定期清理
sql
-- 清理过期快照(30天前)
CALL remove_snapshots('db', 'table', '30 d');
-- 清理孤儿文件
CALL remove_orphan_files('db', 'table');
相关代码位置
- Snapshot定义:
paimon-api/src/main/java/org/apache/paimon/Snapshot.java - Manifest文件:
paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java - 路径工厂:
paimon-core/src/main/java/org/apache/paimon/utils/FileStorePathFactory.java - 快照管理:
paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java
思考题
-
为什么Paimon需要同时维护baseManifestList和deltaManifestList?
- 提示:考虑快速清理过期快照的需要
-
如何理解Snapshot是"原子"的?
- 提示:多个reader同时读Snapshot时的一致性
-
如果表有1000天的数据,且每天生成1个快照,会有什么问题?
- 提示:考虑元数据管理的成本
-
分区和桶的区别是什么?为什么不能只用其中一个?
- 提示:考虑查询效率和写入吞吐