第二讲:Snapshot、Manifest、Data File 的物理与逻辑结构剖析
上一讲我们聊了 Iceberg 的定位和基本概念。这一讲,咱们深入底层,看看 Iceberg 最核心的三个东西:Snapshot、Manifest、Data File。
说白了,理解这三者的关系,你就掌握了 Iceberg 的“骨架”。我当年刚接触 Iceberg 时,就是先啃透了这个结构,后面看什么分区演进、时间旅行、小文件合并,都豁然开朗。
1. 三者的逻辑层级
先给个直观的比喻。你想想看,一个 Iceberg 表就像一本书:
- Snapshot —— 书的目录。告诉你这本书有哪些章节,每个章节在哪儿。
- Manifest —— 章节的详细索引。列出这一章里每页纸(数据文件)的信息。
- Data File —— 书页本身。真正存着文字(数据)的地方。
这个比喻虽然简单,但核心逻辑是对的。Snapshot 指向一组 Manifest,每个 Manifest 又指向一组 Data File。查询时,引擎先读 Snapshot,再读 Manifest,最后才读 Data File。
核心要点:Iceberg 的元数据层是“多级索引”结构。这种设计让 Iceberg 能高效地做分区裁剪和列裁剪,而不需要扫描全表。
2. Snapshot:表的“快照指针”
Snapshot 是什么?它不是一个完整的数据副本,而是一个指针。它记录了某个时间点下,表的所有元数据状态。
每个 Snapshot 包含以下关键信息:
- snapshot-id —— 全局唯一的快照 ID,单调递增。
- parent-snapshot-id —— 上一个快照的 ID,形成链式结构。
- manifest-list —— 指向 Manifest 列表文件的路径。
- timestamp-ms —— 快照创建时间。
- summary —— 一些统计信息,比如总行数、分区数等。
我个人习惯把 Snapshot 看作“元数据的元数据”。它本身不存数据,但它告诉你“去哪找数据”。
小技巧:在 Spark 里用 SELECT * FROM table.VERSION AS OF 12345 查询历史快照时,Iceberg 就是通过 Snapshot 链找到对应时间点的 Manifest 列表,然后只读那个快照下的数据。这就是“时间旅行”的底层原理。
3. Manifest:数据文件的“索引清单”
Manifest 是 Iceberg 里最精巧的设计之一。它是一个元数据文件,里面记录了一组 Data File 的详细信息。
每个 Manifest 文件里,每一行对应一个 Data File,包含:
| 字段 | 说明 |
|---|---|
file_path |
数据文件的完整路径(HDFS、S3 等) |
file_format |
Parquet、Avro、ORC 等 |
partition |
该文件所属的分区信息 |
record_count |
文件中的记录数 |
file_size_in_bytes |
文件大小 |
lower_bound / upper_bound |
各列的最小/最大值(用于谓词下推) |
null_value_counts |
各列的空值数量 |
嗯,这里要注意:Manifest 里存的不是数据,而是数据的“元信息”。查询引擎在读数据前,先扫 Manifest,就能知道哪些文件可以跳过。
我曾经在项目中遇到过一个问题:某个表每天写入大量小文件,导致 Manifest 文件越来越大,查询时读 Manifest 的开销甚至超过了读数据本身。后来通过调整 write.target-file-size-bytes 参数,把文件合并到合理大小,才解决了这个问题。
4. Data File:真正的数据载体
Data File 就是实际存储数据的文件。Iceberg 支持 Parquet、Avro、ORC 三种格式。我个人最推荐 Parquet,列式存储 + 压缩率高,适合分析场景。
Data File 本身没什么特别的,但 Iceberg 对它的管理方式很讲究:
- 不可变性:一旦写入,Data File 就不会被修改。更新数据是通过写新文件 + 废弃旧文件实现的。
- 分区组织:Data File 按分区目录存放,比如
/data/dt=2024-01-01/hour=12/。 - 文件级统计:每个 Data File 在 Manifest 里都记录了列级别的 min/max 值,查询时能快速过滤。
避坑指南:我曾经见过有人把 Iceberg 的 Data File 直接删了,以为元数据里还有记录。结果查询时报“文件不存在”错误。记住:Iceberg 的元数据和数据是分离的,删了数据文件,元数据不会自动清理。一定要用 expire_snapshots 或 remove_orphan_files 来清理。
5. 三者的协作流程
为了让你更直观地理解,我画了一张图。这张图展示了从查询到数据读取的完整链路。
你看,这个链路很清晰。查询引擎不需要扫描全表,只需要沿着这个索引链往下走,就能精准定位到需要的数据文件。
6. 物理存储结构
说完逻辑结构,咱们看看物理上这些文件是怎么存的。
一个 Iceberg 表的目录结构大致如下:
/warehouse/db/table/
├── metadata/
│ ├── version-hint.text
│ ├── v1.metadata.json
│ ├── v2.metadata.json
│ ├── snap-12345.avro
│ ├── snap-12346.avro
│ └── ...
├── data/
│ ├── dt=2024-01-01/
│ │ ├── hour=12/
│ │ │ ├── 00000-0.parquet
│ │ │ └── 00001-0.parquet
│ │ └── hour=13/
│ │ └── 00002-0.parquet
│ └── dt=2024-01-02/
│ └── ...
└── metadata/
└── ... (Manifest 文件也常放在这里)
这里有几个关键点:
- metadata 目录:存放元数据文件,包括 Snapshot 文件(.avro 格式)和 Manifest 文件。
- data 目录:按分区组织的数据文件。
- version-hint.text:指向当前最新的 metadata 文件版本。
个人经验:我建议你在排查问题时,直接去 metadata 目录下看看。比如用 avro-tools 查看 Snapshot 文件内容,能直观地看到每个 Snapshot 指向了哪些 Manifest。这比看日志快多了。
7. 避坑与最佳实践
最后,分享几个我在实战中踩过的坑:
- Manifest 文件过多:频繁的小写入会导致 Manifest 文件数量爆炸。建议设置
write.metadata.metrics.default来控制 Manifest 的粒度。 - Snapshot 链过长:默认保留 100 个 Snapshot,但如果你不做清理,链会越来越长,影响查询性能。定期执行
expire_snapshots是必须的。 - Data File 大小不均:有些文件 1MB,有些 1GB,会导致查询时任务倾斜。用
rewrite_data_files做文件合并,目标大小建议 256MB~1GB。