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

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

改了列名旧文件照样能读,原因在列 ID

在 TT Lab 中继续学习

一句话总结

Iceberg 给每一列分配一个永不改变的字段 ID,并把这个 ID 也写进数据文件。读取时不是按名称而是按 ID 来配对,所以添加列、删除列、重命名、调整顺序、拓宽类型,都只需修改 metadata 中的一行就能完成,旧文件也不用重写。

为什么修改 schema 曾经是事故

在数据湖里,修改 schema 长期以来都是件令人害怕的事。原因在于靠什么去找文件里的列。

在按名称查找的表(大部分 Hive 风格的 Parquet 表)中,把 amount 改名为 amount_krw 之后,旧文件里只有名为 amount 的列,所以用新名称读取时,这一列会整个变成 null。还有更糟的情况:删除了 coupon,几个月后又新建一个含义不同的 coupon,旧文件里残留的旧 coupon 值就会复活,混进新列里出现。

按位置查找的格式(CSV、按顺序的 Hive 表)则朝相反的方向出错。删除第三列后,第四列会被挪到第三列的位置,名称和值就对不上了。

Evolution 文档举的正是这两种失败。按名称跟踪的格式,如果重新使用某个名称,就会让已删除的列复活;按位置跟踪的格式,删除一列会让其他列所用的名称发生改变。所以团队们每次修改 schema,都要把整张表重写一遍,或者干脆改不了,带着错误的名称过上好几年。

工作原理——按 ID 配对

创建表时,每一列都会被分配一个 ID(order_id 为 1,customer_id 为 2,……)。写入的引擎会把这个 ID 作为 field_id,一并写进 Parquet 文件的列定义中。schema 在 metadata 中以列表的形式累积,每个 schema 各有一个 schema-id,而快照会记住创建自己时的 schema ID。

列投影规则很简单。数据文件的列是按字段 ID 来选取的。表 schema 中有、而文件中没有的 ID,会用 null 填充(没有名称映射或默认值的情况下)。有了这一条规则,五种变更就全都变得安全了。

变更 metadata 中发生的事 读取旧文件时
添加 出现一个带有新 ID 的字段 没有这个 ID,所以是 null
重命名 只改变同一个 ID 的名称 通过文件里的旧名称与 ID 相连,值保持不变
删除 当前 schema 中去掉了这个 ID 即使文件里还留着值也不读取
调整顺序 只改变字段顺序 按 ID 选取,所以值不会混淆
拓宽 类型改为更宽的一种 把以较窄类型写入的值拓宽后读取

与被删除的列同名重新添加的列会得到新的 ID。旧文件里只有旧 ID 的值,所以新列中什么都不会出现。文档所保证的“新添加的列绝不会读取其他列的已有值”,指的就是这一点。

类型只能拓宽

规范中的 schema 演进一节明确规定,格式版本 1 和 2 允许的类型变更只有三种——int → long、float → double、decimal(P, S) → decimal(P', S)(要求 P' > P,也就是只拓宽精度)。版本 3 中又增加了 date → timestamp 之类的几种。全都是值不会被截断的方向。把 long 改回 int,可能会截断文件里已有的大数值,所以不被允许,引擎会拒绝这种变更。

在现场相遇的样子

修正名称有误的列。许多表带着 amout 这样的拼写错误列过了好几年。在 Iceberg 中,重命名只是 metadata 的一行,当天就能改正。不过要告知使用方:直接打开文件的使用方(不认识字段 ID 的脚本)看到的仍然是旧名称。

金额列溢出了 int。以韩元计的金额,总有一天会超过 21 亿。int → long 的拓宽不用重写文件就能立即完成。反过来,“为了节省空间”要把 long 改成 int 的请求,被拒绝才是对的。

删除列后又重建,旧值混了进来。这是按名称读取的旧表里常见的事故。迁移到 Iceberg 之后,即使用同样的名称重新创建,新列也是从空开始的。

实际工作中真正重要的事

下一项实验要做什么

创建表并写入 3 月 1 日的数据,加上 coupon 列后再写入 3 月 2 日的数据。把 amount 重命名为 amount_krw 之后,用 pyarrow 直接打开第一次提交的 Parquet 文件页脚,确认旧名称 amount 和 field_id 4 原样都在。把 amount_krw 拓宽为 BIGINT,并写下再改回 INT 被拒绝时的错误条件,然后删除 coupon 再重新添加,确认新的 ID 和空值,最后数一数:尽管累积了多个 schema,快照和数据文件依然原样不变。