3、核心概念详解:表格式、快照、清单、数据文件与元数据层

好,咱们进入正题。这一章是整个 Iceberg 的基石,说白了就是搞清楚它到底是怎么存数据的。我刚开始接触 Iceberg 时,也被那一堆名词绕晕过——表格式、快照、清单、数据文件……感觉像俄罗斯套娃。但等你真正理解了这层结构,就会发现 Iceberg 的版本管理、时间旅行、增量读取这些能力,全是从这套设计里长出来的。

3.1 表格式(Table Format)——不只是“存数据”

传统 Hive 表,你往 HDFS 上扔一个 Parquet 文件,再更新一下分区元数据,就算完事了。但 Iceberg 的“表格式”要复杂得多。它定义了一套完整的规范:数据怎么组织、元数据怎么管理、快照怎么切换。

我个人习惯把表格式理解成“数据仓库的蓝图”。它不关心底层存储是 HDFS 还是 S3,也不关心文件格式是 Parquet 还是 ORC。它只规定:你要想成为一个 Iceberg 表,就必须按照这套目录结构和元数据文件来组织。

核心要点:表格式 = 元数据层 + 数据层 + 操作协议。它让计算引擎(Spark、Flink、Trino)能通过同一套 API 读写数据,而不用关心底层文件怎么摆。

举个例子。我在项目中遇到过团队把 Iceberg 表直接当 Hive 表用,往目录里手动塞 Parquet 文件。结果 Iceberg 根本认不出来——因为它找不到对应的 metadata.json。嗯,这里要注意:Iceberg 表不是文件目录,它是一个由元数据驱动的逻辑视图。

3.2 快照(Snapshot)——表的“时间胶囊”

快照是 Iceberg 版本管理的核心。每次对表的写操作(INSERT、UPDATE、DELETE、MERGE)都会生成一个新的快照。快照本质上是一个不可变的、指向某一时刻完整数据状态的指针。

你想想看,传统表做版本管理多麻烦——要么全量拷贝,要么靠 Hive 分区时间戳来猜。Iceberg 的做法就优雅多了:每个快照只记录“这次操作改了哪些文件”,没改的部分直接复用之前的清单。

我的经验:快照 ID 是一个单调递增的 long 值。你可以通过 SELECT * FROM table FOR SYSTEM_VERSION AS OF 123456 直接查询某个历史快照。我曾经用这个功能帮业务部门恢复了误删的 3 小时数据,全程没停服务。

快照之间是链式关系:每个快照都记录了它的父快照 ID。这样你就能回溯整个操作历史。Iceberg 默认保留最近 10 个快照,但你可以通过 write.snapshot.num-retained 调整。

快照属性 说明
snapshot-id 全局唯一,单调递增
parent-snapshot-id 上一个快照 ID,-1 表示首次提交
timestamp-ms 快照创建时间(毫秒)
manifest-list 指向该快照的清单列表文件
summary 操作摘要(行数变化、文件数等)

3.3 清单(Manifest)——数据的“索引卡片”

清单是连接快照和数据文件的桥梁。每个清单文件(.avro 格式)记录了一组数据文件的元信息,包括:文件路径、分区信息、列统计信息(最大值、最小值、空值数等)。

为什么需要清单?直接读数据文件不行吗?

当然不行。你想想看,一个 Iceberg 表可能有几千个数据文件。如果每次查询都去扫所有文件,那跟 Hive 全表扫描有什么区别?清单的作用就是让查询引擎能快速过滤掉不相关的文件。

避坑指南:我曾经遇到过一个性能问题——清单文件太大导致查询变慢。后来发现是因为频繁的小文件写入,每个清单只记录几个文件。解决方案是调大 write.target-file-size-bytes,减少清单数量。记住:清单不是越多越好。

清单分为两种:

  • Data Manifest:记录数据文件
  • Delete Manifest:记录删除文件(用于 Merge-on-Read)

每个清单文件内部还包含一个 partition-spec-id,用来标识它属于哪个分区版本。这样即使分区策略变了,旧数据依然能被正确读取。

3.4 数据文件(Data File)——真正的“货”

数据文件就是实际存储行数据的文件。Iceberg 支持 Parquet、Avro、ORC 三种格式。每个数据文件在清单中都有详细的统计信息,比如:

{
  "file_path": "s3://bucket/table/data/2024/01/part-00001.parquet",
  "file_format": "PARQUET",
  "partition": {"dt": "2024-01-01"},
  "record_count": 1024000,
  "file_size_in_bytes": 268435456,
  "column_sizes": {"id": 1024, "name": 2048},
  "value_counts": {"id": 1024000, "name": 1024000},
  "null_value_counts": {"name": 0},
  "lower_bounds": {"id": 1, "name": "A"},
  "upper_bounds": {"id": 999999, "name": "Z"}
}

这些统计信息有什么用?我举个例子。假设你要查 WHERE id = 500,Iceberg 会先看清单里每个数据文件的 lower_bounds 和 upper_bounds。如果某个文件的最小 id 是 1000,那它肯定不包含 id=500 的数据,直接跳过。这就是谓词下推的底层实现。

3.5 元数据层——整个体系的“大脑”

元数据层是 Iceberg 最精妙的设计。它分为三层:

  1. 元数据文件(Metadata File):v1.metadata.json、v2.metadata.json……每个版本对应一次 schema 变更或分区策略变更。它记录了表的完整元信息,包括当前快照 ID、schema 定义、分区规范、排序顺序等。
  2. 清单列表(Manifest List):每个快照对应一个 snap-123456.avro 文件,里面列出了该快照包含的所有清单文件。
  3. 清单文件(Manifest):上面已经讲过,记录数据文件的索引。

这三层的关系可以用一张图来概括:

Iceberg 元数据层结构 元数据文件 v1.metadata.json / v2.metadata.json 指向当前快照 清单列表 snap-123456.avro 包含多个清单 清单文件 1 manifest-1.avro 清单文件 2 manifest-2.avro 清单文件 3 manifest-3.avro data-1.parquet data-2.parquet data-3.parquet data-4.parquet data-5.parquet 元数据文件 → 清单列表 → 清单文件 → 数据文件:四层结构,层层索引,互不依赖

这套分层设计的好处是什么?我总结三点:

  • 原子性:元数据文件的更新是原子的(通过 rename 或原子写操作),不会出现部分写入导致的数据损坏。
  • 隔离性:每个快照的清单列表是独立的,读操作只访问当前快照的清单,写操作不影响正在进行的读操作。
  • 可扩展性:元数据文件只记录当前快照 ID,清单列表只记录文件列表,数据文件只存数据。每一层都可以独立扩展。

我的建议:在生产环境中,一定要给元数据目录配置独立的存储策略。我见过有人把元数据文件和业务数据混在一个桶里,结果清理数据时误删了元数据文件,整个表都读不了。元数据文件虽然小,但它是整个表的命根子。