第6章:提交流程与事务保证
导言:从写入到可见的最后一步
在第5章,我们讲解了如何写入数据(WriteBuffer、Flush、生成DataFile)。但是数据何时对读取者可见?如何保证数据不丢失或重复答案就是提交流程与事务保证。
对比:
- 写入流程:将数据从应用放入内存和磁盘
- 提交流程:确保数据对外可见且持久化
write(record) → 数据在内存或临时文件
↓
prepareCommit() → 数据准备好提交
↓
commit() → 数据对外可见! ← 本章的重点
↓
Snapshot生成 → 定点记录
第一部分:提交的基本概念
1.1 CommitMessage
当应用调用 prepareCommit() 时,FileStoreWrite 返回一个 CommitMessage 列表:
public interface CommitMessage {
// 分区和桶信息
BinaryRow partition();
int bucket();
// 新增和删除的文件
DataIncrement newFilesIncrement(); // 新写入的文件
CompactIncrement compactIncrement(); // 压缩产生的文件变化
}
CommitMessage包含什么?
CommitMessage {
partition: {dt="2024-01-01"},
bucket: 0,
newFilesIncrement: {
newFiles: [
DataFile_1.parquet (100MB, rows=100K),
DataFile_2.parquet (95MB, rows=95K)
],
changelogFiles: [...],
deleteFiles: [],
newIndexFiles: [...]
},
compactIncrement: {
compactBefore: [DataFile_old_1.parquet],
compactAfter: [DataFile_compact_1.parquet],
changelogFiles: [...]
}
}
1.2 两阶段提交(Two-Phase Commit)
Paimon采用两阶段提交协议来保证原子性:
╔════════════════════════════════════════════════════════════╗
║ 阶段1:Prepare(准备) ║
│ │
│ 1. FileStoreWrite.prepareCommit() │
│ ├─ 返回CommitMessage列表 │
│ └─ 数据还在临时位置,尚未可见 │
│ │
│ 2. 应用检查是否可以提交 │
│ ├─ 检查数据完整性 │
│ ├─ 检查业务规则 │
│ └─ 决定提交或回滚 │
╚════════════════════════════════════════════════════════════╝
↓
╔════════════════════════════════════════════════════════════╗
║ 阶段2:Commit(提交) ║
│ │
│ 1. FileStoreCommit.commit(messages) │
│ ├─ 原子操作:rename临时文件为最终文件 │
│ ├─ 生成Snapshot │
│ └─ 数据对外可见! │
│ │
│ 2. 回滚(如果需要) │
│ └─ FileStoreWrite.abort(messages) │
│ ├─ 删除所有临时文件 │
│ └─ 数据丢弃 │
╚════════════════════════════════════════════════════════════╝
1.3 提交的原子性
为什么需要原子性?
场景:正在提交时,系统崩溃
不原子的实现:
Step 1: 更新Manifest ✓
Step 2: 删除旧文件 ← 崩溃!
Step 3: 生成Snapshot ✗
结果:
├─ Manifest已更新,但旧文件仍存在
├─ 新旧数据混存,可能读错误
└─ 数据可能重复或丢失
原子的实现:
所有操作在Snapshot生成时才算提交成功
├─ 要么全部成功
├─ 要么全部失败
└─ 无中间状态
第二部分:提交的执行流程
2.1 完整的提交链路
应用程序
↓
List<CommitMessage> msgs = write.prepareCommit(...)
├─ 返回要提交的文件列表
└─ 数据仍在临时位置
↓
FileStoreCommit commit = fileStore.newCommit(...)
↓
int generatedSnapshots = commit.commit(msgs, checkAppendFiles)
├─ Step 1: 冲突检测
├─ Step 2: 读取现有Manifest
├─ Step 3: 合并文件变化
├─ Step 4: 写新Manifest
├─ Step 5: 生成Snapshot(原子点)
└─ Step 6: 清理旧文件
↓
Snapshot生成成功 → 数据对外可见!
2.2 详细的提交步骤
Step 1: 冲突检测
检查是否有并发修改:
前提条件:已知最新的SnapshotId
新的CommitMessage涉及的分区:{dt="2024-01-01", bucket=0}
新的CommitMessage的文件:[File_new_1, File_new_2]
检查:
├─ 其他job是否也在修改 dt="2024-01-01", bucket=0 ?
│ ├─ YES → 冲突!需要合并或失败
│ └─ NO → 继续
└─ 新文件与现有文件是否有key重叠?
├─ YES(写了相同key)→ 可能冲突,需要去重检测
└─ NO → 继续
Step 2: 读取现有Manifest
从最新Snapshot读取该分区/桶的现有文件:
当前Manifest:
├─ File_1.parquet (key 1-1000)
├─ File_2.parquet (key 1001-2000)
└─ File_3.parquet (key 2001-3000)
新文件:
├─ File_new_1.parquet (key 500-1500)
├─ File_new_2.parquet (key 2500-3500)
Step 3: 合并文件变化
合并后的文件列表:
ADD操作(来自CommitMessage):
├─ File_new_1.parquet (key 500-1500)
├─ File_new_2.parquet (key 2500-3500)
DELETE操作(来自CompactIncrement):
├─ File_old_1.parquet
最终文件集合:
├─ File_1.parquet (key 1-1000) ← 保留
├─ File_2.parquet (key 1001-2000) ← 保留
├─ File_new_1.parquet (key 500-1500) ← 新增
├─ File_3.parquet (key 2001-3000) ← 保留
└─ File_new_2.parquet (key 2500-3500) ← 新增
(File_old_1被删除)
Step 4: 写新Manifest
生成新的Manifest文件:
Old Manifest (manifest-v1-0):
├─ File_1.parquet
├─ File_2.parquet
└─ File_3.parquet
New Manifest (manifest-v2-0):
├─ File_1.parquet
├─ File_2.parquet
├─ File_3.parquet
├─ File_new_1.parquet
└─ File_new_2.parquet
特点:
├─ 新Manifest是原子写入的
├─ 旧Manifest仍然保存(用于恢复)
└─ 应用还看不到新数据(因为没有Snapshot)
Step 5: 生成Snapshot(原子点)
原子操作:创建Snapshot文件
Snapshot-v2 {
snapshotId: 2,
baseManifestList: "manifest-list-v2-0",
deltaManifestList: "manifest-list-v2-1",
schemaId: 1,
commitKind: "APPEND",
commitTime: 1704067200000,
commitUser: "job-001",
recordCount: 5000000,
...
}
← 这一刻,数据对外可见!
所有读取操作现在可以看到:
├─ File_new_1.parquet
├─ File_new_2.parquet
└─ 其他现有文件
Step 6: 清理旧文件
提交成功后,清理无用文件:
删除:
├─ 旧的Manifest版本(如manifest-v1-0)
├─ 被Compact删除的文件(如File_old_1.parquet)
└─ 临时文件(如准备失败的中间文件)
保留:
├─ 最新Manifest和Snapshot
├─ 前N个Snapshot(用于Rollback)
└─ 被引用的所有DataFile
第三部分:冲突检测与处理
3.1 并发冲突场景
场景1:两个job同时修改同一分区
Job A:
├─ 修改 dt="2024-01-01", bucket=0
├─ 新增 File_A.parquet (key 1-500)
└─ Snapshot: 10
Job B(同时进行):
├─ 修改 dt="2024-01-01", bucket=0
├─ 新增 File_B.parquet (key 501-1000)
└─ Snapshot: 11
问题:
Job A在Snapshot 9的基础上做的修改
Job B也在Snapshot 9的基础上做的修改
现在两个都要提交 → 冲突!
3.2 ConflictDetection(冲突检测器)
public class ConflictDetection {
public void checkNoConflictsOrFail(
Snapshot latestSnapshot,
List<ManifestEntry> baseEntries, // Job A读到的最新文件
List<ManifestEntry> newEntries, // Job A新增的文件
CommitKind commitKind) throws Exception {
// Step 1: 获取最新Snapshot之后的修改
List<ManifestEntry> concurrentChanges =
getConcurrentChanges(latestSnapshot);
// Step 2: 检查是否有冲突
for (ManifestEntry newEntry : newEntries) {
for (ManifestEntry concurrentEntry : concurrentChanges) {
// 相同的分区/桶?
if (newEntry.partition().equals(concurrentEntry.partition()) &&
newEntry.bucket() == concurrentEntry.bucket()) {
// 有重叠的key范围?
if (hasKeyOverlap(newEntry, concurrentEntry)) {
throw new CommitConflictException(
"Concurrent modification detected");
}
}
}
}
}
}
3.3 冲突解决策略
策略1:重试(推荐)
Job A提交失败 → 冲突检测
↓
Job A回滚:
├─ 删除临时文件(File_A.parquet)
├─ 恢复写入状态
└─ 重新读取最新Snapshot(包含Job B的修改)
↓
Job A重新写入:
├─ 读取最新数据
├─ 合并Job B的修改
├─ 重新生成File_A.parquet
└─ 重新提交
↓
成功!
策略2:覆盖(谨慎使用)
配置:conflict-detection-mode = NONE
↓
跳过冲突检测,直接覆盖
↓
风险:
├─ 可能丢失并发修改
└─ 仅在单writer场景安全
第四部分:Manifest合并
4.1 什么是Manifest合并
随着时间推移,会生成多个Manifest版本:
提交历史:
Snapshot 1 → manifest-v1-0
Snapshot 2 → manifest-v2-0
Snapshot 3 → manifest-v3-0
Snapshot 4 → manifest-v4-0
...
Snapshot 100 → manifest-v100-0
问题:
├─ 100个Manifest文件堆积
├─ 读取最新数据需要逐个扫描
└─ Manifest本身的大小不断增长
4.2 Manifest合并的目标
合并前:
├─ manifest-v98-0: 新增2个文件,删除1个文件
├─ manifest-v99-0: 新增3个文件
└─ manifest-v100-0: 新增1个文件
总计:修改了6个文件,但分散在3个Manifest中
合并后:
└─ manifest-v100-merged-0:
├─ 最终新增的5个文件(合并重复)
└─ 最终删除的1个文件
优点:
├─ 文件数减少
├─ 读取性能提升(无需逐个扫描)
└─ 磁盘占用减少
4.3 什么时候触发合并
# 配置参数
manifest-target-size: 8MB # 单个Manifest目标大小
manifest-merge-min-count: 30 # 最少积累30个Manifest才合并
manifest-full-compaction-size: 512MB # 总大小超过512MB时全压缩
触发条件:
├─ 1. Manifest文件数 > 30个
├─ 2. Manifest总大小 > 512MB
└─ 3. 定期检查(每1小时)
4.4 Manifest合并的实现
public class ManifestFileMerger {
public static List<ManifestFileMeta> merge(
List<ManifestFileMeta> manifests,
ManifestFile manifestFile,
long targetSize,
int minMergeCount) {
// 只有积累足够数量的Manifest才合并
if (manifests.size() < minMergeCount) {
return manifests;
}
// Step 1: 读取所有Manifest中的条目
List<ManifestEntry> allEntries = new ArrayList<>();
for (ManifestFileMeta manifest : manifests) {
List<ManifestEntry> entries =
manifestFile.read(manifest.fileName());
allEntries.addAll(entries);
}
// Step 2: 按文件分组(同一文件的操作要合并)
Map<DataFileMeta, List<ManifestEntry>> byFile =
groupByFile(allEntries);
// Step 3: 去重
// 同一文件可能被多次ADD/DELETE,只保留最终状态
List<ManifestEntry> deduplicated = deduplicateByFile(byFile);
// Step 4: 生成新的合并Manifest
return generateMergedManifest(deduplicated, targetSize);
}
}
第五部分:生产级提交案例
5.1 案例1:高并发提交(Flink Streaming)
场景:
- Checkpoint间隔:1分钟
- 并发度:8
- 每个Checkpoint生成:~200个CommitMessage
提交流程:
Checkpoint 1(1:00)
├─ 8个Task分别生成CommitMessage
├─ 合并成1个大的提交:200条消息
└─ FileStoreCommit.commit() → Snapshot 1生成
├─ 冲突检测:OK(单writer)
├─ Manifest合并:无需(第一次)
└─ 耗时:500ms
Checkpoint 2(1:01)
├─ 生成200个CommitMessage
├─ 合并成1个大的提交
└─ FileStoreCommit.commit() → Snapshot 2生成
├─ 冲突检测:OK
├─ Manifest合并:无需(只有2个Manifest)
└─ 耗时:500ms
...
Checkpoint 60(1:59)
├─ 生成200个CommitMessage
├─ 合并成1个大的提交
└─ FileStoreCommit.commit() → Snapshot 60生成
├─ 冲突检测:OK
├─ Manifest合并:触发!(60个Manifest > 30)
│ ├─ 合并前:60个Manifest文件,总大小240MB
│ ├─ 合并后:8个Manifest文件,总大小240MB
│ └─ 耗时:2秒(额外延迟)
└─ 总耗时:2.5秒
性能指标:
| 指标 | 实际值 |
|---|---|
| 平均提交延迟 | 500ms |
| 提交吞吐 | 200 msg/s |
| Manifest合并频率 | 每60分钟一次 |
| 合并时提交延迟 | 2-3秒 |
5.2 案例2:批处理提交(Spark Batch)
场景:
- 批处理任务,1小时跑一次
- 生成数据:50GB
- 产生的CommitMessage:~10000条
提交流程:
Batch Job启动 → 写入50GB数据
Step 1: prepareCommit()
├─ FileStoreWrite汇总所有writer的输出
├─ 生成10000条CommitMessage
└─ 耗时:10秒(检查临时文件)
Step 2: commit()
├─ 冲突检测:OK(batch job有锁)
├─ 读取现有Manifest:2GB(该分区/桶有100个旧Snapshot)
├─ 合并文件变化:
│ ├─ 新增:~5000个DataFile
│ ├─ 删除:~1000个旧文件(压缩删除)
│ └─ 耗时:30秒
├─ 写新Manifest:
│ ├─ Manifest大小:~50MB
│ └─ 耗时:5秒
├─ 生成Snapshot:
│ ├─ 原子操作
│ └─ 耗时:1秒
└─ 清理旧文件:
├─ 删除过期Manifest和Snapshot
└─ 耗时:5秒
总提交耗时:41秒
5.3 案例3:多地域跨区域写入
场景:
- 多个数据中心同时写入同一表
- 潜在冲突较高
处理:
DataCenter A 提交 Snapshot 100
├─ 修改分区:dt=2024-01-01
└─ 文件:File_A.parquet
DataCenter B 同时提交 Snapshot 101
├─ 修改分区:dt=2024-01-01
└─ 文件:File_B.parquet
冲突检测发现重叠!
↓
DataCenter B重试:
├─ 读取Snapshot 100(包含DataCenter A的修改)
├─ 验证是否真的冲突
│ ├─ 如果File_A和File_B的key不重叠 → 合并
│ └─ 如果key重叠 → 真的冲突,需要解决
└─ 重新提交
第六部分:事务保证
6.1 ACID特性
Paimon提供以下事务保证:
A (Atomicity - 原子性):
├─ 提交要么全部成功,要么全部失败
├─ 无中间状态
└─ 通过Snapshot原子创建实现
C (Consistency - 一致性):
├─ 每个Snapshot代表一个一致的数据版本
├─ 同一Snapshot内的所有数据一致
└─ 通过Manifest完整性检查实现
I (Isolation - 隔离性):
├─ 不同Snapshot间数据隔离
├─ 读取时选择某个Snapshot,不受后续修改影响
└─ 通过Snapshot版本管理实现
D (Durability - 持久性):
├─ 提交成功的数据永不丢失
├─ 即使系统崩溃也能恢复
└─ 通过多个副本和WAL实现
6.2 故障恢复
场景:提交期间系统崩溃
提交前:
├─ Snapshot 10已存在
└─ 新数据在临时文件位置
提交中 → 崩溃!
恢复步骤:
├─ Step 1: 检测未完成的提交
│ └─ 扫描临时文件目录
├─ Step 2: 清理未完成的提交
│ ├─ 删除临时Manifest
│ ├─ 删除临时Snapshot
│ └─ 保留DataFile(可能被后续重用)
├─ Step 3: 恢复到最后一个完整Snapshot(10)
│ └─ 应用看不到未提交的数据
└─ Step 4: 重启writer,重新开始写入
重要特性:无数据丢失
虽然本次提交失败,但DataFile仍然保留,可以在下次提交时重用。
第七部分:提交参数调优
7.1 提交相关配置
| 参数 | 默认值 | 说明 |
|---|---|---|
commit-timeout | 60s | 单次提交超时 |
commit-max-retries | 10 | 最大重试次数 |
conflict-detection-mode | value | 冲突检测模式:none/value/row_incremental |
manifest-target-size | 8MB | 单个Manifest目标大小 |
manifest-merge-min-count | 30 | Manifest合并触发数 |
7.2 提交延迟优化
# 快速提交(延迟优先)
commit-timeout: 10s # 快速失败
commit-max-retries: 3 # 少重试
conflict-detection-mode: none # 无检测
manifest-merge-min-count: 100 # 宽松合并
# 可靠提交(可用性优先)
commit-timeout: 120s # 给足时间
commit-max-retries: 20 # 充分重试
conflict-detection-mode: value # 完整检测
manifest-merge-min-count: 30 # 及时合并
第八部分:常见问题
Q1: 提交超时怎么办?
现象:commit()抛出TimeoutException
原因:
├─ Manifest太多,合并慢
├─ 冲突检测发现问题需要解决
└─ 磁盘IO堵塞
解决:
1. 增加commit-timeout
2. 立即运行Manifest全量压缩
3. 检查磁盘IO是否正常
Q2: 为什么同一数据被读到两次?
现象:count(*)返回两倍的行数
原因:
├─ 重试时,前一次的数据未被清理
├─ 两个Snapshot都引用了同一文件
解决:
1. 检查是否启用了冲突检测
2. 验证Snapshot的一致性
3. 运行文件清理工具
Q3: 并发提交冲突频繁
现象:多个job同时提交时,经常失败
原因:
├─ 都在修改同一分区/桶
├─ 冲突检测太严格
解决方案:
1. 改进分区策略,让不同job写不同分区
2. 放宽冲突检测:conflict-detection-mode=none
3. 减少提交频率,聚合多个Checkpoint再提交
总结
提交流程的关键点
prepareCommit()
↓ 返回待提交的数据清单
commit()
├─ 冲突检测:确保并发安全
├─ Manifest合并:优化元数据
├─ Snapshot生成:原子提交
└─ 文件清理:回收旧数据
↓ 数据对外可见
ACID保证总结
| 特性 | 实现方式 |
|---|---|
| 原子性 | Snapshot原子创建 |
| 一致性 | Manifest完整性检查 |
| 隔离性 | Snapshot版本隔离 |
| 持久性 | 多副本+WAL |
调优checklist
- 根据业务选择冲突检测模式
- 设置合理的提交超时
- 配置Manifest合并参数
- 定期执行Manifest全量压缩
- 监控提交延迟
- 处理并发冲突
下一章:第7章将讲解读取流程全解析,包括FileStoreScan、Split分发、SplitRead、谓词下推等