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

284 阅读15分钟

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

导言

如果说第1章告诉你 Paimon "是什么",那么第2章就要说清楚它"怎么存"。Paimon 采用了分层的元数据管理体系(Snapshot → ManifestList → Manifest → DataFile)以及精心设计的文件布局(分区 → 桶 → LSM 层级)。这套设计保证了:

  • 原子性:多个文件的更新可以原子提交
  • 快照隔离:读取操作总是看到一致的版本
  • 高效查询:元数据层级设计使得文件查找极快
  • 可扩展性:支持数百亿级别的文件数

本章将深入讲解这套存储模型的核心设计。


2.1 分区(Partition)与桶(Bucket)机制

2.1.1 为什么需要分区和桶?

在处理超大规模数据时,如果所有数据都存储在一个目录下,会遇到以下问题:

问题1:目录膨胀
├─ 10 亿条记录
├─ 1000 个文件
└─ 单个 ls 命令需要几分钟才能列出所有文件

问题2:查询效率低
├─ 需要读取所有 1000 个文件
├─ 即使只查询 2023-01-01 的数据
└─ 无法有效过滤

问题3:并发写入冲突
├─ 多个 Writer 竞争写同一个文件
├─ 必须加锁,顺序化处理
└─ 写入吞吐量受限

解决方案:分区 + 桶

warehouse/db/table/
├── pt=2023-01-01/          # 分区(按日期)
│   ├── bucket-0/           # 桶(按哈希值)
│   │   ├── data-xxx-0.parquet
│   │   ├── data-xxx-1.parquet
│   │   └── ...
│   ├── bucket-1/
│   │   ├── data-yyy-0.parquet
│   │   └── ...
│   └── bucket-9/
├── pt=2023-01-02/
│   ├── bucket-0/
│   │   └── ...
│   └── ...

2.1.2 分区设计详解

什么是分区?

分区是一种逻辑分组机制,相同分区值的数据物理存储在不同的目录中。

分区的作用:

作用说明例子
分区裁剪扫描时可以跳过不相关的分区查询 WHERE pt='2023-01-01' 时,不读 01-02 目录
分区隔离不同分区的数据互不影响可以独立删除过期分区
分布式扩展多个 Writer 可以并行写入不同分区并行写 10 个分区,无冲突
数据组织按业务维度组织数据按日期、地区、部门等

最佳实践:

// 场景 1:日志系统(日常数据量很大)
CREATE TABLE logs (
    log_id BIGINT,
    content STRING,
    log_time TIMESTAMP,
    
    PRIMARY KEY (log_id)
) PARTITIONED BY (dt STRING)  // 按日期分区

配置参数:
- 分区数量:一般选择 1-10 个分区值(每个分区对应一个日期)
- 分区命名:自动生成 dt=2023-01-01 格式的目录

// 场景 2:用户维度表(跨国业务)
CREATE TABLE users_dim (
    user_id BIGINT,
    country STRING,
    name STRING,
    
    PRIMARY KEY (user_id)
) PARTITIONED BY (country STRING)  // 按国家分区

配置参数:
- 分区数量:取决于国家数量(通常 20-50)
- 分区策略:精确匹配或范围分区

// 场景 3:不分区表
CREATE TABLE raw_data (
    data STRING,
    
    PRIMARY KEY (id)
)  // 不指定 PARTITIONED BY

注意:不分区表会严重影响性能,仅在数据量小(< 1GB)时使用

2.1.3 桶(Bucket)设计详解

什么是桶?

桶是一种哈希分组机制,根据主键的哈希值将数据分散到不同的桶中。

桶与分区的区别:

特性分区
使用场景按时间、地理等业务维度按主键值哈希分散
作用快速过滤(能跳过分区)并发写入(降低冲突)
数量通常 1-100通常 1-1000
生命周期可以删除整个分区分散在多个分区内
示例日期、地区、部门用户 ID、订单 ID

桶的实现原理:

主键值:user_id = 12345
        ↓
    hash(12345) % bucket_num
        ↓
    12345 % 10 = 5
        ↓
    放入 bucket-5/ 目录中

如果有 user_id = 67895,hash 后也是 % 10 = 5,
那么 user_id 12345 和 67895 会存储在同一个桶中

最佳实践:

// 场景 1:高并发写入(需要多 Writer)
CREATE TABLE orders (
    order_id BIGINT,
    user_id BIGINT,
    amount DECIMAL(10,2),
    
    PRIMARY KEY (order_id)
) PARTITIONED BY (ds STRING)
WITH (
    'bucket' = '100'  // 100 个桶
);

选择理由:
- 100 个 Writer 可以并行写,无锁竞争
- 单个桶的数据量相对平衡
- 查询时需要扫描 100 个桶(加速)

// 场景 2:单 Writer 写入(不需要桶)
CREATE TABLE users_dim (
    user_id BIGINT,
    name STRING,
    
    PRIMARY KEY (user_id)
) PARTITIONED BY (ds STRING)
WITH (
    'bucket' = '1'  // 1 个桶
);

选择理由:
- 只有一个 Writer,无竞争
- 1 个桶比 100 个桶快(减少文件数)

// 场景 3:追加表(无需桶)
CREATE TABLE logs (
    log_id BIGINT,
    content STRING
) PARTITIONED BY (dt STRING)
WITH (
    'bucket' = '-1'  // -1 表示无桶
);

选择理由:
- 追加表无主键约束,无法分桶
- 直接写入分区目录,最简单

动态桶(Dynamic Bucket):

动态桶是 Paimon 的创新特性,支持在表创建后自动调整桶数。

WITH (
    'dynamic-bucket.target-row-num' = '10000000'  // 每个桶的目标行数
)
// Paimon 会自动计算:桶数 = 总行数 / 10000000
// 表大时自动增加桶数,无需手动调整

2.2 文件布局与目录结构

2.2.1 完整的目录树示例

warehouse/
└── mydb/
    └── users_table/
        ├── schema/                      # Schema 管理目录
        │   ├── schema-1                 # Schema 版本 1
        │   ├── schema-2                 # Schema 版本 2
        │   └── ...
        │
        ├── snapshot/                    # 快照目录
        │   ├── snapshot-1               # 快照 1 的 JSON
        │   ├── snapshot-2
        │   ├── snapshot-3
        │   └── ...
        │
        ├── manifest/                    # 元数据目录
        │   ├── manifest-list-{uuid}-0   # ManifestList 0(引用多个 Manifest)
        │   ├── manifest-{uuid}-0        # Manifest 0(引用多个 DataFile)
        │   ├── manifest-{uuid}-1
        │   └── ...
        │
        ├── changelog/                   # 变更日志目录(仅主键表)
        │   ├── changelog-data/
        │   └── changelog-meta/
        │
        ├── index/                       # 索引文件目录
        │   ├── index-{uuid}-0
        │   └── ...
        │
        ├── statistics/                  # 统计信息目录
        │   ├── stat-{uuid}-0
        │   └── ...
        │
        ├── pt=2023-01-01/               # 分区 1(按日期)
        │   ├── bucket-0/                # 桶 0
        │   │   ├── data-{uuid}-0.parquet
        │   │   ├── data-{uuid}-1.parquet
        │   │   └── ...
        │   ├── bucket-1/
        │   │   ├── data-{uuid}-0.parquet
        │   │   └── ...
        │   └── bucket-9/
        │
        ├── pt=2023-01-02/               # 分区 2
        │   ├── bucket-0/
        │   │   └── ...
        │   └── ...
        │
        └── ... 更多分区

2.2.2 关键路径说明

路径说明访问频率
snapshot/快照文件夹(JSON 格式)每次查询/写入时访问最新快照
manifest/Manifest 文件夹(元数据)每次查询时读取(通常缓存)
schema/Schema 文件夹表结构变更时访问
pt=*/bucket-*/实际数据文件夹查询时读取数据
changelog/变更日志文件夹CDC 消费时访问
index/索引文件夹查询加速(可选)

2.2.3 数据文件命名规则

数据文件格式:{prefix}-{uuid}-{counter}.{format}[.{compression}]

例如:data-a1b2c3d4-0.parquet.zstd

字段说明:
- prefix:data(数据文件)或 changelog(变更日志)
- uuid:进程级唯一标识符(防止重名)
- counter:文件计数器(同一个 uuid 可能生成多个文件)
- format:文件格式(parquet/orc/avro)
- compression:压缩算法(zstd/snappy/gzip等,可选)

2.3 Snapshot 快照管理

2.3.1 什么是 Snapshot?

Snapshot(快照) 是表在某一时刻的完整版本记录,包含:

{
  "version": 2,           // Snapshot 版本(Paimon 内部版本)
  "id": 1,                // 快照ID(从 1 开始递增)
  "schemaId": 1,          // 使用的 Schema 版本
  "baseManifestList": "manifest-list-abc-0",     // 基础Manifest清单
  "deltaManifestList": "manifest-list-abc-1",    // 增量Manifest清单
  "changelogManifestList": null,                 // 变更日志清单(可选)
  "commitUser": "user1",
  "commitIdentifier": 1,
  "commitMillis": 1640000000000,
  "watermark": -9223372036854775808,
  "indexManifest": null,
  "properties": {}
}

快照 vs 版本的区别:

  • 快照(Snapshot):逻辑概念,代表某一时刻的完整数据集
  • 版本(Version):物理概念,代表元数据的版本号

2.3.2 快照链的形成

时间轴 ─────────────────────────────────────────────────>

Snapshot-1          Snapshot-2          Snapshot-3
   ↓                   ↓                   ↓
 [数据A]           [数据A、B]           [数据A、B、C]
   ↓                   ↓                   ↓
写入 100A  写入 50B          写入 30 条 C
提交                提交                提交
   ↓                   ↓                   ↓
manifest-1         manifest-2          manifest-3
   ↓                   ↓                   ↓
manifest-list-1    manifest-list-2    manifest-list-3

快照链:123 → ...

时间旅行查询:
- SELECT * FROM table AS OF SNAPSHOT 1  // 只有数据A
- SELECT * FROM table AS OF SNAPSHOT 2  // 有数据AB
- SELECT * FROM table AS OF SNAPSHOT 3  // 有数据AB、C

2.3.3 BaseManifestList 与 DeltaManifestList

Paimon 使用了两层 ManifestList 结构来优化 Manifest 管理:

Snapshot-1(初始)
├── BaseManifestList = "manifest-list-1"
│   └── 引用 10 个 Manifest 文件(每个约 10MB)
└── DeltaManifestList = null

Snapshot-2(增量提交)
├── BaseManifestList = "manifest-list-1"(不变)
│   └── 引用 10 个 Manifest(每个约 10MB)
└── DeltaManifestList = "manifest-list-2"
    └── 引用 1 个 Manifest(约 10MB)

结果:
- 不需要重写 10 个 Manifest,只新增 1 个
- ManifestList 文件相对较小,查询快

Snapshot-3
├── BaseManifestList = "manifest-list-3"
│   └── 引用 11 个 Manifest(合并后的)
└── DeltaManifestList = null

这时会进行一次合并:
- 将旧的 BaseManifestList + DeltaManifestList 合并成新的 BaseManifestList
- 清理掉旧的两层结构

合并触发条件:

配置参数:
- manifest-merge-min-count: 30    // DeltaManifestList 中的 Manifest 数超过 30
- manifest-target-size: 8MB       // BaseManifestList + DeltaManifestList 大小超过 8MB

// 当满足任一条件时,触发合并

2.4 Manifest 清单文件详解

2.4.1 Manifest 的结构

Manifest 文件包含对数据文件的引用统计信息

Manifest 文件结构:

manifest-abc-0(Avro 格式)
├── Entry 1:
   ├── Kind: ADD(文件新增)
   ├── Partition: pt=2023-01-01
   ├── Bucket: 0
   ├── File:
      ├── FileName: data-xxx-0.parquet
      ├── FileSize: 104857600 (100MB)
      ├── RowCount: 1000000
      ├── SchemaId: 1
      ├── MinKey: [user_id=1, ...]
      ├── MaxKey: [user_id=1000000, ...]
      ├── KeyStats: {...}
      └── ValueStats: {...}
   └── Level: 0(LSM 层级)

├── Entry 2:
   ├── Kind: ADD
   └── ... 另一个文件的信息

└── ... 更多 Entry

文件元数据:
├── NumAddedFiles: 2
├── NumDeletedFiles: 0
├── PartitionStats: {...}
└── LevelRange: 0 ~ 5

2.4.2 ManifestEntry 的四种操作类型

Kind = ADD(文件新增)
- 表示新写入的数据文件
- 查询时必须读取这个文件
- 示例:新写入的 data-xxx-0.parquet

Kind = DELETE(文件删除)
- 表示要删除某个文件
- 查询时要跳过这个文件
- 不是物理删除,而是逻辑标记删除
- 示例:过期数据 data-old-1.parquet 被标记删除

Kind = UPDATE(文件更新)[LSM 特有]
- 表示文件被更新(如 Compaction)
- 新文件替代旧文件
- 示例:L0 的 2 个文件合并为 L1 的 1 个文件

Kind = MODIFY(文件修改)[Merge Engine]
- 表示文件经过合并引擎处理
- 示例:主键表的去重、聚合等

合并规则示例:
假设查询所有 Entry,需要:

1. 遍历所有 Manifest Entry(按顺序)
2. 维护一个 Set<FileIdentifier>
3. 对每个 Entry:
   - 如果 Kind = ADD,加入 Set
   - 如果 Kind = DELETE,从 Set 删除
   - 最后 Set 中的文件就是真实存在的文件

代码实现:
Map<Identifier, ManifestEntry> map = new LinkedHashMap<>();
for (ManifestEntry entry : manifestEntries) {
    if (entry.kind() == ADD) {
        map.put(getIdentifier(entry), entry);
    } else if (entry.kind() == DELETE) {
        map.remove(getIdentifier(entry));
    }
}
Collection<ManifestEntry> result = map.values();

2.4.3 ManifestFileMeta(Manifest 的元数据)

每个 ManifestList 文件中存储的不是 Entry,而是 ManifestFileMeta。

ManifestFileMeta 的内容:

{
    "fileName": "manifest-abc-0",           // Manifest 文件名
    "fileSize": 1048576,                   // 文件大小(1MB)
    "numAddedFiles": 50,                   // 该 Manifest 中的 ADD Entry 数
    "numDeletedFiles": 0,                  // 该 Manifest 中的 DELETE Entry 数
    "schemaId": 1,
    "minBucket": 0,                        // 该 Manifest 涉及的桶范围
    "maxBucket": 9,
    "minLevel": 0,                         // 该 Manifest 涉及的 LSM 层级范围
    "maxLevel": 2,
    "partitionStats": {
        "numPartitions": 1,
        "minPartition": "pt=2023-01-01",
        "maxPartition": "pt=2023-01-01"
    }
}

用途:
1. 快速了解 Manifest 的内容(无需读取文件)
2. 用于分区裁剪、桶过滤
3. 用于 Manifest 文件缓存的判断

2.4.4 Manifest 文件的合并

在 Compaction 过程中,多个 Manifest 文件会被合并:

原始状态:
Snapshot-10
├── manifest-1ADD 10 个文件)
├── manifest-2ADD 5 个文件)
├── manifest-3ADD 3 个文件)
└── manifest-4DELETE 2 个文件)

Compaction 后(L0 → L1):
Snapshot-11
├── manifest-1(保留)
├── manifest-2(保留)
├── manifest-3(保留)
├── manifest-4(保留)
├── manifest-5(新增,DELETE 旧的 10 文件)
└── manifest-6(新增,ADD 新的 5 文件)

结果:
- 5 个旧的 L0 文件被删除(标记为 DELETE- 1 个新的 L1 文件被添加(标记为 ADD)

合并时不需要重写所有 Manifest,只追加新的 Entry

2.5 数据文件(DataFile)存储

2.5.1 DataFile 的内容

data-{uuid}-0.parquet(二进制文件)

内部结构(以 Parquet 格式为例):
┌─────────────────────────┐
│  Parquet Header         │  // 格式标识
├─────────────────────────┤
│  Column 1 Data Chunk    │  // 字段 1 的压缩数据
│  Column 2 Data Chunk    │  // 字段 2 的压缩数据
│  ...                    │
├─────────────────────────┤
│  Row Group Metadata     │  // 行组的统计信息
│  - Min/Max of Column1   │
│  - Min/Max of Column2   │
│  - Null Count           │
├─────────────────────────┤
│  File Metadata          │  // 文件级元数据
│  - Schema               │
│  - Codec                │
│  - Row Count            │
└─────────────────────────┘

重要特性:
- 列式存储:相同列数据相邻,便于压缩和快速访问
- 统计信息嵌入:无需额外文件就能获取 Min/Max
- 谓词下推友好:可以快速跳过不相关的行

2.5.2 DataFileMeta(DataFile 的元数据)

DataFileMeta 的内容:

{
    "fileName": "data-abc-0.parquet",
    "fileSize": 104857600,              // 100MB
    "rowCount": 1000000,                // 行数
    "schemaId": 1,
    "level": 0,                         // LSM 层级(主键表)
    "minKey": {                         // 最小键
        "user_id": 1,
        "timestamp": "2023-01-01 00:00:00"
    },
    "maxKey": {                         // 最大键
        "user_id": 1000000,
        "timestamp": "2023-01-01 23:59:59"
    },
    "keyStats": {
        "user_id": {
            "minValue": 1,
            "maxValue": 1000000,
            "nullCount": 0,
            "distinctCount": 1000000
        }
    },
    "valueStats": {
        "amount": {
            "minValue": 0.01,
            "maxValue": 99999.99,
            "nullCount": 100
        }
    },
    "creationTime": 1640000000000,
    "allIndexFiles": ["index-abc-0"],    // 可选的索引文件
    "extraFiles": [...]
}

2.6 实际生产案例

案例 1:日志系统(每天 1TB)

需求:
- 每天写入 1TB 日志
- 需要查询最近 30 天的数据
- 每天数据过期后删除

表设计:
CREATE TABLE app_logs (
    event_id BIGINT,
    app_id INT,
    user_id BIGINT,
    event_type STRING,
    event_time TIMESTAMP,
    properties MAP<STRING, STRING>,
    
    PRIMARY KEY (event_id)
) PARTITIONED BY (dt STRING)
WITH (
    'bucket' = '50',                    // 50 个 Writer 并行
    'target-file-size' = '128MB',       // 文件大小平衡
    'write-buffer-size' = '256MB',
    'snapshot-retention.num-retained' = '32'  // 保留 32 个快照(32 天)
);

生成的目录结构:
warehouse/applog_db/app_logs/
├── pt=2023-12-01/
│   ├── bucket-0/ ← 50 个 Writer 之一写这里
│   │   ├── data-uuid1-0.parquet (128MB)
│   │   ├── data-uuid1-1.parquet (128MB)
│   │   └── ...
│   ├── bucket-1/
│   │   └── ...
│   └── ...
├── pt=2023-12-02/
│   └── ... (同上)
└── ... 更多分区(最多 30 天)

快照管理:
- Snapshot-1(2023-12-01):包含 50 个文件
- Snapshot-2(2023-12-02):包含 100 个文件
- ...
- Snapshot-30(2023-12-30):包含 1500 个文件
- Snapshot-31(2023-12-31):包含 1550 个文件

清理策略:
- 2024-01-02 时执行分区过期
- 删除 pt=2023-12-01 分区
- 快照保留 31、32(对应 12-31 和 2024-01-01)

性能指标:
- 写入吞吐:100MB/s(受网络限制)
- 查询延迟:< 5s(按分区过滤后)
- 存储占用:30TB(30 天 × 1TB/天)

案例 2:用户维度表(实时更新)

需求:
- 用户数:1000 万
- 更新频率:每秒 10 万笔
- 需要支持 JOIN 查询
- 更新延迟:< 1s

表设计:
CREATE TABLE users_dim (
    user_id BIGINT,
    country STRING,
    province STRING,
    city STRING,
    name STRING,
    age INT,
    balance DECIMAL(10,2),
    vip_level INT,
    update_time TIMESTAMP,
    
    PRIMARY KEY (user_id)
) PARTITIONED BY (country STRING)
WITH (
    'bucket' = '100',                       // 100 个桶支持并发
    'changelog-producer' = 'input',         // 只输出用户修改部分
    'num-sorted-runs-compaction-trigger' = '5',
    'write-buffer-size' = '512MB',
    'target-file-size' = '256MB',
    'snapshot-retention.num-retained' = '100'
);

数据分布(1000 万用户,100 个国家):
warehouse/user_db/users_dim/
├── country=CN/          # 中国用户 400 万
│   ├── bucket-0/        # 4 万用户
│   ├── bucket-1/
│   └── ... (100 个桶)
├── country=US/          # 美国用户 200 万
│   ├── bucket-0/
│   └── ...
├── country=JP/          # 日本用户 100 万
│   └── ...
└── ... 其他国家

并发写入示例:
Writer-1 写 user_id=123(国家=CN)→ bucket-3
Writer-2 写 user_id=456(国家=US)→ bucket-6
Writer-3 写 user_id=789(国家=CN)→ bucket-8
↓
100 个不同的桶,无锁竞争,吞吐量达到 10 万/秒

Snapshot 链:
- Snapshot-1:初始 1000 万用户
- Snapshot-2:用户新增、更新合并
- ...
- Snapshot-100:保留 100 个版本

性能指标:
- 写入吞吐:10 万 QPS
- 写入延迟:< 100ms
- 读取延迟:< 50ms(内存缓存)
- 存储占用:20GB(1000 万用户 × 2KB/用户)

案例 3:订单事实表(超大规模)

需求:
- 订单数:100 亿(历史)
- 每天新增:1000- 每天更新:500 万(支付、物流状态等)
- 查询延迟:< 1s

表设计:
CREATE TABLE orders (
    order_id BIGINT,
    user_id BIGINT,
    shop_id INT,
    product_id BIGINT,
    order_time TIMESTAMP,
    amount DECIMAL(10,2),
    payment_time TIMESTAMP,
    delivery_time TIMESTAMP,
    status STRING,
    
    PRIMARY KEY (order_id)
) PARTITIONED BY (
    ds STRING  // 分区:日期
)
WITH (
    'bucket' = '200',                   // 200 个桶
    'num-sorted-runs-compaction-trigger' = '10',
    'snapshot-retention.num-retained' = '365',  // 保留 1 年快照
    'partition-expiration.mode' = 'days',
    'partition-expiration.time-retains' = '365'  // 1 年后删除
);

存储架构:
100 亿 订单 / 365 天 ≈ 2.7 亿 订单/2.7 亿 / 200 桶 ≈ 135 万 订单/桶

单个文件(256MB)存储 100 万条:
每天需要 2.7 亿 / 100= 270 个文件
200 桶 × 1.35 个文件/桶 ≈ 270 个文件 ✓

存储容量规划:
- 每天新增:1000 万 × 5KB ≈ 50GB
- 365 天保留:50GB × 36518.25TB
- 考虑 Compaction 开销:20TB(实际)

并发写入:
- Writer 数量:200(对应 200 个桶)
- 每个 Writer 处理吞吐:1000/ 200 = 5 万 QPS
- 支持的并发更新:200 × 5= 1000 万 QPS

Snapshot 链和合并:
- 每次提交生成新 Snapshot
- Snapshot 1365:保留(支持 1 年内的时间旅行)
- 超过 365 天自动删除

性能指标:
- 写入吞吐:1000 万 QPS(新增 + 更新)
- 写入延迟:< 500ms(P99)
- 查询延迟:< 2s(按日期过滤后)
- 存储利用率:高度压缩(ORC 格式)

2.7 核心参数调优

文件相关参数

参数默认值调优指导
target-file-size128MB大:查询快,文件少;小:写入快,内存占用少
write-buffer-size256MB大:吞吐高;小:延迟低
file-formatorcORC 压缩率最高,Parquet 兼容性最好,Avro 速度最快
file-compressionzstdzstd:压缩率好;snappy:速度快;gzip:兼容性好

Manifest 相关参数

参数默认值调优指导
manifest-target-size8MBBaseManifestList 目标大小,越小查询越快
manifest-merge-min-count30DeltaManifestList 中的 Manifest 数超过 30 时合并,越大合并频率越低
manifest-full-compaction-threshold-size100MBBaseManifestList + DeltaManifestList 超过 100MB 时触发完全合并

Snapshot 保留策略

参数默认值调优指导
snapshot-retention.num-retained3保留的快照数,太少影响时间旅行,太多占用存储
snapshot-retention.time-retains30min保留的快照时长
snapshot-expiration.expire-time30min快照过期后清理的延迟

2.8 总结

概念核心要点
分区逻辑分组,按业务维度(日期、地区),支持分区裁剪和并行写入
哈希分组,按主键值,支持并发写入(降低锁竞争)
Snapshot版本快照,记录表在某时刻的完整文件列表,支持时间旅行查询
ManifestList清单列表,分为 Base 和 Delta,优化大规模文件管理
Manifest清单文件,包含 Entry(ADD/DELETE)和统计信息
DataFile实际数据文件(Parquet/ORC/Avro),列式存储

下一章:第 3 章深入讲解 Catalog 体系,介绍如何在分布式环境中管理元数据,以及如何实现并发安全的表操作。