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

【数据湖与开放表格式】行级删除与 Merge-on-Read

文章导航

分类入口
databasestorage
标签入口
#iceberg#merge-on-read#copy-on-write#deletion-vector#position-delete#equality-delete#puffin#table-format

目录

Iceberg 元数据树 里我们确立了一条铁律:除了 catalog 那个指针,元数据树的三层文件与 data file 全部不可变,新提交写新文件、复用旧文件。隐藏分区与分区演进 又说明分区演进「一行历史数据都不重写」也是靠这条铁律。

可不可变带来一个尖锐的问题:对象存储不能就地改写一个对象,data file 又不可变,那「删掉表里的某一行」物理上怎么实现?

只有两条出路:

这两条路把同一份代价在「写」和「读」之间挪来挪去。本文回答四件事:

证据锚定 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 的特点:

equality delete:按「列值」删

表规范《Equality Delete Files》定义:equality delete 文件按「一组列的值」标记删除。它存表的任意列子集,用 equality_ids(delete 文件元数据里的字段)标明哪些列用于匹配。一行数据被删,当且仅当它在这些列上的值与 equality delete 文件里某一行的删除列全部相等(多列即多个等值条件的 ANDnullIS 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 的特点:

作用域规则:哪个 delete 作用于哪个 data file

delete 文件不是对全表生效,而是按 data sequence number(数据序号)+ 分区 限定作用域。序号是每次成功提交分配的单调值(第 8 篇),它给「数据与删除的相对新旧」定序。表规范《Scan Planning》的作用域规则:

position delete 作用于某 data file,当且仅当:

equality delete 作用于某 data file,当且仅当:

两条规则的「小于等于」与「严格小于」差一个等号,原因正是两者语义不同:

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 类型分三种:

一个 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 的删除位。这带来两个读端痛点:

读端因此需要做「把若干 delete 文件按 file_path 过滤、再按 pos 归并」的活,删除越多越碎,开销越大。

V3 的回答:deletion vector

表规范在 V3 引入 deletion vector(DV,删除向量),并明确:V3 表不得再新增 position delete 文件(已有的 V2 position delete 文件仍合法可读),更新某 data file 的位置删除时必须写成 DV。规范《Deletion Vectors》给出几条硬约束:

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 迁移

迁移的关键是规范的兼容约定:

三种删除编码横向对比:

维度 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

两个事实直接落地了前面的理论:

  1. 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。这印证第五节的版本边界提醒:表属性声明的是「期望」,能否兑现取决于引擎实现。
  2. CoW 的写放大是真实的。删 1 行(id = 5),结果是含该行的那个文件被整体重写:added-data-files = 1deleted-data-files = 1total-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 = Nonedeleted-data-files = 1,操作类型是 DELETE(而非 OVERWRITE):没有重写任何文件,只是新 snapshot 不再引用那个整体被删的文件。对应第一节说的「删除谓词覆盖整文件」的廉价路径——无论 CoW 还是 MoR,整文件删除都退化成纯元数据操作。这也是表规范《Commit Conflict Resolution and Retry》说的「基于表达式的删除总能应用」的物理基础。

这次实验能与不能说明什么


七、CoW 与 MoR 选型

表属性开关

Iceberg 用表属性控制删除/更新/合并各自走哪条路线(取值 copy-on-writemerge-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。


八、小结

不可变文件上删一行,只有两条路,区别在于把代价放在写端还是读端:

整套删除机制都挂在第 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_filescontent=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 清理。


参考资料

  1. Apache Iceberg Table Spec, Row-level Deletes / Scan Planning(delete 作用域规则)(iceberg.apache.org/spec
  2. Apache Iceberg Table Spec, Position Delete Files / Equality Delete Files(字段、排序、equality_ids、严格小于规则)
  3. Apache Iceberg Table Spec, Deletion Vectors(V3,deletion-vector-v1、一文件一向量、Roaring bitmap、迁移)
  4. Apache Iceberg Table Spec, Data File Fieldsreferenced_data_file 143 / content_offset 144 / content_size_in_bytes 145 / record_count 103)
  5. Apache Iceberg Table Spec, Snapshot Summaryadded-dvs/removed-dvs/total-delete-files 等计数)
  6. Apache Puffin Spec, deletion-vector-v1 blob
  7. apache/iceberg 1.x 源码:org.apache.iceberg.{DeleteFile, PositionDeletesTable, deletes.*}org.apache.iceberg.TablePropertieswrite.delete.mode 等)
  8. 实验环境与脚本: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=2write.delete.mode=merge-on-read(实测回退 CoW)

同主题继续阅读

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

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 .