第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]
↓ ↓ ↓
写入 100 条 A 写入 50 条 B 写入 30 条 C
提交 提交 提交
↓ ↓ ↓
manifest-1 manifest-2 manifest-3
↓ ↓ ↓
manifest-list-1 manifest-list-2 manifest-list-3
快照链:1 → 2 → 3 → ...
时间旅行查询:
- SELECT * FROM table AS OF SNAPSHOT 1 // 只有数据A
- SELECT * FROM table AS OF SNAPSHOT 2 // 有数据A和B
- SELECT * FROM table AS OF SNAPSHOT 3 // 有数据A、B、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-1(ADD 10 个文件)
├── manifest-2(ADD 5 个文件)
├── manifest-3(ADD 3 个文件)
└── manifest-4(DELETE 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 × 365 ≈ 18.25TB
- 考虑 Compaction 开销:20TB(实际)
并发写入:
- Writer 数量:200(对应 200 个桶)
- 每个 Writer 处理吞吐:1000 万 / 200 = 5 万 QPS
- 支持的并发更新:200 × 5 万 = 1000 万 QPS
Snapshot 链和合并:
- 每次提交生成新 Snapshot
- Snapshot 1 → 365:保留(支持 1 年内的时间旅行)
- 超过 365 天自动删除
性能指标:
- 写入吞吐:1000 万 QPS(新增 + 更新)
- 写入延迟:< 500ms(P99)
- 查询延迟:< 2s(按日期过滤后)
- 存储利用率:高度压缩(ORC 格式)
2.7 核心参数调优
文件相关参数
| 参数 | 默认值 | 调优指导 |
|---|---|---|
target-file-size | 128MB | 大:查询快,文件少;小:写入快,内存占用少 |
write-buffer-size | 256MB | 大:吞吐高;小:延迟低 |
file-format | orc | ORC 压缩率最高,Parquet 兼容性最好,Avro 速度最快 |
file-compression | zstd | zstd:压缩率好;snappy:速度快;gzip:兼容性好 |
Manifest 相关参数
| 参数 | 默认值 | 调优指导 |
|---|---|---|
manifest-target-size | 8MB | BaseManifestList 目标大小,越小查询越快 |
manifest-merge-min-count | 30 | DeltaManifestList 中的 Manifest 数超过 30 时合并,越大合并频率越低 |
manifest-full-compaction-threshold-size | 100MB | BaseManifestList + DeltaManifestList 超过 100MB 时触发完全合并 |
Snapshot 保留策略
| 参数 | 默认值 | 调优指导 |
|---|---|---|
snapshot-retention.num-retained | 3 | 保留的快照数,太少影响时间旅行,太多占用存储 |
snapshot-retention.time-retains | 30min | 保留的快照时长 |
snapshot-expiration.expire-time | 30min | 快照过期后清理的延迟 |
2.8 总结
| 概念 | 核心要点 |
|---|---|
| 分区 | 逻辑分组,按业务维度(日期、地区),支持分区裁剪和并行写入 |
| 桶 | 哈希分组,按主键值,支持并发写入(降低锁竞争) |
| Snapshot | 版本快照,记录表在某时刻的完整文件列表,支持时间旅行查询 |
| ManifestList | 清单列表,分为 Base 和 Delta,优化大规模文件管理 |
| Manifest | 清单文件,包含 Entry(ADD/DELETE)和统计信息 |
| DataFile | 实际数据文件(Parquet/ORC/Avro),列式存储 |
下一章:第 3 章深入讲解 Catalog 体系,介绍如何在分布式环境中管理元数据,以及如何实现并发安全的表操作。