2、表格式与快照:Iceberg 表格式详解、Manifest 文件结构、Snapshot 隔离机制、ACID 事务原理
好,咱们今天聊聊 Iceberg 最核心的东西——表格式。
很多人刚开始接触 Iceberg 时,觉得它就是个“带 ACID 的 Parquet”。其实不然。Iceberg 之所以能实现快照隔离、时间旅行、并发写入不冲突,靠的就是它这套精心设计的元数据层。说白了,它把“表”这个抽象概念,拆成了一组可以原子化替换的文件。
我个人习惯把 Iceberg 的表格式想象成一个“洋葱”。从外往里剥,一共三层:Catalog → Metadata Layer → Data Layer。今天咱们重点看 Metadata Layer 里的两个关键角色:Manifest 文件和Snapshot。
2.1 三层架构:从 Catalog 到数据文件
先搭个框架。Iceberg 的表结构长这样:
Catalog (Hive / Nessie / JDBC ...)
└── Table Metadata File (.metadata.json)
├── Snapshot-1
│ ├── Manifest-List-1 (avro)
│ │ ├── Manifest-File-A (avro)
│ │ │ ├── Data-File-1 (parquet)
│ │ │ └── Data-File-2 (parquet)
│ │ └── Manifest-File-B (avro)
│ │ └── Data-File-3 (parquet)
│ └── ...
└── Snapshot-2
└── ...
每一层都有明确的职责:
- Catalog:只存一个指针,指向当前最新的
.metadata.json文件路径。 - Table Metadata File:表的“户口本”。记录 schema、分区信息、所有 Snapshot 列表、当前 Snapshot ID。
- Manifest-List:一个 Snapshot 的“目录”。列出该 Snapshot 引用了哪些 Manifest 文件。
- Manifest:一组数据文件的“索引”。每行记录一个数据文件的路径、分区值、列统计信息、行数、大小。
- Data File:真正的数据,Parquet / Avro / ORC 格式。
关键点:Metadata 和 Manifest 都是不可变的。每次写入只新增文件,从不原地修改。这是 Iceberg 实现 ACID 的基石。
2.2 Manifest 文件结构:数据文件的“索引卡”
Manifest 文件是 Avro 格式,里面每条记录对应一个数据文件。我截取一个简化后的 schema 给你看:
{
"type": "record",
"name": "ManifestEntry",
"fields": [
{"name": "status", "type": "int"}, // 0:EXISTING, 1:ADDED, 2:DELETED
{"name": "snapshot_id", "type": "long"},
{"name": "data_file", "type": {
"type": "record",
"name": "DataFile",
"fields": [
{"name": "file_path", "type": "string"},
{"name": "partition", "type": "bytes"}, // 分区值,序列化后的
{"name": "record_count", "type": "long"},
{"name": "file_size_in_bytes", "type": "long"},
{"name": "column_sizes", "type": {"type": "map", "values": "long"}},
{"name": "value_counts", "type": {"type": "map", "values": "long"}},
{"name": "null_value_counts", "type": {"type": "map", "values": "long"}},
{"name": "lower_bounds", "type": {"type": "map", "values": "bytes"}},
{"name": "upper_bounds", "type": {"type": "map", "values": "bytes"}}
]
}}
]
}
看到 lower_bounds 和 upper_bounds 了吗?这就是 Iceberg 能做分区裁剪和谓词下推的秘密武器。查询引擎读 Manifest 时,直接看列的最大最小值,就能跳过不相关的数据文件。
我的经验:有一次排查查询慢的问题,发现 Manifest 文件特别大。一查,原来是每次写入都产生大量小文件,导致 Manifest 里记录了上千个数据文件。后来调大了 write.target-file-size-bytes,Manifest 小了,查询也快了。Manifest 的规模直接影响元数据操作的开销。
2.3 Snapshot 隔离机制:读与写互不干扰
Snapshot 是什么?就是一个时间点的表状态。每次提交(commit)都会生成一个新的 Snapshot。
Iceberg 的隔离机制,说白了就是读固定 Snapshot,写新 Snapshot。
- 读操作:查询开始时,拿到当前最新的 Snapshot ID。整个查询过程中,只读这个 Snapshot 指向的所有 Manifest 和数据文件。即使后面有写入,也完全不影响。
- 写操作:写入新数据时,生成新的数据文件,创建新的 Manifest 文件,最后通过一次原子操作(比如 Hive 的 rename、Nessie 的 commit)把 Table Metadata 里的当前 Snapshot 指针指向新 Snapshot。
你想想看,这跟 Git 的分支机制是不是很像?每次 commit 都是快照,你可以随时 checkout 到任意历史版本。
注意:Iceberg 默认的隔离级别是 Serializable。这意味着并发写入时,如果两个事务修改了同一批数据文件,后提交的那个会失败并重试。我曾经在生产环境遇到过因为并发写入冲突导致任务失败的情况,后来通过调整 commit.retry.num-retries 和 commit.retry.min-wait-ms 解决了。
2.4 ACID 事务原理:原子性 + 隔离性
Iceberg 的 ACID 靠的是“乐观锁 + 原子交换”。
| ACID 属性 | Iceberg 实现方式 |
|---|---|
| 原子性 (Atomicity) | 提交时只做一次原子操作:把 Table Metadata 文件中的当前 Snapshot 指针从旧值换成新值。要么全换,要么不换。 |
| 一致性 (Consistency) | Schema 和数据文件通过 Manifest 严格绑定。写入时校验 schema 兼容性,不兼容的写入会被拒绝。 |
| 隔离性 (Isolation) | Snapshot 隔离。读操作看到的是提交前的 Snapshot,写操作在提交前对其他事务不可见。 |
| 持久性 (Durability) | 数据文件和元数据文件都写入对象存储或 HDFS。只要文件系统不丢数据,Iceberg 就不会丢。 |
具体到写入流程,大概是这样的:
- 开始事务:从 Catalog 读取当前 Table Metadata,拿到最新的 Snapshot ID。
- 写入数据:生成新的数据文件(Parquet),同时生成对应的 Manifest 文件。
- 创建新 Snapshot:生成新的 Manifest-List,指向新 Manifest,再生成新的 Table Metadata 文件,把 Snapshot 指针指向新 Snapshot。
- 提交:通过 Catalog 的原子操作(如 Hive 的
rename),把 Table Metadata 文件替换成新的。如果发现当前 Snapshot 已经被其他事务改了,就重试(乐观锁)。
核心思想:Iceberg 把“提交”这个动作简化成了一个文件指针的原子交换。没有锁表,没有两阶段提交,全靠文件系统的原子 rename 操作。这也是它能在对象存储上跑得这么顺的原因。
2.5 知识体系总览
下面这张图把 Iceberg 表格式的核心逻辑串起来了。你可以看到从 Catalog 到数据文件的完整链路,以及 Snapshot 如何作为隔离边界。
嗯,这张图把整个流程讲清楚了。从 Catalog 出发,经过 Table Metadata,到 Snapshot,再到 Manifest-List、Manifest,最后到数据文件。右侧的说明框强调了 Snapshot 隔离的核心:读和写各自操作不同的 Snapshot,谁也不等谁。
2.6 避坑指南:我踩过的几个坑
- Manifest 文件膨胀:如果每次写入都产生大量小文件,Manifest 会变得很大,导致元数据操作变慢。我建议设置
write.target-file-size-bytes=512MB,减少小文件数量。 - 并发写入冲突:高并发场景下,乐观锁重试可能导致写入延迟。可以适当调大
commit.retry.num-retries=10,但也要注意不要无限重试。 - Snapshot 过期清理:Iceberg 默认保留 5 天的 Snapshot 历史。如果你做时间旅行,记得调大
history.expire.max-snapshot-age-ms,否则历史数据会被清理掉。
一个小技巧:排查问题时,我经常直接读 Manifest 文件里的列统计信息。用 SELECT * FROM table.manifests 就能看到每个 Manifest 包含的数据文件范围,快速定位数据分布是否均匀。
好了,这一章的内容就到这儿。表格式是 Iceberg 的根基,理解了 Manifest 和 Snapshot,后面的实时写入和查询优化就好办了。