在 Iceberg 元数据树 里我们确立了一条铁律:除了 catalog 那个指针,元数据树的三层文件与 data file 全部不可变,新提交写新文件、复用旧文件。隐藏分区与分区演进 又说明分区演进「一行历史数据都不重写」也是靠这条铁律。
可不可变带来一个尖锐的问题:对象存储不能就地改写一个对象,data file 又不可变,那「删掉表里的某一行」物理上怎么实现?
只有两条出路:
- 把含有该行的整个 data file 读出来、去掉那一行、重写成一个新文件,再让新 snapshot 指向新文件——这就是 copy-on-write(CoW,写时复制)。
- 不动 data file,另写一个小文件记下「哪个文件的哪些行被删了」,读取时再把数据和删除信息合并算出可见行——这就是 merge-on-read(MoR,读时合并)。
这两条路把同一份代价在「写」和「读」之间挪来挪去。本文回答四件事:
- position delete 与 equality delete 的语义、字段与作用域规则,各自的代价;
- CoW 与 MoR 的写放大 / 读放大如何量化取舍;
- V2 的 delete file 与 V3 的 deletion vector(用 Puffin 承载)差在哪、怎么迁移;
- 读路径如何把 data file 加 delete 合并出可见行。
证据锚定 Apache Iceberg 表规范(iceberg.apache.org/spec,以
V2 为主线,V3 deletion vector
单独标注其规范状态)的《Row-level Deletes》《Scan
Planning》《Position Delete Files》《Equality Delete
Files》《Deletion Vectors》各节、Puffin
规范 的 deletion-vector-v1 blob
定义,以及 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_wh10,建表format-version = 2。
一、不可变文件上怎么删一行
先把约束摆清楚。Iceberg 的 data file 一旦写完就不再改:对象存储的对象本身可以整体覆盖(PUT 同名 key),但 Iceberg 从不这么做——它靠「写新文件 + 原子切换元数据指针」获得快照隔离与时间旅行(第 8、11 篇)。如果允许就地改写 data file,老 snapshot 引用的文件内容就变了,时间旅行立刻失效。
所以「删一行」不能是「打开文件、删掉那行、存回去」。能做的只有两类动作:
| 动作 | 物理表现 | 名字 |
|---|---|---|
| 把受影响文件整体重写成「少了那几行」的新文件 | 写新 data file,旧 data file 在新 snapshot 中不再被引用 | copy-on-write |
| 另写一个「删除标记」文件,原 data file 不动 | 写 delete file / deletion vector,读时合并 | merge-on-read |
一个特例值得先单独拎出来:如果删除谓词恰好覆盖某个
data file
的全部行,那么两条路线都退化成同一件廉价操作——直接在新
snapshot 里不再引用这个文件,既不重写也不写
delete,纯元数据操作。第六节实验会看到这正是
Operation.DELETE(只删文件)与
Operation.OVERWRITE(重写文件)的区别。真正的难点是部分删除:要删的行只占某个
data file 的一小部分,文件其余行还得留着。
更新(UPDATE)在湖上一般被拆成「删旧行 + 写新行」:CoW 下是重写整文件并替换若干行,MoR 下是写一条删除标记再 append 新行。所以把删除讲透,更新就只是它的一个变体。
二、两条路线:copy-on-write 与 merge-on-read
直觉与代价转移
CoW 把代价压在写端:删一行也要把整文件读出来重写,写端做了大量「搬运未改动行」的无用功;但读端拿到的是干净文件,读时不需要任何合并。
MoR 把代价压在写端之外:写端只追加一个很小的 delete 文件,几乎不搬运数据;代价转移到读端——每次读都要把 delete 应用到 data file 上,过滤掉被删的行。
表规范《Row-level Deletes》把这层关系说得直接:行级删除有两种编码,position delete「按文件 + 行号」标记删除,equality delete「按列值」标记删除;二者都是为「不重写数据文件就能删行」服务(即 MoR),与之相对的就是重写数据文件的 CoW。
flowchart TD
D["删除谓词:删掉某些行"]
D --> Q{"删除范围"}
Q -->|覆盖整个 data file| META["纯元数据删除<br/>新 snapshot 不再引用该文件"]
Q -->|文件内部分行| MODE{"write.delete.mode"}
MODE -->|copy-on-write| COW["读旧文件 → 去掉删的行<br/>→ 写新 data file<br/>旧文件被新 snapshot 弃用"]
MODE -->|merge-on-read| MOR["data file 不动<br/>另写 delete file / DV"]
COW --> RC["读端:直接扫新文件,无需合并"]
MOR --> RM["读端:扫 data file + 应用 delete"]
用公式量化写放大与读放大
设一次删除逻辑上要删 \(D\) 行,这些行散落在文件集合 \(F\) 中,文件 \(f\) 含 \(R_f\) 行。定义写放大为「实际写出的行数 / 逻辑删除的行数」。
CoW 必须把每个被触及的文件整体重写一遍(重写后的文件含 \(R_f\) 减去其中被删行)。被搬运的行数约等于这些文件的总行数:
\[ A_w^{\text{CoW}} \;=\; \frac{\sum_{f \in F}\,R_f}{D} \]
最坏情况是「每个文件只删一行」:\(D = |F|\),而 \(\sum R_f\) 是这些满文件的总行数,写放大可达 \(R_f\) 量级(单文件几千到上百万行时极其昂贵)。读端则没有额外成本,可视作读放大 \(A_r^{\text{CoW}} = 1\)。
MoR 写端只写删除标记,写出的「行」是 delete
记录而非数据行,量级约为 \(D\)(position delete
每删一行写一条 (file_path, pos),deletion
vector 则把同一文件的删除位压进一个 bitmap,写出更小):
\[ A_w^{\text{MoR}} \;\approx\; \frac{c \cdot D}{D} \;=\; c \quad (c \ll R_f) \]
代价转移到读端。若一个 data file 上累积了若干 delete 文件,读它时除了扫数据,还要读并应用这些 delete。把单文件读取代价粗记为「扫描数据 + 合并删除」:
\[ A_r^{\text{MoR}} \;\propto\; \text{scan}(R_f) \;+\; \sum_{j}\text{apply}(\text{delete}_j) \]
随着删除不断累积,\(\sum_j\) 这一项变大,读越来越慢——这就是 MoR 表必须靠 compaction(第 17 篇)定期把 delete 合并回 data file 的原因。
一句话总结取舍:删除少而读频繁 → CoW;删除频繁(如 CDC upsert)而能容忍读端合并 → MoR。第七节给出更细的选型与表属性开关。
三、Merge-on-Read 的两种 delete
MoR 的删除标记在 V2 里有两种编码,语义、匹配方式与代价都不同。
position delete:按「文件 + 行号」删
表规范《Position Delete Files》定义:position delete
文件按「数据文件路径 +
行在文件内的序号」标记删除。它存的结构
file_position_delete 有三个字段(注意 field id
是很大的保留值):
| 字段(field id) | 类型 | 含义 |
|---|---|---|
file_path(2147483546) |
string | 被删行所在 data file 的完整 URI,必须与 manifest
里目标文件的 file_path 一致 |
pos(2147483545) |
long | 被删行在该 data file 内的序号,从 0 起 |
row(2147483544) |
struct(可选) | 被删行的列值,可省略 |
规范要求 position delete 文件内的行按
file_path 再按 pos
排序:按 file_path
排便于按文件做列存下推过滤,按 pos
排便于扫描时边读边过滤、不必把删除集合全部驻留内存。
position delete 的特点:
- 精确、与列值无关。它直接点名「第几行」,写端必须先知道被删行在文件里的物理位置。
- 写端要先定位。要删
id = 5,写端得先扫一遍找到id = 5落在哪个文件的第几行,才能写出(file, pos)。所以纯 position delete 的写入并不是「零读」。
equality delete:按「列值」删
表规范《Equality Delete Files》定义:equality delete
文件按「一组列的值」标记删除。它存表的任意列子集,用
equality_ids(delete
文件元数据里的字段)标明哪些列用于匹配。一行数据被删,当且仅当它在这些列上的值与
equality delete
文件里某一行的删除列全部相等(多列即多个等值条件的
AND,null 按 IS NULL
匹配)。
规范给的例子,一张表:
1: id | 2: category | 3: name
-------|-------------|---------
1 | marsupial | Koala
2 | toy | Teddy
3 | NULL | Grizzly
4 | NULL | Polar
删 id = 3 可以写成只含 id
列、equality_ids=[1] 的 equality delete:
equality_ids=[1]
1: id
-------
3
每条 equality delete
行产生一个等值谓词;多列就是多个等值条件的
AND。比如要删「category 为空且 name 为
Grizzly」的行,可写 equality_ids=[2,3],一行
(NULL, 'Grizzly')——规范明确 null
删除列按 col IS NULL 匹配(这也是 equality
delete 列可以是 optional 的原因,与 identifier
字段限制略有放宽)。equality delete
文件存的是「表的任意列子集 + 用 equality_ids
标出其中哪些列参与匹配」,匹配列必须用表的 field id 标识,靠
id 而非位置对齐(与第 8 篇 schema 演进同源)。
equality delete 的特点:
- 不需要定位行号。要删
id = 3,直接写一条id=3的 equality delete 即可,写端不必先扫数据找位置——这正是流式 upsert / CDC 的理想形态(来一条变更就写一条 equality delete + 一条新数据,写端几乎零读)。 - 匹配代价转给读端。读端要把 equality delete 当成一组等值谓词去筛每个 data file 的每一行,代价高于「按行号直接跳过」。
- 容易误删未来数据,所以作用域规则比 position delete 更严(见下)。
作用域规则:哪个 delete 作用于哪个 data file
delete 文件不是对全表生效,而是按 data sequence number(数据序号)+ 分区 限定作用域。序号是每次成功提交分配的单调值(第 8 篇),它给「数据与删除的相对新旧」定序。表规范《Scan Planning》的作用域规则:
position delete 作用于某 data file,当且仅当:
- 若 delete 的
referenced_data_file非空,则其等于该 data file 的file_path; - 该 data file 的数据序号 小于等于 position delete 的数据序号;
- 二者分区(spec 与分区值)相等;
- 且没有必须应用于该文件的 deletion vector(DV 存在时优先用 DV)。
equality delete 作用于某 data file,当且仅当:
- 该 data file 的数据序号 严格小于 equality delete 的数据序号;
- 二者分区相等,或 equality delete 写在 unpartitioned spec 下(此时作为全局删除生效)。
两条规则的「小于等于」与「严格小于」差一个等号,原因正是两者语义不同:
- position delete 点名具体
(file, pos),同一次提交里「写数据 + 写它自己的 position delete」是自洽的(同序号也能作用),所以用 \(\le\); - equality delete 是「值匹配」,必须只删它之前已存在的数据,否则同一批次里刚 append 的、恰好命中等值条件的新行会被错误删掉——所以用严格 \(<\),把「未来同值数据」排除在作用域外。
flowchart LR
subgraph 数据
DF1["data file A<br/>seq=1"]
DF2["data file B<br/>seq=3"]
end
EQ["equality delete<br/>id=3, seq=2"]
EQ -->|seq 2 > 1,作用| DF1
EQ -.->|seq 2 < 3,不作用| DF2
上图:序号为 2 的 equality delete 只删序号严格小于 2 的 data file(A,seq=1)里的匹配行;序号 3 的新文件 B 不受影响。这保证「先 append 新数据、再 append 同值 delete」不会反过来删掉新数据。
四、读路径:data file + delete 合并出可见行
把作用域规则落到一次具体的 scan。读端 planning 仍是第 8 篇那套「读元数据树收敛到文件集合」,只是这次要同时收敛出 data file 集合与作用于它们的 delete 集合,再在读取时合并。
delete file 与 data file 由不同的 manifest 跟踪(content=1/2 的 delete manifest 与 content=0 的 data manifest),但用同一套 manifest schema。planning 先扫 delete manifest、再扫 data manifest,按上一节作用域规则把每个 data file 配上它该应用的 delete。
sequenceDiagram
participant Q as Query
participant L as manifest list
participant DM as delete manifest
participant FM as data manifest
participant R as Reader
Q->>L: 读当前 snapshot 的 manifest list
Q->>DM: 扫 delete manifest,收集 delete / DV(按谓词 + 分区裁剪)
Q->>FM: 扫 data manifest,收集候选 data file
Note over Q: 按作用域规则配对<br/>(序号、分区、referenced_data_file)
Q->>R: 对每个 data file:数据 + 它的 delete 集合
R->>R: 应用删除(pos 跳过 / 等值过滤 / DV bitmap)
R-->>Q: 可见行
合并的具体做法按 delete 类型分三种:
- position delete:把作用于该文件的所有
pos收成一个有序集合,扫描时按行号跳过被删位置。由于 delete 文件按pos排过序,可以与数据流做归并式跳过,不必把全部删除位驻留内存。 - equality delete:把作用于该文件的 equality delete 行变成一组等值谓词,对数据每行求值,命中则丢弃。代价更高,因为要逐行比对。
- deletion vector(V3):直接拿到该文件对应的 bitmap,第 P 位为 1 即第 P 行被删,按位跳过——这是三者里读端最快的(见下一节)。
一个 data file 上可能同时挂着 position/equality delete(V2 演进过程中)或一个 DV(V3)。读端把它们全部应用后,剩下的才是这次 snapshot 下该文件的可见行。整张表的可见行就是所有 data file 各自合并删除后的并集。
举个具体的合并过程。data file
X.parquet(seq=1)含 5 行(pos
0..4),其上累积了两份位置删除:position delete 文件
P1(seq=2,删 X 的 pos 1)、P2(seq=4,删
X 的 pos 3)。读 X
时按作用域规则(两者 seq 都 \(\ge\)
1、同分区、file_path 指向
X)都生效,读端把删除位收成
{1, 3},扫描时跳过第 1、3 行,输出 pos
{0, 2, 4} 对应的行。若同时还有一条 equality
delete E(seq=3,id=42),它对
X(seq 1 严格小于 3)生效,读端再对剩下的
{0,2,4} 行逐行判断 id 是否等于
42、命中再删。最终可见行 = 数据行 − position 删除位 −
equality 命中行。
这解释了为什么 MoR 表「读越来越慢」:delete 不断累积,每个 data file 要应用的删除集合变大,合并成本随之上升。把读单文件代价写成累积形式,\(k\) 是作用于它的 delete 数:
\[ A_r^{\text{MoR}}(k) \;\propto\; \text{scan}(R_f) \;+\; \sum_{j=1}^{k}\text{apply}(\text{delete}_j) \]
\(k\) 随写入单调增长,直到 compaction(第 17 篇)把 delete 物化回 data file、把 \(k\) 重置为 0。这是 MoR 表必须配 compaction 调度的根本原因。
五、V2 delete file 与 V3 deletion vector
V2 的问题:position delete 文件太碎、太杂
V2 把 position delete 存成独立的数据文件(Parquet/Avro/ORC),一个 position delete 文件可以记录多个 data file 的删除位。这带来两个读端痛点:
- 一个 data file 的删除位可能散落在多个 position delete 文件里,读它时要打开并归并多份;
- 一个 position delete 文件可能含别的 data file 的删除位,读端为了一个文件却要读进无关数据再过滤。
读端因此需要做「把若干 delete 文件按
file_path 过滤、再按 pos
归并」的活,删除越多越碎,开销越大。
V3 的回答:deletion vector
表规范在 V3 引入 deletion vector(DV,删除向量),并明确:V3 表不得再新增 position delete 文件(已有的 V2 position delete 文件仍合法可读),更新某 data file 的位置删除时必须写成 DV。规范《Deletion Vectors》给出几条硬约束:
- DV 用 bitmap 编码「某个 data file 内被删的行位置」,第 P 位为 1 即第 P 行被删;
- 一个 data file 在一个 snapshot 内至多一个 DV。写端新增删除时必须把新删除与已有 DV(及残留的 position delete 文件)合并成一个 DV;
- DV 存在时,读端可安全忽略匹配的 position delete 文件(DV 必须已包含它们的全部删除);
- 移除 data file 时要从 delete manifest 里移除其 DV,但不要求重写承载它的 Puffin 文件。
DV 的「一文件一向量」直接消解了 V2 的两个痛点:读一个 data file 只需拿它唯一的 DV,按位跳过即可,不再归并多份、不再读无关数据。
DV 用 Puffin 承载
DV 的物理载体是 Puffin 文件(Iceberg
的统计/索引附属文件格式,第 17 篇细讲)。规范规定 DV 用
Puffin 的 deletion-vector-v1 blob
定义存放,位图用「一组 32 位 Roaring bitmap」表示:DV 支持
64 位行位置,但针对「绝大多数位置落在 32 位内」优化——把 64
位位置的高 4 字节当 key、低 4 字节当子位置,每个 key
维护一个 32 位 Roaring bitmap。
manifest 如何指向一个 DV?V3 给 data_file
结构加了三个字段:
| 字段(field id) | 含义 |
|---|---|
referenced_data_file(143) |
该 DV/delete 引用的 data file 的完整 URI(DV 必填) |
content_offset(144) |
DV blob 在 Puffin 文件中的起始偏移 |
content_size_in_bytes(145) |
DV blob 的长度;必须与 Puffin footer 里记录的该 blob 的 offset/length 完全一致 |
也就是说,delete manifest 的一条目通过
(file_path=Puffin文件, content_offset, content_size_in_bytes)
精确定位到 Puffin 里的某个 DV blob,并通过
referenced_data_file 标明它删的是哪个 data
file。多个 DV 可以放在同一个 Puffin 文件里,且同一 Puffin
文件里的 DV 可以引用不同 data
file(规范无限制)。record_count(field
103)此时记的是 DV 的基数(被删行数)。
flowchart TD
DM["delete manifest 条目<br/>content=1, file_format=puffin"]
DM -->|content_offset / content_size| BLOB["Puffin: deletion-vector-v1 blob<br/>(Roaring bitmap)"]
DM -->|referenced_data_file| DATA["data file X.parquet"]
BLOB -.->|位 P=1 → 第 P 行删除| DATA
V2 → V3 迁移
迁移的关键是规范的兼容约定:
- V3 不重写已有 V2 position delete 文件,它们继续按 V2 规则被读;
- 一旦要更新某 data file 的位置删除,写端必须把「已有 position delete 文件中属于该文件的删除 + 新删除」合并成一个 DV,此后该文件改由 DV 表达;
- snapshot summary
里区分了相关计数:
added-dvs/removed-dvs(DV 增减)、position-delete-file-count(不含 DV 的 position delete 文件数)、position-delete-record-count(含 DV 与文件的位置删除总数),运维可据此判断一张表迁移到 DV 的进度。
三种删除编码横向对比:
| 维度 | position delete(V2 文件) | equality delete(V2 文件) | deletion vector(V3) |
|---|---|---|---|
| 引入版本 | V2 | V2 | V3(V2/V1 不支持) |
| 匹配方式 | (file_path, pos) 行号 |
列值等值(equality_ids) |
data file 内位图位 |
| 一个文件覆盖范围 | 可跨多个 data file | 按分区/全局生效 | 严格一个 data file |
| 一个 data file 对应几份 | 可多份(碎) | 可多份 | 至多一个 DV |
| 写端是否需先定位行 | 需要(找 pos) | 不需要(写值即可) | 需要(找 pos) |
| 读端代价 | 按文件归并多份、按 pos 跳 | 逐行等值比对 | 按位跳,最快 |
| 物理载体 | Parquet/Avro/ORC | Parquet/Avro/ORC | Puffin deletion-vector-v1 blob |
| 典型场景 | 批量按位置删 | 流式 upsert / CDC | V3 下位置删除的统一形态 |
V3 表「不得新增 position delete 文件、位置删除统一走 DV」,正是为了把表中第三列「一个 data file 对应多份、且要按文件归并」的读端复杂度,收敛成「至多一个 DV、按位跳」。equality delete 在 V3 仍保留(流式写端无法零定位地写 DV)。
V3 状态标注:deletion vector 属于表规范 V3(V3 已被社区正式采纳),但各查询/写入引擎对 V3 与 DV 的实现进度不一。本文实验所用 pyiceberg 0.11.1 默认建 V2 表,其写路径目前不产生 DV(见下节实测)。是否能写/读 DV 取决于所用引擎与版本;遇到 DV 特性按「规范已定义、引擎支持视版本而定」对待,不当作随处可用的稳定结论。
六、实验:CoW 写放大与 MoR 回退
下面用 pyiceberg 0.11.1 实测。建一张两份 data file、各
1000 行的表,并显式声明
write.delete.mode = merge-on-read,观察
pyiceberg 实际怎么处理删除。脚本骨架(已删 import):
from pyiceberg.catalog.sql import SqlCatalog
from pyiceberg.schema import Schema
from pyiceberg.types import NestedField, LongType, StringType, DoubleType
import pyarrow as pa
cat = SqlCatalog("demo", uri="sqlite:////tmp/ice_wh10/catalog.db",
warehouse="file:///tmp/ice_wh10")
cat.create_namespace("sales")
schema = Schema(
NestedField(1, "id", LongType(), required=True),
NestedField(2, "category", StringType(), required=False),
NestedField(3, "amount", DoubleType(), required=False),
)
tbl = cat.create_table("sales.orders", schema=schema,
properties={"format-version": "2", "write.delete.mode": "merge-on-read"})
# 两份 data file,各 1000 行
tbl.append(batch(range(0, 1000), ["a"]*1000, ...))
tbl.append(batch(range(1000, 2000), ["b"]*1000, ...))初始落盘(实跑):
== initial: 2 data files x 1000 rows ==
content=0 rows=1000 size= 6168 00000-0-0c2964f9-7d88-4f
content=0 rows=1000 size= 5812 00000-0-544d6c62-60e7-46
data files: 2, total bytes: 11980
declared write.delete.mode = merge-on-read
部分删除:删 1 行触发整文件重写
执行
tbl.delete("id = 5"),id = 5
只落在其中一个文件里:
--- partial delete: id = 5 (1 row, lives in one file) ---
WARN: Merge on read is not yet supported, falling back to copy-on-write
operation = Operation.OVERWRITE
added-data-files = 1 deleted-data-files = 1 added-delete-files = None total-delete-files = 0
== after partial delete (CoW rewrite) ==
content=0 rows=1000 size= 5812 00000-0-544d6c62-60e7-46
content=0 rows= 999 size= 6166 00000-0-99601963-dd0d-4f
data files: 2, total bytes: 11978
visible rows: 1999
两个事实直接落地了前面的理论:
- pyiceberg 0.11.1 明确不支持 MoR
写。尽管我们声明了
write.delete.mode = merge-on-read,它打印Merge on read is not yet supported, falling back to copy-on-write并回退到 CoW。这印证第五节的版本边界提醒:表属性声明的是「期望」,能否兑现取决于引擎实现。 - CoW 的写放大是真实的。删 1
行(
id = 5),结果是含该行的那个文件被整体重写:added-data-files = 1、deleted-data-files = 1、total-delete-files = 0——没有任何 delete 文件,而是产生了一个新的 999 行文件(00000-0-99601963),另一个 1000 行文件(00000-0-544d6c62)原样保留。操作类型是OVERWRITE。
代入写放大公式:被触及文件 1 个、含 1000 行,删除 1 行,
\[ A_w^{\text{CoW}} = \frac{\sum_{f\in F} R_f}{D} = \frac{1000}{1} = 1000 \]
为删掉 1 行,物理上重写了约 1000 行。把单文件行数换成几十万,CoW 删一行的代价就触目惊心——这正是高频删除场景需要 MoR 的根本原因。
整文件删除:纯元数据,不重写
再执行
tbl.delete("category = 'b'"),这个谓词恰好覆盖某个文件的全部行:
--- whole-file delete: category = 'b' (matches an entire file) ---
operation = Operation.DELETE
added-data-files = None deleted-data-files = 1
== after whole-file delete (metadata-only) ==
content=0 rows= 999 size= 6166 00000-0-99601963-dd0d-4f
data files: 1, total bytes: 6166
visible rows: 999
这次
added-data-files = None、deleted-data-files = 1,操作类型是
DELETE(而非
OVERWRITE):没有重写任何文件,只是新 snapshot
不再引用那个整体被删的文件。对应第一节说的「删除谓词覆盖整文件」的廉价路径——无论
CoW 还是
MoR,整文件删除都退化成纯元数据操作。这也是表规范《Commit
Conflict Resolution and
Retry》说的「基于表达式的删除总能应用」的物理基础。
这次实验能与不能说明什么
- 能:CoW 的写放大、整文件删除走元数据、pyiceberg 0.11.1 的 MoR 写未实现,都是实跑结论。
- 不能:本机环境没能真实产出
position/equality delete 文件或 DV(pyiceberg 写路径回退到
CoW)。要实测 MoR delete 文件,需要支持 MoR 写的引擎(如配
Iceberg 的 Spark,设
write.delete.mode=merge-on-read并执行DELETE/MERGE),或 Flink 流式 upsert 产出 equality delete。本文据此把 MoR delete 文件与 DV 的结构、字段、作用域规则锚定到表规范与 Puffin 规范,而非伪造一份 delete 文件输出。复现 MoR 写的步骤见附录。
七、CoW 与 MoR 选型
表属性开关
Iceberg 用表属性控制删除/更新/合并各自走哪条路线(取值
copy-on-write 或
merge-on-read):
| 属性 | 控制的操作 | 取值 |
|---|---|---|
write.delete.mode |
DELETE |
copy-on-write /
merge-on-read |
write.update.mode |
UPDATE |
同上 |
write.merge.mode |
MERGE INTO |
同上 |
三者可分别设置:例如低频 DELETE 用 CoW、高频
MERGE(upsert)用
MoR。能否生效取决于执行引擎是否实现对应模式(pyiceberg
0.11.1 全部回退 CoW)。
决策维度
| 维度 | 倾向 CoW | 倾向 MoR |
|---|---|---|
| 删除/更新频率 | 低频、批量 | 高频、流式(CDC upsert) |
| 单次改动占文件比例 | 大(接近整文件) | 小(零散少量行) |
| 读延迟敏感度 | 高(要最快的读) | 可容忍读端合并 |
| 写延迟/写放大敏感度 | 可容忍重写 | 要最小写放大 |
| compaction 运维 | 不依赖 | 强依赖(必须定期合并 delete) |
核心权衡回到第二节的两个公式:CoW 把成本压在写端(\(A_w^{\text{CoW}}\) 可达 \(R_f\) 量级),换读端干净(\(A_r=1\));MoR 把写端成本降到约 \(c\),换读端持续的合并开销,且必须用 compaction 兜住读端不退化。
一个常见误区:以为开了 MoR 就「又快又省」。MoR 省的是写,欠下的是读和运维——没有配套 compaction 调度的 MoR 表,会随删除累积越读越慢。选 MoR 等于承诺去维护 compaction。
八、小结
不可变文件上删一行,只有两条路,区别在于把代价放在写端还是读端:
- copy-on-write:重写含被删行的整个 data file,写放大 \(A_w^{\text{CoW}} = \frac{\sum_{f\in F} R_f}{D}\),最坏达单文件行数量级;读端干净无合并。本文实测删 1 行重写 1000 行。
- merge-on-read:另写删除标记,写放大约 \(c\);读端每次要应用 delete,靠 compaction 兜底。
- position delete 按
(file_path, pos)精确删、作用域用 \(\le\);equality delete 按列值删、写端零定位但作用域用严格 \(<\)(避免误删同批新数据)。 - V3 deletion vector 用 Puffin 的
deletion-vector-v1blob(Roaring bitmap)承载,一文件一向量,读端按位跳过,消解了 V2 position delete 文件「碎、杂」的读端开销;迁移时不重写老 position delete 文件,更新时合并成 DV。 - 读路径先扫 delete manifest 再扫 data manifest,按序号 + 分区配对,把每个 data file 与其 delete 合并出可见行。
整套删除机制都挂在第 8 篇的元数据树上:delete 文件由独立的 delete manifest 跟踪、用序号定相对新旧。而「写新 delete/data 文件后如何原子地让它成为当前 snapshot」「两个写者同时删同一批文件怎么办」,是下一篇提交协议与并发控制的主题。
上一篇:隐藏分区与分区演进
下一篇:提交协议与并发控制
返回 系列目录
附录、扩展阅读与工程注记
下面是行级删除常踩或常问的点,尽量锚定规范条款或实跑观察。
如何在本机真实产出 MoR delete 文件
pyiceberg 0.11.1 写路径回退 CoW,要看真实 delete 文件需带
Iceberg 的 Spark:建表设
'write.delete.mode'='merge-on-read'、'format-version'='2',执行
DELETE FROM t WHERE ...(部分命中),再
SELECT * FROM t.files /
t.delete_files 查 content=1/2
的文件;流式 equality delete 可用 Flink Iceberg sink 做
upsert。复现时锚定 Spark 与 iceberg-spark-runtime 版本,并按
WRITING_GUIDE 交代环境。
update 为什么也归到删除
UPDATE 在湖上拆成「删旧 + 写新」。CoW
下重写整文件并替换若干列值;MoR
下写一条删除标记(标记旧行)再 append
新行。write.update.mode 单独控制其路线。所以
CoW/MoR 的取舍对 UPDATE 同样成立。
MERGE INTO 与 upsert
MERGE INTO(按 key
匹配则更新、不匹配则插入)是 CDC 入湖的核心算子,由
write.merge.mode 控制。高频 upsert 用 MoR +
equality delete 写放大最小,但读端与 compaction 压力最大(第
19 篇展开 CDC 入湖)。
equality delete 为何用严格小于
若用 \(\le\),同一批「append 新行 + 写同值 equality delete」时,新行的序号等于 delete 序号,会被自己的 delete 命中删掉。严格 \(<\) 把作用域限定在「delete 之前已存在的数据」,保证 upsert 语义正确(先删旧值、再写的新值不被删)。
position delete 的
row 列作用
position delete
可选携带被删行的列值(row,field
2147483544)。携带后好处是写端能据此维护统计、读端某些优化可不回原文件取值;规范要求「要么所有条目都带
row,要么整列省略」,以保证统计准确(故该列类型是
required)。
一个 data file 同时有 position + equality delete
V2 演进期,一个 data file 可能同时被 position delete(删已知位置)与 equality delete(删某值)作用。读端要把两类都应用。V3 的 DV 要求「写 DV 时合并所有适用的 position delete」,收敛到一文件一向量,简化读端。
referenced_data_file
的双重用途
字段 143 既用于 DV(必填,标明删哪个 data
file),也可用于「只删单个 data file」的 V2 position delete
文件(选填)。设置后 planning 能更快把 delete
配到目标文件,省去按 file_path 列过滤。
DV 的 Roaring bitmap 选型
DV 用「按高 32 位分桶的一组 32 位 Roaring bitmap」表示 64 位位置(规范《Deletion Vectors》)。Roaring 在「位置稀疏」与「位置稠密」两种分布下都比朴素 bitmap 或位置列表省空间,适合「一个大文件里删了零散若干行」的典型场景。
为什么 DV 放 Puffin 而非独立 delete 文件
Puffin 是 Iceberg 既有的「统计/索引 blob 容器」(第 17
篇)。把 DV 作为一种 blob 复用 Puffin,省得为 DV
另造文件格式;多个 DV 可共享一个 Puffin 文件,减少小文件,且
content_offset/size 直接定位 blob。
total-record-count 何时不可信
含 equality delete 或残留 V2 position delete
文件时,分区的 total_record_count
需要读数据才能算准,规范允许写 NULL
表示未知(《Partition Statistics》相关)。只有「无删除或仅
DV」时才鼓励用元数据精确填充。运维做容量估算要意识到这点。
删除与时间旅行
delete 也产生新 snapshot,老 snapshot
仍引用删除前的文件集合(CoW 下含被重写前的旧文件,MoR
下不含新 delete)。所以「删错了」可以靠回滚到删除前 snapshot
找回(第 16 篇),前提是相关文件还没被
expire_snapshots 清理。
CoW 重写的并发含义
CoW 删除是 OVERWRITE,属于「replace
类」操作,提交时要校验被重写的文件仍在表中(第 11
篇)。两个并发 CoW
删除若动到同一批文件会冲突;基于表达式的删除(如
ts < X)则总能重试应用。
小文件与删除
MoR 高频删除会产生大量小 delete 文件,与数据小文件叠加(第 17 篇)。compaction 既要 bin-pack 数据文件,也要把 delete/DV 合并回 data file。只调度数据 compaction 而忽略 delete 合并,MoR 读端仍会退化。
与 ClickHouse mutation 的对照
ClickHouse 的 ALTER ... DELETE mutation(见
Merge
与 Mutation)是异步重写 Part,本质是 CoW;lightweight
delete 标记删除则更接近 MoR。Iceberg 把这两种思路显式做成
write.delete.mode
的两个取值,并由表格式统一管理 delete 文件的生命周期。
delete file 也由 manifest + 序号管理
delete file(content=1/2)和 DV 与 data file 一样被 manifest 跟踪、带 data sequence number,只是放在独立的 delete manifest(同一套 manifest schema)。所以第 8 篇的「读元数据树收敛文件集合」对 delete 同样成立:planning 用分区摘要裁 delete manifest,用序号 + 分区把 delete 配到 data file,不需要 list 目录。
为什么 position delete 文件要排序
规范要求 position delete 文件内按 file_path
再按 pos 排序:按 file_path
排让列存能按文件做下推过滤(一个 delete
文件含多文件删除时,快速定位本文件那段);按
pos
排让读端能与数据流做归并式跳过,边读边丢,不必把删除位全驻留内存。
equality delete 的全局删除
equality delete 写在 unpartitioned spec 下时作为全局删除生效(作用于所有更老的 data file,不限分区)。分区 spec 下的 equality/position delete 只作用于同分区文件。所以「跨分区按 key 删」要么用 unpartitioned equality delete,要么逐分区写——这影响 CDC 入湖的 delete 写法(第 19 篇)。
DV 基数记在哪
DV 的被删行数记在
data_file.record_count(field
103,规范注明「该文件的记录数,或 deletion vector
的基数」)。读 manifest 就能知道一个 DV 删了多少行,不必打开
Puffin。运维据此估算 MoR 表的删除堆积量。
写放大与文件大小的关系
CoW 写放大 \(A_w=\frac{\sum R_f}{D}\) 直接随单文件行数 \(R_f\) 增长。所以「大文件 + 高频小删除」是 CoW 最坏组合;若必须 CoW,宁可让数据文件小一些(牺牲扫描效率换删除成本),或干脆切 MoR。文件大小目标要把删除模式一起考虑(第 17 篇 compaction)。
读到旧 snapshot 时的删除可见性
delete 也带序号、也属于某个 snapshot。时间旅行读旧
snapshot 时,只应用那个 snapshot 可见的
delete——删除「发生在未来」的 delete
不会作用于过去的读。这让「删错了回滚到删除前」自洽(第 16
篇),前提是相关文件未被 expire_snapshots
清理。
参考资料
- Apache Iceberg Table Spec, Row-level Deletes / Scan Planning(delete 作用域规则)(iceberg.apache.org/spec)
- Apache Iceberg Table Spec, Position Delete Files / Equality Delete Files(字段、排序、equality_ids、严格小于规则)
- Apache Iceberg Table Spec, Deletion
Vectors(V3,
deletion-vector-v1、一文件一向量、Roaring bitmap、迁移) - Apache Iceberg Table Spec, Data File
Fields(
referenced_data_file143 /content_offset144 /content_size_in_bytes145 /record_count103) - Apache Iceberg Table Spec, Snapshot
Summary(
added-dvs/removed-dvs/total-delete-files等计数) - Apache Puffin Spec, deletion-vector-v1 blob
apache/iceberg1.x 源码:org.apache.iceberg.{DeleteFile, PositionDeletesTable, deletes.*}、org.apache.iceberg.TableProperties(write.delete.mode等)- 实验环境与脚本: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,write.delete.mode=merge-on-read(实测回退 CoW)
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】表格式为什么存在
目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。
【数据湖与开放表格式】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。
【数据湖与开放表格式】隐藏分区与分区演进
拆解 Iceberg 的 partition spec 与 transform(identity/bucket[N]/truncate[W]/year/month/day/hour/void):隐藏分区如何让查询不写分区列谓词也能裁剪,分区演进为何不重写历史数据(文件携带所属 spec),以及与 Hive 静/动态分区的本质差异。基于 pyiceberg 0.11.1 真实演进 spec 并观察新旧文件。