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

【数据湖与开放表格式】提交协议与并发控制

文章导航

分类入口
databasestorage
标签入口
#iceberg#optimistic-concurrency#commit#compare-and-swap#rest-catalog#isolation-level#table-format

目录

Iceberg 元数据树 留下一句关键话:整棵树里只有 catalog 那一个指针是可变的,其余三层文件与 data file 全部不可变。行级删除与 Merge-on-Read 又把删除归结为「写新文件、让新 snapshot 指向它们」。

这就把 lakehouse 的 ACID 问题压缩成一个极简的形式:怎么把那一个指针,从旧 metadata 原子地切到新 metadata? 没有数据库进程、没有全局锁管理器、底下是「不能原子 rename、list 很贵」的对象存储——Iceberg 必须只靠「一次原子指针替换」就撑起原子提交、快照隔离和并发控制。

本文回答四件事:

证据锚定 Apache Iceberg 表规范iceberg.apache.org/spec)的《Optimistic Concurrency》《Commit Conflict Resolution and Retry》《Metastore Tables》《File System Tables》各节、Iceberg REST Catalog OpenAPIUpdateTableRequest / TableRequirement / TableUpdate,与 apache/iceberg 1.x 源码类名。实验在本机用 pyiceberg 0.11.1 + pyarrow 24.0.0 真实制造并发冲突,带 == 标记的输出均来自实跑。

实验环境:Intel Core i9-12900K(24 线程),32 GB 内存,Linux 6.6(WSL2,Arch),Python 3.14.5,pyiceberg 0.11.1,pyarrow 24.0.0。catalog 用 SQLite(pyiceberg.catalog.sql.SqlCatalog),warehouse 为本地文件系统 file:///tmp/ice_wh11,建表 format-version = 2


一、提交 = 元数据指针的原子 swap

一次提交干了什么

回顾第 8 篇:每次改表(append/delete/演进)都会写出一份新的 vN+1.metadata.json,里面含新的 snapshot。但写出这个文件还不算提交——它还只是「一个躺在存储上的候选版本」。提交的本质,是让 catalog 把「表的当前 metadata 指针」从 vN 切到 vN+1

表规范《Optimistic Concurrency》把它说得很干脆:写者乐观地创建表元数据文件,假设当前版本在自己提交前不会被改动;然后通过把表的 metadata 文件指针从基线版本 swap 到新版本来完成提交。

flowchart LR
  subgraph 不可变存储
    M0["v0.metadata.json"]
    M1["v1.metadata.json(候选)"]
  end
  PTR["catalog 指针<br/>table → 当前 metadata"]
  PTR -->|提交前指向| M0
  PTR -.->|CAS:若仍==v0 则切到 v1| M1

为什么必须是 CAS 而不是普通写

如果提交只是「把指针写成 v1」,两个并发写者就会互相覆盖:A 基于 v0 写出 v1、B 也基于 v0 写出另一个 v1’,谁后写谁赢,先写者的提交被静默丢失(lost update)。

正确的提交必须是条件写:「当且仅当当前指针仍是我基于的那个 v0,才把它切到 v1」。这就是 compare-and-swap:比较(当前 == 期望旧值)+ 交换(设为新值),两步原子完成。

整张表的 ACID 就立在这一次 CAS 上:原子性来自 CAS 的不可分割,隔离性来自「每个读者读一个不可变 snapshot」,一致性来自提交前的冲突校验,持久性来自对象存储对已写对象的持久保证。所有难点收敛到「catalog 能不能提供一次可靠的 CAS」——这正是第四节要拆的「原子性来源」。


二、乐观并发:基于当前 snapshot 生成新 snapshot

乐观的含义

「乐观」指写者不预先加锁,而是假设「我提交时不会有人和我撞」,先把活干完(写数据文件、写 manifest、写新 metadata),最后用一次 CAS 赌这个假设成立;赌输了再重试。与之相对的「悲观并发」会先抢锁再干活。对象存储上没有廉价的全局锁,且大多数提交确实不冲突,所以 Iceberg 选乐观。

一次写的完整流程:

sequenceDiagram
  participant W as Writer
  participant S as 对象存储
  participant C as Catalog
  W->>C: 读表,拿到 base = 当前 metadata(vN) 与 snapshot
  W->>S: 写 data/delete 文件
  W->>S: 写 manifest、manifest list(新 snapshot)
  W->>S: 写 vN+1.metadata.json(候选)
  W->>C: CAS:指针 vN → vN+1(期望仍是 vN)
  alt CAS 成功
    C-->>W: 提交成功,vN+1 为当前
  else CAS 失败(指针已变)
    C-->>W: 冲突
    W->>W: 基于新当前版本校验+重放,重试
  end

把上图的「写 manifest、写 manifest list、写 metadata」摊开到文件级,一次 append 提交大致是(对应 apache/icebergSnapshotProducer 族):

  1. 为本次新增的 data/delete 文件写一个或多个新 manifest(只装本次新文件,sequence number 先留空待继承)。
  2. 复用所有未改动的旧 manifest(直接引用,不重写)——这是第 8 篇「append 元数据写入量与表已有文件数无关」的来源。
  3. 写一个新的 manifest list(即新 snapshot),把新旧 manifest 都列进去,并写入本 snapshot 乐观分配的 sequence number。
  4. 写一个新的 vN+1.metadata.json,把新 snapshot 加入 snapshots 列表、更新 current-snapshot-id
  5. 请求 catalog 做 CAS:指针 vN → vN+1

前四步全是「写新的不可变文件」,可以并发安全地进行;只有第 5 步是全局串行点。这就是为什么重试只需重做第 3、4、5 步(第 1、2 步的产物可复用),代价被压到最低。

sequence number 的乐观分配与重试复用

第 8 篇说过每次成功提交分配一个单调递增的 sequence number。这里有个关键设计:表规范《Optimistic Concurrency》指出,snapshot 创建时乐观地被赋予「下一个」sequence number 并写进 snapshot 元数据;如果提交失败要重试,sequence number 会被重新分配并写进新的 snapshot 元数据

更妙的是 sequence number 的继承机制(第 8 篇):新文件的 sequence number 在写 manifest 时先留空,读取时从 manifest list 记录的该 manifest 的 sequence number 继承。规范《Optimistic Concurrency》明确这样做的目的——一个 manifest 写一次就能在多次提交重试中复用,要改 sequence number 只需重写 manifest list(而 manifest list 本来每次提交都要重写)。

这把乐观重试的代价压到了最低:

\[ \text{重试代价} \approx \text{重写 manifest list} + \text{重写 metadata.json} + \text{1 次 CAS} \]

而不是重写整棵元数据树或重写数据文件。数据文件和 manifest 在第一次就写好,重试只动最上面两层小文件。这是 Iceberg 能承受「高并发下少量冲突重试」的工程基础。


三、冲突检测与重试条件

CAS 失败只说明「指针变了」,但不是所有指针变化都意味着真冲突。表规范《Commit Conflict Resolution and Retry》给出按操作类型的可重试条件:

两个提交同时基于同一版本时,只有一个成功。多数情况下,失败的提交可以应用到新的当前版本并重试。更新会校验「能否安全应用到新版本」的条件,满足才重试。

操作 重试条件(能否应用到新当前版本)
Append(只增数据文件) 无任何要求,总能应用
Replace(如 compaction、格式替换) 必须校验「将被删除的文件仍在表中」
Delete(删文件) 删特定文件要校验这些文件仍在;基于表达式的删除(如 ts < X)总能应用
Schema / partition spec 变更 必须校验基线到当前之间 schema 没变过

直觉:

隔离级别:serializable 与 snapshot

「能否应用到新版本」的校验严格程度由隔离级别决定。注意一个边界:隔离级别不是表规范里的字段,而是引擎/库在提交校验时的策略apache/icebergorg.apache.iceberg.IsolationLevel,取值 SERIALIZABLE / SNAPSHOT),用于 RowDeltaOverwriteFiles 等操作的冲突校验。

两者的差别在「校验新增的并发数据」:

隔离级别 校验维度 拦截的并发
SERIALIZABLE(更严) 既校验「我要删/改的文件还在」,也校验「没有新文件落入我的删除/更新谓词范围 别人并发 append 了一条满足我 WHERE 的新行,也会让我冲突
SNAPSHOT(较松) 只校验「我要删/改的文件还在」 允许别人并发 append 满足我谓词的新行(我不管它们)

举例:我执行 DELETE FROM t WHERE region='eu'。与此并发,另一个写者 append 了一条 region='eu' 的新行。

sequenceDiagram
  participant D as Deleter (WHERE region='eu')
  participant A as Appender (insert region='eu')
  participant C as Catalog
  D->>C: 读 base 版本 V
  A->>C: 写入新 eu 行,提交成功 → V+1
  D->>C: 提交删除(base 仍是 V)
  Note over C: SERIALIZABLE:检测到 V→V+1 间<br/>有新文件落入 region='eu' 谓词 → 冲突
  Note over C: SNAPSHOT:只查"我要删的文件还在吗"<br/>→ 不冲突,放过新 eu 行

选择是延迟与正确性的权衡:SERIALIZABLE 拦截更多并发、冲突率更高、可能更多重试,但杜绝「幻写」;SNAPSHOT 冲突更少、吞吐更高,但放过并发新数据。CDC / 流式高并发场景常用 SNAPSHOT 控成本,强一致需求用 SERIALIZABLE。


四、不同 catalog 的原子性来源

第一节把一切归到「catalog 能否提供一次可靠 CAS」。不同 catalog 实现 CAS 的底层机制不同,这直接决定它在对象存储上安不安全

Metastore / 数据库类:DB 的条件更新

表规范《Metastore Tables》:原子 swap 通过「在 metastore 或数据库里存一个指针,用 check-and-put 更新」实现——check-and-put 校验「写所基于的版本仍是当前」,然后把新 metadata 设为当前。

REST Catalog:把 CAS 收进服务端

REST Catalog 把「当前指针 + 提交校验」收到一个 HTTP 服务后面,客户端只发「我的前提 + 我的改动」,由服务端做原子提交(详见第五节)。服务端内部仍要落到某种原子存储(DB、其他 catalog),但对客户端屏蔽了细节,且让多引擎共享同一套提交语义与权限。

对象存储条件写:If-Match / If-None-Match

近年 S3 等对象存储支持条件写If-None-Match: * 仅当对象不存在才写、If-Match: <etag> 仅当 etag 匹配才覆盖)。这给了「不依赖外部数据库、直接在对象存储上做 CAS」的可能:把指针对象的写入设成条件写,并发写者只有一个能成功。这类 catalog 实现把第 6 篇讲的条件写语义直接当成 CAS 原语。能力取决于具体对象存储是否支持相应条件头。

一种典型用法:把「当前版本号」编码进指针对象,提交时用 If-None-Match: * 创建 vN+1 的指针对象——若对象已存在(别人先建了 vN+1),条件失败,等价于 CAS 失败。流程与 file-based 的 rename 选版本号相似,但把「原子性」从「文件系统 rename」换成了「对象存储条件写」,因而在支持条件写的对象存储上是安全的,这正是 file-based 方案不安全而条件写方案安全的关键区别。

File System(Hadoop)catalog:靠 rename,对象存储上不安全

表规范《File System Tables》给出基于文件系统的方案:用文件系统的原子 rename 把候选文件改名成 vN+1.metadata.json 这个「众所周知的名字」,rename 成功即提交成功。但规范同时给了明确警告:

注意:这种基于文件系统提交 metadata 文件的方案已被弃用,并将在规范 v4 移除。该方案在对象存储和本地文件系统上不安全

原因:HDFS 的 rename 是原子的,但对象存储没有原子 rename(rename = copy + delete,非原子,见第 6 篇)。在纯对象存储上用 file-based catalog,两个写者可能都「认为自己 rename 成功」,造成丢提交或元数据损坏。源码 HadoopTableOperations结论:生产在对象存储上绝不要用 Hadoop file-based catalog,要用 JDBC/REST/Glue/Nessie 等有真正 CAS 能力的 catalog(catalog 形态对比见第 15 篇)。

catalog 类型 CAS 来源 对象存储上安全?
JDBC / Metastore / Glue 数据库行级条件更新 / 事务
REST Catalog 服务端原子提交(内部落 DB 等)
对象存储条件写 If-Match/If-None-Match 取决于存储是否支持条件头
Hadoop file-based 文件系统原子 rename (对象存储无原子 rename)

五、REST Catalog 的提交语义

REST Catalog 把提交表达成一个结构化请求:客户端不直接写指针,而是发一份 UpdateTableRequest,里面分两部分——requirements(前提断言)updates(要应用的改动)。这正是 CAS 的「比较」与「交换」的协议化表达。

POST /v1/{prefix}/namespaces/{namespace}/tables/{table}
UpdateTableRequest = {
  "requirements": [ ...TableRequirement ],   // 比较:服务端逐条断言,任一不成立则整体拒绝
  "updates":      [ ...TableUpdate ]          // 交换:断言全过后原子应用
}

服务端的处理是原子的:先逐条校验 requirements,全部成立才应用 updates;任一不成立,整个提交以冲突失败。这把第三节的「冲突检测」标准化成一组可声明的断言。

TableRequirement 的常见子类型(REST OpenAPI 定义):

requirement 断言内容
assert-create 该表尚不存在(建表用)
assert-table-uuid 表 UUID 等于期望值(确认操作的是同一张表,没被删了重建)
assert-ref-snapshot-id 某个分支/标签当前指向的 snapshot 等于期望值(最常用的乐观并发断言)
assert-last-assigned-field-id 上次分配的列 field id 等于期望(保护 schema 演进)
assert-current-schema-id 当前 schema id 等于期望
assert-last-assigned-partition-id 上次分配的 partition field id 等于期望
assert-default-spec-id 默认 partition spec id 等于期望
assert-default-sort-order-id 默认 sort order id 等于期望

TableUpdate 则是改动动作,如 add-snapshot(加新 snapshot)、set-snapshot-ref(把分支指针指向新 snapshot)、add-schemaset-current-schemaadd-spec 等。

一次普通 append 提交的请求体大致长这样(字段名按 OpenAPI,值示意):

{
  "requirements": [
    { "type": "assert-ref-snapshot-id", "ref": "main", "snapshot-id": 4192942007012105516 }
  ],
  "updates": [
    { "action": "add-snapshot", "snapshot": { "snapshot-id": 8269657661112172278,
        "parent-snapshot-id": 4192942007012105516, "sequence-number": 2,
        "manifest-list": "file:.../snap-8269657661112172278-....avro", "summary": { "operation": "append" } } },
    { "action": "set-snapshot-ref", "ref-name": "main", "type": "branch",
        "snapshot-id": 8269657661112172278 }
  ]
}

服务端读到 assert-ref-snapshot-id 后比较 main 当前是否仍是 …516:是则原子应用两条 update(加 snapshot、把 main 指向它),返回新表元数据;否则返回 409 冲突。

一次普通 append 提交的核心断言就是 assert-ref-snapshot-id:「main 分支当前 snapshot 仍是我基于的那个」。服务端校验它,成立才把 add-snapshot + set-snapshot-ref 应用上去——失败即第六节将看到的 CommitFailedException。下一节实测里,pyiceberg 对 SQL catalog 也用同名要求 AssertRefSnapshotId(type=assert-ref-snapshot-id),可见这套语义在不同 catalog 实现间是一致的。


六、实验:并发提交冲突与重试

下面用 pyiceberg 0.11.1 制造一次真实的并发冲突。SQL catalog 的指针存在 SQLite 一行里,提交时做条件更新(CAS)。脚本骨架(已删 import):

cat = SqlCatalog("demo", uri="sqlite:////tmp/ice_wh11/catalog.db",
                 warehouse="file:///tmp/ice_wh11")
tbl = cat.create_table("sales.t", schema=schema, properties={"format-version": "2"})
tbl.append(batch([1], ["init"]))

# 两个独立 handle 加载同一个 base snapshot(模拟两个并发写者)
A = cat.load_table("sales.t")
B = cat.load_table("sales.t")

A.append(batch([2], ["A"]))   # 写者 A 先提交
B.append(batch([3], ["B"]))   # 写者 B 基于过期 base 提交 → 冲突

实跑输出:

== two writers load the same base snapshot ==
A base = 4192942007012105516
B base = 4192942007012105516

== writer A commits first ==
A new snapshot = 8269657661112172278

== writer B (stale base) commits -> conflict ==
exception: CommitFailedException
message : Requirement failed: branch main has changed: expected id 4192942007012105516, found 8269657661112172278

逐点对照前面的理论:

  1. A、B 基于同一个 base4192942007012105516)——两个并发写者从同一版本出发。
  2. A 先提交成功,把 main 推到新 snapshot 8269657661112172278
  3. B 提交失败,异常是 CommitFailedException,信息正是 assert-ref-snapshot-id 这条 requirement 没过:branch main has changed: expected id 4192942007012105516, found 8269657661112172278。B 断言「main 仍是 …516」,但服务端发现已经是 …278,CAS 比较失败,整体拒绝。这就是第五节 assert-ref-snapshot-id 的实跑印证。

注意 B 的提交是 append——按第三节,append 总能重试。pyiceberg 这里没有自动重放,而是把冲突抛给调用方。我们手动重试:reload 拿到新 base,再 append。

except CommitFailedException:
    B2 = cat.load_table("sales.t")   # 重新加载,base 变为 A 的新 snapshot
    B2.append(batch([3], ["B"]))     # 重放,这次成功
== writer B reloads (new base) and retries ==
B2 base = 8269657661112172278
B2 new snapshot = 4572432698423261421

== final ==
snapshot chain: [(4192942007012105516, None), (8269657661112172278, 4192942007012105516), (4572432698423261421, 8269657661112172278)]
visible ids   : [1, 2, 3]

重试成功后:

对比之下,若 A、B 是「删除/重写同一批文件」的 replace/delete 操作,B 重试时会校验「我要删的文件还在吗」——若 A 已经删了它们,B 的前提不成立,重试也会失败(第三节)。append 之所以总能成功,是因为它对别人的改动没有前提依赖。

这个实验是单机顺序模拟两个写者(A 先、B 后),而非真多线程竞争。但它精确复现了「基于过期 base 提交 → CAS 因 assert-ref-snapshot-id 失败 → reload 重试」的完整路径,与真并发下的语义一致——真并发只是把「谁先 CAS 成功」交给运行时,胜者推进指针、败者收到同样的 CommitFailedException 去重试。


七、小结

没有数据库进程,Iceberg 把 lakehouse 的 ACID 收敛到「对一个元数据指针做一次可靠的 CAS」:

至此 Iceberg 主线四章(元数据树、隐藏分区、行级删除、提交并发)完成:一棵不可变元数据树 + 一个可变指针 + 一次 CAS,撑起了对象存储上的 ACID 表。下一篇转向 Delta Lake,看它用有序事务日志_delta_log)而非 snapshot 树,怎么解决同一组问题。


上一篇行级删除与 Merge-on-Read

下一篇Delta Lake 事务日志

返回 系列目录


附录、扩展阅读与工程注记

下面是提交与并发常踩或常问的点,尽量锚定规范条款或实跑观察。

commit.retry.* 重试调参

提交冲突后重试几次、怎么退避,由一族表属性控制(具体默认值以所用 Iceberg 版本文档为准):

属性 作用
commit.retry.num-retries 自动重试次数(表规范《Table Metadata》示例中出现过 "4"
commit.retry.min-wait-ms 重试退避的最小等待
commit.retry.max-wait-ms 重试退避的最大等待
commit.retry.total-timeout-ms 整个提交(含重试)的总超时

引擎(Spark/Flink)通常内置自动重试循环并读这些属性;pyiceberg 0.11.1 在本文实验里把冲突抛给调用方,需手动 reload 重放。生产要按写并发度调:重试次数过小会在高并发下频繁失败,过大会在真冲突(如 replace/delete 前提已不成立)时空转浪费。

为什么 append 总能重试

append 不依赖「别人没动某些文件」这个前提——它只往表里加新文件。重放时把新文件挂到新当前 snapshot 之后即可,逻辑上等价于「排在别人提交之后」。第六节实测的线性 snapshot 链就是证据。

lost update 与 CAS 的关系

若提交是无条件写指针,两个基于同一版本的写者会互相覆盖,先写者提交丢失(lost update)。CAS 的「比较期望旧值」正是为杜绝 lost update 设计的经典手段,与数据库乐观锁、CPU 的 compare_exchange 同源。

sequence number 重试时为何会变

snapshot 的 sequence number 在提交时乐观取「下一个」,重试时当前版本可能已被别人推进,于是要重新取下一个并写进新 snapshot(规范《Optimistic Concurrency》)。因为采用继承机制,改这个号只需重写 manifest list,不必重写已写好的 manifest 与数据文件。

多分支提交

提交是「把某个分支引用 CAS 到新 snapshot」(规范《Snapshot References》)。main 是默认分支;WAP(write-audit-publish)会先提交到审计分支、校验后再 fast-forward 到 mainassert-ref-snapshot-id 针对的是具体分支,所以不同分支的并发提交互不冲突。

REST catalog 的 requirements 是声明式冲突检测

把冲突条件做成 TableRequirement 列表的好处:服务端不需要理解客户端的业务语义,只逐条断言「期望值 == 当前值」。这让多种引擎共享一套提交语义,也让冲突检测可组合(append 只带 assert-ref-snapshot-id,DDL 再加 assert-current-schema-id 等)。

SERIALIZABLE 与 SNAPSHOT 的选择

IsolationLevelapache/iceberg Java API)作用于 RowDelta/OverwriteFiles 的冲突校验:SERIALIZABLE 额外校验「无新增文件落入操作谓词范围」,杜绝幻写但冲突率更高;SNAPSHOT 只校验目标文件仍在。它是库/引擎层概念,不是表规范字段——同一张表用不同隔离级别提交,由发起操作的引擎决定。

对象存储条件写如何当 CAS

S3 等支持 If-None-Match: *(仅当 key 不存在才写)与 If-Match: <etag>(仅当 etag 匹配才覆盖),见第 6 篇。把「当前指针对象」的写入设为条件写,并发写者中只有一个条件成立、其余收到前提失败——这就是一次对象存储原生的 CAS,省去外部数据库。可用性取决于具体存储/网关是否支持这些条件头。

file-based catalog 的具体风险

Hadoop catalog 靠 rename 选版本号。对象存储 rename = copy+delete 且非原子,两个写者可能都把各自候选 copy 成 vN+1.metadata.json,互相覆盖或都「成功」,导致丢提交。规范已标注该方案弃用并将在 v4 移除,对象存储上必须改用有真 CAS 的 catalog。

提交与行级删除的耦合

第 10 篇的 CoW 删除是 OVERWRITE(replace 类),提交要校验被重写文件仍在表中;MoR 的 RowDelta 提交按隔离级别校验。所以「并发删同一批数据」会真冲突,而「并发 append + 并发基于表达式 delete」往往可重试——删除路线影响并发表现。

提交频率与小文件、元数据膨胀

每次提交都产生新 metadata.json、新 manifest list(第 8 篇)。高频小提交(如流式每秒提交)会让元数据目录与小文件双双膨胀,并抬高冲突概率。缓解靠攒批提交、rewrite_manifestsexpire_snapshots(第 17 篇)。

catalog 是并发的单点

所有提交都过 catalog 的 CAS,catalog 的可用性与吞吐是湖仓写入的瓶颈与单点。REST catalog 要做好高可用与限流;JDBC catalog 的数据库是关键依赖。catalog 形态、锁语义与多引擎共享的兼容性是第 15 篇的主题。

与 PostgreSQL MVCC 的对照

PostgreSQL 用 MVCC + WAL 在单进程内做事务隔离(见 PostgreSQL 内核系列)。Iceberg 没有进程与共享内存,把「版本」做成不可变文件、把「提交」做成指针 CAS、把「隔离」做成读不可变 snapshot——是同一组 ACID 目标在「无中心进程 + 对象存储」约束下的另一种实现。

与 Delta/Hudi 的并发对照预告

Delta 用 _delta_log 的有序编号 commit 文件(00000.json00001.json …),提交即「原子创建下一个编号文件」,冲突检测基于日志比对(第 12 篇)。Hudi 用 timeline 的 instant 与并发控制(第 13 篇)。三者「不可变记录 + 原子产生下一版本」的内核一致,落地形态不同。

候选文件先写、后 CAS 的代价

写者在 CAS 之前就把数据/manifest/metadata 写进了对象存储。若 CAS 失败且放弃(超过重试次数),这些文件就成了没有被任何 snapshot 引用的孤儿文件。它们不影响正确性(读不到),但占存储,需 remove_orphan_files(第 17、20 篇)清理。这是乐观并发的固有代价:先干活、可能白干。

CAS 失败 ≠ 数据损坏

CAS 失败只意味着「我基于的版本过期了」,已写出的候选文件仍是合法、自洽的不可变文件,只是没被指针采纳。所以重试是安全的:重放逻辑、重写 manifest list 与 metadata、再 CAS,不会污染已成功的提交。这也是「读永远读到一个完整 snapshot、不会读到半成品」的来源。

Nessie 的类 git 分支

Nessie catalog 把「多分支/多标签 + 跨表原子提交」做成类 git 的模型(第 15 篇):提交是对某个分支引用的原子推进,分支间可 merge。它扩展了单表 assert-ref-snapshot-id 的思路到「多表一次原子提交」,但内核仍是「对引用做条件更新」。

重试不是免费的并发上限

乐观并发在低冲突下接近无锁的吞吐,但冲突率随「写并发 × 单次提交耗时」上升。当大量写者都改动重叠文件集合时,重试会形成活锁式空转。这类场景要么按分区/分支拆分写入降低重叠,要么改用串行化的写入编排(如单 writer + 队列)。

sequence number 与删除作用域的耦合

第 10 篇的 delete 作用域靠 data sequence number 定相对新旧,而序号是提交时分配的。所以「提交顺序」直接决定「哪个 delete 删哪批数据」:乱序回灌历史数据时若简单继承序号,可能让本应被删的旧数据逃过 delete。需要显式控制 data sequence number(规范《Sequence Number Inheritance》)。

提交的可观测性

snapshot 的 summary.operationappend/overwrite/delete/replace)与各类计数(第 8 篇)让运维不展开文件就能看「这次提交干了什么、是否引入 delete、文件增减多少」。冲突频繁时,结合 catalog 侧的提交失败指标定位热点表。


参考资料

  1. Apache Iceberg Table Spec, Optimistic Concurrency(指针 swap、sequence number 乐观分配与重试复用)(iceberg.apache.org/spec
  2. Apache Iceberg Table Spec, Commit Conflict Resolution and Retry(append/replace/delete/DDL 的重试条件)
  3. Apache Iceberg Table Spec, Metastore Tables / File System Tables(check-and-put 与 rename,及 file-based 弃用警告)
  4. Apache Iceberg Table Spec, Snapshot References(分支/标签提交)
  5. Apache Iceberg REST Catalog OpenAPI, UpdateTableRequest / TableRequirementassert-ref-snapshot-id 等)/ TableUpdateadd-snapshot/set-snapshot-ref 等)
  6. apache/iceberg 1.x 源码:org.apache.iceberg.{BaseMetastoreTableOperations, HadoopTableOperations, SnapshotProducer, IsolationLevel, RowDelta, OverwriteFiles}
  7. 实验环境与脚本:pyiceberg 0.11.1、pyarrow 24.0.0、Python 3.14.5、Linux 6.6(WSL2/Arch)、Intel i9-12900K(24 线程)、32 GB;catalog=SQLite,warehouse=本地 FS,format-version=2;实测 CommitFailedExceptionassert-ref-snapshot-id 未通过)与 reload 重试成功

同主题继续阅读

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

2026-06-30 · database / storage

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

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

2026-06-30 · database / storage

【数据湖与开放表格式】表格式为什么存在

目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。

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

【数据湖与开放表格式】隐藏分区与分区演进

拆解 Iceberg 的 partition spec 与 transform(identity/bucket[N]/truncate[W]/year/month/day/hour/void):隐藏分区如何让查询不写分区列谓词也能裁剪,分区演进为何不重写历史数据(文件携带所属 spec),以及与 Hive 静/动态分区的本质差异。基于 pyiceberg 0.11.1 真实演进 spec 并观察新旧文件。


By .