建一张表,从 metadata.json 一路走到数据文件
目标
用 Spark 创建 Iceberg 表,把一天的订单分两次提交,然后不借助工具,亲手顺着 Catalog → metadata.json → 清单列表 → 清单 → 数据文件这棵树往下走。把每一层找到的值写成文件,由评分器把这些值与实际的元数据对照。
为什么重要
Iceberg 表不是“目录”,而是“文件列表”。读取的一方不扫描目录,只相信 metadata.json 所指向的列表。所以提交不是移动文件,而是写好一份新列表,再把 Catalog 中的一个指针改掉,指针改掉的那一刻,所有读取的人看到的都是新状态。 亲手顺着这个结构走一遍,就会发现后面所有的功能都是同一原理的变体。时间旅行是读取旧快照的列表,回滚是把指针拨回旧快照,文件合并和过期则是重写列表,并删除再也没有人指向的文件。 出了故障时最先要看的,也是这棵树。“这一行为什么看不到”几乎总是变成“那个文件在当前快照的列表里吗”。
步骤
- 用 /root/ice/meta/create.py(应用
ice-meta-create)创建命名空间lake.meta和表lake.meta.orders。列为order_id STRING, customer_id STRING, region STRING, amount INT, status STRING, order_ts TIMESTAMP,属性为'format-version' = '2'。 - 让 /root/ice/meta/load.py 接收一个日期作为参数,把
/data/ice/orders/<날짜>.csv一次性提交到表中(占位符为日期),并传入2026-03-01。 - 用同一个脚本传入
2026-03-02,使快照变成两个。 - 从 Catalog
/root/ice/catalog.db的iceberg_tables中读取该表的metadata_location和previous_metadata_location,分两行写入 /root/ice/meta/out/pointer.txt。 - 从当前 metadata.json 中取出快照列表,写入 /root/ice/meta/out/snapshots.json。
- 解开当前快照的清单列表(Avro),把清单列表写入 /root/ice/meta/out/manifests.json。
- 解开这些清单,把现在存活的数据文件列表写入 /root/ice/meta/out/datafiles.json。
- 在 /root/ice/meta/report.md 中写出
## 포인터、## 스냅샷、## 파일三个小节。第二节写入快照数,第三节写入清单数和数据文件数。
参考
- 脚本这样运行:
cd /root/ice/meta && spark-submit create.py。Spark 启动需要 15–20 秒。 ice-loc meta.orders会打印 Catalog 所指向的当前 metadata.json 路径。ice-avro <파일>会把 Avro 逐行解成 JSON(请用jq过滤;占位符为 Avro 文件)。- 路径前面的
file:由 Java 添加,file:///由 Python 添加。评分器会把两者视为同一个路径。 - 常见错误:用同一个日期运行两次
load.py,使快照变成三个。想从头再来,就在执行spark-sql -e "DROP TABLE lake.meta.orders PURGE"之后从第 1 步重新做——记下的值都必须从新表重新取出。 - 官方文档:Table Spec — Overview · Spark Getting Started · JDBC Catalog
创建表——还没有快照的第一份 metadata
创建 /root/ice/meta/create.py,应用名称为 ice-meta-create,创建 lake.meta 命名空间和 lake.meta.orders 表(六个列,'format-version' = '2'),并用 spark-submit 运行。
创建表后会生成一个 metadata 文件(00000-….metadata.json),并在 Catalog 中写入一行该路径。因为还没有提交数据,所以没有快照。评分器会检查第一个 metadata 文件中是否没有快照,以及格式版本、列名和类型是否正确。
第一次提交——一个快照
创建 /root/ice/meta/load.py,应用名称为 ice-meta-load,让它按给定的 schema 读取作为参数传入的日期所对应的 /data/ice/orders/<날짜>.csv(占位符为日期),并用 writeTo("lake.meta.orders").append() 写入。用 spark-submit load.py 2026-03-01 运行。
一次 append 就是一次提交,一次提交就是一个快照。评分器会检查第一个快照是否没有父快照、是以 append 产生的,以及摘要(summary)中的 added-records 是否与当天文件的行数相同。
第二次提交——指向父快照的快照
用同一个脚本运行 spark-submit load.py 2026-03-02,使快照恰好变成两个。
新快照会把前一个快照作为父快照(parent-snapshot-id),序列号加一。第一个快照的文件不会被重写,只是再添加一个新文件。如果同一个日期放了两次,就用 PURGE 删除表,从第 1 步重来。
Catalog 中的两个指针
从 /root/ice/catalog.db 的 iceberg_tables 中,读取 meta.orders 这一行的 metadata_location 和 previous_metadata_location,分别写在 /root/ice/meta/out/pointer.txt 的第一行和第二行。
JDBC Catalog 每张表只有一行。提交是条件式 UPDATE——“只有当前值与我读到的值相同时,才改成新值”,改变之前的值会留在 previous 一栏。用 sqlite3 -separator 可以把两列打印成两行。
metadata.json 中的快照列表
从当前 metadata.json(ice-loc meta.orders)生成 /root/ice/meta/out/snapshots.json,形式为 {"current_snapshot_id": 정수, "snapshots": [{"snapshot_id", "parent_snapshot_id", "sequence_number", "manifest_list"}, …]}(占位符为整数)。
metadata.json 的键使用连字符(current-snapshot-id、parent-snapshot-id)。在 jq 中要像 .["snapshot-id"] 这样用方括号读取。快照 ID 是 19 位整数,手抄容易出错——请用 jq 原样搬运。
清单列表——清单的列表
用 ice-avro 解开当前快照的 manifest_list 文件,把 [{"manifest_path", "added_snapshot_id", "added_files_count", "existing_files_count"}, …] 数组写入 /root/ice/meta/out/manifests.json。
第二个快照的清单列表里,原样再次包含了第一次提交所创建的清单。新的提交不会改写旧清单,只是指向它——所以提交便宜,旧快照也原样保留。用 added_snapshot_id 可以看出某个清单是由哪次提交创建的。
清单——数据文件和行数
解开 manifests.json 中的各个清单,把 status 不是 2(DELETED)的条目所对应的数据文件,以 [{"file_path", "record_count"}, …] 的形式写入 /root/ice/meta/out/datafiles.json。
清单的一行(条目)由 status(0 EXISTING、1 ADDED、2 DELETED)和 data_file 结构体组成。读取的引擎只看这份列表和列统计信息(下界、上界)来决定打开哪些文件——不扫描目录。评分器会检查列表是否与当前快照的存活文件完全一致,以及行数之和是否与表的行数相等。
用一页纸画出这棵树
在 /root/ice/meta/report.md 中写出 ## 포인터、## 스냅샷、## 파일 三个小节。第二节以数字写入快照数,第三节以数字写入当前快照的清单数和数据文件数。
请按顺序写出,当有人说“昨天放进去的数据看不到了”时,应该从哪一层开始检查。数字从你的 out/ 文件中搬过来。