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

【数据湖与开放表格式】Delta Lake 事务日志

文章导航

分类入口
databasestorage
标签入口
#delta-lake#transaction-log#deletion-vector#liquid-clustering

目录

第 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 表把「目录里有哪些文件」直接等同于「表的内容」,于是有三个老问题:

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):

下面逐层拆。


三、七类 action

一条 commit JSON 的每一行是一个 action 对象,最外层 key 标明类型。协议(PROTOCOL.mdActions)定义的核心类型如下。下面给出的是规范里定义的字段含义,不是某次真实运行的输出。

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
}}

要点:

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
}}

要点:

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:

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 早期用两个单调整数表达能力:minReaderVersionminWriterVersion。规则很简单:客户端的读能力 ≥ 表的 minReaderVersion 才能读,写能力 ≥ minWriterVersion 才能写,否则必须报错拒绝,而不是装作看不见。这是为了防止旧客户端误读/误写新特性表导致数据损坏。

问题是「整数版本」太粗:每加一个特性就要把版本号 +1,而一张表可能只想启用其中一个特性。于是协议(PROTOCOL.mdTable Features)引入 table features

概念 含义
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.mdCheckpoints)。

5.1 checkpoint 是什么

checkpoint 是一个 Parquet 文件,把「截至版本 N 的全部 action 重放后的状态」物化下来:所有当前活跃的 add(含其 stats)、尚在保留期内的 remove tombstone、最新的 metaDataprotocol,全部压平成 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.json12.json …… 到最新。没有它也能工作(退化为从 0 重放或自己找最大 checkpoint),有它则省一次 LIST + 大量 JSON 读取。

5.3 多文件与 V2 checkpoint

大表的 checkpoint 本身可能很大,协议支持两种切分:

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 逻辑):

  1. LIST _delta_log/,或先读 _last_checkpoint 拿到最近 checkpoint 版本 c
  2. 读 checkpoint(版本 c)得到截至 c 的全量 action 状态。
  3. 增量读 c+1.json … 到最大版本 n 的 JSON。
  4. 按顺序重放add 加入活跃集合,remove 从集合移除(用 path 匹配);后出现的 metaData/protocol 覆盖前面的。
  5. 得到:当前 schema、协议、活跃文件集合(每个文件带 stats 和可选 deletion vector)。
  6. 用查询谓词 + 文件 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),结构含:storageTypeu 相对路径 / i 内联 / p 绝对路径)、pathOrInlineDvoffsetsizeInBytescardinality(被删行数)。位图本身以 RoaringBitmap 序列化,存在独立的 .bin 文件里(一个 .bin 可承载多个 DV)。

一次 DELETE 删掉某文件的少量行,日志里的表现是:对该数据文件写一条新的 add(或在协议允许时复用),其 deletionVector 指向新生成的 DV,标记被删行号——数据文件一个字节都没改。

7.3 物化:何时真正重写

DV 是「软删除」,被删行仍在 Parquet 里占空间。下列操作会把 DV「落地」成真正的文件重写(文档 Apply changes to Parquet data files):

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),目标是「不重写历史就能改聚簇列、且自然处理倾斜」。要点(引自官方文档):

CREATE TABLE t1(col0 int, col1 string) USING DELTA CLUSTER BY (col0);
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 的步骤:

  1. 读当前最新版本 n,记下「我基于版本 n 做的修改」(读了哪些文件、要 add/remove 哪些)。
  2. 本地把数据 Parquet 写好。
  3. 尝试把 commit 写成 _delta_log/<n+1>.json,用底层存储的 put-if-absent(目标文件不存在才写成功)。
  4. <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 … 当前最新),逐一检查是否和自己的修改逻辑冲突:

隔离级别(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 章)是同一思路。

工程含义有两条:


十一、tombstone、保留期与 VACUUM

第三节说过 remove 只是逻辑删除(tombstone),物理 Parquet 文件还在。这是时间旅行的基础:要能读版本 v,版本 v 引用的文件就不能被删。代价是过期文件会堆积。

清理靠 VACUUM:删除「不再被任何保留期内版本引用」的物理文件。关键参数是保留阈值,默认 7 天delta.deletedFileRetentionDuration):

VACUUM my_table;                 -- 用默认保留期
VACUUM my_table RETAIN 168 HOURS;-- 显式 7 天

两个必须理解的约束:

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_typeinsert/update_preimage/update_postimage/delete)的行。

ALTER TABLE my_table SET TBLPROPERTIES (delta.enableChangeDataFeed = true);
-- 读版本 5 到 10 之间的变更
SELECT * FROM table_changes('my_table', 5, 10);

要点:


十三、数据裁剪:stats 如何被用

第三节提到 add.stats 里有 numRecords、每列 minValues/maxValues/nullCount。这些不是装饰,是 Delta「不读数据就裁文件」(data skipping)的全部依据,机制和 Iceberg manifest 的 lower/upper bound(第 8 章)同构。

裁剪发生在读路径第 5 步(第六节):构造完活跃文件集合后,对每个文件用谓词比对 stats。例如查询 WHERE id = 12345

几个工程细节:

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 表,而不必自己实现日志重放和协议版本检查。两个相关抽象:

工程意义:

版本提示: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:UPDATE

10.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

观察点:

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/features

10.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。


十六、小结

下一章看 Apache Hudi——同样的问题,它用 timeline + file group + 强索引给出「upsert 优先」的答案。


返回 系列目录 | 上一篇:提交协议与并发控制 | 下一篇:Apache Hudi

参考资料

  1. Delta Lake 协议规范,delta-io/delta 仓库 PROTOCOL.md(master)— Actions、Checkpoints、Table Features、Deletion Vectors、Concurrency Control 各节。
  2. Delta Lake 文档,What are deletion vectors?(docs.delta.io,latest)— DV 支持矩阵与物化条件。
  3. Delta Lake 文档,Use liquid clustering for Delta tables(docs.delta.io,latest)— CLUSTER BYOPTIMIZE FULL、限制。
  4. Delta Lake 3.x 发布说明与 delta-spark 源码(OptimisticTransactionConflictCheckerSnapshot)。
  5. 本系列 第 8 章 Iceberg 元数据树第 11 章 提交协议与并发控制

同主题继续阅读

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

2026-06-30 · database / storage

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

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

2026-06-30 · database / storage

【数据湖与开放表格式】行级删除与 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 回退。

2026-06-30 · database / storage

【数据湖与开放表格式】Iceberg、Delta、Hudi 对照与互通

把前面 08–13 章拆过的 Iceberg、Delta、Hudi 放在一个坐标系里对照:元数据模型、行级更新、并发控制、引擎生态四维,每维标清口径。再讲两条互通路线——Delta UniForm(写时同步生成 Iceberg/Hudi 元数据)与 Apache XTable(事后转换元数据),以及它们的边界。最后给一棵按写入模式/引擎栈/更新频率展开的选型决策树,不做排名。

2026-06-29 · database / storage

【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解

拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。


By .