Manifest · 湖表元数据索引核心¶
一句话理解
湖表元数据的二层索引文件。Manifest 记录一批数据文件的路径 + 列级统计,Manifest List 索引一批 Manifest。查询引擎不再靠 LIST 扫目录——读两层元数据文件定位数据。这是 Lakehouse "10× 性能优势"的关键一环。
TL;DR
- 两层索引:Manifest List(avro)→ Manifest(avro)→ Data / Delete File
- 列级统计(min/max/null_count)在 Manifest 里 → 支持 file-level pruning
- 写入只追加 manifest,不改历史 → 原子提交友好
- 查询 planning 从 Hive 的分钟级降到湖表的秒级
- 各家实现差异:Iceberg/Paimon(avro)· Delta(JSON)· Hudi(Timeline)
1. 为什么需要 Manifest · Hive 时代的痛¶
Hive 时代"哪些文件属于这个分区"靠扫目录回答。几十万文件时:
| 瓶颈 | 代价 |
|---|---|
| S3 LIST 开销 | 每次最多返回 1000 keys,百万文件要翻千页 |
| HDFS NameNode | 数百万 inode 压力,单点崩 |
| 目录结构即元数据 | 改目录 = 改表,无事务语义 |
| 列统计无处存 | 扫到数据才知道要不要跳过 |
典型事故: - Netflix Hive 某大表 LIST 100+ 万分区要 30-90s - 多个 Spark 作业并发写同分区 → 文件丢失
Manifest 的革命:写端维护索引 → 读端不扫目录 → 写事务 = 替换 Manifest 根指针。
2. Iceberg 两层结构深挖¶
flowchart TD
snap["Snapshot<br/>(metadata.json)"] -->|manifest-list-path| mlist["Manifest List<br/>(avro)<br/>snap-<hash>.avro"]
mlist -->|partition range, file count| m1["Manifest 1<br/>(avro)"]
mlist --> m2["Manifest 2"]
m1 -->|column stats per file| d1["data_file_a.parquet"]
m1 --> d2["data_file_b.parquet"]
m2 --> d3["data_file_c.parquet"]
m2 -.-> del["delete_file_x.parquet"]
Manifest List 内容(avro schema 简化)¶
manifest_path: string
manifest_length: long
partition_spec_id: int
content: int # 0=data 1=deletes
sequence_number: long
snapshot_id: long
added_files_count: int
existing_files_count: int
deleted_files_count: int
partitions: list<struct> # 分区值范围 (min/max per partition column)
关键字段:partitions 让读者不打开 manifest 即可按分区剪枝。
Manifest 内容(avro schema 简化)¶
status: int # 0=existing 1=added 2=deleted
data_file: {
file_path: string
file_format: string # PARQUET
partition: struct
record_count: long
file_size_in_bytes: long
column_sizes: map<int, long>
value_counts: map<int, long>
null_value_counts: map<int, long>
nan_value_counts: map<int, long>
lower_bounds: map<int, binary> # per column
upper_bounds: map<int, binary>
key_metadata: binary
split_offsets: list<long>
}
核心:lower_bounds / upper_bounds 让读者不打开 data file 即可按谓词剪枝。
Iceberg v3 的 Manifest 扩展 · Row Lineage¶
v3 之前的缺口:Manifest 只记"文件级"变化(增/删/修改了哪些 data file),下游消费者要自己推导"具体哪一行变了"——update 本质上是 delete 老行+insert 新行,跨 snapshot 对同一业务实体的追踪要额外工程。
v3 的补强:Manifest entry 加了两个字段,让行级身份在 snapshot 间保持:
first-row-id:该 data file 里第一行的全局行 ID(单调递增,跨 snapshot 稳定)last-updated-sequence-number:该行最近一次被修改时的 sequence number
落地价值:
- 精确 CDC · 流消费者能追踪"这一行在 snapshot N 是值 V1、在 N+5 被更新为 V2"——而不是"N+5 里有行被更新"
- Materialized View 增量刷新 · 基于 row-level diff 刷 MV 比 file-level diff 少大量全刷情况
- 行级审计 · 合规场景"这一条记录 5 年内被谁改过"可直接查
3. 查询剪枝流程¶
假设 SELECT * FROM sales WHERE ts >= '2024-12-01' AND region = 'NA':
Step 1: 读 metadata.json → 拿到 Manifest List 路径
Step 2: 读 Manifest List (几 KB) → 按 partitions 字段剪枝,只保留相关 Manifest
Step 3: 读幸存 Manifest (每个几 KB-几 MB) → 按 lower/upper_bounds 剪枝
Step 4: 只打开幸存的 Data File 扫描
量化: - 10 万 data file 的表 - Hive LIST:30-60 秒 - Iceberg Manifest 读+剪枝:100-500 ms - 性能提升 60-600×
4. 和其他格式对比¶
| 系统 | 元数据载体 | 格式 | 查询剪枝支持 |
|---|---|---|---|
| Iceberg | Manifest + Manifest List | Avro | 分区 + 列 min/max + Bloom(v3+) |
| Paimon | Manifest + Manifest List | Avro(同 Iceberg) | 同上 + bucket |
| Delta | _delta_log/*.json + checkpoint Parquet |
JSON + Parquet | add action 有 stats |
| Hudi | Timeline instant + Metadata Table(1.0+) | Avro + JSON + Parquet | Metadata Table 七类索引:Files / Column Stats / Bloom / Partition Stats / Record-level / Secondary / Expression |
深层差异:
- Iceberg / Paimon:Manifest-per-batch,多次写入产生多 Manifest,periodic 合并
- Delta:每次 commit 产生一个 JSON 事务日志,checkpoint 成 Parquet(周期压缩)
- Hudi:Timeline 是事件流(commit / clean / compaction / rollback 的全序日志);Metadata Table(1.0 成熟)才是现代 Hudi 的主元数据载体,提供七类索引
本质都是"不扫目录、读索引文件"——但索引的组织方式 + 合并策略不同。
5. Manifest 的隐藏价值¶
增量读 / CDC 基础¶
Iceberg table.changes(start_snap, end_snap):
- 对比两个 Snapshot 的 Manifest List
- 找新增的 Manifest(以及新增/删除的 data files)
- 返回差量数据
小文件治理信号¶
可以 SELECT * FROM db.tbl.files 查看 Iceberg 的 metadata 表:
- 每个 data file 大小
- 识别 < target 大小的文件
- 触发 rewrite_data_files
Schema Evolution 审计¶
每个 Manifest 记录写入时的 schema 版本 ID → Schema 演化后老 Manifest 仍可读、新 Manifest 用新 schema。
Partition Spec 演化¶
Iceberg 支持不同 Manifest 用不同分区规范(partition evolution 的基础)。老数据按旧分区、新数据按新分区,读者分别按 partition_spec_id 处理。
6. 工程细节¶
Manifest 数量管理¶
一个 Snapshot 有几十到几千 Manifest 合适: - 太少(< 10):每 Manifest 过大,I/O 单点 - 太多(> 10k):Manifest List 变大,读 metadata 慢
治理命令(Iceberg):
-- 合并小 Manifest
CALL system.rewrite_manifests('db.tbl');
-- 查看 Manifest 信息
SELECT * FROM db.tbl.manifests;
Manifest 大小¶
- 典型 1-50 MB
- 一个 Manifest 引用 10-1000 个 data file
读取缓存¶
Trino / Spark 都会缓存 Manifest: - 减少重复 I/O - 小查询共享 Manifest 缓存
7. 代码示例¶
Iceberg 查 Manifest 表¶
-- 查看所有 Manifest
SELECT path, length, partition_summaries, added_data_files_count
FROM iceberg.db.tbl.manifests;
-- 查看某文件所在的 Manifest
SELECT * FROM iceberg.db.tbl.files
WHERE file_path LIKE '%data_file_a.parquet%';
-- 查看 Manifest 与 Snapshot 的关联
SELECT committed_at, snapshot_id, manifest_list
FROM iceberg.db.tbl.snapshots;
用 Java API 直接读 Manifest¶
Table table = catalog.loadTable(TableIdentifier.of("db", "tbl"));
for (ManifestFile manifest : table.currentSnapshot().allManifests(table.io())) {
for (ManifestEntry<DataFile> entry : ManifestFiles.read(manifest, table.io())) {
DataFile df = entry.file();
System.out.println(df.path() + " rows=" + df.recordCount() +
" size=" + df.fileSizeInBytes());
}
}
8. 陷阱与反模式¶
- Manifest 不定期 rewrite:百万小 Manifest → 查询慢
- 流写无后台 compaction:Manifest 爆炸 + Data File 爆炸
- Expire Snapshot 不跑:老 Manifest 不释放,对象存储成本爆
- 手工改 Manifest:元数据和数据不一致 → 查询崩
- 统计信息开销当问题:忽视 lower/upper_bounds 反而让查询慢
- Delete File 不管:MoR 模式下 Delete File 堆积 → 查询合并慢