4. Delta Lake 基础:架构、事务日志与 ACID 保证

Delta Lake 这东西,说白了就是给数据湖加了个「数据库引擎」。我最早接触它的时候,心里也犯嘀咕:不就是个 Parquet 加了一层壳吗?后来真在项目里用上了,才发现这层壳才是精髓。

咱们先看整体架构,再拆开细聊。

4.1 Delta Lake 整体架构

Delta Lake 不是独立服务,它是个存储层中间件。跑在 Spark 或 Flink 里,读写的是 Parquet 文件,但多了一套 事务日志(Transaction Log)。

核心思路:用日志记录每次数据变更,而不是直接改文件。这样读的时候能拿到一致性快照,写的时候能冲突检测。

Delta Lake 架构图 计算引擎(Spark / Flink) Delta Lake 核心层 事务日志(Transaction Log) ACID 事务管理器 分布式存储层(HDFS / S3 / ADLS) Parquet 数据文件 (不可变,按版本存储) _delta_log 目录 (JSON 事务日志) Checkpoint 文件 (Parquet 格式快照) 说明:Delta Lake 在计算引擎和存储层之间,通过事务日志实现 ACID 保证

你看这个图,从上到下三层。计算引擎只管读写,Delta Lake 核心层负责事务管理,底层存储就是普通的文件系统。我当年第一次部署的时候,以为要装什么服务端,结果发现就是个 Jar 包,直接往 Spark 里一丢就行。

4.2 事务日志(Transaction Log)

这是 Delta Lake 最核心的部件。没有它,ACID 就是空谈。

事务日志存在 _delta_log 目录下,每个文件是一个 JSON,按版本号命名:

mnt/delta-table/
├── _delta_log/
│   ├── 00000000000000000000.json
│   ├── 00000000000000000001.json
│   ├── 00000000000000000002.json
│   └── ...
├── part-00000-xxx.snappy.parquet
├── part-00001-xxx.snappy.parquet
└── ...

每个 JSON 文件记录一次原子操作。比如新增文件、删除文件、修改 schema、提交事务等。

个人习惯:我排查数据问题时,第一件事就是去翻 _delta_log 目录。看版本号有没有断层,看操作类型对不对。比查代码快多了。

4.2.1 日志条目结构

一个典型的 JSON 日志条目长这样:

{
  "add": {
    "path": "part-00000-xxx.snappy.parquet",
    "partitionValues": {},
    "size": 123456,
    "modificationTime": 1700000000000,
    "dataChange": true
  },
  "commitInfo": {
    "timestamp": 1700000000000,
    "operation": "WRITE",
    "operationParameters": {
      "mode": "Append"
    }
  }
}

这里有几个关键字段:

  • add / remove:标记文件是新增还是删除
  • dataChange:是否改变了数据内容(元数据操作设为 false)
  • commitInfo:记录谁、什么时间、做了什么操作

嗯,这里要注意:dataChange 这个字段很容易被忽略。我遇到过同事把 OPTIMIZE 操作误标记为 dataChange=true,结果下游流作业重复消费了全量数据。坑啊。

4.2.2 日志的原子提交

Delta Lake 怎么保证写日志不丢?靠文件系统的原子 rename 操作。

流程是这样的:

  1. 先写一个 .tmp 临时文件
  2. 然后 rename 成正式的 .json 文件
  3. 如果 rename 失败,整个事务回滚

说白了,就是利用底层文件系统的原子性。HDFS 的 rename 是原子的,S3 的 putObject 也是原子的。这招简单但有效。

我曾经踩过的坑:在 EMR 上跑 Delta Lake,S3 的 list-after-write 有最终一致性延迟。新版 Delta Lake 加了 S3ConsistencyGuard 才解决。如果你用 S3,记得升级到 1.2.0 以上。

4.3 ACID 事务保证

ACID 四个特性,Delta Lake 怎么实现的?咱们一个一个看。

ACID 特性 Delta Lake 实现方式 我见过的典型问题
原子性(Atomicity) 事务日志原子提交 + 文件不可变 写一半进程挂了,重启后自动回滚
一致性(Consistency) Schema 校验 + 约束检查 写入字段类型不匹配,直接拒绝
隔离性(Isolation) 乐观并发控制(OCC) 两个作业同时写,一个失败重试
持久性(Durability) 数据落盘 + 日志持久化 机器断电,数据不丢

4.3.1 原子性与持久性

这两个放一起说。Delta Lake 的每次写入,要么全部成功,要么全部失败。不会出现「写了一半文件,另一半没写」的情况。

怎么做到的?

  • 先把数据写到临时目录
  • 然后原子提交事务日志
  • 日志提交成功后,临时文件才变成正式文件

你想想看,如果写日志那一步挂了,临时文件根本不会被引用。下次读的时候,还是上一个版本的数据。

4.3.2 一致性

Delta Lake 强制 Schema 校验。你往表里写数据,字段名、类型必须匹配。

-- 假设表有 id INT, name STRING
INSERT INTO my_table VALUES ('abc', 123)  -- 类型不匹配,报错

我习惯在写入前用 spark.read.schema() 先检查一下目标表的 schema,避免运行时才发现不匹配。

4.3.3 隔离性(乐观并发控制)

这是 ACID 里最复杂的部分。Delta Lake 用的是 乐观并发控制(OCC),不是悲观锁。

流程:

  1. 读当前版本的事务日志
  2. 在内存中做数据变更
  3. 尝试提交新版本日志
  4. 如果版本号冲突(别人先提交了),当前事务重试

关键点:冲突检测是在提交阶段做的,不是读写阶段。所以读操作永远不会被阻塞。写操作冲突了,自动重试,对用户透明。

我遇到过最典型的场景:两个流作业同时往一张表写数据,其中一个频繁报冲突重试。后来发现是 checkpoint 间隔太短,日志文件太多,冲突概率上升。调大 checkpoint 间隔后就好了。

4.4 Checkpoint 机制

事务日志越来越多,总不能每次都从头读到尾吧?Checkpoint 就是用来解决这个问题的。

每 delta.checkpointInterval 个版本(默认 10),Delta Lake 会生成一个 Parquet 格式的 checkpoint 文件。里面记录了到当前版本为止,所有活跃文件的状态。

_delta_log/
├── 00000000000000000000.json
├── ...
├── 00000000000000000010.json
├── 00000000000000000010.checkpoint.parquet  ← 快照
├── 00000000000000000011.json
└── ...

读数据时,先读最近的 checkpoint,再读 checkpoint 之后的增量日志。这样就不用从头解析所有 JSON 了。

我建议:如果表的数据量很大,把 checkpoint 间隔调小一点(比如 5),能加快读性能。但写操作会多一步生成 checkpoint 的开销。需要根据实际场景权衡。

4.5 时间旅行(Time Travel)

既然所有变更都记在日志里,那回到任意历史版本就顺理成章了。

Delta Lake 支持三种回溯方式:

方式 语法 适用场景
按版本号 VERSION AS OF 5 知道具体版本号时
按时间戳 TIMESTAMP AS OF '2024-01-01' 知道大概时间点时
按字符串 @v5 或 @20240101 Spark SQL 快捷写法
-- 按版本号回溯
SELECT * FROM my_table VERSION AS OF 5

-- 按时间戳回溯
SELECT * FROM my_table TIMESTAMP AS OF '2024-01-01 12:00:00'

-- Spark SQL 快捷写法
SELECT * FROM my_table@v5

时间旅行不是无限保留的。Delta Lake 默认保留 30 天的日志。超过这个时间,旧版本的数据文件和日志会被 VACUUM 清理掉。

我曾经犯过的错:有一次跑 VACUUM 忘了设保留期,把 7 天前的数据全清了。业务方要回溯数据,我只好从备份恢复。现在我的 VACUUM 脚本里永远带着 RETAIN 168 HOURS(7天),不敢偷懒。

4.6 小结

Delta Lake 的架构其实不复杂:事务日志记录一切,Checkpoint 加速读取,OCC 保证并发安全。你想想看,这些机制组合起来,就把一个普通的文件目录变成了一个有 ACID 保证的数据湖表。