订阅更新

同时写入同一张表会冲突吗?——并发控制和隔离级别是什么?

数据湖手册 第 7 题 约 14 分钟读完

本文目录
  1. 先说结论
  2. 真实案例:数据覆盖导致报表错误
  3. 并发写入的核心挑战
  4. Delta Lake的并发控制机制
  5. 数据湖格式并发控制对比
  6. 如何避免并发冲突?
  7. 面试高频追问
  8. 实战踩坑:并发冲突导致ETL失败
  9. 总结

先说结论

传统Hive问题:多个任务同时写入同一分区,后写的会覆盖先写的,导致数据丢失。

数据湖解决方案:通过ACID事务(乐观并发控制)+ 元数据层(事务日志)实现。

Delta Lake做法:用Optimistic Concurrency Control,每个事务尝试提交,冲突时自动重试。

最佳实践:避免多个任务写入同一分区、使用MERGE INTO而不是INSERT OVERWRITE、监控冲突重试次数。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

这道题面试官问得很多,因为并发写入是数据湖的核心能力之一,也是传统Hive的痛点。我在某出海硬件品牌遇到过'多个任务同时写入用户表,数据互相覆盖'的问题。很多人只知道'数据湖支持并发写入',但说不清楚'ACID是怎么实现的、什么情况下会冲突、怎么解决冲突'。面试官想听的是你能说清楚原理和实战经验。

真实案例:数据覆盖导致报表错误

2022年在某知名出海硬件品牌,我们遇到过一次'数据覆盖导致报表错误'的事故。当时有两个任务:

任务A:用户行为数据同步(每小时运行),写入user_behavior表,按dt+hour分区

任务B:数据质量修复任务(每小时运行),也写入user_behavior表,修复错误数据

两个任务都是'INSERT OVERWRITE ... PARTITION (dt='2024-03-19', hour='10')',结果:任务A 10:05开始写,任务B 10:06开始写,任务B先完成,任务A后完成,最终数据是任务A的(任务B的修复数据被覆盖了)。

-- 问题代码:两个任务都用INSERT OVERWRITE
-- 任务A:用户行为同步
INSERT OVERWRITE TABLE user_behavior
PARTITION (dt='2024-03-19', hour='10')
SELECT * FROM source_user_behavior
WHERE dt='2024-03-19' AND hour='10';
 
-- 任务B:数据质量修复
INSERT OVERWRITE TABLE user_behavior
PARTITION (dt='2024-03-19', hour='10')
SELECT * FROM user_behavior_staging
WHERE dt='2024-03-19' AND hour='10';  -- 修复后的数据
 
-- 结果:后完成的任务会覆盖先完成的任务的数据

更严重的是:我们发现问题时已经是下午3点了,上午10点的数据已经'错了5小时',业务部门的日报数据全部不准。事后我们调查:任务A和任务B有30%的概率会'时间重叠',之前没有暴露是因为'运气好'——任务重叠时B总是先完成。

解决方案:我们改用Delta Lake,用MERGE INTO代替INSERT OVERWRITE,并且让任务B'延迟1小时'运行(避免和任务A冲突)。改后没有再出现数据覆盖问题。

并发写入的核心挑战

并发写入的核心问题是'写写冲突'(Write-Write Conflict):多个事务同时修改同一份数据,如何保证数据一致性?传统数据库用'锁'(悲观并发控制),但数据湖的分布式场景不适合用锁(锁开销太大、容易死锁)。数据湖用'乐观并发控制'(Optimistic Concurrency Control):

乐观并发控制的三步:1)每个事务先读取数据,记录版本号;2)修改数据,尝试提交(带版本号);3)提交时检查版本号,如果没变则提交成功,如果变了则冲突,回滚重试。

// 乐观并发控制的基本逻辑(伪代码)
val transaction = new Transaction()
val startVersion = transaction.getCurrentVersion()  // 读取当前版本
 
// 修改数据
val newData = transformData(readData(startVersion))
 
// 尝试提交
val committed = transaction.tryCommit(newData, startVersion)
if (committed) {
  println("提交成功")
} else {
  println("冲突,重试...")
  retry()  // 自动重试
}

Delta Lake、Iceberg、Hudi都用乐观并发控制,但实现细节不同。

Delta Lake的并发控制机制

Delta Lake用'_delta_log'目录下的JSON文件记录所有事务(类似Hive的Metastore,但更强大)。每个事务写一个JSON文件(文件名是版本号,如00000000000000000001.json),包含'添加哪些文件、删除哪些文件'的原子操作。

# Delta Lake的事务日志结构
/user/hive/warehouse/user_behavior/_delta_log/
├── 00000000000000000001.json  -- 第1次事务:创建表
├── 00000000000000000002.json  -- 第2次事务:写入数据
├── 00000000000000000003.json  -- 第3次事务:MERGE INTO
└── 00000000000000000004.json  -- 第4次事务:OPTIMIZE
 
# 每个JSON文件记录这次事务的详细信息
# {'add': ['/path/to/file1.parquet', ...],
#  'remove': ['/path/to/file2.parquet', ...]}

Delta Lake的并发控制流程:1)事务开始:读取最新的JSON文件(假设是000005.json),记录版本号5;2)修改数据:写新的Parquet文件;3)尝试提交:写000006.json(包含'添加新文件、删除旧文件'的操作);4)检查冲突:如果写000006.json成功,说明没有其他事务在同时修改(因为文件系统保证'原子写'),提交成功;如果发现'已经有000006.json'了(其他事务先提交了),说明冲突,需要回滚重试。

冲突类型:Delta Lake会检测两种冲突:1)写写冲突(Write-Write Conflict):两个事务同时修改同一分区,后提交的会失败;2)读写冲突(Read-Write Conflict):一个事务读取数据时,另一个事务修改了同一分区,读事务会失败。

数据湖格式并发控制对比

维度Delta LakeApache IcebergApache Hudi
并发控制乐观并发控制乐观并发控制乐观并发控制
元数据存储_delta_log JSON文件Hive Metastore + 元数据文件.hoodie元数据目录
冲突处理自动重试(可配置次数)手动重试自动重试
隔离级别Serializable(可串行化)Serializable(可串行化)Write Serializable(写可串行化)
INSERT OVERWRITE支持(原子性)支持(原子性)支持(原子性)
MERGE INTO支持(原子性)支持(原子性)支持(原子性)
我的选择Databricks生态首选多引擎生态首选流式写入首选

如何避免并发冲突?

方法1:避免多个任务写入同一分区——这是最简单有效的方法。例如:任务A写入dt=2024-03-19/hour=10,任务B写入dt=2024-03-19/hour=11(错开时间);或者任务A写入user_behavior表,任务B写入user_behavior_staging表,然后用MERGE INTO合并。

-- 错误做法:两个任务写入同一分区
-- 任务A:INSERT OVERWRITE user_behavior PARTITION (dt='2024-03-19', hour='10')
-- 任务B:INSERT OVERWRITE user_behavior PARTITION (dt='2024-03-19', hour='10')
 
-- 正确做法1:错开时间
-- 任务A:INSERT OVERWRITE ... PARTITION (dt='2024-03-19', hour='10')
-- 任务B:INSERT OVERWRITE ... PARTITION (dt='2024-03-19', hour='11')
 
-- 正确做法2:用MERGE INTO代替INSERT OVERWRITE
-- 任务B:MERGE INTO user_behavior t
-- USING staging_data s ON t.user_id = s.user_id
-- WHEN MATCHED THEN UPDATE SET ...
-- WHEN NOT MATCHED THEN INSERT *;

方法2:使用动态分区覆盖——Delta Lake的INSERT OVERWRITE可以'dynamically overwrite',即只覆盖'有数据的分区',不影响其他分区。这样可以减少冲突范围。

-- 动态分区覆盖(Delta Lake)
INSERT OVERWRITE TABLE user_behavior
PARTITION (dt, hour)  -- 分区列
SELECT user_id, event_type, event_time, dt, hour  -- 包含分区列
FROM source_data
WHERE dt='2024-03-19' AND hour IN ('10', '11');
-- 只会覆盖dt=2024-03-19/hour=10和hour=11的分区
-- 其他分区(如hour=12)不会被影响

方法3:监控和调优并发参数——监控'冲突重试次数',如果重试次数太多(>5次),说明设计有问题。可以调整spark.sql.delta.commitRetry.maxAttempts(默认10次)和spark.sql.delta.commitRetry.interval(默认100ms)。

面试高频追问

追问一:Delta Lake的ACID是怎么实现的?

Delta Lake通过'事务日志+ 乐观并发控制'实现ACID:1)原子性(Atomicity):每次事务写一个新的JSON文件(如000006.json),包含'添加哪些文件、删除哪些文件'的完整操作,文件系统的'原子写'保证'要么全成功要么全失败';2)一致性(Consistency):事务日志记录了所有操作,保证数据始终处于'一致状态';3)隔离性(Isolation):通过乐观并发控制,并发事务互相隔离;4)持久性(Durability):事务日志和数据文件都持久化到磁盘,不会丢失。

追问二:什么是'Serializable'隔离级别?

'Serializable'是最高级别的隔离级别,保证'并发事务的结果'和'串行执行的结果'完全一致。例如:事务A读取dt=2024-03-19的数据,事务B修改同一分区的数据,如果隔离级别是'Read Committed',事务A可能读到'脏数据'(事务B修改前的数据);如果是'Serializable',事务A会被阻塞或重试,保证不会读到'不一致'的数据。Delta Lake和Iceberg都是'Serializable',Hudi是'Write Serializable'(只保证写的可串行化)。

追问三:并发冲突会自动重试吗?需要手动处理吗?

Delta Lake会自动重试(默认最多10次),如果重试10次还是失败,才会抛出异常。大部分情况下不需要手动处理,但如果'冲突频繁',说明设计有问题(多个任务写入同一分区),需要调整ETL设计。可以通过spark.sql.delta.commitRetry.maxAttempts调整重试次数,通过spark.sql.delta.commitRetry.interval调整重试间隔。

实战踩坑:并发冲突导致ETL失败

2023年在某出海硬件品牌,我们遇到过一次'并发冲突导致ETL失败'的问题。当时我们上线了一个'实时数仓'项目,用Structured Streaming每5分钟写入一次Delta Lake表。运行一段时间后发现:

1)Streaming任务频繁失败,报错'ConcurrentAppendException'

2)查看日志:'事务冲突,重试10次后失败'

3)排查发现:多个Streaming任务同时写入同一张表(user_behavior)的同一分区

// 问题代码:多个Streaming任务同时写入同一表
// Streaming任务A:写入page_view事件
val streamA = spark.readStream.format("delta")...
streamA.writeStream.format("delta")
  .option("checkpointLocation", "/checkpoint/a")
  .partitionBy("dt", "hour")
  .start("user_behavior")  // 同一张表
 
// Streaming任务B:写入click事件
val streamB = spark.readStream.format("delta")...
streamB.writeStream.format("delta")
  .option("checkpointLocation", "/checkpoint/b")
  .partitionBy("dt", "hour")
  .start("user_behavior")  // 同一张表!
 
// 结果:两个任务写入同一分区,频繁冲突

解决方案:我们改用'Union后统一写入'——先启动多个Streaming任务读取不同数据源,然后用'union'合并,最后用一个任务写入Delta Lake。这样只有一个'写入任务',不会冲突。

// 正确做法:Union后统一写入
val streamA = spark.readStream.format("delta").load("source_a")
val streamB = spark.readStream.format("delta").load("source_b")
 
// Union后统一写入
val stream = streamA.union(streamB)
stream.writeStream.format("delta")
  .option("checkpointLocation", "/checkpoint/combined")
  .partitionBy("dt", "hour")
  .start("user_behavior")  // 只有一个写入任务

改进效果:改后Streaming任务再也没有出现过'并发冲突',稳定性大幅提升。

总结

并发写入是数据湖的核心能力,通过'事务日志+ 乐观并发控制'实现ACID。Delta Lake、Iceberg、Hudi都支持并发写入,但实现细节不同。避免并发冲突的核心是:不要让多个任务写入同一分区、用MERGE INTO代替INSERT OVERWRITE、监控冲突重试次数。面试时能说清楚'乐观并发控制的原理、ACID怎么实现、实战踩坑经验',就很稳。

下一篇聊一个很多人纠结的问题:分区分桶选错了,查询很慢?——分区策略和动态分区怎么设计?

你们生产环境遇到过并发冲突吗?怎么解决的?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器