第6章:提交流程与事务保证
如何确保原子提交?
Paimon使用两阶段提交协议确保分布式写入的一致性:
scss
阶段1:Prepare(准备)
├─ 验证数据(检查schema, 类型等)
├─ 生成临时元数据
└─ 返回CommitMessage给用户
阶段2:Commit(提交)
├─ 使用分布式锁
├─ 冲突检测
├─ 原子写入Snapshot
└─ 释放锁
提交的关键步骤
6.1 两阶段提交协议
阶段1:Prepare(数据端)
在Flink中,prepare通常发生在:
- Checkpoint触发时
- 应用关闭时
java
// 写数据
write.write(record1);
write.write(record2);
write.write(record3);
// Prepare:生成元数据,但不提交
CommitMessage msg = write.prepareCommit();
msg包含:
├─ 生成的数据文件列表
├─ 文件的元数据(min_key, max_key, row_count)
├─ 操作类型(ADD/DELETE等)
└─ 其他统计信息
阶段2:Commit(协调端)
Commit通常由协调器(Flink JobManager)调用:
java
// 收集所有并行度的CommitMessages
List<CommitMessage> messages = [msg1, msg2, msg3, ...];
// 提交
commit.commit(checkpointId, messages);
为什么需要两阶段?
yaml
单阶段提交的问题:
Task1: 写入文件 → 立即提交 ✓
Task2: 写入文件 → 立即提交 ✓
Task3: 写入文件 → 提交失败 ✗
结果:表中只有Task1和Task2的数据,数据丢失!
两阶段提交的优势:
Task1: 写入文件 → 等待 (Prepare)
Task2: 写入文件 → 等待 (Prepare)
Task3: 写入文件 → 等待 (Prepare)
协调器: 检查所有Task准备完毕 → 全部Commit
如果Task3失败,Coordinator可以:
- 回滚Task1和Task2
- 重新启动全部Task
结果:要么全部成功,要么全部失败(原子性)
6.2 FileStoreCommit提交实现
提交的步骤
markdown
1. 获取分布式锁
↓
2. 检查冲突
├─ 读取最新Snapshot
├─ 比较新增文件与现有文件
└─ 检测是否有冲突
↓
3. 生成新Manifest
├─ 合并旧Manifest + 新文件
└─ 写入新Manifest文件
↓
4. 生成ManifestList
└─ 更新清单列表
↓
5. 原子写入Snapshot
└─ 写入snapshot-N文件(原子操作)
↓
6. 释放锁
冲突检测的原理
markdown
情况1:无冲突(两个writer在不同bucket)
Writer1: 写入 bucket-0 的文件
Writer2: 写入 bucket-1 的文件
✓ 完全独立,无冲突
情况2:冲突(两个writer在同一bucket)
Writer1: 写入 bucket-0 的文件A
Writer2: 也想写入 bucket-0
检测过程:
- 读当前Snapshot → manifest-5
- 检查bucket-0的现有文件 → [file1, file2]
- Writer1要添加fileA → [file1, file2, fileA] ✓
- Writer2也要添加fileB
- 但fileA和fileB都写了user_id=100的数据
- 冲突!✗ 需要重试或合并
6.3 Manifest文件合并
Manifest的演化
scss
时刻1:初始状态
snapshot-1 → manifest-1 → [file-1, file-2]
时刻2:Writer1提交
新Manifest包含:[file-1, file-2] + [file-3] = manifest-2
snapshot-2 → manifest-2
时刻3:Writer2提交(可能与Writer1并发)
读最新Snapshot-2的manifest-2 → [file-1, file-2, file-3]
新Manifest包含:[file-1, file-2, file-3] + [file-4] = manifest-3
snapshot-3 → manifest-3
避免Manifest爆炸
多次写入会产生很多Manifest文件,需要定期合并:
css
定期任务:CompactManifest
before: manifest-1, manifest-2, manifest-3, ..., manifest-100
(100个文件,查询时需要遍历所有)
after: manifest-compact-1
(1个合并文件,查询快速)
执行命令:
CALL compact_manifests('db', 'table');
6.4 冲突检测与乐观锁
乐观锁的思想
markdown
假设冲突很少发生:
尝试提交:
1. 读最新版本 (不加锁)
2. 准备新数据
3. 提交时加锁
4. 验证版本未改变
5. 写新版本
6. 释放锁
好处:
- 大多数写入不需要等待锁
- 只在冲突时重试
版本号机制
java
Snapshot {
id: 5, // 快照ID
commitIdentifier: 1673088000, // 版本号
baseManifestList: "...",
deltaManifestList: "..."
}
提交流程:
预期版本:currentSnapshotId = 5
↓
写入数据...
↓
检查:当前仍是snapshot-5吗?
✓ 是 → 提交新snapshot-6
✗ 否 → 有其他writer已提交 → 需要重试
总结:事务保证的关键点
1. 原子性(Atomicity)
scss
Snapshot文件的原子写入
↓
要么整个snapshot-N写入成功
要么完全不写(故障回滚)
↓
不可能出现部分更新的状态
2. 一致性(Consistency)
冲突检测
↓
如果发现冲突,自动重试或失败
↓
确保最终结果是一致的
3. 隔离性(Isolation)
sql
分布式锁
↓
Commit阶段串行化
↓
多个writer不会同时修改表
4. 持久性(Durability)
文件系统的可靠性
↓
Snapshot一旦写入,永不丢失
相关代码
- FileStoreCommit:
paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommit.java - Snapshot:
paimon-api/src/main/java/org/apache/paimon/Snapshot.java - CommitMessage:
paimon-api/src/main/java/org/apache/paimon/table/sink/CommitMessage.java