Apache Iceberg v3 深度拆解:当表格式决定给每一行发身份证——删除向量、行血缘继承链与 v4 相对路径的元数据手术
如果你在 2021 年问一个数据工程师「表格式(table format)是什么」,大概率会得到一个含糊的答案:「就是在对象存储上模拟 Hive 分区表的一层元数据吧」。
到 2026 年,这个答案已经彻底过时了。Iceberg 规范的版本演进史,本质上是一部**「把数据库内核的能力,一件一件搬到对象存储上」**的施工日志:
- v1 解决的是「文件级别的原子性」——我要能在 S3 上做出可串行化隔离的快照。
- v2 解决的是「行级别的可变性」——我要能在不可变文件上删掉某一行。
- v3 解决的是「行级别的可标识性与可追溯性」——我要能给每一行发一张身份证,并且知道它最后一次被谁改过。
- v4(规范原文明确写着 under active development and has not been formally adopted)在解决「元数据的可搬迁性」——我要能把整张表连桶带路径搬走,而不重写一个字节的元数据。
这篇文章不聊「Iceberg 是什么」这种入门话题,直接钻进 v3 规范的骨头缝里。我会逐条拆解删除向量(Deletion Vectors)的位图编码、行血缘(Row Lineage)那条四级继承链、variant/geometry 类型的设计取舍、多参数变换的 JSON 互斥规则、表加密密钥的托管模型,以及 v4 相对路径解析的那几个反直觉的边界情况。
所有结论都对齐 apache/iceberg 仓库 format/spec.md 的当前主干内容(Java 侧最新发布线为 1.11.0)。凡是我自己的推断和估算,我会明确标注出来,不会跟规范原文混在一起。
一、先建立版本坐标系:四个版本各自在动谁的奶酪
规范原文对版本号的定义非常克制:「格式版本号在新增会破坏前向兼容(forward-compatibility)的特性时递增——也就是老读者无法正确读取新表特性的时候。」
注意这句话的重点是前向兼容,不是向后兼容。这是理解整个升级策略的第一把钥匙:
- 向后兼容(backward):新读者能读老文件。Iceberg 一直保持得很好,v1 的数据和元数据文件在升级到 v2 后依然有效。
- 前向兼容(forward):老读者能读新文件。这是版本号递增的唯一触发条件。
所以「要不要升 v3」这个问题,真正的形式是:你的读侧集群里,最老的那个引擎,能不能容忍 v3 的元数据结构? 而不是「v3 有什么新功能」。
v3 到底加了什么
规范原文列出的 v3 能力清单:
* New data types: nanosecond timestamp(tz), unknown, variant, geometry, geography
* Default value support for columns
* Multi-argument transforms for partitioning and sorting
* Row Lineage tracking
* Binary deletion vectors
* Table encryption keys
六条。我按「对生产系统的破坏力」重新排个序:
| 特性 | 破坏力 | 原因 |
|---|---|---|
| 行血缘 Row Lineage | ★★★★★ | 强制性:v3 及以后,表必须为所有新建行追踪血缘字段。这不是可选功能。 |
| 删除向量 DV | ★★★★☆ | position delete file 在 v3 被弃用,写侧不允许再新增 |
| 表加密密钥 | ★★★☆☆ | 元数据结构变化,不理解 encryption-keys 的读者会失败 |
| 新类型 | ★★★☆☆ | 只在你真用了才有影响,但一旦用了就是硬破坏 |
| 多参数变换 | ★★☆☆☆ | JSON 层 source-id / source-ids 互斥,解析器要改 |
| 默认值 | ★★☆☆☆ | write-default 是前向兼容的,initial-default 不是 |
第一行那个「强制性」是很多人会踩的坑:升到 v3 之后,行血缘不是你想不想用的问题,是写侧引擎必须正确维护 next-row-id 的问题。一个不懂行血缘的写侧引擎往 v3 表里写数据,产生的不是报错,而是静默的元数据损坏——这才是最可怕的。
二、删除向量:把 anti-join 降级成一次位测试
2.1 v2 position delete 的结构性问题
先看 v2 的方案。position delete file 存的是一个结构体:
| 字段 id / 名称 | 类型 | 说明 |
|---|---|---|
2147483546 file_path | string | 被删行所在数据文件的完整 URI |
2147483545 pos | long | 被删行在该文件中的序号,从 0 开始 |
2147483544 row | required struct<...> | 被删行的值,不存就整列省略 |
规范要求这些行必须先按 file_path 再按 pos 排序,理由写得很清楚:
- 按
file_path排序 → 列式存储可以做文件级谓词下推 - 按
pos排序 → 扫描时可以流式过滤,不必把删除集合全量驻留内存
这个设计在 2021 年是合理的妥协,但它有几个绕不过去的结构性问题(以下是我的归纳,不是规范原文):
问题一:读路径是一次 anti-join,不是一次查表。
读一个数据文件时,引擎必须先把所有可能命中它的 delete file 读进来,按 file_path 过滤出相关行,再和数据流按 pos 做归并。这是一个 join,不是一个 lookup。当一张表积累了几千个小 delete file 时,光是打开这些文件的 IO 次数就能把查询打垮。
问题二:一个数据文件对应多少个 delete file,是不确定的。
每次 DELETE 都可能产生一个新的 delete file,它们之间没有合并义务。删了 100 次,就是 100 个文件,读的时候要全开。
问题三:delete file 可以跨数据文件。
一个 delete file 里可以同时包含 A、B、C 三个数据文件的删除位置。这意味着即使你只扫 A,也可能要读一个 90% 内容都属于 B 和 C 的文件。规范原文在 v3 的迁移说明里特意提到了这个麻烦:「包含多个数据文件删除记录的 position delete file,必须一直保留在表元数据里,直到所有删除都被 DV 替换。」
问题四:存储放大。
存一个 long 型的 pos 是 8 字节。删掉 100 万行就是 8MB 的 pos 列(压缩前)。而如果这 100 万行集中在某个 1000 万行的文件里,用位图表示只需要约 1.25MB(100万位 = 125KB,考虑 Roaring 的容器开销)——差一个数量级。
问题五:语义上无法回答「这个文件现在还剩多少行」。
因为删除记录分散在 N 个文件里且可能重复,你没法在不做全量归并的情况下算出有效行数。
2.2 DV 的设计:一个数据文件,至多一个位图
v3 的答案简单粗暴。规范原文:
Unlike equality or position delete files, there can be at most one deletion vector for a given data file in a snapshot. Writers must ensure that there is at most one deletion vector per data file and must merge new deletes with existing vectors or position delete files.
「一个快照内,一个数据文件至多一个 DV」——这条不变式(invariant)是整个设计的地基。它把上面五个问题一次性解决了:
- 读路径从 anti-join 退化成 「打开一个位图,测一个 bit」
- 文件数量从 O(删除次数) 降到 O(1)
- 每个 DV 明确绑定一个
referenced_data_file - 位图编码天然压缩
- 位图的
cardinality()就是被删行数,O(1) 可得
代价是:写侧承担了合并义务。你不能只追加,必须读旧 DV、合并、写新 DV。这是典型的「把读放大转成写放大」的交易。
2.3 编码细节:为什么是「32 位 Roaring 位图的集合」
这是 v3 里最精巧的一处工程设计,规范原文:
Deletion vectors support positive 64-bit positions, but are optimized for cases where most positions fit in 32 bits by using a collection of 32-bit Roaring bitmaps. 64-bit positions are divided into a 32-bit "key" using the most significant 4 bytes and a 32-bit sub-position using the least significant 4 bytes.
拆开看:
- 行位置在类型上是 64 位的(
pos是long),理论上一个 Parquet 文件可以有 2^63 行。 - 但现实中不会。一个数据文件通常在 128MB~1GB 之间,行数量级在 10^6 ~ 10^8,远远小于 2^32 = 42.9 亿。
- 所以:用 64 位位图是浪费,用 32 位位图是不安全。
- 解法:把 64 位位置切成「高 32 位 key + 低 32 位 sub-position」,为每个出现过的 key 维护一个独立的 32 位 Roaring bitmap。
对于 99.99% 的真实数据文件,key 恒等于 0,整个结构退化成单个 32 位 Roaring bitmap——零额外开销。而对于那种极端的超大文件,结构自动扩展,不会溢出。
这是一个教科书级别的「为常见情况优化、为极端情况兜底」的设计。
查询逻辑规范也写了:
To test whether a certain position is set, its most significant 4 bytes (the key) are used to find a 32-bit bitmap and the least significant 4 bytes (the sub-position) are tested for inclusion in the bitmap. If a bitmap is not found for the key, then it is not set.
用 Python 把这个语义写清楚(这是我按规范语义实现的教学版,不是生产实现):
from typing import Dict, Iterable
MASK32 = 0xFFFFFFFF
class DeletionVector:
"""
Iceberg v3 deletion-vector-v1 的语义模型。
真实实现请用 RoaringBitmap 库(Java: RoaringBitmap;Python: pyroaring)。
这里用 set 表达语义,重点是那个 64->32+32 的切分。
"""
def __init__(self) -> None:
# key(高32位) -> 该 key 下的 32 位 sub-position 集合
self._buckets: Dict[int, set] = {}
@staticmethod
def _split(pos: int):
if pos < 0:
raise ValueError("DV 只支持正的 64 位位置")
return (pos >> 32) & MASK32, pos & MASK32
def add(self, pos: int) -> None:
key, sub = self._split(pos)
self._buckets.setdefault(key, set()).add(sub)
def add_many(self, positions: Iterable[int]) -> None:
for p in positions:
self.add(p)
def contains(self, pos: int) -> bool:
key, sub = self._split(pos)
bucket = self._buckets.get(key)
# 规范原文:If a bitmap is not found for the key, then it is not set.
if bucket is None:
return False
return sub in bucket
def cardinality(self) -> int:
return sum(len(b) for b in self._buckets.values())
def merge(self, other: "DeletionVector") -> "DeletionVector":
"""
v3 强制的写侧合并义务:新删除必须与已有 DV 合并,
而不是像 v2 那样再追加一个 delete file。
"""
merged = DeletionVector()
for key, bucket in self._buckets.items():
merged._buckets[key] = set(bucket)
for key, bucket in other._buckets.items():
merged._buckets.setdefault(key, set()).update(bucket)
return merged
def bucket_count(self) -> int:
"""诊断用:正常情况下应该恒为 1(key=0)。>1 说明文件行数超过 42.9 亿。"""
return len(self._buckets)
# 典型场景:一个 5000 万行的数据文件,删掉其中 3 行
dv = DeletionVector()
dv.add_many([17, 2_000_000, 49_999_999])
assert dv.bucket_count() == 1 # 全部落在 key=0
assert dv.contains(2_000_000) is True
assert dv.contains(2_000_001) is False
assert dv.cardinality() == 3
# 极端场景:超过 2^32 行的巨型文件
huge = DeletionVector()
huge.add(4_294_967_296) # 恰好 2^32 -> key=1, sub=0
huge.add(1) # key=0, sub=1
assert huge.bucket_count() == 2 # 结构自动扩展成两个桶
assert huge.contains(4_294_967_296) is True
2.4 DV 存在哪:Puffin 与 manifest 的三个新字段
DV 不是独立文件格式,它复用了 Puffin 容器,blob 类型为 deletion-vector-v1。规范原文:
These vectors are stored using the
deletion-vector-v1blob definition from the Puffin spec.
Multiple deletion vectors can be stored in the same file. There are no restrictions on the data files that can be referenced by deletion vectors in the same Puffin file.
也就是说:一个 Puffin 文件里可以塞很多个 DV,分别属于不同的数据文件。这是为了避免小文件泛滥——如果每个 DV 都是一个独立对象,一次删除涉及 500 个数据文件就会在 S3 上产生 500 个几 KB 的小对象,成本和 LIST 性能都会爆炸。
那么读的时候怎么定位到某个具体的 DV?靠 manifest 里新增的三个字段(v3 新增,规范 Appendix E 明确列出):
| 字段 | 作用 |
|---|---|
referenced_data_file | 这个 DV 属于哪个数据文件。对 DV 是必填;对 v2 那种「只删一个数据文件」的 position delete file 是选填 |
content_offset | DV blob 在 Puffin 文件里的起始偏移 |
content_size_in_bytes | DV blob 的长度 |
于是读路径变成了一次精确的范围读(ranged GET):
def load_dv_for(data_file_path: str, delete_entries: list) -> "DeletionVector | None":
"""
扫描规划阶段:从 delete manifest 里找到目标数据文件对应的 DV,
然后对 Puffin 文件做一次 ranged read,只取那一段 blob。
"""
for e in delete_entries:
if e.get("content") != 2: # 2 = 位置删除 / DV
continue
if e.get("referenced_data_file") != data_file_path:
continue
# 关键:不需要读整个 Puffin 文件,只读 [offset, offset+size)
blob = ranged_get(
path=e["file_path"],
offset=e["content_offset"],
length=e["content_size_in_bytes"],
)
return decode_deletion_vector_v1(blob)
return None
对比 v2:v2 要打开 N 个 delete file、各读一遍、按 file_path 过滤、再归并。v3 是一次 ranged GET + 一次位图反序列化。
在对象存储上,这个差别不是常数因子,是量级——因为对象存储的成本模型里,请求次数(GET 数)往往比传输字节数更贵。假设一个数据文件在 v2 下平均要开 20 个 delete file,v3 只需要 1 次 ranged GET,那么单是 GET 请求数就降到 1/20。(这是我基于成本模型的推算,不是实测数据,你的实际比例取决于删除频率和 compaction 策略。)
2.5 那条容易被忽略的读侧捷径
规范里有一句非常关键、但很容易被跳过的话:
If a DV is written for a data file, it must replace all previously written position delete files so that when a DV is present, readers can safely ignore matching position delete files.
翻译成工程语言:只要某个数据文件有 DV,读侧就可以无条件跳过所有匹配它的 position delete file,不用做任何一致性校验。
这是一条由写侧不变式换来的读侧优化。写侧承诺了「写 DV 时必须把旧的 position delete 全部合并进来」,读侧才敢这么激进地跳过。
这也解释了为什么 v3 明令禁止写侧新增 position delete file——一旦有一个不守规矩的写侧在 DV 之外偷偷加了 position delete,读侧的这条捷径就会静默地丢删除(该删的行没删掉)。这类 bug 在生产上极难发现:数据不会报错,只会多出几行。
这是我认为 v3 升级中最需要卡死的一条准入检查:所有写侧引擎必须确认已经支持 DV,一个都不能漏。
2.6 还有一个删除方式没被 DV 取代:等值删除
规范里 v3 依然保留了 equality delete file,因为它解决的是完全不同的问题:写侧不想读数据就能删。
CDC 场景里,Flink 收到一条 DELETE WHERE id = 12345,它并不知道这行在哪个文件的第几个位置。要转成 position delete,就必须先做一次点查——这在流式写入里是不可接受的延迟。
所以 equality delete 的定位是「用值删,不用位置删」,它和 DV 是互补而非竞争关系。代价我们下一节会讲到:等值删除的行不追踪血缘。
三、行血缘:一条四级继承链,和它背后的乐观提交
如果说 DV 是性能优化,那 Row Lineage 就是能力跃迁——它让 Iceberg 第一次能在表格式层面回答两个数据库级别的问题:
- 这一行,是不是一开始那一行?(身份)
- 这一行,最后一次被哪次提交改的?(版本)
3.1 两个保留字段
规范在保留字段 ID 区间里定义了两个新的行级元数据列:
| 字段 id / 名称 | 类型 | 说明 |
|---|---|---|
2147483540 _row_id | long | 表内唯一的行标识,首次加入表时通过继承赋值 |
2147483539 _last_updated_sequence_number | long | 最后一次更新这行的那次提交的 sequence number |
注意这两个 ID:2147483540 = Integer.MAX_VALUE - 107。Iceberg 的保留字段都从 Integer.MAX_VALUE 往下倒着分配,避免和用户 schema 的字段 ID 撞车。
3.2 核心问题:为什么必须用「继承」,不能直接写死
这是整个设计里最值得琢磨的地方。规范原文给了理由:
These fields are assigned and updated by inheritance because the commit sequence number and starting row ID are not assigned until the snapshot is successfully committed. Inheritance is used to allow writing data and manifest files before values are known so that it is not necessary to rewrite data and manifest files when an optimistic commit is retried.
拆开这个逻辑链:
- Iceberg 用乐观并发控制提交:写侧先写好数据文件和 manifest,最后原子交换 metadata 指针。
- 如果交换时发现别人先提交了,本次提交要重试。
- 重试时,sequence number 会被重新分配(因为它是提交序,不是写入序)。
- 如果
_row_id和_last_updated_sequence_number在写数据文件时就写死了,那么每次重试都要把所有数据文件重写一遍。 - 一个几十 GB 的 append 任务,因为并发冲突重试三次,就要重写三次几十 GB——这在成本上完全不可接受。
所以答案是:写的时候写 null,读的时候算出来。
这个思路和 Iceberg 早已存在的 sequence_number 继承机制一模一样。v3 只是把同一个模式复用到了行 ID 上。能看出这一层,你就理解了 Iceberg 元数据设计的核心美学:所有随提交时刻变化的量,都不进数据文件,只进 manifest 层,并且能延迟到读时求值。
3.3 四级继承链完整推演
_row_id 的赋值链条一共穿过四层:
表元数据 table.next-row-id
↓ 提交时快照取用
快照 snapshot.first-row-id
↓ 写 manifest list 时逐个分配
manifest.first_row_id
↓ 读 manifest 时逐个分配
data_file.first_row_id
↓ 读数据文件时逐行计算
row._row_id = data_file.first_row_id + row._pos
每一层的规则:
第 1 层 → 第 2 层(快照取号)
A snapshot's
first-row-idis assigned to the table's currentnext-row-idon each commit attempt. If a commit is retried, thefirst-row-idmust be reassigned based on the table's currentnext-row-id.
注意「每次提交尝试」和「重试时必须重新分配」。这就是继承机制存在的意义——重试只改快照元数据,不碰数据文件。
第 2 层 → 第 3 层(manifest 分配)
The first manifest without a
first_row_idis assigned a value that is greater than or equal to thefirst_row_idof the snapshot. Subsequent manifests without afirst_row_idare assigned one based on the previous manifest to be assigned afirst_row_id.
这里的措辞是「大于等于」而不是「等于」。这是一个刻意留出的松弛度(slack):允许写侧多留区间,宁可浪费 ID 空间也不要重算。规范甚至给了推荐的偷懒公式:
first_row_id = last_assigned.first_row_id
+ last_assigned.added_rows_count
+ last_assigned.existing_rows_count
_row_id 是 64 位 long,能表示 9.2 × 10^18 个 ID。就算你每天浪费 10 亿个 ID,也能用 2500 万年。浪费 ID 空间是这个设计里最便宜的资源,规范作者非常清楚这一点。
第 3 层 → 第 4 层(数据文件分配)
When reading, the
first_row_idis assigned by replacingnullwith the manifest'sfirst_row_idplus the sum ofrecord_countfor all data files that preceded the file in the manifest that also had a nullfirst_row_id.
关键限定:只累加那些 first_row_id 也为 null 的前序文件。已经有 ID 的 EXISTING 文件不参与累加——因为它们的 ID 是之前分配好的,直接沿用。
第 4 层 → 行(最终计算)
_row_id = data_file.first_row_id + _pos
3.4 逐步走一遍规范里那个例子
规范给了一个例子,值得手推一遍。起始状态:next-row-id = 1000。
写一次 append,快照元数据:
{
"operation": "append",
"first-row-id": 1000
}
manifest list 长这样:
| manifest | added_rows | existing_rows | first_row_id |
|---|---|---|---|
| existing | 75 | 0 | 925(之前分配好的) |
| added1 | 100 | 25 | 1000 |
| added2 | 0 | 100 | 1125 |
| added3 | 125 | 25 | 1225 |
推演:
added1是第一个没有 ID 的 manifest,拿快照的first-row-id= 1000added2= 1000 + 100 + 25 = 1125added3= 1125 + 0 + 100 = 1225
这里有个反直觉的点,规范专门解释了:added2 的 added_rows_count 是 0(一个新增数据文件都没有),但它依然把下一个 manifest 的起点从 1125 推到了 1225。为什么?
Note that the second file,
added2, changes thefirst_row_idof the next manifest even though it contains no added data files because any data file without afirst_row_idcould be assigned one, even if it has existing status.
因为任何还没有 first_row_id 的数据文件都可能被分配 ID,哪怕它的状态是 EXISTING。added2 里那 100 个 existing 行,如果它们的文件还没 ID(比如是刚从 v2 升上来的表),就会在这次读取中被赋号。写侧无法在写 manifest list 时确定这一点,所以只能保守地把区间留出来。
再往下一层,看 added1 内部:
| status | file | record_count | first_row_id |
|---|---|---|---|
| EXISTING | data1 | 25 | 800(已有,直接复制) |
| ADDED | data2 | 50 | null → 1000 |
| ADDED | data3 | 50 | null → 1050 |
data1 有 ID,不参与累加;data2 拿 manifest 的 1000;data3 拿 1000 + 50 = 1050。
最后更新表状态:
Because 375 rows were in data files in manifests that were assigned a
first_row_id(added1100+25,added20+100,added3125+25) the new value is 1,000 + 375 = 1,375.
next-row-id 从 1000 变成 1375。注意这里加的是 375,而真正新增的行只有 100(data2 的 50 + data3 的 50)。多出来的 275 就是前面说的「刻意浪费」。
3.5 added-rows 是上界,不是精确值
这是我认为最容易被下游误用的一个字段。规范原文:
The snapshot's
added-rowscaptures the upper bound of the number of rows with assigned row IDs. It can be more than the number of rows added in this snapshot and include some existing rows.
added-rows 是上界(upper bound),不是精确的新增行数。
如果你拿它去做「今天入库了多少条数据」的监控指标,在有 compaction 或表刚升级的时段会出现莫名其妙的尖刺。想要精确值,去读快照 summary 里的 added-records(那是统计口径),不要用 added-rows(那是 ID 空间口径)。
这两个字段名字太像了,是我预判的第一大踩坑点。
3.6 行移动时的三条规则
当一行因为 compaction、格式转换、重分区等原因被搬到新文件时:
- The row's existing non-null
_row_idmust be copied into the new data file- If the write has modified the row, the
_last_updated_sequence_numberfield must be set tonull- If the write has not modified the row, the existing non-null
_last_updated_sequence_numbervalue must be copied
用一段代码把这个状态机写清楚:
def rewrite_row(row: dict, modified: bool) -> dict:
"""
Compaction / 重写场景下,如何正确搬运一行的血缘字段。
这是 v3 写侧最容易写错的地方。
"""
out = dict(row)
# 规则 1:身份必须保留。compaction 不改变行的身份。
if row.get("_row_id") is not None:
out["_row_id"] = row["_row_id"]
else:
# 还没分配过(比如刚升级的表),继续留 null,读时继承
out["_row_id"] = None
if modified:
# 规则 2:改过 -> 置 null,让新提交的 sequence number 通过继承覆盖
out["_last_updated_sequence_number"] = None
else:
# 规则 3:纯搬运 -> 原值原样复制,不能因为文件变了就改版本
out["_last_updated_sequence_number"] = row.get("_last_updated_sequence_number")
return out
第 3 条是最容易写错的:一次纯粹的 compaction 不应该改变任何一行的 _last_updated_sequence_number。如果写侧图省事,把所有行的这个字段都置 null,那么每次 compaction 之后,所有行看起来都像是「刚被改过」——下游所有基于血缘做增量的逻辑全部失效。
这个 bug 的可怕之处在于:它不报错,只是让你的增量管道每次 compaction 后都全量重跑一遍。等你发现账单异常时,可能已经过去一个月了。
3.7 等值删除的血缘缺口
规范明确划了一条线:
Row lineage does not track lineage for rows updated via Equality Deletes, because engines using equality deletes avoid reading existing data before writing changes and can't provide the original row ID for the new rows. These updates are always treated as if the existing row was completely removed and a unique new row was added.
这是能力和性能之间的一次诚实妥协:
- 等值删除的全部价值在于「不读旧数据就能删」
- 而保留
_row_id必须先知道旧行的 ID → 必须读旧数据 - 二者根本矛盾
所以规范选择了:等值删除路径上,UPDATE 一律降级为「删旧 + 插新」,新行拿全新的 _row_id。
这对架构选型有直接影响:
| 你的诉求 | 建议 |
|---|---|
| 要精确的行级血缘(审计、SCD2、精确增量) | 写侧必须走 DV 路径,禁用 equality delete |
| 要极低延迟的流式 upsert | 用 equality delete,接受血缘断裂 |
| 两者都要 | 分层:Flink 用 equality delete 写入 staging,定期 compaction 转成 DV 落到服务层。代价是服务层的行 ID 在每次转换时会变 |
第三种方案的那个「代价」是硬伤,没有免费午餐。如果你的审计需求要求行 ID 跨 upsert 稳定,那就只能牺牲写入延迟。
3.8 升级表的血缘:从 0 开始,且分支间区间不相交
规范对升级场景有专门规定:
When a table is upgraded to v3, its
next-row-idis initialized to 0 and existing snapshots are not modified. For such snapshots withoutfirst-row-id,first_row_idvalues for data files and data manifests are null, and values for_row_idare read as null for all rows.
三个要点:
next-row-id从 0 开始,历史快照不动- 升级前的快照,所有行的
_row_id读出来都是 null——时间旅行回到升级点之前,血缘就消失了 - 升级后的第一次提交,必须给所有 data manifest 分配
first_row_id,这会给全表所有存量数据文件赋号
第 3 点意味着:升级后的第一次写入,manifest list 会被整体重写。 对一张有几万个 manifest 的大表,这一次提交会显著慢于平时。规范原文:
Snapshots that are created after upgrading to v3 must set the snapshot's
first-row-idand assign row IDs to existing and added files in the snapshot. When writing the manifest list, all data manifests must be assigned afirst_row_id.
运维建议(我的经验判断):升级 v3 之后,主动触发一次小的 append 提交把这次元数据重写做掉,而不是留给下一个业务高峰期的写入任务去背这个锅。
还有一个分支相关的坑:
After upgrading, new snapshots in different branches will assign disjoint ID ranges to existing data files. For a data file in multiple branches, a writer may write the
first_row_idfrom another branch or may assign a newfirst_row_idto the data file.
同一个数据文件,在不同分支里可能拿到不同的 first_row_id。也就是说,跨分支比较 _row_id 是没有意义的。如果你用 Iceberg 的 branch 做 WAP(Write-Audit-Publish),审计逻辑千万别假设 _row_id 在 branch 和 main 之间一致。
四、类型系统扩展:variant 是个「一等公民的黑盒」
4.1 variant:为什么不是 map<string, string>,也不是 struct
v3 新增的 variant 类型,规范给了一个很有意思的定位:
As a semi-structured type,
variantis neither a primitive type nor a nested type.
它既不是原始类型,也不是嵌套类型,而是自成一档的「半结构化类型」。 二进制编码直接复用 Parquet 项目定义的 VariantEncoding(当前支持 V1)。
它和 JSON 的差别:
Variants are similar to JSON with a wider set of primitive values including date, timestamp, timestamptz, binary, and decimals.
比 JSON 多了日期、时间戳、二进制和精确小数。 这一条解决了 JSON 在数据仓库里最大的痛点:所有数字都是 double,所有时间都是字符串,精度和语义双双丢失。
它和 struct / map 的差别,规范说得很清楚:
- Variant arrays are similar to lists, but may contain any variant value rather than a fixed element type.
- Variant objects are similar to structs, but may contain variable fields identified by name and field values may be any variant value rather than a fixed field type.
一句话:struct 的字段集合是 schema 的一部分,variant 的字段集合是数据的一部分。
这带来一个非常实际的收益。假设你在存埋点日志,properties 字段每个业务线都往里塞不同的 key:
- 用
map<string,string>:所有值退化成字符串,price存成"19.99",查询时到处 CAST,而且丢了精度 - 用
struct:每加一个新 key 就要改 schema,schema 会膨胀到几千个字段,元数据本身成为瓶颈 - 用
variant:schema 只有一列,值保留原始类型,引擎可以对 variant 内部做 shredding 优化
-- schema 层面永远只有一列
CREATE TABLE events (
event_id BIGINT,
ts TIMESTAMP_NS, -- v3 新增的纳秒精度
properties VARIANT -- 结构由数据决定,不由 schema 决定
) USING iceberg
TBLPROPERTIES ('format-version' = '3');
代价:规范规定 variant 列必须默认 null:
All columns of
unknown,variant,geometry, andgeographytypes must default to null. Non-null values forinitial-defaultorwrite-defaultare invalid.
你不能给一个 variant 列设默认值。想清楚这一点再用。
4.2 unknown 类型:一个「合法的占位符」
这个类型看起来鸡肋,实际上解决了一个真实的工程问题。规范定义:
unknown: Default / null column type used when a more specific type is not known.
Requirements: Must be optional withnulldefaults; not stored in data files.
「不存储在数据文件里」——这是关键。它是一个纯元数据层的占位符。
用途(我的理解):当你从一个 schema 不完整的源系统导入数据时,某些列你知道它存在,但不知道类型(比如整列都是 null,无法推断)。以前你只能瞎猜一个 string,一旦猜错,后续类型演进就受限于 Iceberg 的类型提升规则(string 几乎不能提升到任何东西)。
现在你可以先标成 unknown,等真实数据到了再演进到具体类型。这是一个把「类型推断」这个决策从导入时推迟到运行时的设计。
4.3 geometry / geography:把 GIS 塞进湖仓,但拒绝把 PROJJSON 塞进 schema
v3 引入了两个地理空间类型,参数化方式有讲究:
geometry(C):C 是 CRS(坐标参考系),默认OGC:CRS84。边插值永远是线性/平面的,CRS 不影响几何计算,计算永远是笛卡尔的。geography(C, A):C 是 CRS,A 是边插值算法,默认OGC:CRS84+spherical。
A 的可选值有五个:spherical、vincenty、thomas、andoyer、karney。这不是学术炫技——在计算两点间距离时,把地球当球体(spherical)和当椭球体(vincenty/karney)的结果差异可以达到 0.5%。跨洲际距离上就是几十公里,对物流、航空、保险定价是实打实的业务差异。
最值得学习的是规范对 CRS 的一条硬性禁令:
CRS value must not contain inlined PROJJSON definitions and implementations must not parse the contents of the CRS as PROJJSON. PROJJSON definitions are very verbose, hence inlining them as part of schema would cause significant performance degradation.
禁止在 schema 里内联 PROJJSON,理由是「太啰嗦,会显著拖慢性能」。正确做法是存到表属性里,用 projjson:<property-name> 引用:
{
"type": "geometry",
"crs": "projjson:my_custom_crs"
}
-- 表属性
my_custom_crs = '{"$schema": "...", "type": "ProjectedCRS", ...}'
这条设计原则值得抄进任何 schema 系统:schema 是被高频解析的热路径,任何大块的、低频变化的定义都应该外置成引用,而不是内联。
一个 PROJJSON 定义动辄几 KB。如果一张表有 20 个地理列,schema JSON 就凭空胖了 100KB。而 schema 会被每一次 planning 解析——这就是典型的「把冷数据放进了热路径」。
另外,identity 变换不支持 geometry 和 geography(规范原文:Any primitive except for geometry and geography)。想按地理位置分区,只能自己算 geohash 存成 string 再分区。
4.4 默认值:initial-default 和 write-default 的语义分野
v3 给 struct 字段加了两个默认值,语义完全不同:
| 字段 | 语义 | 何时设置 |
|---|---|---|
initial-default | 填充字段加入 schema 之前写的所有记录 | 只在字段被加到已有 schema 时设置 |
write-default | 填充字段加入之后写入但未提供值的记录 | 初始等于 initial-default,之后可通过 schema 演进修改 |
关键收益,规范原文:
The
initial-defaultandwrite-defaultproduce SQL default value behavior, without rewriting data files.
不重写数据文件就实现 SQL 的 DEFAULT 语义。 这在 PB 级表上是刚需——传统方案里 ALTER TABLE ADD COLUMN ... DEFAULT x 要么重写全表,要么默认值只能是 null。
兼容性影响规范也讲得很直白:
- The
write-defaultis a forward-compatible change because it is only used at write time. Old writers will fail because the field is missing.- Tables with
initial-defaultwill be read correctly by older readers ifinitial-defaultis always null for optional fields. Otherwise, old readers will default optional columns with null. Old readers will fail to read required fields which are populated byinitial-default.
翻译成人话:
write-default只在写时用 → 老读者完全不受影响initial-default非 null 的可选列 → 老读者读出来是 null 而不是默认值(静默的数据错误!)initial-default非 null 的必填列 → 老读者直接报错
中间那条是最危险的:不报错,但读出来的值是错的。 如果你的集群里还有老读者,加带非 null initial-default 的可选列,就是在给自己埋雷。
4.5 嵌套 struct 默认值的那张真值表
规范给了一张容易看晕但很重要的表。规则是:嵌套 struct 的默认值不能包含子字段的默认值,子字段默认值各自在自己的元数据里追踪。
以 point 结构体(含 x、y 两个字段,各自默认 0)为例:
point 默认 | x 默认 | y 默认 | 数据值 | 结果 |
|---|---|---|---|---|
null | 0 | 0 | (缺失) | null |
null | 0 | 0 | {"x": 3} | {"x": 3, "y": 0} |
{} | 0 | 0 | (缺失) | {"x": 0, "y": 0} |
{} | 0 | 0 | {"y": -1} | {"x": 0, "y": -1} |
核心区别在第 1 行和第 3 行:
point默认是null→ 整个 struct 缺失时结果是null,子字段默认值不生效point默认是{}(空 struct)→ 整个 struct 缺失时,逐个应用子字段默认值,得到{"x":0,"y":0}
所以「非 null 的 struct 默认值」是通过设一个空 struct {} 来表达的,而不是把子字段的值写进去。
def resolve_struct_default(struct_default, field_defaults, data_value):
"""
v3 嵌套 struct 默认值求值。
最反直觉的一点:struct 默认值是 {} 而不是 {"x":0,"y":0},
子字段值来自各自的 field 元数据。
"""
if data_value is not None:
# 有部分数据:缺失的子字段用各自默认值补齐
return {k: data_value.get(k, dv) for k, dv in field_defaults.items()}
if struct_default is None:
return None # 表第 1 行
# struct_default == {} : 逐字段应用默认值(表第 3 行)
return dict(field_defaults)
fd = {"x": 0, "y": 0}
assert resolve_struct_default(None, fd, None) == None
assert resolve_struct_default(None, fd, {"x": 3}) == {"x": 3, "y": 0}
assert resolve_struct_default({}, fd, None) == {"x": 0, "y": 0}
assert resolve_struct_default({}, fd, {"y": -1}) == {"x": 0, "y": -1}
五、多参数变换:一个只改了 JSON 序列化的「小」特性
v3 允许分区和排序使用多参数变换。规范层面的改动小得出奇——就是分区字段 JSON 里加了一个 source-ids:
| V1 | V2 | V3 | 字段 | JSON 表示 | 示例 |
|---|---|---|---|---|---|
| required | required | optional | source-id | JSON int | 1 |
| optional | source-ids | JSON list of ints | [1,2] | ||
| required | required | field-id | JSON int | 1000 | |
| required | required | required | name | JSON string | id_bucket |
| required | required | required | transform | JSON string | bucket[16] |
规则是互斥的:
For partition fields with a transform with a single argument, only
source-idis written. In case of a multi-argument transform, onlysource-idsis written.
单参数写 source-id,多参数写 source-ids,只写一个,不能同时写。
看起来是个 trivial 的改动,但对解析器来说是硬破坏:v2 时代 source-id 是 required,v3 变成 optional。任何假设 source-id 一定存在的解析代码,遇到多参数变换的表会直接 NPE。
def parse_partition_field(js: dict) -> dict:
"""
v3 兼容的分区字段解析。
v2 代码里 js["source-id"] 这种写法在 v3 会炸。
"""
if "source-ids" in js:
source_ids = list(js["source-ids"]) # 多参数变换
elif "source-id" in js:
source_ids = [js["source-id"]] # 单参数变换
else:
raise ValueError(f"分区字段缺少 source-id / source-ids: {js}")
return {
"field_id": js["field-id"],
"name": js["name"],
"transform": js["transform"],
"source_ids": source_ids,
}
配套的还有一条强制读侧要求:
All readers are required to read tables with unknown partition transforms, ignoring the unsupported partition fields when filtering.
在 v1/v2 里,遇到未知 transform,读者「应该」(should)忽略;在 v3 里,这变成了「必须」(required)。
这个措辞升级很重要:它给未来的 transform 扩展铺了路。规范作者显然预期还会有更多 transform 加进来,所以先把「遇到不认识的就跳过」这条协议固化成硬要求。
对应的写侧约束是:写侧不允许用包含未知 transform 的分区规范提交数据。 一读一写两条规则合起来,构成了一个安全的扩展协议。
5.1 顺带一个类型提升的隐蔽陷阱
规范在 schema 演进那节埋了一个和分区强相关的坑:
Type promotion is not allowed for a field that is referenced by
source-idorsource-idsof a partition field if the partition transform would produce a different value after promoting the type. For example,bucket[N]produces different hash values for34and"34"(2017239379 != -427558391) but the same value for34and34L.
看这两个具体的哈希值:bucket(34) = 2017239379,bucket("34") = -427558391。
也就是说:
int→long提升:允许,因为 Murmur3 对 34 和 34L 产生相同哈希int→string提升:禁止,哈希完全不同
如果允许后者,那么同一个逻辑值在提升前后会落到不同的分区桶里,分区裁剪就会漏数据——查询返回的结果会静默地少行。
这条规则的存在提醒我们:分区列的类型是被「冻结」的,选型时要格外谨慎。 bucket 变换的定义是:
bucket_N(x) = (murmur3_x86_32_hash(x) & Integer.MAX_VALUE) % N
32 位 Murmur3(x86 变体,种子为 0),按位与 Integer.MAX_VALUE 丢掉符号位,再对 N 取模。
六、表加密:Iceberg 只管密钥的「壳」,不管「芯」
v3 在表元数据里加了 encryption-keys 列表:
| 字段 | 类型 | 说明 |
|---|---|---|
key-id (必填) | string | 密钥 ID |
encrypted-key-metadata (必填) | string | 加密后的密钥及元数据,base64 编码 |
encrypted-by-id (选填) | string | 用于加密/包装上述 key-metadata 的密钥 ID |
properties (选填) | map<string,string> | 加密方案需要的额外元数据 |
规范对 encrypted-key-metadata 的格式刻意不做规定:
The format of encrypted key metadata is determined by the table's encryption scheme and can be a wrapped format specific to the table's KMS provider.
这是一个非常克制的设计:Iceberg 只提供密钥的存放位置和引用关系,不定义加密算法、不定义 KMS 协议、不定义信封格式。
encrypted-by-id 这个字段撑起了密钥分层(key hierarchy):
KMS 主密钥 (外部,Iceberg 不可见)
└── encrypted-by-id 指向它
└── 表级密钥 (encryption-keys 里的一条)
└── 快照通过 snapshot.key-id 引用
└── manifest list 的 key metadata 被它加密
快照层面对应新增了一个可选字段:
key-id(optional):ID of the encryption key that encrypts the manifest list key metadata
注意加密的层级:快照的 key-id 加密的是「manifest list 的密钥元数据」,不是直接加密数据。 这是标准的信封加密(envelope encryption)套路——密钥加密密钥,最底层的数据密钥才加密数据。
好处是密钥轮换(key rotation)只需要重新包装密钥,不需要重新加密数据。轮换一个 PB 级表的主密钥,代价是重写几 KB 的元数据,而不是重写 1PB 的 Parquet。
七、v4 前瞻:相对路径,以及那个会产生双斜杠的坑
必须先强调规范原文的措辞:
Version 4 is under active development and has not been formally adopted.
v4 尚未正式采纳,随时可能变。 下面的内容是趋势解读,不要拿去做生产决策。
v4 目前的唯一主线是:元数据里支持相对路径。
7.1 为什么这件事重要
在 v3 及以前,所有 location 字段都必须是全限定路径。这意味着 Iceberg 表的元数据里,硬编码了成千上万个 s3://my-bucket/warehouse/db/table/data/00000-0.parquet。
后果是三个真实的运维噩梦:
- 换桶等于重写全部元数据。 从
s3://old-bucket迁到s3://new-bucket,需要重写每一个 manifest、每一个 manifest list、每一个 metadata JSON。一张有 10 万个数据文件的表,这是几个小时的作业。 - 跨云容灾没法做。 你把数据 rsync 到另一个云的对象存储,元数据里的 scheme 还写着
s3:,另一边根本读不了。 - 本地测试没法用生产元数据。 把生产表的元数据拷到本地想复现问题?路径全是
s3://,动不了。
v4 的相对路径直接解掉这三个问题:元数据里只存 data/00000-0.parquet,表的 base location 由 catalog 提供。搬桶变成改一个 catalog 配置。
7.2 解析规则和那张边界情况表
规范给了极简的两条规则:
- If the path starts with a URI scheme, it is absolute and is used without modification.
- If the path does not start with a URI scheme, the resolved path is the table location followed by the relative path joined by the URI separator character
/.
判断绝对/相对的依据就是有没有 URI scheme(s3:、gs:、hdfs:、file:)。
关键是这张边界情况表:
| 场景 | 版本 | 表位置 | 文件路径 | 解析结果 |
|---|---|---|---|---|
| 相对路径 | v4 | s3://bucket/db/table | data/00000-0.parquet | s3://bucket/db/table/data/00000-0.parquet |
| 绝对路径 | v4 | s3://bucket/db/table | hdfs://wh/db/table/data/00000-0.parquet | hdfs://wh/db/table/data/00000-0.parquet |
| 重复分隔符 | v4 | s3://bucket/db/table/ | data/00000-0.parquet | s3://bucket/db/table//data/00000-0.parquet |
| 重复分隔符 | v4 | s3://bucket/db/table | /data/00000-0.parquet | s3://bucket/db/table//data/00000-0.parquet |
第 3、4 行是坑。 规范明确说了拼接过程不做任何分隔符规范化:
The relative portion is joined to the prefix (table location) without consideration of any additional separator characters.
结果就是 s3://bucket/db/table//data/...,中间两个斜杠。
在 POSIX 文件系统上,// 和 / 等价,没事。但在 S3 上,// 是一个合法且不同的 key!db/table/data/x.parquet 和 db/table//data/x.parquet 是两个完全不同的对象。
所以规范给了一条建议:
The recommended convention for table location is to not end in a path separator.
表位置不要以 / 结尾。 这条建议如果没人告诉你,等你在生产上遇到「文件明明在那儿但读不到」的时候,能排查一整天。
同时,相对路径不支持 . 和 ..:
Relative resolution within a URI (e.g.
.and..) and other file system navigation conventions are not supported in relative paths.
这是安全考虑——避免路径穿越攻击,也避免解析歧义。
7.3 相对化规则与 location 变成可选
反向的「相对化」(写元数据时把绝对路径转成相对)规则同样朴素:
- If an absolute path starts with the table location immediately followed by a separator character, the relative path is the remainder of the string after the separator character.
- If an absolute path does not start with the table location immediately followed by the separator character, it is stored as an absolute path.
能相对化就相对化,不能就老实存绝对路径。 这保证了「数据文件放在表目录外」这种场景(比如 add_files 导入外部数据)依然可用。
另一个变化是表元数据里的 location 字段从必填变成可选:
locationis now optional and must be absolute when present- When not present, the table location must be managed externally and provided when loading the metadata
这一步的哲学意义比技术意义大:表的物理位置不再是表自身的属性,而是 catalog 的职责。 规范原文:catalogs should provide a table's location.
这是湖仓架构从「文件系统心智」向「catalog 心智」迁移的又一个标志。同一份元数据可以被挂载到不同位置,表本身对自己在哪儿一无所知——这跟容器镜像不关心自己被跑在哪台机器上,是同一个抽象层次的进步。
八、实战:v2 → v3 升级剧本
8.1 升级前的元数据体检
升级前先摸清家底。以下 SQL 基于 Spark + Iceberg 的元数据表(.files、.snapshots、.manifests 等系统表):
-- 1. 现在是什么版本?有没有已经在用 v3 特性?
SELECT * FROM db.tbl.metadata_log_entries
ORDER BY timestamp DESC LIMIT 5;
-- 2. position delete 的存量有多少?(content=1 是 position delete)
-- 这是升级 v3 后必须逐步转成 DV 的债务
SELECT
count(*) AS pos_delete_files,
sum(file_size_in_bytes)/1024/1024 AS total_mb,
sum(record_count) AS total_delete_records,
avg(record_count) AS avg_records_per_file
FROM db.tbl.delete_files
WHERE content = 1;
-- 3. equality delete 有没有在用?(content=2 在 delete_files 里表示 equality)
-- 如果有,说明写侧是流式 upsert,升 v3 后这部分行不会有血缘
SELECT count(*) AS eq_delete_files
FROM db.tbl.delete_files
WHERE content = 2;
-- 4. manifest 数量 —— 决定升级后第一次提交要重写多少元数据
SELECT
count(*) AS manifest_count,
sum(added_data_files_count
+ existing_data_files_count) AS tracked_data_files
FROM db.tbl.manifests;
-- 5. 「跨多个数据文件的 position delete file」——最难消化的那一类
-- 规范:这类文件必须保留到所有删除都被 DV 替换
SELECT file_path, count(DISTINCT referenced_data_file) AS refs
FROM db.tbl.delete_files
WHERE content = 1
GROUP BY file_path
HAVING count(DISTINCT referenced_data_file) > 1
ORDER BY refs DESC
LIMIT 20;
第 5 条查出来的那批文件,是升级过程里最顽固的技术债:它们必须一直挂在元数据里,直到里面每一个引用的数据文件都有了 DV。
8.2 写侧兼容性准入清单
这一步没法用 SQL 查,只能人工核对。我把它做成一张必答清单:
| 检查项 | 为什么致命 |
|---|---|
| 所有写侧引擎是否支持 DV? | 不支持的写侧会继续写 position delete → 读侧的「有 DV 就忽略 position delete」捷径会静默丢删除 |
所有写侧是否正确维护 next-row-id? | 不维护会导致 _row_id 重复 → 血缘彻底失效 |
Compaction 作业是否正确搬运 _last_updated_sequence_number? | 写错会让每次 compaction 后全表看起来「刚被改过」 |
| 有没有第三方工具直接写元数据 JSON? | 手写 JSON 的工具几乎肯定不懂 next-row-id 和 encryption-keys |
| 读侧最老的引擎版本是什么? | v3 是前向不兼容的,老读者会失败 |
注意第一项和第二项:这两个问题都不会报错,只会产生静默的数据错误。 在数据平台里,静默错误比崩溃可怕十倍——崩溃你当场就知道了,静默错误你三个月后才从下游对账里发现。
8.3 分阶段升级
我建议的顺序(这是我的工程判断,不是规范要求):
阶段 0:影子验证。 复制一张小表升 v3,把全部读写链路跑一遍。重点验证 compaction 后 _last_updated_sequence_number 有没有被错误重置。
阶段 1:先升读侧。 所有读侧引擎升级到支持 v3 的版本,但表还是 v2。这一步零风险——新读者读老表天然兼容。
阶段 2:升级表版本 + 主动触发元数据重写。
ALTER TABLE db.tbl SET TBLPROPERTIES ('format-version' = '3');
-- 立刻做一次轻量提交,把「给所有 data manifest 分配 first_row_id」
-- 这次昂贵的 manifest list 重写做掉,别留给业务高峰
CALL catalog.system.rewrite_manifests('db.tbl');
阶段 3:消化 position delete 债务。 用 compaction 把存量 position delete 逐步转成 DV:
CALL catalog.system.rewrite_data_files(
table => 'db.tbl',
options => map(
'delete-file-threshold', '1', -- 有 delete file 就重写
'max-concurrent-file-group-rewrites', '10'
)
);
阶段 4:验收。 确认 content = 1(position delete)的文件数归零:
SELECT count(*) FROM db.tbl.delete_files WHERE content = 1;
-- 期望:0
阶段 5:启用血缘下游。 到这一步才开始基于 _row_id 做增量、审计、SCD2。
-- 血缘可用后,做一个真正精确的行级增量
SELECT
_row_id,
_last_updated_sequence_number,
*
FROM db.tbl
WHERE _last_updated_sequence_number > 12345 -- 上次消费到的位点
AND _row_id IS NOT NULL; -- 排除升级前的历史行
那个 _row_id IS NOT NULL 的条件不能省——升级前的历史快照里所有行的 _row_id 都是 null。
九、七条工程规律(我的经验总结,非规范内容)
规律一:DV 的收益和「删除的分散度」成正比,和「删除的总量」关系不大。
删 1000 万行但集中在 10 个文件里,v2 也扛得住(10 个 delete file)。删 1 万行但散布在 5000 个文件里,v2 会产生海量小 delete file,这才是 DV 的主场。判断标准应该是 delete_files_count / data_files_count 这个比值,而不是删除行数。
规律二:next-row-id 的浪费永远不值得优化。
64 位空间 9.2×10^18,随便浪费。任何为了「精确分配 row id」而增加提交路径复杂度的优化,都是负收益。规范作者用「大于等于」而不是「等于」的措辞,就是在明示这一点。
规律三:升级 v3 后的第一次提交必然慢,把它安排在低峰。
全表 data manifest 都要分配 first_row_id,manifest list 会被整体重写。
规律四:变量类型(variant)不是万能药,热字段还是要提升成真列。
variant 保留了类型和结构,但谓词下推和统计信息的效果通常弱于原生列。经验做法:查询频率进前 20 的字段,从 variant 里提升成独立列;长尾字段留在 variant 里。 这是空间换性能的经典权衡。
规律五:分区列一旦定了就别想改类型。
bucket/truncate 变换和源类型强绑定,int→string 这类提升会被规范禁止。分区列选型时优先用 long——它是类型提升链的终点,没有后患。
规律六:表位置千万别以 / 结尾。
v4 相对路径解析不做分隔符规范化,会拼出 //。S3 上 // 是不同的 key。这条现在就该执行,别等 v4 落地了才改。
规律七:血缘的价值在下游,不在表本身。_row_id 本身不产生任何业务价值,它的价值在于让下游能做精确的行级增量——不用全量扫、不用比对哈希、不用维护额外的变更表。如果你的下游全是全量重算的批作业,那 v3 的血缘对你就是纯成本。 先想清楚下游怎么用,再决定要不要升。
十、十条踩坑清单
added-rows是上界不是精确值。 拿它做入库量监控会看到莫名尖刺,用快照 summary 的added-records。Compaction 错误重置
_last_updated_sequence_number。 规则是「改过才置 null,纯搬运原值复制」。写错会让所有增量管道在每次 compaction 后全量重跑。不支持 DV 的写侧混在集群里。 它会继续写 position delete,而读侧因为「有 DV 就忽略 position delete」的规则会静默丢删除。升级前必须把所有写侧盘一遍,一个都不能漏。
跨分支比较
_row_id。 规范明说不同分支会给同一个数据文件分配不相交的 ID 区间。WAP 流程里的审计逻辑不能假设 branch 和 main 的_row_id一致。等值删除路径上期待血缘连续。 走 equality delete 的 UPDATE 一律被当成「删旧+插新」,新行拿全新 ID。这是规范明确的行为,不是 bug。
给可选列加非 null 的
initial-default,但集群里还有老读者。 老读者会读出 null 而不是默认值,不报错,静默错误。嵌套 struct 默认值写成
{"x":0,"y":0}。 规范要求写空 struct{},子字段默认值在各自的字段元数据里。写成前者是无效的。在 CRS 里内联 PROJJSON。 规范明令禁止,而且要求实现不得把 CRS 内容当 PROJJSON 解析。正确做法是存表属性 +
projjson:<name>引用。v3 解析器假设
source-id一定存在。 多参数变换只写source-ids,source-id在 v3 已经是 optional。硬取会 NPE。表位置以
/结尾。 v4 相对路径拼接不做规范化,会产生//。在 S3 上这是两个不同的 key,表现为「文件明明上传了却读不到」。
十一、总结:表格式正在长成一个数据库内核
把 v1 到 v4 连起来看,Iceberg 的演进路线其实特别清晰——它在逐个复刻传统数据库内核的能力,只不过底座从本地磁盘换成了对象存储:
| Iceberg 特性 | 对应的数据库内核概念 |
|---|---|
| 快照 + 原子指针交换 | MVCC + 事务提交 |
| sequence number | 事务 ID / LSN |
| 删除向量 | 可见性位图 / delete bitmap |
行血缘 _row_id | ROWID / 物理行标识 |
_last_updated_sequence_number | 行版本号 |
| 加密密钥分层 | TDE 密钥管理 |
| v4 相对路径 | 表空间(tablespace)重定位 |
v3 最重要的那一步是 _row_id。因为在此之前,Iceberg 里的「一行」是没有身份的——它只是某个文件里第 N 个位置上的一串字节。compaction 一跑,这个位置就变了,行就「换了个人」。
有了 _row_id,行第一次成为了一个跨越文件、跨越快照、跨越 compaction 的持久实体。这是把 CDC、审计、SCD2、精确增量物化视图从「上层应用自己维护」下沉到「表格式原生保证」的前提条件。
而 v4 的相对路径,虽然看起来只是个路径拼接的小改动,方向上却在做另一件事:把表从物理位置里解耦出来。 表元数据不再知道自己在哪个桶里,位置由 catalog 注入。这跟容器镜像不关心自己跑在哪台机器上,是同一个抽象层次的跃迁。
给还在观望的团队三句实在话:
- 不要为了「用上新版本」而升 v3。 先回答一个问题:你的下游需不需要行级血缘?如果全是全量重算的批作业,v3 对你只有成本没有收益。
- 如果要升,写侧准入检查比什么都重要。 DV 支持、
next-row-id维护、compaction 血缘搬运——这三件事错任何一件,产生的都是静默的数据错误,不是崩溃。静默错误的排查成本是崩溃的十倍。 - v4 还没定稿,别押注。 但「表位置不要以
/结尾」这条现在就可以执行,零成本,将来省一整天排查。
最后说句题外话。读 Iceberg 的 spec 是一件很有收获的事,不是因为你要去实现它,而是因为它把**「在一个只能追加、没有事务、请求收费的存储介质上,怎么造出一个可串行化的数据库」**这件事的每一处权衡都写了下来。
那些「继承而不是写死」「宁可浪费 ID 也不重算」「热路径禁止内联大对象」「用写侧不变式换读侧捷径」的决策,换个场景照样成立。规范文档是最被低估的架构教材——它不讲道理,它直接给你看结论和约束。
本文技术细节对齐 apache/iceberg 仓库 format/spec.md 主干内容。v4 相关内容规范原文标注为「under active development and has not been formally adopted」,随时可能变更,不建议用于生产决策。文中标注为「我的判断/经验」的部分为作者推断,与规范原文区分。