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 之间的层级关系:
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 实现和查询优化机制。