第 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)都:
- 写出新的 data files(旧的不动);
- 写出新的 manifest 与 manifest list;
- 生成一个新 snapshot,记录它的父 snapshot、操作类型、指向的 manifest list;
- 通过 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 ID。id 是
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=2,total-files-size
同理累加。它们让「这张表现在多大、上次提交加了多少」无需扫描数据就能从元数据直接读出,也是
第 17 章
监控小文件、判断是否该 compaction
的数据来源(total-data-files、total-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]
history的is_current_ancestor标识某个曾经的 current snapshot 是否还在当前链上——rollback 后会出现「曾经 current 但已不是祖先」的行,是排查回滚的利器。refs列出所有命名引用(branch/tag)及其保留策略字段(max_snapshot_age_in_ms等,见第五节)。这里只有默认的mainbranch,保留字段为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 存储过程;语义上:
- 回滚不删除回滚点之后的 snapshot(它们仍在历史里),只是把「当前」挪回去;
- 因此回滚本身可逆——再 rollback 回去即可;
- 回滚后新的写入会基于回滚点产生新 snapshot,历史出现分叉但 ref 只认一条。
回滚适合「刚提交的一批数据有问题,立刻退回上一个好状态」。它不是事务级 undo,而是快照级指针移动。
2.3 过期(expire snapshots)
不可变快照链会无限增长:snapshot、manifest、data files 越积越多。expire snapshots 是清理动作,删除早于某个时间点(或超出保留数量)的旧 snapshot,并物理删除「只被这些过期 snapshot 引用、不被任何保留 snapshot 引用」的 data/manifest 文件。
这是时间旅行与存储成本之间的权衡刻度盘:
\[ \text{可回溯窗口} \;\propto\; \text{snapshot 保留时长} \;\propto\; \text{元数据与孤儿数据的存储开销} \]
要点与陷阱:
- 过期会切断时间旅行:过期点之前的 snapshot 读不了了。保留策略要匹配业务的「最久要回看多远」。
- 过期是删数据的危险操作:如果保留窗口设太短,可能删掉仍被需要的版本。生产里通常配「保留最近 N 天 + 至少 M 个」双约束。
- 过期与 compaction、孤儿文件清理是一套组合拳,第 17 章 会把它们放在一起讲。
| 操作 | 改什么 | 删数据吗 | 可逆吗 |
|---|---|---|---|
| 时间旅行读 | 什么都不改 | 否 | —— |
| 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]}
逐项看这份输出,三个事实全在里面:
- 改名对老数据透明:行 1/2/3
是演进前写入的(当时列叫
name),现在用full_name读出来仍是a/b/c。底层 data file 一个字节没改,靠 field-2 这个 ID 接上了新名字。 - 加列对老数据返回 NULL:行 1/2/3 的
email是None。老 data file 里根本没有 field-3,reader 发现缺列就按规范填 NULL(这要求新增列必须是 optional,不能直接加 required 列——否则老数据无法满足非空约束)。 - 新行带全部列:行 4 是演进后写的,三列齐全。
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 写的数据。
- 老 reader 遇到它不认识的新 field ID(新增列),按规范应忽略该列——前向兼容成立。
- 但如果演进涉及老 reader 不支持的特性(例如某些 V3 才有的类型、或 reader 版本太旧不认 field-ID 解析),就可能读错或报错。
- 实务结论:schema 演进是写端单方面的元数据变更,但「能不能被所有消费方正确读」取决于消费方的引擎/库版本。跨团队共享表时,演进前要确认下游 reader 版本。
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。于是嵌套类型的演进遵循同样规则:
- 给一个 struct 加子字段:子字段拿新 ID,老数据该子字段读 NULL。
- 给 struct 子字段改名:子字段 ID 不变,透明。
- list 元素类型做安全提升(如
list<int>→list<long>):允许。
要避开的坑是「删了再加同名子字段」:新子字段是新 ID,不会复活老数据。这一点和顶层列完全一致——名字是视图,ID 才是身份。所以重构嵌套 schema 时,宁可改名保留 ID,也不要「删除 + 重新添加」。
3.7 列默认值(V3)
表规范 V2 里新增列对老数据只能读 NULL,这限制了「加一个非空且有意义默认值的列」。表规范 V3 引入列默认值,区分两个语义:
initial-default:老数据(写入这列之前的行)读到的值——回填语义,不重写老文件,读时按这个默认值呈现。write-default:之后写入若未提供该列时使用的默认值。
有了
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):
- 旧文件仍标着旧 spec,按
day分区; - 新文件按新 spec,按
hour分区; - 查询规划时,Iceberg 对不同 spec 的文件分别做分区裁剪,不强求全表统一。
这避免了传统 Hive 表「改分区粒度 = 重写整张表」的剧痛。代价是查询规划要处理多 spec 共存,且分区裁剪在跨 spec 边界上不如单 spec 干净——但正确性不受影响。
| 维度 | 传统 Hive 表 | Iceberg |
|---|---|---|
| 改 schema 列名 | 可能要重写/对不上 | 改元数据,不重写 |
| 加列 | DDL,老数据行为依赖引擎 | 老数据读 NULL,规范明确 |
| 改分区粒度 | 重写全表 | 新旧 spec 共存,不重写 |
| 字段标识 | 位置/名字 | field ID |
4.1 跨 spec 的查询规划
举个具体的:一张事件表起初按 day(ts)
分区,跑了一年;后来流量大了,改成
hour(ts)。演进后:
- 去年的文件标着
spec-0(
day),按天聚簇; - 今年的文件标着
spec-1(
hour),按小时聚簇。
查询
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):
- branch:可移动的 snapshot
指针,可独立提交。默认的
main就是一个 branch。 - tag:指向某个 snapshot
的不可变标签,用于「钉住一个版本」(如
release-2026-06、eom-snapshot)。
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 不受影响"]
- Write:ETL
把新数据写到一个临时/审计分支,
main上的用户完全看不到。 - Audit:在分支上跑校验(行数、空值率、对账),可以用时间旅行对比分支与
main。 - Publish:校验通过,才把
main原子地推进到分支的 snapshot;不通过就丢弃分支,生产数据零污染。
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 的能力。这正反过来印证了本章的核心论点——稳定演进的前提是「字段身份」与「显示名字」解耦,两家殊途同归。
七、常见陷阱
把前面分散的坑集中列出,都是生产里真实会踩的:
- 加了 required 列:老数据没值,违反非空约束。新增列必须 optional(或带默认值,V3 才支持列默认值)。
- 删列后用同名再加列:以为「恢复了那一列」,其实是全新 field ID,老数据不会回来,新列对老行全是 NULL。要恢复语义应改名而非删了重加。
- 把 expire 窗口设太短:时间旅行/审计需要的旧快照被删,且不可逆。保留窗口要由「最久要回看多远」倒推,重要版本用 tag 钉住免被过期。
- 以为 rollback 删了数据:rollback 只移动指针,回滚点之后的 snapshot 还在;真正释放空间要靠 expire。反过来,以为 rollback 不可逆也是错的——再 rollback 回去即可。
- 跨团队演进不通知下游:写端单方面演进 schema,过旧的下游 reader 可能不支持新特性而报错。共享表演进前确认下游引擎/库版本。
- 依赖列位置写代码:按列序号取数据的代码,在重排/插列后会错位。永远按列名/字段访问,不按位置。
- 时间旅行读却假设是当前
schema:读旧快照用的是那个快照当时的
schema(实测列名是旧的
name),下游若按当前 schema 解析会对不上。
八、复现实验
完整脚本(已在上文环境真实运行,输出见上):
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 章节;不要把未运行的输出当实测。
九、小结
- Iceberg 的元数据是不可变 snapshot
的链,旧版本不删,这是时间旅行的物理基础。每个
snapshot 绑定一个 schema-id,按 snapshot
读会连同当时的 schema
视图一起回到过去(实测:读旧快照列名是演进前的
name)。 - rollback 是把 current 指针移回历史 snapshot,可逆、不删数据;expire snapshots 删旧 snapshot 与孤儿文件,不可逆、会切断时间旅行,保留策略要匹配业务回溯窗口。
- schema evolution 按 field ID
而非位置/名字:改名(field ID
不变)、加列(老数据读 NULL,必须
optional)、删列、重排都只动元数据,不重写 data
files(实测:演进后行 1/2/3 用新名
full_name读出旧值,email为 NULL)。类型提升只允许不丢信息的方向。 - 分区演进用同样思想:data file 记录所属 spec,改分区粒度新旧 spec 共存,不重写历史。
- 兼容边界在消费方:写端单方面演进,能否被正确读取决于下游 reader 的引擎/库版本,跨团队共享表演进前要确认。
下一章处理快照链与频繁写入的副作用——小文件与元数据膨胀,以及 compaction、rewrite manifests、expire、孤儿清理这套治理组合。
返回 系列目录 | 上一篇:Catalog 之争 | 下一篇:小文件与 Compaction
参考资料
- Apache Iceberg, Spec — Schema Evolution、Snapshots、Sort/Partition 章节(表规范 V2)— A 级。
- Apache Iceberg 文档, Evolution、Maintenance(expire snapshots、rollback、time travel)— A 级。
- Apache Iceberg, Type promotion 规则(表规范)— A 级。
- 本机实验:PyIceberg 0.11.1 + PyArrow 24.0.0 + SQLite SQL catalog,schema 演进与时间旅行(环境见开头与第五节)— A 级(实测)。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】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。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】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 与编码。
【数据湖与开放表格式】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。