- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — v3 中行血统无法关闭
- 行血统给你的东西
- 核心设计 — 不写值,靠继承
- 读取时的规则
- 从这里开始才是重点 — 血统会在哪些地方断裂
- 引擎现实核查 — 规范的「must」与实现之间的距离
- 什么时候不该用
- 结语
- 参考资料
引言 — v3 中行血统无法关闭
Apache Iceberg 规范原文的「Format Versioning」一节是这样开头的 — 版本 1、2、3 已经完成并被社区采用,版本 4 正在积极开发中、尚未正式采用。这意味着 v3 已经不再是将来时。Java 库的常量早已设置为可以读写到 v4(SUPPORTED_TABLE_FORMAT_VERSION = 4)。
列一下 v3 新增的内容大致是这样 — 纳秒级时间戳、unknown/variant/geometry/geography 类型、列默认值、多参数分区变换、行血统追踪、二进制删除向量、表加密密钥。其中删除向量已经被讨论了很多。本文要看的是相对较少被谈及、却可能悄无声息地咬伤你流水线的 行血统。
先要知道一个事实:行血统不是一个可以开关的表属性。Iceberg 核心的判定逻辑就这么多。
public static boolean supportsRowLineage(Table table) {
Preconditions.checkArgument(null != table, "Invalid table: null");
if (table instanceof BaseMetadataTable) {
return false;
}
return formatVersion(table) >= TableMetadata.MIN_FORMAT_VERSION_ROW_LINEAGE;
}
MIN_FORMAT_VERSION_ROW_LINEAGE 是 3。也就是说,只要格式版本大于等于 3,行血统就是开启的。早期设计里本有一个用来开启它的元数据字段,但后来被移除了("Core: Enable row lineage for all v3 tables",PR #12593),规范的措辞很笃定 — 在 v3 及以上版本,Iceberg 表必须为每一个新创建的行追踪行血统字段。一旦升级到 v3,写入该表的每一个引擎都背上了维护血统的义务。不管你是不是想要这个功能。
反过来也有一个让人安心的事实。在同一个文件里,DEFAULT_TABLE_FORMAT_VERSION 依然是 2 — 截至 2026 年 7 月的 main 分支也是如此。创建新表并不会自动变成 v3 — v3 是一扇需要明确选择才会走进去的门。
行血统给你的东西
v3 定义了两个保留的元数据列。
| 字段 ID | 名称 | 类型 | 含义 |
|---|---|---|---|
| 2147483540 | _row_id | long | 表内每一行唯一的标识符 |
| 2147483539 | _last_updated_sequence_number | long | 最后一次修改该行的那次提交的序列号 |
核心在于 _row_id 是行的身份。即便这一行被 UPDATE,即便被压缩重写进了另一个文件,即便分区变了,都必须保持同一个 _row_id。这是把物理位置和逻辑身份分离开来 — 没有这个,「这一行是什么时候第一次出现的、期间改过几次」这个问题,如果没有表之外的另一本账,就答不出来。
再加上 _last_updated_sequence_number,「只给我序列号 N 之后变化过的行」这种需求,就能仅凭表元数据表达出来了。这正是在不依赖外部工具的情况下搭建增量处理和 CDC 的基础。
核心设计 — 不写值,靠继承
这就是这个功能的全部。_row_id 不存放在数据文件里。
写新行的时候,writer 会把 _row_id 和 _last_updated_sequence_number 留成 null。甚至完全省略这两列也可以 — 规范说,如果列不存在,reader 应该把它当作「一列在所有行上都是 null」来处理。真正的值是在读取的那一刻才计算出来的。
至于为什么要这样做,规范自己给出了答案 — 提交序列号和起始行 ID,在快照成功提交之前是无法确定的。Iceberg 的提交用的是乐观并发控制。两个 writer 同时尝试提交时,一个会输,输的那一方要在最新快照之上重试。如果把行 ID 硬写进了数据文件里,那么每次提交重试,都得把已经写好的数据文件和清单文件全部重写一遍。写入越大,一次冲突的代价就越会随之放大。用规范自己的话说,继承的存在,就是为了让文件可以在值确定之前先写出来,从而让乐观提交在重试时不必重写文件。
代价很明确。正因为没有存值,生成这个值的这条链条就必须精确无误。 这条链条是这样往下走的:
表元数据 next-row-id (下一批要分配的 ID 起点)
└─ 快照 first-row-id = 提交时刻表的 next-row-id
└─ 清单 first_row_id = 按前面各清单的行数累加偏移的值
└─ 数据文件 first_row_id = 按前面各文件的 record_count 累加偏移的值
└─ 行 _row_id = 数据文件的 first_row_id + _pos
最底下这一行就是结论 — 一行的 ID,等于它所在数据文件的 first_row_id,加上它在文件内的行位置(_pos)。这个设计不存 ID,而是把 ID 空间按区间预留出来分发,再用位置去做偏移。
跟着规范里的例子走一遍
规范里给的例子很好地展示了这条链。从一张 next-row-id 为 1000 的表开始。append 快照会拿到表当前的 next-row-id(也就是 1000)作为自己的 first-row-id。
写清单列表(manifest list)时,每份清单会被分配一个 first_row_id。
manifest_path | added_rows_count | existing_rows_count | first_row_id |
|---|---|---|---|
| existing | 75 | 0 | 925 |
| added1 | 100 | 25 | 1000 |
| added2 | 0 | 100 | 1125 |
| added3 | 125 | 25 | 1225 |
added1 拿到和快照相同的 1000,从这以后,每一份清单都按前一份清单的行数(新增行 + 既有行)累加偏移:1000 + 125 = 1125,1125 + 100 = 1225。
这里规范特别指出了一个陷阱 — added2 明明一个新增数据文件都没有,却依然把下一份清单的 first_row_id 往后推了 100。原因是:一个没有 first_row_id 的数据文件,即便处于 EXISTING 状态,也可能被分配 ID,所以要预先把这部分空间留出来。
第一份清单 added1 里的数据文件是这样的:
status | file_path | record_count | first_row_id |
|---|---|---|---|
| EXISTING | data1 | 25 | 800 |
| ADDED | data2 | 50 | null → 1000 |
| ADDED | data3 | 50 | null → 1050 |
data1 之前已经被分配过 800,那个值原样保留、直接复制过来。data2 和 data3 写的是 null,读取的时候会从清单的 first_row_id(1000)开始、按前面文件的 record_count 累加偏移,分别得到 1000 和 1050。所以 data3 里第三行的 _row_id 就是 1050 + 2 = 1052。
提交完成后,表的 next-row-id 会被更新。规范的强制条款要求偏移量至少等于该快照新分配的行 ID 数量,推荐的计算方式是把所有被分配了 first_row_id 的清单的 added_rows_count 和 existing_rows_count 全部加起来。在这个例子里,那个和是 375(added1 的 125 + added2 的 100 + added3 的 150),所以 next-row-id 变成 1000 + 375 = 1375。
注意,这里 EXISTING 的行也在消耗 ID 空间。这些行明明已经有自己的 ID、不会拿到新 ID,范围却依然被预留了出来。因为强制规则只定了下限,而推荐的计算方式又留得比这更宽裕,所以 ID 空间里出现空洞是正常现象。 _row_id 只保证唯一,不保证连续 — 不能拿它去数行数,也不能假设它连续。
读取时的规则
reader 一侧的规则被写死在规范附录 E 里。
- 如果数据文件的
first_row_id非空,reader 会用first_row_id + _pos把为 null 或缺失的_row_id填上。 - 同样地,把为 null 或缺失的
_last_updated_sequence_number,用该数据文件的data_sequence_number填上。 - 已经是非空的值,原样读取,不做任何改动。
- 如果数据文件的
first_row_id为空,reader 就把_row_id和_last_updated_sequence_number都输出为 null。
第三条规则很关键。如果值已经存在,那就意味着这是「被搬运过来的行」,它的身份不能被覆盖。
从这里开始才是重点 — 血统会在哪些地方断裂
压缩会杀死血统
行被搬到另一个数据文件时(无论原因是压缩、重写还是分区变更),writer 必须遵守的规则是这样的:
- 行原有的非空
_row_id必须被复制到新的数据文件里。 - 如果这次写入修改了该行,
_last_updated_sequence_number留成null(这样修改那一刻的序列号才会被继承)。 - 如果没有修改,就把原有非空的
_last_updated_sequence_number原样复制过去。
说起来简单,落到实现上却很棘手。压缩原本是一件单纯的活儿 — 「把数据读出来,重写进更大的文件」。但到了 v3,压缩作业得把两个隐藏的元数据列一起读、一起写。要是照旧简单粗暴地重写,_row_id 就会被全部重新发放,表里的每一行都会变成「刚刚才第一次出现的行」。搭建在血统之上的 CDC 流水线,最后会被逼着做一次全量重处理。悄无声息,一个错误都不会报。
这不是纸上谈兵的担忧,提交日志里全是证据。后面会看到。
equality delete 不追踪血统
规范明确留了一个例外 — 通过 equality delete 修改的行,不追踪行血统。原因规范里也写了。用 equality delete 的引擎,本来就不读已有数据、直接写入变更,所以根本没办法把原始的 row ID 塞进新行里。因此这类更新会被当作原有行被彻底删除、新增了一个全新的、唯一的行来处理。
这一点影响很大。如果工作负载依赖 equality delete(典型场景是 Flink 的 upsert 流式写入),那就算升级到 v3,行的身份也无法跨越 UPDATE 存活下来。想把行血统当作 CDC 基础的计划,可能正好在这里塌掉一半。
升级后的表,过去永远是 null
把 v2 表升级到 v3,next-row-id 会被初始化为 0,而已有的快照不会被修改。在那些没有 first-row-id 的快照里,数据文件和清单的 first_row_id 都是 null,因此所有行的 _row_id 读出来都是 null。用规范原话说 — 升级之前创建的快照没有行 ID。
升级后的第一次提交,确实会给已有文件分配 ID。但那只意味着「这些文件刚刚拿到了 ID」,不代表过去的历史被恢复了。行血统不会追溯既往。
ID 范围因分支而分裂
规范里悄悄留了一条脚注。升级之后,不同分支上的新快照,会依据提交时刻各自的 next-row-id,给已有数据文件分配互不重叠的 ID 范围。对于横跨多个分支的数据文件,writer 既可能沿用另一个分支的 first_row_id,也可能为了避免大规模重写元数据而分配一个新的 first_row_id。
也就是说,在使用分支的表里,_row_id 并不保证在不同分支间指向同一行。如果你原本打算把 _row_id 当作连接键(join key)来用,计划应该在这里止步。
引擎现实核查 — 规范的「must」与实现之间的距离
这就是写这篇文章的原因。厂商博客把「支持 v3」画成一个勾选框。实际的代码讲的是另一个故事。以下是截至 2026 年 7 月 16 日,我直接核对各仓库 main/master 分支及已合并 PR 得到的内容。
Spark 4.0 事实上是参考实现。 行血统支持通过 PR #13310 在 2025 年 7 月 14 日合入。根据 PR 描述,它用到了 Spark 4.0 新出现的条件式空值化(conditional nullification)机制,而这个机制在 Spark 3.5 上不存在,所以 Iceberg 不得不在那里单独实现自己的 rewrite 规则(PR #12736,2025 年 4 月 28 日)。那份 PR 梳理出的规则,正是我们前面看到的规范原文 — 已有的 row ID 无论操作类型如何都始终被继承,INSERT 时为 null,未修改的行保留序列号,修改过的行则置为 null。
压缩场景下的保留是单独的一项工作。 前面说「压缩会杀死血统」,说的正是这件事。Spark 4.0 在压缩时保留行血统,直到 PR #13555 才在 2025 年 7 月 22 日合入,并在第二天被回移植(backport)到了 3.5 和 3.4(PR #13637、#13641)。实现方式很有意思,用 PR 作者自己的话说,对于 rewrite 场景,做法是「劫持(hijacking)」SparkTable 的 schema 来附加元数据列。作者自己也很清楚这并不优雅。
Trino 走得比想象中靠前。 这一点差点被搜索结果骗过去 — 好几份二手资料写着「截至 2026 年 6 月 Trino 还没准备好 v3」或者「行血统尚未支持」,但直接看仓库就知道这不是事实。删除向量通过 PR #27788 在 2026 年 1 月 16 日合入 master,行血统通过 PR #27836 在 2026 年 3 月 19 日合入 master。后者不只覆盖读取,还包括在 UPDATE/MERGE 中保留原始 row ID,以及在 v3 表上重新开放 OPTIMIZE 的同时守住血统。
Trino 在那之前做的事也值得一看。PR #27786(2026 年 1 月 10 日)开放了 v3 表的创建,同时确保所有尚未实现的 v3 功能都被明确地以 NOT_SUPPORTED 拒绝。v3 表上的 DELETE/UPDATE/MERGE、OPTIMIZE、add_files、删除向量、列默认值、加密,全都被这一条挡住了。用 PR 描述的话说,这是「为了避免违反规范」,选择快速、可预测地失败,而不是悄悄给出错误答案。在部分实现的阶段,这是正确的选择,其他引擎值得学一学。
列名在不同引擎间是不一样的。Iceberg 核心和 Spark 用的是 _row_id / _last_updated_sequence_number,而 Trino 按照自己的元数据列命名惯例,把它们暴露为 $row_id / $last_updated_sequence_number。字段 ID 是一样的,所以数据是兼容的,但查询语句不能原样搬过去。
Flink 分裂成了两条路径,这是最容易让人搞混的部分。 Iceberg main 分支上的 Flink 连接器有两条压缩路径,它们在行血统这件事上的表现正好相反。
旧的 actions API(org.apache.iceberg.flink.actions.RewriteDataFilesAction)现在依然直接拒绝 v3 表。main 分支上的 v1.20、v2.0、v2.1 都原样保留着这段代码。
Preconditions.checkArgument(
!TableUtil.supportsRowLineage(table),
"Flink does not support compaction on row lineage enabled tables (V3+)");
而新的 maintenance API(org.apache.iceberg.flink.maintenance.operator.DataFileRewriteRunner)则会保留血统。它通过 PR #14149 在 2025 年 11 月 6 日合入,代码是这样分叉的:
boolean preserveRowId = TableUtil.supportsRowLineage(value.table());
try (TaskWriter<RowData> writer = writerFor(value, preserveRowId)) {
try (DataIterator<RowData> iterator = readerFor(value, preserveRowId)) {
归纳一下 — 用 Flink 压缩 v3 表,必须用 maintenance API;如果还在用旧的 actions API,一旦升级到 v3,压缩就会在抛出异常后停下来。听起来像坏消息,其实是好设计。与其悄悄毁掉血统,不如吵吵闹闹地停下来。顺带一提,这条拒绝逻辑本身,正是通过 PR #13646("Flink: fail file rewrite for V3 tables as row lineage not supported",2025 年 7 月 23 日)被特意加进去的。
PyIceberg 目前还写不了 v3 表。 main 分支的 pyiceberg/table/metadata.py 里依然这样写着:
SUPPORTED_TABLE_FORMAT_VERSION = 2
原本打算加入 v3 清单/清单列表写入以及行血统快照提交的 PR #3070 没有合并就被关闭了,接续它的 PR #3551("Writing v3 Table Metadata")在 2026 年 7 月 8 日最后更新之后,至今仍开着。v3 升级支持 PR #3623 处于 draft 状态,等着 #3551 合并。如果你的流水线里有用 Python 写 Iceberg 的部分,现阶段最准确的做法是先把它排除在 v3 计划之外。
而且继承逻辑至今仍在被修正。 就在写这篇文章的一周前,2026 年 7 月 9 日,PR #17039("Core: Fix row lineage last updated sequence inheritance")合并了。这个 bug 是这样的 — Java 在填充 _last_updated_sequence_number 元数据列时,用的是 fileSequenceNumber(),而不是 data_sequence_number。
两者的差异平时看不出来。但如果 v2 时代的压缩生成过一个携带更旧 data sequence number 的替代文件,而这张表后来又被升级到 v3,两者就会出现分歧。data_sequence_number 绑定的是文件的内容,所以能在重写之后依然保留;而 file_sequence_number 记录的是那个物理文件被添加的时刻。所以带 bug 的版本,被问到「这一行最后一次修改是什么时候」时,回答的其实是压缩恰好运行的那一刻,而不是原本的修改时刻。
这恰好展示了继承式设计的代价。因为值不在文件里,只要有一条继承规则错了,数据本身完好无损,答案却是错的 — 而且这种错误从不抛异常。规范在 2024 年 10 月(PR #11130)第一次引入行血统之后,21 个月过去了,这样的 bug 依然会冒出来,这说明这个功能的难度,远远超过规范文档所占的篇幅。Avro reader 那一侧在 2026 年 2 月和 3 月也出现过性质相似的修复(PR #15187、#15508)。
什么时候不该用
老老实实地梳理一下,是这样的。
遇到以下情况,先别升级到 v3
- 流水线里哪怕只有一个 PyIceberg writer。它现在还写不了 v3 表。
- 还在用 Flink 的旧 actions API 跑压缩,也没有迁移到 maintenance API 的计划。
- 工作负载以 equality delete 为主(典型场景是 Flink 的 upsert 流式写入)。为了拿到血统而升级,结果偏偏在 UPDATE 上行身份接不上。
- 有多个引擎写入同一张表,而其中哪怕只有一个的血统保留能力没有被验证过。一个不遵守血统规则的 writer,就会毁掉整张表的血统。
可以升级的情况
- writer 仅限于 Spark 4.0(或回移植后的 3.5/3.4)、或者较新版本的 Trino。
- 已经实际验证过压缩路径会保留血统 — 靠自己在压缩前后直接比对
_row_id,而不是靠看文档。 - 光是删除向量带来的收益就已经值回票价。行血统只是 v3 附带送的东西,实际上大多数组织迁移到 v3 的真正原因就是删除向量。
而且,在把行血统当作 CDC 来用之前,务必确认这几点 — _row_id 只是唯一的,并不密集;在分支间不稳定;不存在于升级之前的历史里;也无法在 equality delete 中存活。先看看这四条会不会和你的设计冲突。
结语
行血统的设计很聪明。它不存行 ID,而是把 ID 空间预留成区间分发出去,再用位置做偏移,这样即便乐观提交重试,也不需要重写数据文件。只要了解 Iceberg 提交模型的人,立刻就能明白它为什么长成这个样子。
代价是责任的转移。因为值不在文件里,产出并守住这个值,完全取决于 writer 和 reader 是否做对了。规范写的是「must」,可规范并不执行代码。所以截至 2026 年 7 月,图景是这样的 — Spark 4.0 能做到,Trino 从 3 月起也能做到,Flink 则要看用的是哪个 API,PyIceberg 还不行,核心的继承逻辑上周还在被修正。
这不是说不该用 v3。光是删除向量,就已经值得走向 v3。但你得清楚地知道,一旦升级到 v3,行血统就会以无法关闭的方式打开,触碰这张表的每一个工具都相当于在一份隐性合约上签了字。只要其中混进一个守不住这份合约的工具,你得到的就不是血统,而是「以为自己有血统」的错觉。那要糟糕得多。
Iceberg 整体的架构与运维,我会在Apache Iceberg 数据湖仓表格式指南一文里另行展开。
参考资料
- Apache Iceberg Table Spec — 原文 — 行血统一节与附录 E(按版本的变更)是本文的第一手依据。原文可以在 raw.githubusercontent.com 上的 format/spec.md 直接看到
- Iceberg
TableMetadata.java—DEFAULT_TABLE_FORMAT_VERSION = 2、SUPPORTED_TABLE_FORMAT_VERSION = 4、MIN_FORMAT_VERSION_ROW_LINEAGE = 3 - Iceberg
TableUtil.java— 行血统仅由格式版本决定的判定逻辑 - Iceberg
MetadataColumns.java—_row_id、_last_updated_sequence_number的定义 - PR #11130 — Spec: Adds Row Lineage — 行血统第一次进入规范的提交(2024-10)
- PR #12593 — Core: Enable row lineage for all v3 tables — 它不再是可选项的那个节点
- PR #13310 — Spark 4.0: Row Lineage support
- PR #12736 — Spark 3.5: Update MERGE and UPDATE for row lineage — 血统保留规则表述得最清楚的一份 PR 描述
- PR #13555 — Spark 4.0: Preserve row lineage information on compaction
- PR #17039 — Core: Fix row lineage last updated sequence inheritance — 2026-07-09 合并的继承 bug 修复
- PR #14149 — Flink: Preserve row lineage in RewriteDataFiles
- Flink
RewriteDataFilesAction.java— 旧 API 拒绝 v3 的代码 - Flink
DataFileRewriteRunner.java— 新 maintenance API 的血统保留 - Trino PR #27836 — Iceberg v3 Row lineage — 2026-03-19 合并
- Trino PR #27788 — Iceberg v3 Deletion vector — 2026-01-16 合并
- Trino PR #27786 — Enable reading Iceberg v3 failing on all unimplemented features — 对未实现功能明确拒绝的做法
- Trino
IcebergMetadataColumn.java—$row_id列名的依据 - PyIceberg
metadata.py—SUPPORTED_TABLE_FORMAT_VERSION = 2 - PyIceberg PR #3551 — Writing v3 Table Metadata — 仍处于打开状态
- Apache Iceberg Releases — 1.10.0(2025-09-11)、1.11.0(2026-05-19)发布说明
- Apache Iceberg 数据湖仓表格式指南 (相关文章)