改了列名旧文件照样能读,原因在列 ID
一句话总结
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 之后,即使用同样的名称重新创建,新列也是从空开始的。
实际工作中真正重要的事
- ID 才是列的身份,名称只是标签。重命名和调整顺序随时都是安全的。
- 修改 schema 不会重写数据文件。快照也不会增加,只是在 metadata 中多累积一个 schema。
- 类型只能拓宽。int → long、float → double、decimal 精度拓宽。
- 被删除列的 ID 不会回来。用同样名称重建的列,是一个空的新列。
下一项实验要做什么
创建表并写入 3 月 1 日的数据,加上 coupon 列后再写入 3 月 2 日的数据。把 amount 重命名为 amount_krw 之后,用 pyarrow 直接打开第一次提交的 Parquet 文件页脚,确认旧名称 amount 和 field_id 4 原样都在。把 amount_krw 拓宽为 BIGINT,并写下再改回 INT 被拒绝时的错误条件,然后删除 coupon 再重新添加,确认新的 ID 和空值,最后数一数:尽管累积了多个 schema,快照和数据文件依然原样不变。