数据湖 ACID 事务的三种实现路径对比:MVCC、Optimistic 与 Pessimistic

本文对比数据湖 ACID 事务的三种实现路径:MVCC、乐观并发控制与悲观并发控制,分析各自机制、冲突处理方式和适用场景,并结合 Iceberg、Delta Lake、Hudi 给出选型建议,帮助团队在数据湖多写入者场景下正确设计事务方案。

数据湖为什么需要事务?

如果你们的数仓团队正打算把离线批处理迁到数据湖上,大概率会在某个版本升级的夜晚碰到一类问题:两个流程同时写了同一个分区,一份数据把另一份数据覆盖了,分区数据量对不上,报表结果时对时错。这个问题折腾到一定程度,大家就会开始聊数据湖 ACID 事务。

AI technology illustration

但这几个字听起来简单,真正去了解时却发现实现方式并不统一。市面上的数据湖引擎有的走 MVCC,有的强调乐观并发,有的干脆用悲观锁。本文尝试把这三条路径放在同一张图上做一次对比,讲清各自的机制、代价和适用边界。

三种路径的本质差异

数据湖 ACID 事务要解决的核心问题可以简化成:多个写入者在同一张表上提交变更时,如何保证一致性、可串行化和崩溃恢复。围绕这个目标,主流实现思路大致分成三种:

  • MVCC:通过维护多个版本快照,让读取和写入互不干扰,提交时用原子的元数据切换来发布新版本。
  • 乐观并发控制:事务在内存中暂存变更,提交时通过版本号或写集检查冲突,冲突则重试或中止。
  • 悲观并发控制:在写入前先获取锁,由锁服务保证同一时刻只有一个写入者能修改目标数据。

注意这里的 MVCC 和乐观并发不是非黑即白的关系。比如 Delta Lake 的乐观并发控制,底层其实也是基于多版本快照,只是它把冲突检测的重点放在提交阶段。为了对比方便,我们把 MVCC 理解为侧重“版本存储与快照读”,乐观并发侧重“提交时冲突验证”,悲观并发侧重“操作前加锁阻塞”。

MVCC:让读写互相隔离

MVCC 的核心思路非常符合对象存储的物理特性:数据文件不可变,写入新数据就生成新文件,同时生成一个新的快照,最后通过一次原子操作把表的元数据指针从旧快照切换到新快照。读事务只要拿到某个快照,就可以读取那个时刻的完整数据视图,不受后续写事务影响。

Apache Iceberg 是这条路径的典型代表。Iceberg 的表元数据通过一层层的 JSON 文件描述,每次提交会生成一个新的 manifest list 和 snapshot,表指针由存储系统上的原子操作更新(例如 S3 的条件写或者 HDFS 的 rename 操作)。

一个最小化的 MVCC 提交过程看起来像这样:

# 当前表元数据指针指向 'snapshot-0001'
# 事务在本地写入新文件,并生成新的 manifest

# 提交阶段,尝试原子更新指针
if cas_table_ref('snapshot-0001', 'snapshot-0002'):
    publish()
else:
    # 说明另一个事务已经提交
    # 基于最新的 'snapshot-0002' 重新执行写入
    retry_from_latest()

MVCC 带来的最大收益是读写互不阻塞,这是数据湖最需要的特性。一个运行 5 小时的批处理任务,不需要担心另一个团队正在写入数据而导致读到的结果前后不一致。

不过 MVCC 不是没有代价。快照元数据会不断累积,需要定期清理;如果并发写入者非常多,提交时的 CAS 冲突会导致大量重试,底层作业还得配合幂等写入才能保证安全。

Optimistic:把冲突检查放到提交前

乐观并发控制与 MVCC 在存储层很相似,但它更强调“提交时验证”。每个事务在开始时读取一个快照版本,之后所有写操作都在内存或暂存目录中进行,提交时系统检查本次事务读过的分区或文件是否被其他事务修改过。如果没冲突,就直接提交;如果有,则整体重试。

Delta Lake 的 Optimistic Concurrency Control 就是这种模式的典型。它的冲突检测不仅看表版本,还会针对具体的数据文件、schema、partition 等信息做细粒度验证。提交时验证条件可以简化为:

if validate(table_version, intersecting_files, schema_hash):
    commit()
else:
    abort()

乐观并发控制的优势在于没有锁,读操作永远不需要等待;对大多数读写比例高、写冲突偶发的场景足够高效。但它的短板也明显:如果两个任务经常同时更新同一个分区,重试次数会指数级增加,尤其在写入量大的流批一体场景,每一次失败重试都要重新读取数据,成本很高。

这里有一个很典型的场景:管线 A 每五分钟执行一次 upsert,管线 B 每小时做一次全量覆盖。两个任务都写同一个分区,那么 B 的每次提交都可能撞上 A 的提交窗口。如果乐观并发控制触发重试,一个简单的覆盖任务可能跑一小时还完不成。这种时候,乐观并发就需要向悲观锁妥协,或者对写入路径做更细的拆分设计。

Pessimistic:用锁换确定性

悲观并发控制回归了传统数据库的思路:在任何写操作开始前,先锁定涉及的表或分区;其他事务等待锁释放后才能继续。数据湖里不建议做行级锁,因为一次写操作要处理大量文件,锁粒度通常在表级或分区级。

这种路径通常用在两类地方:一是像 Apache Hudi 在某些写入模式下使用锁服务(比如基于 ZooKeeper 或 Hive Metastore 的锁)来避免并发提交;二是数据湖平台自己实现了一层分布式锁,统一调度所有 DML 和 DDL 操作。

一个典型的悲观锁实现伪代码如下:

lock_key = 'table/events/partition=2024-01'
lock = acquire_distributed_lock(lock_key, ttl=300)

if lock:
    try:
        do_write()
        commit()
    finally:
        release_lock(lock_key)
else:
    wait_or_fail()

悲观锁的优势是确定性高,没有重试风暴;适合高冲突、低并发的管理性操作,比如 schema 变更、全表重写、分区整理。但缺点也很直观:一次写操作可能持续数分钟甚至数小时,锁持有时间过长,所有其他写入都阻塞;同时引入分布式锁组件会增加系统复杂度和运维成本,对大部分只需要分钟级并发写入的团队来说,可能得不偿失。

三种路径对比

下面用一张表总结主要差异:

对比维度 MVCC 乐观并发 悲观并发
核心机制 多版本快照 + 原子元数据切换 快照读取 + 提交时冲突校验 写前加锁,串行化关键路径
读写阻塞 读写互不阻塞 读写互不阻塞 写写阻塞,读写一般可并发
冲突处理 基于 CAS 重试 写集校验后重试 等待锁释放
适用场景 读多写少、长任务并发读 中等并发、冲突低但需要保障一致 高冲突、低频管理操作
代表实现 Apache Iceberg Delta Lake Hudi 部分模式 / 平台自研锁
最大风险 快照膨胀、高并发 CAS 冲突 重试风暴、失败成本高 锁的可用性、长阻塞

需要说明的是,每个主流数据湖表格式并非严格只使用一种策略。Iceberg 也支持基于元数据锁的悲观模式,Delta Lake 也有表级锁和 schema 锁,Hudi 更是在不同版本中提供多种并发控制方案。这张表更像是帮你理解侧重点,而不是贴死标签。

落地时的选择建议

如果你的团队正准备在数据湖上开启多写入者模式,建议先不急着选“最优事务协议”,而是按以下顺序排查:

  • 业务对一致性的要求是什么?是全表不可读脏,还是只要最终一致?
  • 写入的并发度是多高?是几条管道运维写,还是几十个任务实时写?
  • 一天中有多少个写写重叠?重叠涉及全表还是独立分区?

如果你用的是 Spark/Flink 离线管道,写入频次不高,Iceberg 的 MVCC 默认配置就能撑住。如果你有多个流作业同时 upsert 同一张表,Delta Lake 的乐观并发虽然能提供一致性,但要关注冲突重试频率,必要时把表按业务键拆成多张小表,降低写集重叠概率。

如果你需要强一致性的 schema 变更,或者团队已经有现成的 ZooKeeper/etcd,可以考虑引入悲观锁来保护 DDL 和重分区任务,但要限定在低频操作上。数据湖事务是层叠设计,不必为所有操作都选择最严格的机制。

还有一个经常被忽视的点:底层文件系统和对象存储的原子语义决定了很多实现的上限。S3 的条件写、OSS 的版本切换、HDFS 的 rename,各自的可用性都需要单独验证。跨云、跨版本切换时,事务行为可能会发生变化。

三个容易踩的坑

  1. 以为有了 ACID 事务就能随便并发写。很多高冲突场景下,事务协议只是保证不丢数据,但无法解决业务语义上的覆盖问题。比如两个作业都在做更新同一批用户的状态,即使提交成功,后提交的数据也会覆盖先提交的内容,这在业务上并不一定是预期的。
  2. 把乐观并发当成“无代价并发”。乐观并发节省了锁等待的成本,但把代价转移到了提交时的校验和重试上。如果重试频繁,整个集群的计算资源会被白白消耗,最终反而比加锁更差。
  3. 在低版本或默认配置下使用了不完全的并发控制。有些数据湖表格式默认关闭并发提交,或者只是保证单写入者安全。集成到生产环境时,务必要确认事务实现是否支持多写者模式,否则冲突可能导致脏数据而不报错。

最后还有一点:事务协议选型需要回归到你的业务写入模式。没有人会真的用悲观锁去保护一个每秒写入上千次的实时流,也没有人愿意用乐观并发去无限重试一个每天都冲突的全量覆盖任务。

总结

数据湖 ACID 事务的三种路径,没有绝对的高下。MVCC 解决了“读写隔离”这一最基础的问题;乐观并发让多写者在不加锁的情况下获得一致性;悲观并发则在冲突集中、确定性优先的场景下提供了一种更可控的选择。

对大多数团队来说,最可行的演进路径是:先用 MVCC 做好快照隔离和时间旅行,再根据实际写入冲突情况决定是否接入乐观并发或悲观锁。事务协议选型不是一步到位的事,而是和数据写入规模、任务调度方式、团队运维能力一起演化的系统工程。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/982/

(0)
上一篇 50分钟前
下一篇 43分钟前

相关推荐