流式数据湖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. 分区和桶的区别是什么?为什么不能只用其中一个?

    • 提示:考虑查询效率和写入吞吐
相关推荐
老蒋新思维1 天前
创客匠人启示:破解知识交付的“认知摩擦”——IP、AI与数据的三角解耦模型
大数据·人工智能·网络协议·tcp/ip·重构·创客匠人·知识变现
爱埋珊瑚海~~1 天前
基于MediaCrawler爬取热点视频
大数据·python
工程师丶佛爷1 天前
从零到一MCP集成:让模型实现从“想法”到“实践”的跃迁
大数据·人工智能·python
2021_fc1 天前
Flink笔记
大数据·笔记·flink
Light601 天前
数据要素与数据知识产权交易中心建设专项方案——以领码 SPARK 融合平台为技术底座,构建可评估、可验证、可交易、可监管的数据要素工程体系
大数据·分布式·spark
zyxzyx491 天前
AI 实战:从零搭建轻量型文本分类系统
大数据·人工智能·分类
五阿哥永琪1 天前
SQL中的函数--开窗函数
大数据·数据库·sql
程序员小羊!1 天前
数仓数据基线,在不借助平台下要怎么做?
大数据·数据仓库
火山引擎开发者社区1 天前
两大模型发布!豆包大模型日均使用量突破 50 万亿 Tokens
大数据·人工智能
Hello.Reader1 天前
Flink SQL 的 UNLOAD MODULE 模块卸载、会话隔离与常见坑
大数据·sql·flink