Iceberg 表结构详解:元数据层、数据文件层、Manifest List 与 Manifest File 的关系

好,咱们今天来聊聊 Iceberg 的表结构。说实话,我第一次接触 Iceberg 时,也被它那套元数据层、Manifest List、Manifest File 的概念绕得有点晕。但后来我在一个实时数仓项目里被坑过一次——因为没搞懂这些层级关系,导致查询走了全表扫描,几十亿条数据硬生生跑了半小时。从那以后,我就把这套结构刻在脑子里了。

说白了,Iceberg 的表结构就是三层:元数据层、Manifest 清单层、数据文件层。每一层都有自己的职责,环环相扣。咱们一层层拆开看。

1. 元数据层:表的“户口本”

元数据层在最顶层,它存的是表的全局信息。比如表有哪些分区、当前快照是什么、Schema 长什么样。这些信息都放在一个叫 metadata 的目录下。

我习惯把元数据文件想象成表的“户口本”。每次对表做一次提交(比如 INSERT、DELETE、MERGE),都会生成一个新的元数据文件。Iceberg 通过版本号来管理这些文件,比如 v1.metadata.json、v2.metadata.json。

核心内容:元数据文件里记录了当前表的所有快照信息,包括每个快照对应的 Manifest List 位置。

举个例子,你执行了一次 INSERT INTO,Iceberg 会做三件事:

  1. 写入新的数据文件(比如 Parquet 文件)
  2. 生成新的 Manifest File 和 Manifest List
  3. 生成新的元数据文件,指向最新的 Manifest List

嗯,这里要注意:元数据文件本身很小,因为它只存指针,不存具体数据。我见过有人把元数据文件当数据文件来备份,其实没必要——它就是个索引。

2. Manifest List:快照的“目录索引”

Manifest List 是连接元数据层和数据文件层的桥梁。每个快照对应一个 Manifest List 文件。它里面存的是什么呢?说白了,就是一组 Manifest File 的列表,外加每个 Manifest File 的统计信息。

我个人觉得,Manifest List 就像一本书的目录。你翻到目录页,能看到每一章的标题、页码、大概内容。Manifest List 也是类似的——它记录了每个 Manifest File 包含哪些分区的数据、数据范围是什么。

字段 说明
manifest_path Manifest File 的存储路径
partition_spec_id 分区规范 ID
added_snapshot_id 添加该 Manifest 的快照 ID
partition_summary 分区字段的统计信息(最小值、最大值、NULL 值数量等)

为什么要存这些统计信息?为了分区裁剪。你想想看,如果查询只涉及某几个分区,Iceberg 通过 Manifest List 就能直接跳过不相关的 Manifest File,根本不用去读底层的数据文件。我在项目中遇到过,一个没做分区裁剪的查询,扫描了 200 个 Manifest File;优化后只扫了 3 个,速度提升了 60 倍。

3. Manifest File:数据文件的“明细清单”

Manifest File 再往下走一层。每个 Manifest File 里存的是一组数据文件的元数据。包括每个数据文件的路径、格式、行数、大小、列统计信息等等。

你可以把 Manifest File 理解成仓库里的货架清单。每个货架(Manifest File)上摆了什么商品(数据文件),商品的生产日期(数据范围)、数量(行数)、重量(文件大小)都写得清清楚楚。

小技巧:Manifest File 本身也是用 Avro 格式存储的。你可以在 Spark 或 Flink 里直接读它,看看里面到底存了什么。我经常用 spark.read.format("avro").load("path/to/manifest.avro") 来调试。

Manifest File 里每条记录对应一个数据文件,字段大概有这些:

  • file_path:数据文件路径
  • file_format:文件格式(Parquet、ORC、Avro)
  • record_count:记录数
  • file_size_in_bytes:文件大小
  • partition_data:分区数据
  • lower_bound / upper_bound:列级别的统计信息

这里有个关键点:Manifest File 里存的是列级别的统计信息。这意味着 Iceberg 可以做列裁剪——如果查询只涉及某几列,Iceberg 通过 Manifest File 就能判断哪些数据文件不包含这些列,直接跳过。

4. 数据文件层:真正的“数据仓库”

最底层就是数据文件了。Iceberg 支持 Parquet、ORC、Avro 这些常见的列式存储格式。数据文件里存的是真正的业务数据。

数据文件本身没什么特别的,就是普通的列式文件。但 Iceberg 通过上层的 Manifest 机制,让这些文件具备了事务性和快照隔离的能力。

避坑指南:我曾经犯过一个错误——直接删除了底层的数据文件,以为 Iceberg 会自动清理元数据。结果表直接坏了,因为 Manifest File 里还指向那些已经不存在的文件。记住:永远不要手动操作数据文件,Iceberg 的 expire_snapshots 和 remove_orphan_files 才是正确的清理方式。

5. 三层关系:一张图看懂

说了这么多,咱们用一张图来总结这三层的关系。我画了一个结构图,你一看就明白。

Iceberg 表结构三层关系图 元数据层 (Metadata Layer) v1.metadata.json, v2.metadata.json ... 记录快照列表、Schema、分区信息 Manifest List (清单列表) snap-12345-manifest-list.avro 记录 Manifest File 列表 + 分区统计信息 Manifest File 分区A的数据文件清单 列统计、行数、大小 Manifest File 分区B的数据文件清单 列统计、行数、大小 Manifest File 分区C的数据文件清单 列统计、行数、大小 数据文件 00001-0.parquet 00002-0.parquet 数据文件 00003-0.parquet 00004-0.parquet 数据文件 00005-0.parquet 00006-0.parquet 查询时:元数据 → Manifest List → Manifest File → 数据文件(逐层裁剪,只读必要数据)

6. 查询是怎么穿透这三层的?

假设你执行一条 SQL:SELECT * FROM orders WHERE dt = '2024-01-15'。Iceberg 的查询引擎会这样工作:

  1. 读元数据文件:找到当前快照对应的 Manifest List 路径。
  2. 读 Manifest List:根据分区统计信息,只筛选出包含 dt='2024-01-15' 的 Manifest File。
  3. 读 Manifest File:根据列统计信息,进一步筛选出真正包含该分区数据的数据文件。
  4. 读数据文件:只读取筛选后的数据文件,返回结果。

你看,每一步都在做“减法”。这就是 Iceberg 查询快的原因——不是因为它读得快,而是因为它读得少。

个人经验:我在调优 Iceberg 查询时,最常看的就是 Manifest List 和 Manifest File 的统计信息是否准确。如果统计信息过期了(比如长时间没做 rewrite_manifests),裁剪效果会大打折扣。我建议你定期跑一下 rewrite_manifests,保持 Manifest 文件的健康度。

7. 总结一下

Iceberg 的三层结构,说白了就是一套逐层索引的机制:

  • 元数据层:管快照和 Schema
  • Manifest List:管 Manifest 文件的索引
  • Manifest File:管数据文件的索引
  • 数据文件层:存真实数据

每一层都存了下一层的统计信息,这样查询时就能层层裁剪,只读必要的数据。嗯,这套设计其实借鉴了数据库的 B+ 树思想,但 Iceberg 把它用在了文件系统上,效果出奇的好。

下次你遇到 Iceberg 查询慢的问题,别急着调 Spark 参数。先看看是不是 Manifest 文件太多、统计信息过期了。我敢说,80% 的性能问题都出在这上面。