土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】时间旅行、Schema 与分区演进

文章导航

分类入口
databasestorage
标签入口
#time-travel#schema-evolution#snapshot

目录

第 15 章 解决了「谁持有当前指针」。但 Iceberg 的元数据是不可变快照的链——每次提交产生一个新 snapshot,旧 snapshot 并不删除。这条链一旦显式管理起来,就能做三件传统数仓很贵的事:回到过去某个时间点读数据、把表回滚到出错前、在不重写历史数据的前提下改 schema

这三件事听起来像三个独立特性,根子却是同一个:Iceberg 用 ID 而不是位置/名字来标识一切——snapshot 有 ID,schema 有 ID,每个字段有 field ID。位置和名字是会变的视图,ID 才是稳定的锚。理解了这一点,schema evolution 的「为什么改个列名不用重写 PB 级数据」就一目了然。

本章先讲快照的生命周期(过期、回滚、按时间/快照读),再讲 schema evolution 的 field-ID 规则与兼容边界,全程用 PyIceberg 真跑验证。

版本锚定:Iceberg 表规范 V2。实验环境:Arch Linux on WSL2(kernel 6.6.87.2),i9-12900K,Python 3.14.5,PyIceberg 0.11.1,PyArrow 24.0.0,本地文件系统 + SQLite SQL catalog。


一、快照链:不可变的提交历史

1.1 一次提交 = 一个新 snapshot

回顾 第 8 章:表的 metadata.json 里有一个 snapshots 列表和一个 current-snapshot-id。每次写入(append/overwrite/delete)都:

  1. 写出新的 data files(旧的不动);
  2. 写出新的 manifest 与 manifest list;
  3. 生成一个新 snapshot,记录它的父 snapshot、操作类型、指向的 manifest list;
  4. 通过 catalog 把 current-snapshot-id 原子切到新 snapshot。

旧 snapshot 仍然完整地躺在 metadata 里,它引用的 data files 也还在对象存储上。这就是时间旅行的物理基础:过去的状态没有被销毁,只是不再是「当前」。

下面用 PyIceberg 真建一张表、写两次、演进一次 schema,观察快照链。建表与第一次写入后:

from pyiceberg.catalog.sql import SqlCatalog
import pyarrow as pa

catalog = SqlCatalog("demo",
    uri="sqlite:////tmp/ice_wh16/catalog.db",
    warehouse="file:///tmp/ice_wh16")
catalog.create_namespace("db")

s1 = pa.schema([("id", pa.int64(), False), ("name", pa.string())])
tbl = catalog.create_table("db.users", schema=s1)
tbl.append(pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]}, schema=s1))

此时 schema(真实输出):

table {
  1: id: required long
  2: name: optional string
}

注意左边的 1:2:——这就是 field IDid 是 field-1,name 是 field-2。记住这两个数字,它们才是字段的真身。

1.2 快照历史长什么样

写第二次(且中间演进了 schema,见第三节)后,打印快照链(真实输出):

=== 快照历史 ===
  snapshot_id=4387548087484650653 parent=None              op=Operation.APPEND
  snapshot_id=4044267194436670622 parent=4387548087484650653 op=Operation.APPEND

两个 snapshot 形成父子链:第一个 parent=None(表的初始 append),第二个的 parent 正是第一个的 ID。每个 snapshot 都自带 operation(这里都是 append)。这条链就是表的「git log」。

flowchart LR
  S1["snapshot 4387…0653<br/>op=append<br/>schema-id=0"] --> S2["snapshot 4044…0622<br/>op=append<br/>schema-id=1"]
  CUR["current-snapshot-id"] -.指向.-> S2

1.3 snapshot 里记了什么

每个 snapshot 除了指向 manifest list,还带一个 summary——一组统计与操作信息。换一张表(两次 append)打印真实 summary:

snapshot 2222197236092209889 op=APPEND
   added-data-files = 1      added-records = 3      added-files-size = 932
   total-data-files = 1      total-records = 3      total-files-size = 932
   total-delete-files = 0    total-position-deletes = 0   total-equality-deletes = 0
snapshot 3888050469541575763 op=APPEND
   added-data-files = 1      added-records = 2      added-files-size = 920
   total-data-files = 2      total-records = 5      total-files-size = 1852
   total-delete-files = 0    total-position-deletes = 0   total-equality-deletes = 0

这些 added-* / total-* 字段不是装饰:第二个 snapshot 的 total-records=5 = 上一快照的 3 + 本次 added-records=2total-files-size 同理累加。它们让「这张表现在多大、上次提交加了多少」无需扫描数据就能从元数据直接读出,也是 第 17 章 监控小文件、判断是否该 compaction 的数据来源(total-data-filestotal-delete-files)。

1.4 用元数据表观察历史

PyIceberg(以及 Spark 的 .history / .snapshots / .refs 元数据表)能把快照链查出来。真实列:

inspect.history() 列: [made_current_at, snapshot_id, parent_id, is_current_ancestor]
inspect.refs():
  name=['main'] type=['BRANCH'] snapshot_id=[3888050469541575763]
  max_reference_age_in_ms=[None] min_snapshots_to_keep=[None] max_snapshot_age_in_ms=[None]

二、按时间/快照读、回滚与过期

2.1 按 snapshot 读(时间旅行)

拿到任意历史 snapshot 的 ID,就能让扫描固定在那个版本:

old = tbl.scan(snapshot_id=4387548087484650653).to_arrow()
print(old.column_names)
print(old.to_pydict())

真实输出:

旧快照列名: ['id', 'name']
{'id': [1, 2, 3], 'name': ['a', 'b', 'c']}

这里有个关键且容易被忽略的细节:旧快照读出来的列名是 name,而不是表当前的 full_name(第三节会演进成 full_name)。这说明 Iceberg 的每个 snapshot 都绑定一个 schema-id,时间旅行读会用「那个 snapshot 当时的 schema」来呈现数据。时间旅行不只是「读旧数据文件」,而是「连同当时的 schema 视图一起回到过去」。

按时间戳读是同一个机制的语法糖:Iceberg 在 metadata 里维护 snapshot-log(时间戳 → snapshot-id),AS OF TIMESTAMP 先把时间戳解析成最接近的 snapshot-id,再走 snapshot 读。

读法 语义
scan()(默认) 读 current snapshot
scan(snapshot_id=X) 读指定 snapshot,用其绑定的 schema
AS OF TIMESTAMP t(SQL 引擎) 解析 t → snapshot-log 中 ≤ t 的最近 snapshot

时间旅行的典型用途:复现一次报表口径、给训练任务固定数据版本(见 第 21 章)、排查「昨天这张表是什么样」。

2.2 回滚(rollback)

回滚是把 current-snapshot-id 改回某个历史 snapshot——本质上又是一次 catalog 的原子指针变更,只不过目标是旧值。Spark 里是 rollback_to_snapshot / rollback_to_timestamp 存储过程;语义上:

回滚适合「刚提交的一批数据有问题,立刻退回上一个好状态」。它不是事务级 undo,而是快照级指针移动。

2.3 过期(expire snapshots)

不可变快照链会无限增长:snapshot、manifest、data files 越积越多。expire snapshots 是清理动作,删除早于某个时间点(或超出保留数量)的旧 snapshot,并物理删除「只被这些过期 snapshot 引用、不被任何保留 snapshot 引用」的 data/manifest 文件。

这是时间旅行与存储成本之间的权衡刻度盘:

\[ \text{可回溯窗口} \;\propto\; \text{snapshot 保留时长} \;\propto\; \text{元数据与孤儿数据的存储开销} \]

要点与陷阱:

操作 改什么 删数据吗 可逆吗
时间旅行读 什么都不改 ——
rollback current-snapshot-id 是(再 rollback)
expire snapshots 删旧 snapshot + 孤儿文件

三、Schema Evolution:按 field ID,不按位置

3.1 为什么位置/名字靠不住

传统目录式 Parquet 表,列是按位置名字对应的。这带来两个老问题:

Iceberg 的解法是给每个字段分配一个永不复用的 field ID。schema 里记的是 field-id → (name, type, required),data file(Parquet)里也按 field ID 与 schema 对应。于是:

列名、列顺序只是「当前 schema 这个视图」的属性;数据与字段的绑定靠 field ID。改名、重排、增删都只动 schema 元数据,不碰已有 data files

3.2 真实演进:重命名 + 加列

接着第一节的表(field-1 id、field-2 name),演进:把 name 改名为 full_name,新增 email

from pyiceberg.types import StringType

with tbl.update_schema() as us:
    us.rename_column("name", "full_name")
    us.add_column("email", StringType())

演进后 schema(真实输出):

table {
  1: id: required long
  2: full_name: optional string
  3: email: optional string
}

打印 field-id 映射(真实输出):

=== field id 映射 ===
  field-id=1 name=id        type=long
  field-id=2 name=full_name type=string
  field-id=3 name=email     type=string

注意:full_name 的 field-id 仍是 2——它就是原来的 name,只换了显示名,ID 没变。email 拿到一个全新的 ID 3,绝不复用。

3.3 老数据如何被新 schema 读出

演进后再写一行(带新列),然后读当前快照(真实输出):

# 写入第 4 行,带 full_name 与 email
# 读当前快照
print(tbl.scan().to_arrow().to_pydict())
{'id':        [4, 1, 2, 3],
 'full_name': ['d', 'a', 'b', 'c'],
 'email':     ['d@x', None, None, None]}

逐项看这份输出,三个事实全在里面:

3.4 演进规则与兼容边界

Iceberg 表规范对 schema evolution 给了明确的允许操作类型提升白名单。安全操作:

操作 是否安全 说明
加列(optional) 安全 老数据读为 NULL
删列 安全 field ID 退役,老数据该列被忽略
改名 安全 field ID 不变,仅改显示名
重排列顺序 安全 顺序是视图属性,不影响 ID 映射
加 required 列 不安全/受限 老数据无值,违反非空;规范不允许直接加 required 无默认值列
删列后用同名再加列 危险 新列是新 field ID,不会「复活」老数据

类型提升(type promotion)只允许「不丢信息」的方向,例如:

\[ \texttt{int} \rightarrow \texttt{long}, \quad \texttt{float} \rightarrow \texttt{double}, \quad \texttt{decimal}(P,S) \rightarrow \texttt{decimal}(P',S)\ (P' > P) \]

反方向(long → int、缩小 decimal 精度)会丢数据,规范禁止。

3.5 对「老 reader」的兼容边界

schema 演进还有一个常被忽略的方向:老的 reader 读新 schema 写的数据

flowchart TD
  W[写端演进 schema] --> NEW[新 schema-id 写入新 snapshot]
  NEW --> R1[新 reader: 按 field ID 正确解析]
  NEW --> R2[老 reader: 忽略未知列, 多数前向兼容]
  NEW --> R3[过旧 reader: 不支持的特性可能报错]

3.6 嵌套类型的演进

field ID 不只给顶层列,嵌套结构里的每个字段也有自己的 ID。struct 的子字段、list 的元素、map 的 key/value 都各自带 ID。于是嵌套类型的演进遵循同样规则:

要避开的坑是「删了再加同名子字段」:新子字段是新 ID,不会复活老数据。这一点和顶层列完全一致——名字是视图,ID 才是身份。所以重构嵌套 schema 时,宁可改名保留 ID,也不要「删除 + 重新添加」。

3.7 列默认值(V3)

表规范 V2 里新增列对老数据只能读 NULL,这限制了「加一个非空且有意义默认值的列」。表规范 V3 引入列默认值,区分两个语义:

有了 initial-default,加列对老数据就能给一个有意义的默认值而非只能 NULL,同时仍然不重写历史文件。注意 V3 是演进中的规范,启用前要确认读写两端引擎都支持,否则退回 V2 的 NULL 语义。

3.8 sort order 也能演进

除了 schema 与 partition spec,Iceberg 的排序规范(sort order)同样可演进且带 ID。每个 data file 记录它是按哪个 sort order 写的。演进 sort order 不重写历史文件——新文件按新顺序写,旧文件保持旧顺序。这给 第 17 章 的 sort/z-order compaction 留了空间:可以先改 sort order,再让后台重写逐步把历史数据按新顺序聚集。


四、分区演进(partition evolution)

field-ID 的同一思想也用在分区上。第 9 章 讲过 Iceberg 的隐藏分区与 partition spec。分区演进的关键性质是:改分区方式不重写历史数据

机制:每个 data file 在 manifest 里记录它是按哪个 partition spec 写的(spec 也有 ID)。当你把分区从 day(ts) 改成 hour(ts)

这避免了传统 Hive 表「改分区粒度 = 重写整张表」的剧痛。代价是查询规划要处理多 spec 共存,且分区裁剪在跨 spec 边界上不如单 spec 干净——但正确性不受影响。

维度 传统 Hive 表 Iceberg
改 schema 列名 可能要重写/对不上 改元数据,不重写
加列 DDL,老数据行为依赖引擎 老数据读 NULL,规范明确
改分区粒度 重写全表 新旧 spec 共存,不重写
字段标识 位置/名字 field ID

4.1 跨 spec 的查询规划

举个具体的:一张事件表起初按 day(ts) 分区,跑了一年;后来流量大了,改成 hour(ts)。演进后:

查询 WHERE ts BETWEEN '今天 09:00' AND '今天 10:00' 时,Iceberg 对两批文件分别裁剪:spec-1 文件能精确裁到那个小时分区;spec-0 文件只能裁到天级。规划器不要求全表统一 spec,正确性靠「每个文件自带 spec」保证。代价是规划逻辑要处理多 spec,裁剪粒度在新旧边界上不均匀——但没有任何历史数据被重写。

4.2 与 schema 演进的相同根

分区演进和 schema 演进是同一思想的两个投影:把「当前视图」(schema / partition spec / sort order)与「数据文件」解耦,文件只记录自己属于哪个版本的视图。所以三者都能「改元数据不重写数据」。这也是 Iceberg 相对 Hive 表的根本优势来源——Hive 表把视图和目录布局焊死,任何演进都可能要重排目录。


五、分支、标签与 Write-Audit-Publish

5.1 ref:给 snapshot 起名字

前面一直用 current-snapshot-id 这个单一指针,但 Iceberg 其实支持命名引用(ref)

ref 还能带保留策略(如某 tag 保留 N 天),与 expire 配合——被 ref 引用的 snapshot 不会被过期删掉,即使它很老。这给「我要长期保留这个月末快照做审计」提供了机制:打个 tag,expire 就不会动它。

5.2 Write-Audit-Publish(WAP)

分支最有价值的用法是 Write-Audit-Publish:把「写入」和「对用户可见」解耦,中间插一道质量校验。

flowchart LR
  W["Write: 写到 audit 分支<br/>用户仍读 main"] --> A["Audit: 在分支上跑数据质量校验"]
  A -->|通过| P["Publish: 把分支 fast-forward 到 main"]
  A -->|失败| X["丢弃分支, main 不受影响"]

WAP 把「坏数据已经对用户可见、再回滚」变成「坏数据从未对用户可见」。这比第二节的 rollback 更进一步:rollback 是事后补救,WAP 是事前拦截。

六、与 Delta Lake 的对照

Delta 用顺序事务日志 _delta_log第 12 章)而非 snapshot 树,时间旅行与演进的「味道」因此不同:

维度 Iceberg Delta Lake
版本标识 snapshot-id(长整型) 单调递增的 version(0,1,2…)
时间旅行语法 snapshot_id / AS OF TIMESTAMP VERSION AS OF n / TIMESTAMP AS OF t
字段标识 始终 field ID 默认按位置/名字;column mapping 模式后才用 ID
改列名 一直安全(field ID) 需开启 column mapping(name/id 模式)才安全
分支/tag 原生支持 ref 无等价分支概念(靠外部或 shallow clone)
回滚 移动 ref RESTORE 到某 version/timestamp

关键差异在 column mapping:Delta 早期按列名/位置对应,改名/重排会出问题;引入 column mapping(把列名映射到稳定的物理 ID)后才获得类似 Iceberg field-ID 的能力。这正反过来印证了本章的核心论点——稳定演进的前提是「字段身份」与「显示名字」解耦,两家殊途同归。

七、常见陷阱

把前面分散的坑集中列出,都是生产里真实会踩的:

八、复现实验

完整脚本(已在上文环境真实运行,输出见上):

import os, shutil
import pyarrow as pa
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.types import StringType

warehouse = "/tmp/ice_wh16"
shutil.rmtree(warehouse, ignore_errors=True); os.makedirs(warehouse)
catalog = SqlCatalog("demo",
    uri=f"sqlite:///{warehouse}/catalog.db", warehouse=f"file://{warehouse}")
catalog.create_namespace("db")

s1 = pa.schema([("id", pa.int64(), False), ("name", pa.string())])
tbl = catalog.create_table("db.users", schema=s1)
tbl.append(pa.table({"id": [1,2,3], "name": ["a","b","c"]}, schema=s1))
snap1 = tbl.current_snapshot().snapshot_id

with tbl.update_schema() as us:
    us.rename_column("name", "full_name")
    us.add_column("email", StringType())

s2 = pa.schema([("id", pa.int64(), False), ("full_name", pa.string()), ("email", pa.string())])
tbl.append(pa.table({"id": [4], "full_name": ["d"], "email": ["d@x"]}, schema=s2))

print(tbl.scan().to_arrow().to_pydict())              # 当前快照: 改名透明, 加列补 NULL
print(tbl.scan(snapshot_id=snap1).to_arrow().column_names)  # 时间旅行: 旧 schema 列名

复现步骤(无依赖时):

python3 -m venv /tmp/lakeenv
/tmp/lakeenv/bin/python -m pip install pyarrow "pyiceberg[sql-sqlite]"
/tmp/lakeenv/bin/python exp16.py

若环境无法安装 PyIceberg/PyArrow:以上为可直接执行的脚本与预期行为描述,结论锚定 Iceberg 表规范的 schema evolution 与 snapshot 章节;不要把未运行的输出当实测。


九、小结

下一章处理快照链与频繁写入的副作用——小文件与元数据膨胀,以及 compaction、rewrite manifests、expire、孤儿清理这套治理组合。


返回 系列目录 | 上一篇:Catalog 之争 | 下一篇:小文件与 Compaction

参考资料

  1. Apache Iceberg, Spec — Schema Evolution、Snapshots、Sort/Partition 章节(表规范 V2)— A 级。
  2. Apache Iceberg 文档, EvolutionMaintenance(expire snapshots、rollback、time travel)— A 级。
  3. Apache Iceberg, Type promotion 规则(表规范)— A 级。
  4. 本机实验:PyIceberg 0.11.1 + PyArrow 24.0.0 + SQLite SQL catalog,schema 演进与时间旅行(环境见开头与第五节)— A 级(实测)。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-30 · database / storage

【数据湖与开放表格式】Iceberg 元数据树

拆解 Iceberg 的四层元数据:catalog 指针 → metadata.json → manifest list(snapshot)→ manifest file → data file。讲清 snapshot 与 manifest 里的分区数据和列级 stats(lower/upper bound、null/value count)如何让一次查询不 list 目录就收敛到文件集合,并给出表规范 V1/V2/V3 的版本边界。基于 pyiceberg 0.11.1 真实建表逐层 dump。

2026-06-30 · database / storage

【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式

Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。

2026-06-30 · database / storage

【数据湖与开放表格式】Parquet 文件格式深拆

拆 Parquet 的物理结构:file → row group → column chunk → page,footer 里的 FileMetaData(Thrift)与 PAR1 magic。讲清 PLAIN/RLE-bitpacking/字典/DELTA_BINARY_PACKED/BYTE_STREAM_SPLIT 各自压谁,Dremel 的 repetition/definition level 如何表达嵌套,column index/offset index 与 split-block bloom filter 怎样让谓词在读盘前裁掉 page。基于本机 pyarrow 24.0.0 真实 dump footer 与编码。

2026-06-30 · database / storage

【数据湖与开放表格式】ORC 文件格式与 Parquet 对照

ORC 用 stripe 而非 row group、用三级统计(file/stripe/row-group index)而非独立 page index、用 PRESENT/DATA 等 stream 而非 page 组织一列。本文按 ORC 规范拆其文件尾(postscript + footer)、stripe 内部结构与 RLEv2 整数编码,并用本机 pyarrow 24.0.0 把同一份 30 万行数据写成 ORC 与 Parquet,对比真实体积与物理布局,最后给出什么场景仍用 ORC。


By .