TT Lab
开始
学习 学习路径 课程

湖仓表格式 — 从元数据理解 Apache Iceberg

表不是目录而是文件清单 — 从 metadata.json 到数据文件

在 TT Lab 中继续学习

一句话总结

Iceberg 表不是通过扫描目录来确定的,而是由 metadata.json → 清单列表 → 清单 → 数据文件这样逐层连接的列表树来定义的,提交就是先写好一棵新树,再原子地改掉 Catalog 中的一个指针。

为什么目录方式行不通

在 Hive 风格的表里,“表”就是目录。orders/order_date=2026-03-01/ 下面的文件就是那一天的数据,读取的引擎通过列出目录来收集文件。这种方式很简单,但有三个问题。

第一,没有原子性。写入作业在十个文件中只上传了五个的时候,如果读取作业列出目录,就会看到残缺的结果。第二,列出的代价很高。正如 Reliability 文档所指出的,Hive 表通过元存储(分区)和文件系统(文件)两个地方来跟踪状态,所以每次规划作业,都需要做与分区数成正比的列表查询。在对象存储上,这种查询很慢,过去甚至结果还会延迟才达到一致。第三,对什么是表没有共识。如果有人失误往目录里扔了一个文件,它也会成为表的一部分。

工作原理——四层的树

规范的概述在第一句话里就转换了方向:这种格式不是跟踪目录,而是逐个跟踪每一个文件。写入方可以把文件写在任何地方,只有通过显式的提交,才会把它加入表。

层 格式 内容
metadata.json JSON schema 列表、分区规范列表、属性、快照列表、当前快照 ID、refs
清单列表 Avro 构成一个快照的各个清单,以及各自的分区摘要和文件数
清单 Avro 每个数据(或删除)文件一行:路径、分区值、行数、各列的下界和上界
数据文件 Parquet 等 实际的行

快照拥有 snapshot-id、指向父快照的 parent-snapshot-id、表示提交顺序的 sequence-number,以及自己的 manifest-list 路径。摘要(summary)中的 operation 是 append、replace、overwrite、delete 四者之一,同时还会记录新增的行数、文件数之类的数字。快照的数据是它的各个清单中所记录的存活文件的并集。

清单的一行(条目)带有 status——0 是 EXISTING,1 是 ADDED,2 是 DELETED。扫描规划不会使用 DELETED 的条目。而且它分两个阶段跳过内容:利用清单列表的分区摘要,整个清单被跳过;利用清单的列统计信息,文件被跳过。哪里都没有列出目录的操作。

提交做了什么

想一想第二次提交。写入新的数据文件,写入包含这一个文件的新清单,写入同时指向第一次提交的清单和新清单的新清单列表,再写入增加了一个快照的新 metadata.json。规范指出,清单会在快照之间被复用——旧的清单不会被改写,只是被指向。所以提交的开销与变更的量成正比,而不是与表的大小成正比。

最后,把 Catalog 中“当前 metadata 就是这个文件”的指针原子地改掉。此后打开表的人看到新状态,此前打开的人则始终看到旧状态。不存在残缺的状态。序列号每次提交增加一,在提交竞争中落败后重新提交时,只需重写清单列表即可,这是设计好的。

在现场相遇的样子

昨天放进去的数据看不到了。文件明明在存储桶里,却看不到行,几乎总是因为那个文件不在当前快照的清单里——写入作业只写了文件,在提交之前就挂掉了。在 Iceberg 中,这是正常行为。没有提交的文件不属于表。

目录删掉了,表却完好无损,或者正相反。数据文件的路径在清单中以完整路径记录。目录结构只是一种惯例,没有实际意义。手动删除文件会让表损坏(清单指向了不存在的文件),手动放入文件则什么都不会发生。

规划很慢。用流式写入每分钟提交一次的表,清单会达到几千个。要知道查询规划变慢的原因可能不在数据,而在元数据层,这样才会联系到文件合并和清单整理。

实际工作中真正重要的事

下一项实验要做什么

用 Spark 创建表,把一天的订单分两次提交。然后不借助工具,顺着这棵树往下走:从 SQLite Catalog 的一行中读取当前和之前的 metadata 路径,用 jq 取出 metadata.json 中的快照列表(父快照、序列号、清单列表),解开 Avro 格式的清单列表,确认第二个快照原样指向第一次提交的清单,再解开清单,写下存活的数据文件和行数。评分器会把你写下的值与实际的元数据对照。