第 8
章 到 第
11 章 把 Iceberg
的元数据树拆透了:metadata.json → manifest list
→ manifest → data file,提交是 catalog
对元数据指针做一次原子 swap。Delta Lake
解决的是同一个问题——对象存储上一堆不可变 Parquet
文件,如何当成一张支持
ACID、更新、删除、时间旅行的表——但元数据模型完全不同:Iceberg
是「不可变快照树」,Delta 是「有序追加的事务日志」。
这一章只讲 Delta 的元数据层:_delta_log
目录里到底写了什么、怎么读、版本怎么演进、行级删除怎么做、并发提交怎么不打架。不写
Spark SQL 教程,不写 Databricks 平台功能。
版本锚定:协议条款以 Delta 协议规范
delta-io/delta仓库根目录的PROTOCOL.md为准;库行为以 Delta Lake 3.x(含 3.1/3.2/3.3 增量特性)为准,涉及具体版本边界处单独标注。本机未安装 Spark / Delta,实验一节给出可复现步骤并声明环境,不贴伪造输出。
一、问题:一堆文件怎么变成一张事务表
先把场景钉死。你在 S3(或 HDFS、本地 FS)上有一个目录:
my_table/
part-00000-....snappy.parquet
part-00001-....snappy.parquet
date=2026-06-30/part-00002-....snappy.parquet
第
6 章 讲过对象存储的硬约束:没有原子 rename,LIST 是
O(prefix 下对象数),对象一旦写入不可原地改写。Hive
表把「目录里有哪些文件」直接等同于「表的内容」,于是有三个老问题:
- 没有原子多文件提交。一次写入产生 5 个文件,写到第 3 个时进程挂了,读者会看到半张表。
- planning 要 LIST 目录。文件多、分区深时,光是列目录就很贵。
- 没有快照。读和写并发时,读者可能扫到正在被写的文件。
Delta 的解法:数据文件还是那堆不可变
Parquet,但「表当前由哪些文件组成」不再靠 LIST
目录得到,而是靠一份独立的、只追加的事务日志重放出来。这份日志就在表目录下的
_delta_log/。
flowchart LR
W[Writer] -->|追加一条 commit| LOG[_delta_log/00...N.json]
LOG -->|重放| STATE[当前快照: 活跃文件集合]
R[Reader] -->|读日志构造快照| STATE
STATE --> DF[(Parquet data files)]
一句话概括:Delta 表的「真相」是日志,不是目录。目录里可能残留过期文件、未提交文件,但只有日志认账的文件才属于表。这与 Iceberg「真相是 metadata.json 指针」是同一个抽象的两种落地(见 第 7 章)。
二、_delta_log
的结构
建一张 Delta 表写几次之后,目录长这样(示意,文件名中的省略号是真实哈希/uuid):
my_table/
_delta_log/
00000000000000000000.json
00000000000000000001.json
00000000000000000002.json
...
00000000000000000010.checkpoint.parquet
00000000000000000011.json
_last_checkpoint
part-00000-....snappy.parquet
...
两类文件,分工清楚:
| 文件 | 格式 | 作用 |
|---|---|---|
<version>.json |
每行一个 JSON 对象 | 一次提交(commit)的全部 action,版本号 20 位零填充、严格递增 |
<version>.checkpoint.parquet |
Parquet | 把截至该版本的全部 action 状态「压平」成一个文件,避免读者从 0 重放 |
_last_checkpoint |
小 JSON | 指向最近一个 checkpoint 的版本号与元信息,读者据此跳过前面的 JSON |
关键性质(协议规范 PROTOCOL.md,章节
Delta Log Entries / Checkpoints):
- 版本号即提交序号。
000...000.json是第 0 版,000...001.json是第 1 版。版本号连续、单调,是表的逻辑时钟,时间旅行就按这个号或对应时间戳定位。 - commit JSON 是「一行一个 action」的
newline-delimited
JSON,不是一个大数组。一次提交可以包含多条
action(例如同时写
metaData+protocol+ 多个add)。 - 提交的原子性来自对象存储的「写新文件」是原子的:要么
000...001.json整文件出现,要么不出现,不存在「半个 JSON」。冲突由「目标版本号文件已存在」来发现(见第九节)。
下面逐层拆。
三、七类 action
一条 commit JSON 的每一行是一个 action 对象,最外层 key
标明类型。协议(PROTOCOL.md,Actions)定义的核心类型如下。下面给出的是规范里定义的字段含义,不是某次真实运行的输出。
3.1
metaData:表的 schema 与配置
定义表的元数据,一张表通常每次
schema/属性变更才追加一条新的 metaData:
{"metaData":{
"id":"<table uuid>",
"name":null,
"description":null,
"format":{"provider":"parquet","options":{}},
"schemaString":"{\"type\":\"struct\",\"fields\":[...]}",
"partitionColumns":["date"],
"configuration":{"delta.enableDeletionVectors":"true"},
"createdTime":1730000000000
}}要点:
schemaString是序列化的 schema(含每列的 nullability、metadata)。Delta 的 schema 演进、列映射(column mapping)信息都挂在这里。partitionColumns是物理分区列。注意 Delta 用的是目录式 Hive 分区(date=2026-06-30/),这和 Iceberg 的「隐藏分区」(第 9 章)是关键差异:Delta 的分区列对查询是可见的,分区裁剪靠分区列谓词。configuration是表级属性,像delta.enableDeletionVectors、delta.checkpointInterval、delta.columnMapping.mode都在这里。
3.2
protocol:读写协议版本
声明读/写这张表所需的最低协议能力,决定客户端能不能安全读写(详见第四节):
{"protocol":{
"minReaderVersion":3,
"minWriterVersion":7,
"readerFeatures":["deletionVectors"],
"writerFeatures":["deletionVectors","appendOnly","invariants"]
}}3.3
add:新增一个数据文件
把一个 Parquet 文件加入表的活跃集合。这是最常见的 action:
{"add":{
"path":"date=2026-06-30/part-00000-....parquet",
"partitionValues":{"date":"2026-06-30"},
"size":12345,
"modificationTime":1730000000000,
"dataChange":true,
"stats":"{\"numRecords\":1000,\"minValues\":{...},\"maxValues\":{...},\"nullCount\":{...}}",
"tags":null,
"deletionVector":null,
"baseRowId":0,
"defaultRowCommitVersion":11
}}要点:
stats是 JSON 字符串,含numRecords、每列minValues/maxValues/nullCount。这就是 Delta 的文件级裁剪依据——查询引擎读日志时拿到这些 stats,不读数据文件就能判断某个文件能否被谓词裁掉,对应 Iceberg manifest 里的 lower/upper bound(第 8 章)。默认对前 32 列收集 stats。dataChange=true表示这条 add 引入了新数据(对增量读 / CDC 重要);compaction 这种「只重排不改数据」的操作会写dataChange=false。deletionVector非空时,表示这个文件附带一个删除向量(第七节)。baseRowId/defaultRowCommitVersion是 row tracking(行级血缘)特性的字段。
3.4
remove:从活跃集合移除一个文件
逻辑删除一个文件(tombstone)。物理文件不立即删,等
VACUUM 过保留期才清理:
{"remove":{
"path":"date=2026-06-30/part-00000-....parquet",
"deletionTimestamp":1730000100000,
"dataChange":true,
"extendedFileMetadata":true,
"partitionValues":{"date":"2026-06-30"},
"size":12345,
"deletionVector":null
}}一次 UPDATE/DELETE/MERGE 的 copy-on-write
实现,就是在同一个 commit 里:对旧文件写
remove,对重写后的新文件写
add。读者重放日志时,remove
抵消之前的 add,活跃集合里就只剩新文件。
3.5
txn:幂等写入(流式 exactly-once)
记录某个外部应用的事务进度,给 Spark Structured Streaming 之类做幂等提交用:
{"txn":{"appId":"<streaming query id>","version":42,"lastUpdated":1730000000000}}writer 在提交前检查该 appId 已提交到的
version,避免微批重复写入。这是 Delta 做流式
exactly-once 的关键,第 19
章 会展开。
3.6
commitInfo:提交的来源信息
记录这次提交是谁、什么操作、参数是什么,纯审计/可观测用途,不影响表内容:
{"commitInfo":{
"timestamp":1730000000000,
"operation":"WRITE",
"operationParameters":{"mode":"Append"},
"isolationLevel":"Serializable",
"isBlindAppend":true,
"engineInfo":"Apache-Spark/3.5 Delta-Lake/3.2.0"
}}isBlindAppend
这类字段在冲突检测时有用(纯追加更容易和并发写共存,见第九节)。
3.7 其它 action
协议还定义了若干特性专用 action:
domainMetadata:给某个「域」存自定义元数据,liquid clustering 的聚簇列就存在clustering域里(第八节)。cdc:Change Data Feed 启用时,记录变更数据文件。sidecar/checkpointMetadata:V2 checkpoint 用,把 add/remove 拆到 sidecar 文件(第五节)。
flowchart TD
C["一次 commit (NNNN.json)"] --> M["metaData? schema/配置变更才有"]
C --> P["protocol? 协议升级才有"]
C --> A["add × N: 新数据文件 + stats"]
C --> R["remove × M: tombstone 旧文件"]
C --> T["txn? 流式幂等"]
C --> CI["commitInfo: 审计信息"]
四、protocol 版本与 table features
Delta
早期用两个单调整数表达能力:minReaderVersion 和
minWriterVersion。规则很简单:客户端的读能力
≥ 表的 minReaderVersion 才能读,写能力 ≥
minWriterVersion
才能写,否则必须报错拒绝,而不是装作看不见。这是为了防止旧客户端误读/误写新特性表导致数据损坏。
问题是「整数版本」太粗:每加一个特性就要把版本号
+1,而一张表可能只想启用其中一个特性。于是协议(PROTOCOL.md,Table
Features)引入 table features:
- 当
minReaderVersion = 3且minWriterVersion = 7时,协议进入「table features 模式」。 - 此时
protocolaction 额外带两个数组:readerFeatures和writerFeatures,逐个列出启用的特性名。 - 特性分两类:writer-only
feature(只影响写,读不受影响,如
appendOnly、invariants)只出现在writerFeatures;reader-writer feature(读也得懂,如deletionVectors、columnMapping、v2Checkpoint)同时出现在两个数组。
| 概念 | 含义 |
|---|---|
minReaderVersion |
读这张表所需最低 reader 协议版本 |
minWriterVersion |
写这张表所需最低 writer 协议版本 |
readerFeatures |
(version 3/7 时)读端必须理解的特性集合 |
writerFeatures |
(version 3/7 时)写端必须理解的特性集合 |
常见特性名(reader-writer 类需读端也支持):
| 特性 | 类别 | 作用 |
|---|---|---|
appendOnly |
writer | 表只允许追加,禁止 update/delete |
invariants |
writer | 列约束(NOT NULL / CHECK) |
columnMapping |
reader-writer | 列用 ID/物理名映射,支持安全改名/删列 |
deletionVectors |
reader-writer | 行级删除用删除向量(第七节) |
timestampNtz |
reader-writer | 无时区时间戳类型 |
v2Checkpoint |
reader-writer | sidecar 形式的 checkpoint(第五节) |
clustering |
writer | liquid clustering(第八节) |
rowTracking |
writer | 行级血缘(baseRowId 等) |
工程含义:升级一个特性是「单向门」。一旦把
deletionVectors 加进
writerFeatures,老版本 writer
就再也写不了这张表了。所以生产中开特性前要确认所有读写它的引擎都支持——这正是第
14 章讨论互通时绕不开的兼容矩阵问题。
五、checkpoint:别让读者从第 0 版重放
只有 JSON 日志的话,一张提交过 10000 次的表,读者要读
10000 个 JSON
文件才能算出当前活跃文件集合——这在对象存储上慢且贵。Delta 用
checkpoint
解决(PROTOCOL.md,Checkpoints)。
5.1 checkpoint 是什么
checkpoint 是一个 Parquet 文件,把「截至版本 N 的全部
action 重放后的状态」物化下来:所有当前活跃的
add(含其 stats)、尚在保留期内的
remove tombstone、最新的 metaData
和 protocol,全部压平成 Parquet
的行。读者读一个 checkpoint =
一次性拿到那个版本的全量状态。
默认每 10 次 commit 写一个
checkpoint,由表属性 delta.checkpointInterval
控制。文件名是
00000000000000000010.checkpoint.parquet。
5.2
_last_checkpoint
_delta_log/_last_checkpoint 是个小
JSON,记录最近 checkpoint 的版本号、action
条数、文件大小(以及 V2 时的 sidecar 信息):
{"version":10,"size":523,"sizeInBytes":98765,"numOfAddFiles":500}读者先读它,直接跳到版本 10 的 checkpoint,再增量重放
11.json、12.json ……
到最新。没有它也能工作(退化为从 0 重放或自己找最大
checkpoint),有它则省一次 LIST + 大量 JSON 读取。
5.3 多文件与 V2 checkpoint
大表的 checkpoint 本身可能很大,协议支持两种切分:
- Multi-part checkpoint:把一个
checkpoint 切成多个 Parquet 分片,文件名形如
...checkpoint.0000000001.0000000003.parquet(第 1 片,共 3 片)。 - V2
checkpoint(
v2Checkpoint特性):checkpoint 主文件只放checkpointMetadata和metaData/protocol,把海量 add/remove 拆到sidecar文件,主文件用sidecaraction 引用它们。好处是增量更新 checkpoint 时可以复用未变的 sidecar。
flowchart LR
LC[_last_checkpoint] -->|version=10| CK[10.checkpoint.parquet]
CK -->|全量状态| S0[版本10快照]
S0 -->|+11.json +12.json| S1[最新快照]
5.4 与 Iceberg 的对照
| 维度 | Delta | Iceberg |
|---|---|---|
| 提交单元 | 一个 JSON 文件(多 action) | 一个新的 metadata.json + manifest |
| 历史结构 | 有序追加日志 + 周期 checkpoint | 不可变 snapshot 树(每次新快照引用 manifest) |
| 加速全量读 | checkpoint(压平日志) | manifest list 本身就是「这个快照有哪些 manifest」 |
| 当前指针 | 最大版本号的 JSON(+
_last_checkpoint) |
catalog 指向的 metadata.json |
两者都做到了「不 LIST 数据目录就能 planning」,路径不同:Delta 重放日志,Iceberg 顺着快照树往下收敛。
六、读路径:从日志重放出快照
把前面拼起来,一次「读 Delta 表最新快照」的步骤(对应
delta-spark 的 Snapshot
构造、delta-kernel 的 log replay 逻辑):
- LIST
_delta_log/,或先读_last_checkpoint拿到最近 checkpoint 版本c。 - 读 checkpoint(版本
c)得到截至c的全量 action 状态。 - 增量读
c+1.json… 到最大版本n的 JSON。 - 按顺序重放:
add加入活跃集合,remove从集合移除(用 path 匹配);后出现的metaData/protocol覆盖前面的。 - 得到:当前 schema、协议、活跃文件集合(每个文件带 stats 和可选 deletion vector)。
- 用查询谓词 + 文件 stats 做文件裁剪,再用 deletion vector 过滤被删行,最后只读需要的 Parquet。
时间旅行就是把第 3 步的「读到最大版本
n」改成「读到指定版本
v」或「读到时间戳对应的版本」。因为日志是不可变追加的,任意历史版本都能精确重建。
flowchart TD
A[读 _last_checkpoint] --> B[读 checkpoint.parquet 得全量状态]
B --> C[增量重放后续 .json]
C --> D[活跃文件集合 + schema + protocol]
D --> E[谓词 + stats 文件裁剪]
E --> F[应用 deletion vector 过滤删除行]
F --> G[读 Parquet 列裁剪]
七、deletion vector:Delta 的 merge-on-read
默认情况下,删一行要重写整个 Parquet
文件(copy-on-write):把文件里除被删行外的所有行复制到新文件,旧文件
remove、新文件
add。改一行的代价是「重写一整个文件」,对随机小量删改极不划算。
deletion vector(DV) 是 Delta 的
merge-on-read 方案(特性 deletionVectors,文档
What are deletion
vectors?)。它不重写数据文件,而是额外记一个位图,标记某个文件里哪些行号已被逻辑删除。读时把
DV 应用到数据文件上,过滤掉被标记的行。
7.1 支持时间线
引用 Delta 官方文档的版本矩阵(外部来源,标注引用):
| 操作 | 首次支持的 Delta 版本 | 默认启用的 Delta 版本 |
|---|---|---|
SCAN(读时应用 DV) |
2.3.0 | 2.3.0 |
DELETE |
2.4.0 | 2.4.0 |
UPDATE |
3.0.0 | 3.1.0 |
MERGE |
3.1.0 | 3.1.0 |
启用:
ALTER TABLE my_table SET TBLPROPERTIES ('delta.enableDeletionVectors' = true);7.2 DV 在日志里怎么体现
带 DV 的 add/remove action 里
deletionVector 字段非空(协议 Deletion
Vectors),结构含:storageType(u
相对路径 / i 内联 / p
绝对路径)、pathOrInlineDv、offset、sizeInBytes、cardinality(被删行数)。位图本身以
RoaringBitmap 序列化,存在独立的 .bin
文件里(一个 .bin 可承载多个 DV)。
一次 DELETE
删掉某文件的少量行,日志里的表现是:对该数据文件写一条新的
add(或在协议允许时复用),其
deletionVector 指向新生成的
DV,标记被删行号——数据文件一个字节都没改。
7.3 物化:何时真正重写
DV 是「软删除」,被删行仍在 Parquet 里占空间。下列操作会把 DV「落地」成真正的文件重写(文档 Apply changes to Parquet data files):
- 在禁用 DV 的情况下跑 DML;
- 跑
OPTIMIZE; - 跑
REORG TABLE ... APPLY (PURGE)(强制重写所有含 DV 标记的文件,彻底物理清除)。
REORG TABLE events APPLY (PURGE);
REORG TABLE events WHERE date >= '2026-01-01' APPLY (PURGE);7.4 取舍
| copy-on-write(重写文件) | merge-on-read(deletion vector) | |
|---|---|---|
| 写放大 | 高(删 1 行重写整文件) | 低(只写位图) |
| 读放大 | 0(读到的就是结果) | 有(读时套 DV 过滤) |
| 适合 | 删改少、读多 | 删改频繁、要低写延迟 |
| 清理 | 立即生效 | 需 OPTIMIZE/REORG PURGE
物化 |
这和 Iceberg V2/V3 的 position delete → deletion vector 演进(第 10 章)、Hudi 的 base+log(第 13 章)是「同一道题的三种解法」,第 14 章会并排对照。
八、liquid clustering 与 Z-order
数据布局(同一文件里的行是否「相关」)直接决定文件裁剪效果。Delta 提供两代方案。
8.1 Z-order
OPTIMIZE ... ZORDER BY (col1, col2) 用
Z-order
曲线把多列映射到一维排序,让多个高基数列上的范围查询都能裁掉较多文件。本质是一次性重排,缺点是改聚簇列就得全表重写,且和分区耦合。
8.2 liquid clustering
liquid clustering 是新一代布局方案(文档 Use liquid clustering for Delta tables),目标是「不重写历史就能改聚簇列、且自然处理倾斜」。要点(引自官方文档):
- 建表用
CLUSTER BY:
CREATE TABLE t1(col0 int, col1 string) USING DELTA CLUSTER BY (col0);- 与 partitioning 和
ZORDER互斥:启用 liquid clustering 就不要再 Hive 分区或 Z-order。 - 触发聚簇靠
OPTIMIZE(增量,只重写需要聚簇的新数据);OPTIMIZE FULL强制全量重聚簇(Delta 3.3+)。 - 改聚簇列:
ALTER TABLE t CLUSTER BY (new_col),已有数据不重写,后续OPTIMIZE和写入才按新列聚簇;改列后要OPTIMIZE FULL才让旧数据也对齐新布局。 - 最多 4 个聚簇列;聚簇列必须是有 stats 的列(默认前 32 列有 stats)。
- 写入需 writer 同时支持
Clustering和DomainMetadata两个 table feature——聚簇列就存在domainMetadata的clustering域里,这也是为什么第三节要单独提domainMetadataaction。
flowchart LR
W[写入新数据] --> O["OPTIMIZE 增量聚簇"]
O --> L1[按当前 CLUSTER BY 列聚簇]
ALT["ALTER TABLE CLUSTER BY 新列"] --> NW[新写入按新列]
NW --> OF["OPTIMIZE FULL 才重排旧数据"]
工程提醒:liquid
clustering、Z-order、分区三选一,别叠加;改聚簇列是「软切换」,不
OPTIMIZE FULL
的话旧数据仍按旧布局,查询裁剪可能不及预期。
九、乐观并发与冲突检测:全程基于日志
Delta
的并发控制是乐观并发(OCC),且全部依赖日志这个单一真相(协议
Concurrency Control,实现见 delta-spark 的
OptimisticTransaction /
ConflictChecker)。
9.1 提交流程
一个 writer 提交一次 commit 的步骤:
- 读当前最新版本
n,记下「我基于版本n做的修改」(读了哪些文件、要 add/remove 哪些)。 - 本地把数据 Parquet 写好。
- 尝试把 commit 写成
_delta_log/<n+1>.json,用底层存储的 put-if-absent(目标文件不存在才写成功)。 - 若
<n+1>.json已被别的 writer 抢先写了 → 提交失败,进入冲突检测。
第 3 步的原子性来源因存储而异(这点和 Iceberg catalog 的原子 swap 是同构的,见 第 11 章):
| 存储 / LogStore | 原子提交靠什么 |
|---|---|
| HDFS | rename 是原子的,rename-if-not-exists |
| S3(单 writer) | S3 原生缺跨文件原子性,早期靠外部协调;新方案用条件写
If-None-Match(put-if-absent) |
| 带协调的 catalog | Unity Catalog 等提供 commit 协调(commit coordinator) |
9.2 冲突检测
提交失败不一定是真冲突。writer 会读取在 n
之后被并发提交的版本(n+1 …
当前最新),逐一检查是否和自己的修改逻辑冲突:
- 两个纯追加(blind
append):通常不冲突,重算目标版本号重试即可——这就是
commitInfo.isBlindAppend的用处。 - 并发修改同一批文件:例如双方都要
remove同一个数据文件(同一行被两个事务删/改)→ 冲突,按隔离级别决定失败或重试。 - 并发改 metadata/protocol:schema 变更与数据写并发,通常冲突。
隔离级别(commitInfo.isolationLevel):Delta
支持 Serializable(默认,最强,读写都串行化)和
WriteSerializable(放宽到只保证写串行化,允许某些
blind append
与并发读重叠)。冲突检测的维度就是「我读到的快照在我提交前是否被以冲突方式改动过」。
sequenceDiagram
participant A as Writer A
participant B as Writer B
participant L as _delta_log
A->>L: 读到版本 n
B->>L: 读到版本 n
A->>L: put-if-absent (n+1).json 成功
B->>L: put-if-absent (n+1).json 失败(已存在)
B->>B: 读 (n+1) 做冲突检测
alt 无逻辑冲突(如都是 append)
B->>L: put-if-absent (n+2).json 成功
else 有冲突(改同一文件)
B->>B: 抛 ConcurrentModification, 由上层重试或失败
end
和 Iceberg 一样,冲突检测的粒度决定并发能力:粒度太粗(任何并发都判冲突)会让写吞吐塌掉,太细则要更复杂的检测逻辑。Delta 把这套逻辑落在「重放并比对日志 action」上,没有引入额外的锁服务(除非 catalog 提供 coordinator)。
十、schema 演进与 column mapping
Delta 的 schema 存在 metaData.schemaString
里。新增列、放宽 nullability
这类「安全演进」只需追加一条新的
metaData,老数据文件不重写——读者按新 schema
读旧文件时,缺的列补 null。
但「改列名」「删列」在朴素实现下是危险操作:Parquet
文件里列是按物理名存的,直接改名会让老文件对不上号。Delta
用 column mapping(特性
columnMapping,属性
delta.columnMapping.mode)解决:
| 模式 | 含义 |
|---|---|
none |
默认,逻辑列名 = Parquet 物理列名,不能安全改名/删列 |
name |
schema
里每列带一个稳定的物理名(delta.columnMapping.physicalName),逻辑名改了物理名不变 |
id |
每列带一个稳定的 field ID(写进 Parquet field id),按 ID 对应 |
开启 column mapping 后,改列名只改
schemaString 里的逻辑名,物理名/ID 不动,老
Parquet 文件照样能读。这和 Iceberg「schema 演进按 field ID
而非位置」(第
16 章)是同一思路。
工程含义有两条:
- column mapping 是启用 UniForm Iceberg
的前置条件(第 14 章会提到:UniForm Iceberg 要求
minReaderVersion>=2、minWriterVersion>=7且开启 column mapping),因为 Iceberg 本来就靠 field ID。 - 开了 column mapping 的表,Parquet 物理列名可能是
col-<uuid>这种乱码,直接用别的工具按列名读裸 Parquet 会困惑——必须经 Delta 读路径翻译。
十一、tombstone、保留期与 VACUUM
第三节说过 remove
只是逻辑删除(tombstone),物理 Parquet
文件还在。这是时间旅行的基础:要能读版本
v,版本 v
引用的文件就不能被删。代价是过期文件会堆积。
清理靠
VACUUM:删除「不再被任何保留期内版本引用」的物理文件。关键参数是保留阈值,默认
7
天(delta.deletedFileRetentionDuration):
VACUUM my_table; -- 用默认保留期
VACUUM my_table RETAIN 168 HOURS;-- 显式 7 天两个必须理解的约束:
- VACUUM
之后,早于保留期的时间旅行会失败——因为对应数据文件已被物理删除。时间旅行能回溯多远,由保留期
+ checkpoint/日志保留共同决定(日志保留另有
delta.logRetentionDuration,默认 30 天)。 - 保留期不能随便调小。如果有并发的长事务(一个读者基于旧快照正在读),VACUUM 删掉它仍需要的文件会导致读失败。Delta 默认有安全检查,强行缩短保留期需要显式关闭检查,生产中要谨慎。
flowchart LR
R[remove: tombstone] --> KEEP[保留期内: 文件保留, 可时间旅行]
KEEP -->|超过 deletedFileRetentionDuration| VAC[VACUUM 可物理删除]
VAC --> GONE[文件消失, 该版本不可读]
孤儿文件(写到一半失败、没被任何 commit 引用的 Parquet)也靠 VACUUM 清理逻辑识别——这与 第 20 章 的运维故障模式直接相关。
十二、Change Data Feed
很多下游要的不是「当前快照」,而是「自某版本以来每行发生了什么变化」(insert/update/delete
的前后像)。Delta 用 Change Data
Feed(CDF) 提供(表属性
delta.enableChangeDataFeed)。
启用后,对于「无法从 add/remove 直接推断变化」的操作(如
UPDATE、MERGE),Delta 把变更行写到表目录下的
_change_data/ 文件夹,并在 commit 里用
cdc action 引用。读者按版本区间读 CDF,拿到带
_change_type(insert/update_preimage/update_postimage/delete)的行。
ALTER TABLE my_table SET TBLPROPERTIES (delta.enableChangeDataFeed = true);
-- 读版本 5 到 10 之间的变更
SELECT * FROM table_changes('my_table', 5, 10);要点:
- 纯 append 的变化能直接从日志的
add(dataChange=true)推断,不必额外写_change_data;UPDATE/MERGE 这种「同一行被改」才需要显式记录前后像。 - CDF 是第 19 章 CDC 入湖、增量物化的基础,也是 UniForm 限制之一:UniForm 启用时,CDF 这类 Delta 专有特性在 Iceberg/Hudi 读端不可用(第 14 章)。
十三、数据裁剪:stats 如何被用
第三节提到 add.stats 里有
numRecords、每列
minValues/maxValues/nullCount。这些不是装饰,是
Delta「不读数据就裁文件」(data skipping)的全部依据,机制和
Iceberg manifest 的 lower/upper bound(第 8
章)同构。
裁剪发生在读路径第 5
步(第六节):构造完活跃文件集合后,对每个文件用谓词比对
stats。例如查询 WHERE id = 12345:
- 文件 A 的 stats
minValues.id=0, maxValues.id=999→12345不在[0,999],整文件跳过,不发 Parquet 读请求。 - 文件 B 的 stats
minValues.id=10000, maxValues.id=20000→ 可能命中,读它(再在 Parquet 内部用 row group / page index 进一步裁,见 第 2 章)。
几个工程细节:
- stats 默认只覆盖前 32
列(
delta.dataSkippingNumIndexedCols)。把高选择性的过滤列排到表后面,可能它根本没 stats,data skipping 退化为全扫。建表时把常用过滤列放前面,或调大该值(会增大日志体积)。 - stats 是写入时算的,存在
add里。这意味着 stats 与文件强绑定,不像传统数据库要单独维护统计信息任务;但也意味着OPTIMIZE重写文件后 stats 会重算。 - 字符串列的 min/max
默认按前缀截断(
delta.dataSkippingStatsColumns可指定列),超长字符串不会把日志撑爆。
flowchart TD
Q["WHERE id=12345"] --> L[读日志得到 add 列表 + stats]
L --> A{"文件 A: id∈[0,999]?"}
A -->|不命中| SKIP[跳过, 不读 Parquet]
L --> B{"文件 B: id∈[10000,20000]?"}
B -->|可能命中| READ[读 Parquet, 内部再裁 row group/page]
这条「日志 stats 文件级裁剪 → Parquet 内部 page 级裁剪」的两级链路,是第 18 章对照各引擎读湖能力时的核心评估点。
十四、delta-kernel:协议的可移植实现
早期「读写 Delta」基本等同于「用 delta-spark」,协议逻辑(日志重放、checkpoint、冲突检测)和 Spark 深度耦合,其它引擎要支持 Delta 就得各自重写一遍,容易和规范产生偏差。
Delta 3.x 推出 delta-kernel:把「协议怎么读写」抽成一个引擎无关的库,引擎只需实现少量回调(怎么读 Parquet、怎么列目录、怎么做 put-if-absent),就能正确读写 Delta 表,而不必自己实现日志重放和协议版本检查。两个相关抽象:
- LogStore:抽象「日志文件怎么原子写」。不同存储(HDFS rename、S3 条件写、带 coordinator 的 catalog)实现各自的 put-if-absent 语义(第九节那张表就是 LogStore 的职责)。
- kernel:抽象「日志怎么变成快照」。读端给定一个表路径,kernel
负责找
_last_checkpoint、读 checkpoint、重放 JSON、应用 protocol/table feature 检查,吐出活跃文件 + DV + schema。
工程意义:
- 协议演进(新 table feature)只要在 kernel 实现一次,接入 kernel 的引擎自动获得,降低「某引擎读新特性表读出错」的风险。
- 这也是第 14 章互通讨论的底层支撑:UniForm 之所以能让 Iceberg/Hudi 客户端读 Delta,前提是各格式都有清晰、可独立实现的协议规范,而不是「只有官方引擎才读得对」。
版本提示:delta-kernel 仍在快速演进,不同引擎对它的接入程度不一;具体某引擎能否读某个 table feature,要查该引擎当前版本的 connector 说明,不能假定「支持 Delta」就等于「支持全部特性」。
十五、实验:本机环境与可复现步骤
WRITING_GUIDE 要求:能真跑才贴输出,跑不了就声明环境并给可复现步骤,不造数。
本机环境检测(2026-06-30):
OS: Linux 6.6.x (WSL2)
python3: 可用(系统自带,无 pip)
java / spark-submit / pyspark: 未安装
delta-spark / deltalake(python) / pyarrow: 未安装
结论:本机不具备运行 Delta 的条件(无
JVM、无 Spark、无 pip 安装 Python
包的能力),因此本节不提供「实测输出」,只给可在具备环境的机器上直接复现的步骤。下列命令产出的
_delta_log 文件结构、JSON action
字段,应与第二、三节描述一致。
10.1 用 PySpark + Delta 建表写多次
前置:JDK 17、Spark 3.5.x,Delta Lake 3.2.x(版本对应关系见 Delta 发布说明)。
pyspark \
--packages io.delta:delta-spark_2.12:3.2.0 \
--conf "spark.sql.extensions=io.delta.sql.DeltaSparkSessionExtension" \
--conf "spark.sql.catalog.spark_catalog=org.apache.spark.sql.delta.catalog.DeltaCatalog"path = "/tmp/delta_demo"
spark.range(0, 1000).write.format("delta").save(path) # 版本 0
spark.range(1000, 2000).write.format("delta").mode("append").save(path) # 版本 1
from delta.tables import DeltaTable
dt = DeltaTable.forPath(spark, path)
dt.delete("id = 5") # 版本 2:DELETE
dt.update("id = 7", {"id": "70"}) # 版本 3:UPDATE10.2 打开日志看 action
ls /tmp/delta_demo/_delta_log/
# 期望: 00000000000000000000.json 00000000000000000001.json ...
cat /tmp/delta_demo/_delta_log/00000000000000000000.json | python3 -m json.tool --json-lines 2>/dev/null \
|| cat /tmp/delta_demo/_delta_log/00000000000000000000.json
# 期望: 第 0 版包含 protocol + metaData + 若干 add观察点:
- 版本 0 的 JSON 应含
protocol、metaData和一批add。 - DELETE 那一版(开启 DV 时):含带
deletionVector的add/remove,数据 Parquet 不被重写;未开启 DV 时:含remove旧文件 +add重写后的新文件。 cat _delta_log/_last_checkpoint(连续提交 ≥10 次后出现),看它指向的 checkpoint 版本。
10.3 强制 checkpoint 与读 protocol
spark.sql(f"ALTER TABLE delta.`{path}` SET TBLPROPERTIES ('delta.checkpointInterval' = '2')")
for i in range(3):
spark.range(i*10, i*10+10).write.format("delta").mode("append").save(path)
# 期望: _delta_log 下出现 *.checkpoint.parquet
spark.sql(f"DESCRIBE DETAIL delta.`{path}`").show(truncate=False) # 看 minReaderVersion/minWriterVersion/features10.4 无 Spark 的轻量替代
若只想看 _delta_log 而不想装 Spark,可在有
pip 的机器上用纯 Rust 实现的 deltalake(不需要
JVM):
pip install "deltalake>=0.18" pyarrow
python3 - <<'PY'
import pyarrow as pa
from deltalake import write_deltalake, DeltaTable
t = pa.table({"id": list(range(1000))})
write_deltalake("/tmp/dl", t) # 版本 0
write_deltalake("/tmp/dl", t, mode="append") # 版本 1
print(DeltaTable("/tmp/dl").history()) # 查看每次提交的 commitInfo
PY
ls /tmp/dl/_delta_log/注意:deltalake(Python/Rust)对 deletion
vector、liquid clustering 等高级特性的写支持落后于
delta-spark,要观察这些特性仍需 Spark + Delta 3.x。
十六、小结
- Delta 的「表真相」是
_delta_log这份有序追加日志,不是数据目录;表的当前状态 = 重放日志得到的活跃文件集合。 - 一次提交是一个版本号递增的 JSON 文件,里面是若干
action:
metaData(schema/配置)、protocol(协议版本)、add/remove(增删数据文件,带 stats)、txn(流式幂等)、commitInfo(审计)、domainMetadata(如聚簇列)等。 - checkpoint 每 N 次提交把状态压平成
Parquet,
_last_checkpoint指针让读者跳过前面的 JSON;V2 checkpoint 用 sidecar 进一步切分。 - protocol + table features 用
minReaderVersion/minWriterVersion加readerFeatures/writerFeatures精确表达能力,启用特性是单向门,决定哪些引擎还能读写。 - deletion vector 是 Delta 的
merge-on-read:删行只写位图、不重写文件,靠
OPTIMIZE/REORG PURGE物化。 - liquid clustering 取代 Z-order/分区,支持不重写历史改聚簇列,最多 4 列,与分区/Z-order 互斥。
- 乐观并发全程基于日志:put-if-absent 抢版本号 + 重放比对 action 做冲突检测,没有独立锁服务(除非 catalog 提供 coordinator)。
下一章看 Apache Hudi——同样的问题,它用 timeline + file group + 强索引给出「upsert 优先」的答案。
返回 系列目录 | 上一篇:提交协议与并发控制 | 下一篇:Apache Hudi
参考资料
- Delta Lake 协议规范,
delta-io/delta仓库PROTOCOL.md(master)— Actions、Checkpoints、Table Features、Deletion Vectors、Concurrency Control 各节。 - Delta Lake 文档,What are deletion vectors?(docs.delta.io,latest)— DV 支持矩阵与物化条件。
- Delta Lake 文档,Use liquid clustering for Delta
tables(docs.delta.io,latest)—
CLUSTER BY、OPTIMIZE FULL、限制。 - Delta Lake 3.x 发布说明与
delta-spark源码(OptimisticTransaction、ConflictChecker、Snapshot)。 - 本系列 第 8 章 Iceberg 元数据树、第 11 章 提交协议与并发控制。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】表格式为什么存在
目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。
【数据湖与开放表格式】行级删除与 Merge-on-Read
Iceberg 在不可变文件上做行级删除的两条路线:copy-on-write(重写整文件)与 merge-on-read(写 delete 文件,读时合并)。讲清 position delete 与 equality delete 的语义、字段与作用域规则,写放大/读放大的取舍,V2 delete file 到 V3 deletion vector(Puffin 承载)的差异与迁移,以及读路径如何把 data file 与 delete 合并出可见行。基于 pyiceberg 0.11.1 实测 CoW 写放大并观察 MoR 回退。
【数据湖与开放表格式】Iceberg、Delta、Hudi 对照与互通
把前面 08–13 章拆过的 Iceberg、Delta、Hudi 放在一个坐标系里对照:元数据模型、行级更新、并发控制、引擎生态四维,每维标清口径。再讲两条互通路线——Delta UniForm(写时同步生成 Iceberg/Hudi 元数据)与 Apache XTable(事后转换元数据),以及它们的边界。最后给一棵按写入模式/引擎栈/更新频率展开的选型决策树,不做排名。
【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解
拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。