流式数据湖Paimon探秘之旅 (二) 存储模型与文件组织

第2章:存储模型与文件组织

Paimon如何在文件系统上组织数据

Paimon通过三层设计组织数据:

  1. 分区(Partition) - 逻辑分割,按时间或业务维度划分
  2. 桶(Bucket) - 物理分割,确保同一主键的数据聚合
  3. 文件(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

思考题

  1. 为什么Paimon需要同时维护baseManifestList和deltaManifestList?

    • 提示:考虑快速清理过期快照的需要
  2. 如何理解Snapshot是"原子"的?

    • 提示:多个reader同时读Snapshot时的一致性
  3. 如果表有1000天的数据,且每天生成1个快照,会有什么问题?

    • 提示:考虑元数据管理的成本
  4. 分区和桶的区别是什么?为什么不能只用其中一个?

    • 提示:考虑查询效率和写入吞吐
相关推荐
语落心生37 分钟前
流式数据湖Paimon探秘之旅 (七) 读取流程全解析
大数据
n***786840 分钟前
PostgreSQL 中进行数据导入和导出
大数据·数据库·postgresql
语落心生40 分钟前
流式数据湖Paimon探秘之旅 (四) FileStore存储引擎核心
大数据
语落心生1 小时前
流式数据湖Paimon探秘之旅 (三) Catalog体系深度解析
大数据
语落心生1 小时前
流式数据湖Paimon探秘之旅 (六) 提交流程与事务保证
大数据
梦里不知身是客111 小时前
容量调度器
大数据
跨境海外仓小秋1 小时前
仓库如何实现自动汇总订单波次?TOPWMS波次规则助力海外仓拣货效率翻倍
大数据
民乐团扒谱机1 小时前
【微实验】携程评论C#爬取实战:突破JavaScript动态加载与反爬虫机制
大数据·开发语言·javascript·爬虫·c#
涤生大数据1 小时前
Spark分桶表实战:如何用分桶减少 40%+ 计算时间
大数据·sql·spark·分桶表·大数据校招·大数据八股