3. 事务与并发控制:事务的概念、乐观并发控制、写冲突处理

3.1 事务的核心概念:ACID 在 Delta Lake 中的落地

在传统关系型数据库中,事务(Transaction)是一组不可分割的操作序列,必须具备 ACID 特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。Delta Lake 将这一套语义完整地带入了数据湖环境,使得基于 Parquet 等开放格式的存储也能获得数据库级别的事务保障。

Delta Lake 的事务实现并不依赖传统的锁管理器,而是基于 事务日志(Transaction Log,即 _delta_log 目录下的 JSON 文件) 和 版本化数据文件。每次写操作(INSERT、UPDATE、DELETE、MERGE)都会生成一个新的日志条目,记录该次操作涉及的数据文件变更。读操作则根据快照隔离(Snapshot Isolation)读取某个特定版本的数据,从而保证一致性。

下表总结了 Delta Lake 中 ACID 的具体实现方式:

ACID 属性 Delta Lake 实现机制 关键文件/组件
原子性 事务日志提交时一次性记录所有文件变更;若提交失败则整体回滚 _delta_log/00000000000000000000.json
一致性 Schema 强制校验 + 约束(如 NOT NULL、CHECK)在写入时验证 表 Schema 定义、约束元数据
隔离性 快照隔离:读写互不阻塞,读操作看到的是提交时刻的版本 版本号 + 数据文件清单
持久性 日志落盘后即认为事务成功,数据文件不可变(只追加新版本) Parquet 数据文件 + JSON 日志
理解要点:Delta Lake 的“数据文件不可变”原则是事务实现的基础。每次更新不是修改原文件,而是生成新版本的数据文件,旧版本文件在事务日志中不再被引用,但物理文件仍保留直到 VACUUM 清理。
Delta Lake 事务日志驱动的 ACID 机制 写事务(UPDATE/MERGE) 事务 ID = 版本 N+1 事务日志 _delta_log/ 版本化数据文件 Parquet + 版本清单 提交 引用 日志条目内容(版本 N+1): add 新增文件 part-00001-xxx.snappy.parquet size=2.1GB, partition=date=2024-01-15 remove 删除文件 part-00000-xxx.snappy.parquet 原版本 N 中的旧数据 metaData / protocol Schema 校验、格式版本 minReaderVersion=1 提交过程: ① 尝试创建 0000000000000000000N+1.json → ② 若文件已存在则提交失败(冲突) ③ 成功创建则事务提交 → ④ 新版本对读事务可见 原子性保障 日志文件创建是原子操作

3.2 乐观并发控制(Optimistic Concurrency Control)

Delta Lake 默认采用乐观并发控制(OCC)策略,这与传统数据库的悲观锁机制有本质区别。OCC 的核心假设是:多个事务同时修改同一份数据的概率较低,因此不需要在操作开始时锁定数据,而是让所有事务并行执行,在提交阶段检测是否存在冲突。

OCC 的工作流程可以概括为以下四个阶段:

  1. 读取阶段(Read Phase):事务读取当前表的最新快照(版本 N),记录读取的版本号和涉及的数据文件列表。
  2. 执行阶段(Execution Phase):在内存中基于读取的快照执行转换操作(如过滤、聚合、UPDATE 逻辑),生成新的数据文件。
  3. 验证阶段(Validation Phase):在提交前,检查当前表的最新版本是否仍然是 N。如果期间有其他事务提交了版本 N+1,则当前事务进入冲突处理流程。
  4. 提交阶段(Commit Phase):验证通过后,尝试原子性地创建版本 N+1 的日志文件。若创建成功,则事务提交;若文件已存在(说明有并发事务抢先提交),则同样进入冲突处理。
关键机制:Delta Lake 使用 日志文件名的唯一性 作为提交的“原子比较并交换(Compare-and-Swap)”操作。两个事务同时尝试创建 00000000000000000010.json 时,文件系统保证只有一个能成功创建,另一个会收到 FileAlreadyExistsException。

3.3 写冲突处理:谁赢谁输?

当两个事务并发写入同一张 Delta 表时,冲突处理规则取决于它们操作的数据范围是否重叠。Delta Lake 的冲突检测粒度是数据文件(Parquet 文件)级别,而非行级别。以下通过具体场景说明:

场景一:无冲突(并行提交成功)

事务 A 向 date=2024-01-15 分区写入数据,事务 B 向 date=2024-01-16 分区写入数据。由于两个事务操作的数据文件完全不同,它们可以同时提交成功,最终表版本会连续递增(例如 A 提交为版本 10,B 提交为版本 11),互不干扰。

场景二:文件级冲突(后提交者失败)

事务 A 和事务 B 同时读取了版本 9 的快照,并且都修改了 part-00000-xxx.parquet 这个文件(例如执行 UPDATE 操作更新了同一批订单的状态)。此时:

  • 事务 A 先提交成功,创建了版本 10 的日志,其中包含对 part-00000 的 remove 操作和新增文件 part-00003 的 add 操作。
  • 事务 B 尝试提交时,发现当前表版本已经是 10(而非它读取时的 9),并且它要修改的 part-00000 已经在版本 10 中被移除了。
  • Delta Lake 抛出 ConcurrentAppendException 或 ConcurrentDeleteException,事务 B 提交失败。

下面用流程图展示冲突检测与处理逻辑:

Delta Lake 写冲突检测与处理流程 事务尝试提交版本 N+1 日志文件 已存在? 是(冲突) 提交失败 FileAlreadyExistsException 否 与当前最新版本 存在文件级冲突? 是 提交失败 ConcurrentAppendException 否 提交成功,版本 N+1 生效 冲突解决策略:失败事务可重试(基于最新版本重新执行)或由应用层决定处理方式

3.4 冲突处理策略与最佳实践

当冲突发生时,Delta Lake 不会自动重试事务,而是将异常抛给上层应用。开发者需要根据业务场景选择合适的处理策略:

常见误区:很多初学者认为 Delta Lake 的乐观并发控制会自动解决所有冲突。实际上,Delta Lake 只负责检测冲突并保证数据一致性,不负责自动重试。对于 Spark Structured Streaming 的流式写入任务,可以通过 .option("retryOnConflict", "true") 启用自动重试;但对于批处理任务,需要手动处理异常。

推荐的冲突处理策略包括:

  • 重试机制:捕获 ConcurrentWriteException 后,重新读取最新版本的表快照,再次执行写操作。适用于 UPDATE/MERGE 等需要基于最新数据重新计算的场景。
  • 条件更新(Predicate-based):在 UPDATE 语句中增加版本条件,例如 UPDATE ... WHERE version = 9,若版本不匹配则更新 0 行,避免盲目覆盖。
  • 分区隔离设计:将高频写入的数据按业务键(如日期、地域、用户 ID)进行分区,使并发事务尽量操作不同的数据文件,从源头减少冲突概率。
  • 使用 MERGE 的原子性:MERGE 操作在 Delta Lake 中是原子的,可以安全地用于“存在则更新,不存在则插入”的场景,避免先 SELECT 再 INSERT/UPDATE 的竞态条件。

3.5 案例复盘:并发写入导致的数据丢失风险

某团队使用 Delta Lake 存储用户行为日志,两个 Spark 作业分别按小时和按天聚合数据并写回同一张表。某次运行时,两个作业恰好同时触发,都读取了版本 12 的快照:

  • 作业 A(按小时聚合)修改了 hour=10 分区的数据文件,提交为版本 13。
  • 作业 B(按天聚合)修改了 date=2024-01-15 分区的数据文件,其中包含了 hour=10 的数据。

由于作业 B 读取的是版本 12 的旧快照,它基于旧数据生成了新的聚合结果。当作业 B 尝试提交版本 13 时(与作业 A 的版本号相同),发现日志文件已存在,抛出 FileAlreadyExistsException。此时如果应用层没有捕获异常并重试,作业 B 的聚合结果就会丢失,导致当天报表数据不完整。

复盘结论:该问题的根源在于两个作业的写入范围存在重叠(按天聚合包含了按小时聚合的数据)。解决方案有两种:一是将两个作业的写入目标拆分为不同的 Delta 表,最后通过 MERGE 合并;二是为作业 B 增加重试逻辑,在检测到冲突后基于版本 13 重新计算。通过这次复盘,团队意识到合理设计分区策略和写入路径比单纯依赖并发控制机制更为重要。