流式数据湖Paimon探秘之旅 (六) 提交流程与事务保证

88 阅读12分钟

第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 11:00)
    ├─ 8个Task分别生成CommitMessage
    ├─ 合并成1个大的提交:200条消息
    └─ FileStoreCommit.commit() → Snapshot 1生成
        ├─ 冲突检测:OK(单writer)
        ├─ Manifest合并:无需(第一次)
        └─ 耗时:500ms

Checkpoint 21:01)
    ├─ 生成200个CommitMessage
    ├─ 合并成1个大的提交
    └─ FileStoreCommit.commit() → Snapshot 2生成
        ├─ 冲突检测:OK
        ├─ Manifest合并:无需(只有2个Manifest)
        └─ 耗时:500ms

...

Checkpoint 601: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-timeout60s单次提交超时
commit-max-retries10最大重试次数
conflict-detection-modevalue冲突检测模式:none/value/row_incremental
manifest-target-size8MB单个Manifest目标大小
manifest-merge-min-count30Manifest合并触发数

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、谓词下推等