2、表格式规范:Iceberg的清单层、元数据层、数据层三层架构详解

聊到 Iceberg,很多人第一反应是「它比 Hive 强」。但强在哪?说白了,核心就藏在这三层架构里。

我记得刚接触 Iceberg 时,翻源码看到 metadata、manifest 这些目录,第一感觉是「怎么这么绕」。后来在线上环境踩过一次坑——一个查询跑了半小时没出结果,排查下来发现是元数据文件膨胀得厉害。从那以后,我老老实实把这三层啃透了。

今天我们就把它拆开来看。

2.1 整体架构:三层各司其职

Iceberg 的表格式规范,把数据管理拆成三层:

  • 数据层(Data Layer):存真实数据文件,Parquet、ORC、Avro 都行。
  • 清单层(Manifest Layer):记录每个数据文件的元信息,比如行数、列统计、文件路径。
  • 元数据层(Metadata Layer):管理表的快照、Schema 变更、分区演化等全局信息。

你想想看,这三层像什么?像一本书的目录、章节和正文。查询时先翻目录(元数据层),找到对应章节(清单层),再翻到具体页码(数据层)。

核心思想:把元数据操作和数据操作解耦。读数据时只扫描清单,不用碰数据文件本身。这也是 Iceberg 能实现「快照隔离」和「时间旅行」的根基。

Iceberg 三层架构总览 元数据层(Metadata Layer) metadata.json / snap-xxx.avro 表快照 · Schema · 分区信息 · 历史版本 清单层(Manifest Layer) manifest-xxx.avro 数据文件元信息 · 列统计 · 分区下界/上界 数据层(Data Layer) data/ 目录下的 Parquet / ORC / Avro 真实行数据 · 列式存储 · 压缩 查询路径:元数据层 → 清单层 → 数据层,逐层裁剪,只读必要文件

2.2 数据层:最底层的文件存储

数据层没什么花哨的。它就是一堆数据文件,放在 data/ 目录下。Iceberg 不关心你用哪种文件格式,Parquet、ORC、Avro 都支持。

但有个细节很多人忽略:Iceberg 的数据文件是不可变的。写入时生成新文件,删除时标记文件为「已删除」,但不会真的去改旧文件。这跟 Hive 那种直接覆盖文件的玩法完全不同。

我的经验:曾经有个同事问「Iceberg 更新数据是不是很慢?」其实恰恰相反。因为文件不可变,写入时只需要追加新文件,不用加锁。读的时候通过清单层过滤掉旧文件就行。这在高并发场景下优势很明显。

数据文件在磁盘上的路径大概长这样:

/iceberg-warehouse/db/table/data/
  ├── partition_col=2024/
  │   ├── 00000-0.parquet
  │   └── 00001-0.parquet
  └── partition_col=2025/
      ├── 00000-0.parquet
      └── 00002-0.parquet

每个文件内部是标准的列式存储,Iceberg 不干预文件内部的编码方式。你该用 ZSTD 压缩还是 Snappy,自己说了算。

2.3 清单层:数据文件的「索引卡片」

清单层是 Iceberg 的精髓。它不存数据,只存数据文件的元信息。

每个清单文件(manifest file)是一个 Avro 文件,里面一条记录对应一个数据文件。记录里包含:

  • 文件路径(location)
  • 文件格式(Parquet/ORC/Avro)
  • 分区数据(partition data)
  • 行数(record count)
  • 列统计信息(下界、上界、空值数等)
  • 文件大小(file size in bytes)

为什么说它重要?因为查询时,Iceberg 会先扫清单文件,利用列统计信息做谓词下推。比如你查 WHERE age > 30,如果清单里记录了某个文件 age 列的最大值是 25,那这个文件直接跳过,连打开都不用。

避坑指南:我曾经遇到一个场景——清单文件越来越大,一个表有几万个数据文件,清单文件膨胀到几百 MB。查询时扫清单本身就成了瓶颈。后来通过 REWRITE_DATA_FILES 把小文件合并,清单文件数量降下来,查询快了三倍。

清单文件还分两种:

类型 说明 典型场景
Data Manifest 记录数据文件 常规读写
Delete Manifest 记录删除文件(position delete / equality delete) 行级删除、Merge-on-Read

嗯,这里要注意:Delete Manifest 是 Iceberg 实现行级更新的关键。它不直接改数据文件,而是记录「哪些行被删了」。读的时候把数据文件和删除文件合并,得到最终结果。

2.4 元数据层:表的「大脑」

元数据层是三层架构的顶层,管理着表的全局状态。

每个 Iceberg 表都有一个 metadata/ 目录,里面放着:

  • version-hint.text:指向当前最新的元数据文件版本号。
  • v<N>.metadata.json:元数据文件,包含表快照列表、Schema、分区规范等。
  • snap-<id>.avro:快照文件,记录该快照下引用的所有清单文件。

元数据文件的结构大致如下:

{
  "format-version": 2,
  "table-uuid": "abc-123-def",
  "location": "s3://bucket/table",
  "last-sequence-number": 100,
  "schemas": [ ... ],
  "partition-specs": [ ... ],
  "current-snapshot-id": 98765,
  "snapshots": [
    {
      "snapshot-id": 98765,
      "timestamp-ms": 1700000000000,
      "manifest-list": "s3://bucket/table/metadata/snap-98765.avro"
    }
  ]
}

每次提交(commit)都会生成一个新的元数据文件。Iceberg 通过原子性重命名 version-hint.text 来实现乐观锁,保证并发写入的安全性。

注意:元数据文件不会自动清理。如果表频繁提交,元数据文件会越积越多。我见过一个表跑了半年,metadata 目录下堆了上千个文件。建议定期执行 EXPIRED_SNAPSHOT 清理,保留最近 7 天或最近 100 个快照就够了。

2.5 三层如何协作?一个查询的生命周期

光讲概念太干。我们走一遍查询流程,你就明白了。

  1. 读 version-hint.text:找到最新的元数据文件编号。
  2. 加载元数据文件:拿到当前快照 ID 和对应的 manifest-list 路径。
  3. 读 manifest-list:获取该快照下所有清单文件的路径。
  4. 扫清单文件:利用列统计信息,过滤掉不匹配的数据文件。
  5. 读数据文件:只读取过滤后剩下的文件,返回结果。

你看,每一步都在做「裁剪」。元数据层裁剪掉不相关的快照,清单层裁剪掉不相关的文件,最后数据层只读必要的文件。这也是 Iceberg 在 PB 级数据上依然能秒级响应的原因。

个人习惯:排查查询慢的问题时,我第一步不是看数据文件,而是看清单文件的数量和大小。如果清单文件太多,先合并小文件;如果清单文件太大,检查是不是列统计信息没更新。这两个点搞定,80% 的性能问题都能解决。

2.6 版本演进:从 V1 到 V2

Iceberg 的表格式有两个版本,目前主流是 V2。

特性 V1 V2
行级删除 不支持 支持(通过 Delete Manifest)
列级统计 可选 更完善
分区演化 支持 支持(更稳定)
Sequence Number 无 有(用于冲突检测)

V2 最大的变化就是引入了 Sequence Number 和 Delete Manifest。Sequence Number 解决了并发写入时的冲突问题,Delete Manifest 让 Iceberg 真正支持了行级更新和删除。

我个人建议:新表直接用 V2。V1 虽然兼容性好,但缺少行级删除能力,在实时场景下会很受限。

2.7 小结

三层架构是 Iceberg 的基石。数据层管存储,清单层管索引,元数据层管状态。每一层各干各的活,互不干扰。

理解了这个架构,你就能明白为什么 Iceberg 能做到快照隔离、时间旅行、分区演化这些能力。说白了,就是通过分层把「元数据操作」和「数据操作」彻底解耦了。