跳转至

Manifest · 湖表元数据索引核心

Explanation · 原理资深

一句话理解

湖表元数据的二层索引文件。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 堆积 → 查询合并慢

9. 相关 · 延伸阅读

权威阅读