4. 元数据文件结构:Metadata JSON、Manifest List 与 Manifest 文件格式

这一章我们深入 Iceberg 的“骨架”——元数据文件结构。说白了,Iceberg 之所以能实现 ACID 和快照隔离,靠的就是这套精心设计的文件体系。我刚开始接触时也觉得文件层级有点多,但搞清楚之后会发现,每一层都有它存在的道理。

核心一句话:Iceberg 的元数据由三层文件构成——Metadata JSON(表级元数据)、Manifest List(快照级索引)、Manifest(数据文件清单)。它们像俄罗斯套娃一样层层嵌套,但每一层都只做一件事。

4.1 Metadata JSON 文件结构解析

Metadata JSON 是 Iceberg 表的“身份证”。它记录了表的 Schema、分区信息、快照列表、当前快照 ID 等全局信息。每次提交新快照,都会生成一个新的 Metadata JSON 文件。

来看一个典型的 Metadata JSON 结构(我简化了一些字段):

{
  "format-version": 2,
  "table-uuid": "fb2c5b6a-...",
  "location": "s3://my-bucket/warehouse/db/table",
  "last-sequence-number": 100,
  "last-updated-ms": 1700000000000,
  "current-schema-id": 0,
  "schemas": [
    {
      "schema-id": 0,
      "type": "struct",
      "fields": [
        {"id": 1, "name": "id", "type": "int", "required": true},
        {"id": 2, "name": "name", "type": "string", "required": false},
        {"id": 3, "name": "ts", "type": "timestamp", "required": false}
      ]
    }
  ],
  "partition-specs": [
    {
      "spec-id": 0,
      "fields": [
        {"source-id": 3, "name": "ts_day", "transform": "day"}
      ]
    }
  ],
  "default-spec-id": 0,
  "last-partition-id": 1000,
  "properties": {
    "write.format.default": "parquet",
    "write.target-file-size-bytes": "536870912"
  },
  "current-snapshot-id": 123456789,
  "snapshots": [
    {
      "snapshot-id": 123456789,
      "parent-snapshot-id": null,
      "sequence-number": 100,
      "timestamp-ms": 1700000000000,
      "manifest-list": "s3://.../snap-123456789-manifest-list.avro",
      "summary": {
        "operation": "append",
        "added-data-files": 10,
        "added-records": 1000000,
        "total-data-files": 50
      }
    }
  ],
  "snapshot-log": [...],
  "metadata-log": [...]
}

我的经验:在 v2 格式中,last-sequence-number 和 sequence-number 是关键字段。它们用于实现行级删除和并发冲突检测。我曾经在生产环境遇到过因为 sequence-number 不一致导致的提交失败,排查了半天才发现是某个老版本客户端还在用 v1 格式提交。

Metadata JSON 的核心职责就两个:

  • 描述表结构——Schema、分区、排序顺序等
  • 指向快照——通过 current-snapshot-id 和 snapshots 数组,找到最新的 Manifest List

你可能会问:为什么要把快照列表也放在 JSON 里?直接存一个指针不行吗?嗯,这里有个设计考量——Metadata JSON 是 Iceberg 的“原子更新点”。每次提交只需要原子替换这个文件,就能完成整个表的元数据切换。快照列表放在里面,是为了让读取端能快速回溯历史版本。

4.2 Manifest List 文件格式

Manifest List 是快照的“目录”。它记录了该快照下所有 Manifest 文件的元信息,包括每个 Manifest 的分区范围、数据文件数量、变更类型等。

Manifest List 使用 Avro 格式存储。为什么用 Avro?因为 Avro 是行式存储,适合逐条读取,而且 schema 可以内嵌在文件中,方便跨版本兼容。

来看 Manifest List 的 Avro schema(核心字段):

{
  "type": "record",
  "name": "manifest_list_entry",
  "fields": [
    {"name": "manifest_path", "type": "string"},
    {"name": "manifest_length", "type": "long"},
    {"name": "partition_spec_id", "type": "int"},
    {"name": "content", "type": "int", "doc": "0=data, 1=deletes"},
    {"name": "sequence_number", "type": "long"},
    {"name": "min_sequence_number", "type": "long"},
    {"name": "added_snapshot_id", "type": "long"},
    {"name": "added_files_count", "type": "int"},
    {"name": "existing_files_count", "type": "int"},
    {"name": "deleted_files_count", "type": "int"},
    {"name": "partitions", "type": {
      "type": "array",
      "items": {
        "type": "record",
        "name": "field_summary",
        "fields": [
          {"name": "contains_null", "type": "boolean"},
          {"name": "contains_nan", "type": "boolean"},
          {"name": "lower_bound", "type": ["null", "bytes"]},
          {"name": "upper_bound", "type": ["null", "bytes"]}
        ]
      }
    }}
  ]
}

注意:Manifest List 中的 partitions 字段存储的是每个分区列的统计信息(上下界、是否包含 null 等)。这些信息在查询时用于分区裁剪,能大幅减少需要扫描的 Manifest 数量。但要注意,lower_bound 和 upper_bound 是字节数组,需要根据分区列的转换函数来解析。

Manifest List 的设计巧妙之处在于:它让 Iceberg 可以在不读取所有 Manifest 的情况下,快速判断哪些 Manifest 可能包含目标数据。举个例子,如果你的查询条件是 ts >= '2024-01-01',Iceberg 会先看 Manifest List 中每个 Manifest 的分区上下界,跳过那些完全不匹配的 Manifest。

4.3 Manifest 文件格式

Manifest 是真正的“数据文件清单”。它记录了该分区下所有数据文件(或删除文件)的详细信息,包括文件路径、行数、大小、列统计信息等。

Manifest 同样使用 Avro 格式。它的核心 schema 如下:

{
  "type": "record",
  "name": "manifest_entry",
  "fields": [
    {"name": "status", "type": "int", "doc": "0=EXISTING, 1=ADDED, 2=DELETED"},
    {"name": "snapshot_id", "type": ["null", "long"]},
    {"name": "sequence_number", "type": ["null", "long"]},
    {"name": "file_sequence_number", "type": ["null", "long"]},
    {"name": "data_file", "type": {
      "type": "record",
      "name": "data_file",
      "fields": [
        {"name": "content", "type": "int", "doc": "0=data, 1=position deletes, 2=equality deletes"},
        {"name": "file_path", "type": "string"},
        {"name": "file_format", "type": "string"},
        {"name": "partition", "type": {"type": "record", "name": "partition_data", "fields": [...]}},
        {"name": "record_count", "type": "long"},
        {"name": "file_size_in_bytes", "type": "long"},
        {"name": "column_sizes", "type": ["null", {"type": "map", "values": "long"}]},
        {"name": "value_counts", "type": ["null", {"type": "map", "values": "long"}]},
        {"name": "null_value_counts", "type": ["null", {"type": "map", "values": "long"}]},
        {"name": "nan_value_counts", "type": ["null", {"type": "map", "values": "long"}]},
        {"name": "lower_bounds", "type": ["null", {"type": "map", "values": "bytes"}]},
        {"name": "upper_bounds", "type": ["null", {"type": "map", "values": "bytes"}]},
        {"name": "key_metadata", "type": ["null", "bytes"]},
        {"name": "split_offsets", "type": ["null", {"type": "array", "items": "long"}]},
        {"name": "equality_ids", "type": ["null", {"type": "array", "items": "int"}]}
      ]
    }}
  ]
}

关键点:Manifest 中的 lower_bounds 和 upper_bounds 是列级别的统计信息。它们比 Manifest List 中的分区统计更细粒度。查询引擎可以利用这些信息做“文件裁剪”——跳过那些肯定不包含目标数据的文件。我在项目中见过一个优化案例:通过合理设置排序顺序,让文件裁剪效率提升了 70%。

4.4 Avro 序列化细节

Avro 是 Iceberg 元数据文件的底层序列化格式。理解 Avro 的一些细节,对排查问题很有帮助。

几个关键点:

  • Schema 内嵌:每个 Avro 文件头部都包含完整的 schema 定义。这意味着即使客户端版本不同,也能正确解析文件。
  • 行式存储:Avro 按行组织数据,适合逐条读取 Manifest List 和 Manifest 中的条目。
  • 压缩支持:Iceberg 默认使用 gzip 压缩 Avro 文件。你可以通过 write.metadata.compression-codec 配置来修改。
  • 二进制编码:Avro 使用紧凑的二进制编码,比 JSON 小得多。一个包含 1000 个文件的 Manifest,Avro 格式大约只有几十 KB。

我曾经踩过的坑:有一次在升级 Iceberg 版本后,发现老的 Manifest 文件无法读取。排查后发现是新版本修改了 Avro schema 中某个字段的默认值。解决方案是在升级前先做一次 REWRITE_MANIFESTS 操作,让所有 Manifest 都使用新 schema 重新生成。

4.5 三层结构的关系图

下面这张图展示了 Metadata JSON、Manifest List、Manifest 之间的层级关系:

Iceberg 元数据三层结构 Iceberg 元数据三层结构 Metadata JSON 表级元数据 · Schema · 分区 · 快照列表 Manifest List 快照级索引 · 分区范围 · Manifest 元信息 Manifest 1 数据文件清单 列统计 · 分区信息 Manifest 2 数据文件清单 列统计 · 分区信息 Manifest 3 数据文件清单 列统计 · 分区信息 读取路径 1. 读取 Metadata JSON → 获取当前快照 ID → 找到 Manifest List 路径 2. 读取 Manifest List → 根据分区裁剪筛选 Manifest → 读取目标 Manifest 3. 读取 Manifest → 根据列统计裁剪数据文件 → 读取最终数据文件

4.6 各层文件的对比

特性 Metadata JSON Manifest List Manifest
文件格式 JSON Avro Avro
存储内容 表结构、快照列表 Manifest 索引、分区范围 数据文件清单、列统计
变更频率 每次提交都生成 每次提交都生成 仅当数据文件变更时
文件大小 几 KB 几十 KB 几十 KB ~ 几 MB
读取时机 每次查询都读 每次查询都读 按需读取(分区裁剪后)
压缩方式 无(JSON 本身小) gzip(默认) gzip(默认)

性能注意事项:虽然 Metadata JSON 很小,但如果表有上千个快照,snapshots 数组会变得很大。Iceberg 提供了 write.metadata.previous-versions-max 配置来限制保留的快照数量。我建议生产环境设置为 50-100,既能满足时间旅行查询,又不会让 Metadata JSON 过于臃肿。

4.7 实际排查技巧

最后分享几个我在生产环境中用过的排查方法:

  • 查看当前快照的 Manifest List:用 SELECT * FROM table.snapshots 获取快照 ID,然后直接去存储路径下找对应的 snap-{id}-manifest-list.avro 文件。
  • 检查 Manifest 文件是否损坏:用 avro-tools 命令行工具可以验证 Avro 文件的完整性。java -jar avro-tools.jar getmeta manifest.avro 可以查看 schema 和元数据。
  • 统计信息不准确:如果发现查询计划选择了错误的文件,可以检查 Manifest 中的 lower_bounds 和 upper_bounds 是否与实际情况一致。不一致时,执行 REWRITE_MANIFESTS 可以重建统计信息。

我的习惯:每次上线新表之前,我都会手动检查一次 Metadata JSON 和 Manifest List 的内容。用 curl 下载 JSON 文件,用 avro-tools tojson 把 Avro 转成可读格式。虽然有点麻烦,但能提前发现很多问题,比如分区字段配置错误、文件路径不对等。

好了,这一章的内容就到这里。三层文件结构是 Iceberg 元数据管理的基石,理解了它们,你就能更好地理解 Iceberg 的 ACID 实现和查询优化机制。