Paimon 表 Snapshot 过期流程深度解析
一、文档概述
本文档详细分析 Apache Paimon 的 Snapshot 过期机制,包括:
- Snapshot 过期的触发时机和条件判断
- 过期 Snapshot 的扫描和筛选逻辑
- 数据文件、元数据文件的删除流程
- Tag 保护机制和消费者保护机制
- 关键配置项和调优建议
二、核心架构
2.1 主要组件
核心类:
ExpireSnapshotsImpl- 过期实现主类,负责协调整个过期流程SnapshotDeletion- 快照删除执行器,负责实际的文件删除操作FileDeletionBase- 文件删除基类,提供通用的文件删除方法SnapshotManager- 快照管理器,管理 Snapshot 的读取和删除TagManager- Tag 管理器,管理 Tag 的创建和查询ConsumerManager- 消费者管理器,管理消费者进度并提供保护机制
2.2 过期流程概览
graph TB
Start[触发过期] --> Check[检查过期条件]
Check --> Calc[计算过期范围]
Calc --> Protect[应用保护机制]
Protect --> DelData[删除数据文件]
DelData --> DelChangelog[删除Changelog文件]
DelChangelog --> DelManifest[删除Manifest文件]
DelManifest --> DelSnapshot[删除Snapshot文件]
DelSnapshot --> UpdateHint[更新Earliest Hint]
UpdateHint --> End[完成]
三、过期触发机制
3.1 自动触发
Paimon 在每次 Commit 后会自动触发 Snapshot 过期。核心入口在 TableCommitImpl 类中:
源码位置: paimon-core/src/main/java/org/apache/paimon/table/sink/TableCommitImpl.java
// 在 commitMultiple 方法中,每次提交后都会调用 maintain
public void commitMultiple(List<ManifestCommittable> committables, boolean checkAppendFiles) {
if (overwritePartition == null) {
int newSnapshots = 0;
for (ManifestCommittable committable : committables) {
newSnapshots += commit.commit(committable, checkAppendFiles);
}
if (!committables.isEmpty()) {
maintain(
committables.get(committables.size() - 1).identifier(),
maintainExecutor,
newSnapshots > 0 || expireForEmptyCommit); // 触发维护操作
}
}
// ...
}
// maintain 方法负责执行过期逻辑
private void maintain(long identifier, boolean doExpire) {
// 1. 首先过期消费者,避免阻止 Snapshot 过期
if (doExpire && consumerExpireTime != null) {
consumerManager.expire(LocalDateTime.now().minus(consumerExpireTime));
}
// 2. 执行 Snapshot 过期
if (doExpire && expireSnapshots != null) {
expireSnapshots.run(); // 调用 ExpireSnapshotsImpl.expire()
}
// 3. 执行分区过期
if (doExpire && partitionExpire != null) {
partitionExpire.expire(identifier);
}
// 4. 执行 Tag 相关操作
if (tagAutoManager != null) {
// Tag 自动创建和过期
}
}
关键点:
-
执行模式:通过
snapshot.expire.execution-mode配置控制sync(默认):同步执行,在 Commit 线程中直接执行async:异步执行,在独立线程池中执行
-
执行顺序:先过期消费者 → Snapshot 过期 → 分区过期 → Tag 操作
-
触发条件:
newSnapshots > 0 || expireForEmptyCommit- 有新 Snapshot 生成时触发
- 或配置允许空提交时也触发
3.2 手动触发
3.2.1 Flink SQL 方式
-- Flink 1.18
CALL sys.expire_snapshots('database_name.table_name', 2)
-- Flink 1.19+
CALL sys.expire_snapshots(`table` => 'database_name.table_name', retain_max => 2)
CALL sys.expire_snapshots(`table` => 'database_name.table_name', older_than => '2024-01-01 12:00:00')
CALL sys.expire_snapshots(`table` => 'database_name.table_name', older_than => '2024-01-01 12:00:00', retain_min => 10)
3.2.2 Flink Action 方式
<FLINK_HOME>/bin/flink run \
/path/to/paimon-flink-action.jar \
expire_snapshots \
--warehouse <warehouse-path> \
--database <database-name> \
--table <table-name> \
--retain_max 5 \
--retain_min 10 \
--older_than '2024-01-01 12:00:00' \
--max_deletes 10
四、过期条件检查与范围计算
4.1 保留策略参数
ExpireSnapshotsImpl.expire() 方法中使用四个核心参数控制过期行为:
| 配置项 | 默认值 | 说明 |
|---|---|---|
snapshot.num-retained.min | 10 | 最少保留快照数(硬性保护) |
snapshot.num-retained.max | Integer.MAX_VALUE | 最多保留快照数(触发过期) |
snapshot.time-retained | 1 h | 时间保留窗口 |
snapshot.expire.limit | 10 | 单次最多过期数量 |
4.2 过期范围计算逻辑
源码位置: paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java
@Override
public int expire() {
int retainMax = expireConfig.getSnapshotRetainMax();
int retainMin = expireConfig.getSnapshotRetainMin();
int maxDeletes = expireConfig.getSnapshotMaxDeletes();
long olderThanMills = System.currentTimeMillis() - expireConfig.getSnapshotTimeRetain().toMillis();
Long latestSnapshotId = snapshotManager.latestSnapshotId();
Long earliest = snapshotManager.earliestSnapshotId();
// 步骤 1: 基于 retainMax 计算最小保留 Snapshot ID
// 从最新快照往前数 retainMax 个,超出的可以过期
long min = Math.max(latestSnapshotId - retainMax + 1, earliest);
// 步骤 2: 基于 retainMin 计算最大可过期 Snapshot ID(不包含)
// 必须保留最新的 retainMin 个快照
long maxExclusive = latestSnapshotId - retainMin + 1;
// 步骤 3: 应用消费者保护机制
// 正在被消费的快照不能删除
maxExclusive = Math.min(maxExclusive, consumerManager.minNextSnapshot().orElse(Long.MAX_VALUE));
// 步骤 4: 应用过期数量限制
// 单次最多过期 maxDeletes 个快照
maxExclusive = Math.min(maxExclusive, earliest + maxDeletes);
// 步骤 5: 时间窗口过滤
// 遍历 [min, maxExclusive) 范围,检查时间条件
for (long id = min; id < maxExclusive; id++) {
if (snapshotManager.snapshotExists(id)
&& olderThanMills <= snapshotManager.snapshot(id).timeMillis()) {
// 遇到不满足时间条件的快照,提前退出
return expireUntil(earliest, id);
}
}
return expireUntil(earliest, maxExclusive);
}
计算示例:
假设当前有 Snapshot 1-100,配置如下:
snapshot.num-retained.min= 10snapshot.num-retained.max= 50snapshot.expire.limit= 5- 当前时间 = T,
snapshot.time-retained= 1h
计算过程:
min= max(100 - 50 + 1, 1) = 51(从 51 开始可以考虑过期)maxExclusive= 100 - 10 + 1 = 91(必须保留 90-100)- 假设消费者正在读取 Snapshot 60,则
maxExclusive= min(91, 60) = 60 - 应用限制:
maxExclusive= min(60, 1 + 5) = 6 - 最终过期范围:[1, 6),即删除 Snapshot 1-5
4.3 消费者保护机制
核心方法: ConsumerManager.minNextSnapshot()
源码位置: paimon-core/src/main/java/org/apache/paimon/consumer/ConsumerManager.java
public OptionalLong minNextSnapshot() {
try {
// 读取所有消费者文件
return listOriginalVersionedFiles(fileIO, consumerDirectory(), CONSUMER_PREFIX)
.map(this::consumer)
.filter(Optional::isPresent)
.map(Optional::get)
.mapToLong(Consumer::nextSnapshot) // 获取每个消费者的 nextSnapshot
.reduce(Math::min); // 取最小值
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
工作原理:
- 消费者在读取 Snapshot 时,会在
consumer/consumer-{id}目录下记录nextSnapshot minNextSnapshot()返回所有消费者中最小的nextSnapshot值- 小于该值的 Snapshot 不能被删除,因为可能正在被消费
- 消费者文件本身也会过期(通过
consumer.expire-time配置)
消费者过期:
public void expire(LocalDateTime expireDateTime) {
listVersionedFileStatus(fileIO, consumerDirectory(), CONSUMER_PREFIX)
.forEach(status -> {
LocalDateTime modificationTime = DateTimeUtils.toLocalDateTime(status.getModificationTime());
if (expireDateTime.isAfter(modificationTime)) {
fileIO.deleteQuietly(status.getPath()); // 删除过期的消费者文件
}
});
}
五、文件扫描与筛选机制
5.1 数据文件扫描
核心方法: FileDeletionBase.cleanUnusedDataFiles()
源码位置: paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
public void cleanUnusedDataFiles(String manifestList, Predicate<ExpireFileEntry> skipper) {
// 1. 读取 Manifest List
List<ManifestFileMeta> manifests = tryReadManifestList(manifestList);
// 2. 数据文件路径 -> (原始 manifest entry, 额外文件路径)
Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete = new HashMap<>();
// 3. 遍历每个 Manifest 文件
for (ManifestFileMeta manifest : manifests) {
List<ExpireFileEntry> manifestEntries =
manifestFile.readExpireFileEntries(manifest.fileName(), manifest.fileSize());
// 4. 构建待删除文件列表
getDataFileToDelete(dataFileToDelete, manifestEntries);
}
// 5. 执行删除(应用 Skipper)
doCleanUnusedDataFile(dataFileToDelete, skipper);
}
关键逻辑 - getDataFileToDelete():
protected void getDataFileToDelete(
Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete,
List<ExpireFileEntry> dataFileEntries) {
DataFilePathFactories factories = new DataFilePathFactories(pathFactory);
for (ExpireFileEntry entry : dataFileEntries) {
DataFilePathFactory dataFilePathFactory = factories.get(entry.partition(), entry.bucket());
Path dataFilePath = dataFilePathFactory.toPath(entry);
switch (entry.kind()) {
case ADD:
// ADD 类型:从待删除列表中移除(表示文件仍在使用)
dataFileToDelete.remove(dataFilePath);
break;
case DELETE:
// DELETE 类型:加入待删除列表
List<Path> extraFiles = new ArrayList<>(entry.extraFiles().size());
for (String file : entry.extraFiles()) {
extraFiles.add(dataFilePathFactory.toAlignedPath(file, entry));
}
dataFileToDelete.put(dataFilePath, Pair.of(entry, extraFiles));
break;
}
}
}
重要概念:
- ADD Entry: 表示文件被添加到 Snapshot,文件仍在使用中
- DELETE Entry: 表示文件从 Snapshot 中删除,可以物理删除
- 通过 ADD/DELETE 的配对,确定哪些文件真正不再使用
5.2 Tag 保护机制
核心方法: FileDeletionBase.createDataFileSkipperForTags()
源码位置: paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
public Predicate<ExpireFileEntry> createDataFileSkipperForTags(
List<Snapshot> taggedSnapshots, long expiringSnapshotId) throws Exception {
// 1. 找到小于等于 expiringSnapshotId 的最近的 Tagged Snapshot
int index = SnapshotManager.findPreviousSnapshot(taggedSnapshots, expiringSnapshotId);
if (index >= 0) {
Snapshot previousTag = taggedSnapshots.get(index);
// 2. 缓存机制:避免重复读取相同 Tag 的数据文件
if (previousTag.id() != cachedTag) {
cachedTag = 0;
cachedTagDataFiles.clear();
addMergedDataFiles(cachedTagDataFiles, previousTag); // 读取 Tag 引用的所有数据文件
cachedTag = previousTag.id();
}
// 3. 返回 Skipper:如果文件被 Tag 引用,则跳过删除
return entry -> containsDataFile(cachedTagDataFiles, entry);
}
return entry -> false; // 没有 Tag 保护
}
Tag 数据文件读取:
protected void addMergedDataFiles(
Map<BinaryRow, Map<Integer, Set<String>>> dataFiles, Snapshot snapshot) throws IOException {
// 读取 Snapshot 的所有数据文件(合并 ADD/DELETE)
for (ExpireFileEntry entry : readMergedDataFiles(manifestList.readDataManifests(snapshot))) {
dataFiles
.computeIfAbsent(entry.partition(), p -> new HashMap<>())
.computeIfAbsent(entry.bucket(), b -> new HashSet<>())
.add(entry.fileName()); // 按 partition -> bucket -> fileName 组织
}
}
protected Collection<ExpireFileEntry> readMergedDataFiles(List<ManifestFileMeta> manifests) throws IOException {
Map<Identifier, ExpireFileEntry> map = new HashMap<>();
for (ManifestFileMeta manifest : manifests) {
List<ExpireFileEntry> entries = manifestFile.readExpireFileEntries(...);
FileEntry.mergeEntries(entries, map); // 合并 ADD/DELETE,得到最终状态
}
return map.values();
}
保护逻辑:
- 获取所有 Tagged Snapshots(按 Snapshot ID 排序)
- 对于要过期的 Snapshot ID,找到小于等于它的最近的 Tag
- 读取该 Tag 引用的所有数据文件
- 在删除时,跳过被 Tag 引用的文件
示例:
- Snapshot 1-100,Tag 在 Snapshot 50 和 80
- 要过期 Snapshot 1-60
- 对于 Snapshot 51-60,会使用 Tag 50 的数据文件作为保护集
- Tag 50 引用的文件不会被删除
5.3 Manifest 文件扫描
核心方法: FileDeletionBase.manifestSkippingSet()
public Set<String> manifestSkippingSet(List<Snapshot> skippingSnapshots) {
Set<String> skippingSet = new HashSet<>();
for (Snapshot skippingSnapshot : skippingSnapshots) {
// 1. 保留 Manifest List 文件
skippingSet.add(skippingSnapshot.baseManifestList());
skippingSet.add(skippingSnapshot.deltaManifestList());
// 2. 保留 Manifest 文件
manifestList.readDataManifests(skippingSnapshot).stream()
.map(ManifestFileMeta::fileName)
.forEach(skippingSet::add);
// 3. 保留 Index Manifest 和 Index 文件
String indexManifest = skippingSnapshot.indexManifest();
if (indexManifest != null) {
skippingSet.add(indexManifest);
indexFileHandler.readManifest(indexManifest).stream()
.map(IndexManifestEntry::indexFile)
.map(IndexFileMeta::fileName)
.forEach(skippingSet::add);
}
// 4. 保留 Statistics 文件
if (skippingSnapshot.statistics() != null) {
skippingSet.add(skippingSnapshot.statistics());
}
}
return skippingSet;
}
工作原理:
- 输入:需要保留的 Snapshots(通常是 endExclusiveId 和 Tag Snapshots)
- 输出:这些 Snapshots 引用的所有 Manifest 文件名集合
- 删除时:不在该集合中的 Manifest 文件可以安全删除
5.4 数据文件删除机制详解(重要)
5.4.1 核心原则:文件是整体删除的
关键概念:
-
数据文件是不可变的(Immutable)
- 一旦写入,文件内容永不修改
- 删除时是删除整个物理文件,而不是文件中的某些记录
-
文件状态 vs 记录类型
需要区分两个不同的概念:
概念A:文件内部的记录类型(RowKind)
// 主键表的数据文件中可能包含多种记录类型
+I(1, "Alice") // INSERT:插入记录
-U(1, "Alice") // UPDATE_BEFORE:更新前的记录
+U(1, "Bob") // UPDATE_AFTER:更新后的记录
-D(2, "Charlie") // DELETE:删除记录
概念B:文件在 Snapshot 中的状态(Manifest Entry)
// 在 Manifest 中,文件的状态只有两种
FileKind.ADD // 文件被添加到 Snapshot(文件在使用中)
FileKind.DELETE // 文件从 Snapshot 中移除(文件不再使用)
重要:文件删除是基于 概念B(Manifest Entry 状态) ,而不是概念A(文件内部的记录类型)。
5.4.2 完整的文件生命周期示例
让我们通过一个完整的例子来理解:
时间线:
T1 - Snapshot 1 创建:
写入操作:INSERT INTO table VALUES (1, 'Alice'), (2, 'Bob')
生成文件:file_1.parquet (包含: +I(1, 'Alice'), +I(2, 'Bob'))
Manifest Entry: ADD file_1.parquet
T2 - Snapshot 2 创建:
写入操作:INSERT INTO table VALUES (3, 'Charlie')
生成文件:file_2.parquet (包含: +I(3, 'Charlie'))
Manifest Entry: ADD file_2.parquet
T3 - Snapshot 3 创建:
Compaction 触发,合并 file_1 和 file_2
生成文件:file_3.parquet (包含: +I(1, 'Alice'), +I(2, 'Bob'), +I(3, 'Charlie'))
Manifest Entry:
* ADD file_3.parquet ← 新文件
* DELETE file_1.parquet ← 标记为删除
* DELETE file_2.parquet ← 标记为删除
此时物理存储:file_1.parquet, file_2.parquet, file_3.parquet 都还存在
T4 - Snapshot 4 创建:
写入操作:UPDATE table SET name='David' WHERE id=1
生成文件:file_4.parquet (包含: -U(1, 'Alice'), +U(1, 'David'))
Manifest Entry: ADD file_4.parquet
此时 Snapshot 4 引用的文件:file_3.parquet, file_4.parquet
T5 - Snapshot 1 和 2 过期:
执行过期流程:
1. 读取 Snapshot 3 的 deltaManifest(因为范围是 beginInclusiveId+1)
2. 发现 DELETE entries:file_1.parquet, file_2.parquet
3. 检查这些文件是否被 Tag 引用(假设没有)
4. 物理删除:file_1.parquet, file_2.parquet
5. file_3.parquet 保留(它是 ADD 状态,仍在使用)
结果:物理存储中只剩 file_3.parquet, file_4.parquet
5.4.3 关键代码逻辑
源码位置: paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
protected void getDataFileToDelete(
Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete,
List<ExpireFileEntry> dataFileEntries) {
DataFilePathFactories factories = new DataFilePathFactories(pathFactory);
// 遍历 Manifest 中的所有 Entry
for (ExpireFileEntry entry : dataFileEntries) {
DataFilePathFactory dataFilePathFactory = factories.get(entry.partition(), entry.bucket());
Path dataFilePath = dataFilePathFactory.toPath(entry);
switch (entry.kind()) {
case ADD:
// ADD 类型:文件正在使用,从待删除列表中移除
// 这意味着即使之前某个 Snapshot 标记为 DELETE,
// 但后续 Snapshot 又 ADD 了(例如回滚场景),则不能删除
dataFileToDelete.remove(dataFilePath);
break;
case DELETE:
// DELETE 类型:文件不再使用,加入待删除列表
// 包括主文件和额外文件(如 Deletion Vector)
List<Path> extraFiles = new ArrayList<>(entry.extraFiles().size());
for (String file : entry.extraFiles()) {
extraFiles.add(dataFilePathFactory.toAlignedPath(file, entry));
}
dataFileToDelete.put(dataFilePath, Pair.of(entry, extraFiles));
break;
}
}
}
关键逻辑:
- 遍历 Manifest Entries 时,维护一个
dataFileToDeleteMap - 遇到
ADDentry:从 Map 中移除该文件(表示文件仍在使用) - 遇到
DELETEentry:将文件加入 Map(表示文件可以删除) - 最终 Map 中剩余的文件就是真正可以删除的
5.4.4 特殊场景:主键表的更新和删除
场景 1:主键表有更新操作
// 用户执行:UPDATE table SET name='Bob' WHERE id=1
//
// 这会生成一个新的数据文件,包含:
// -U(1, 'Alice') ← UPDATE_BEFORE
// +U(1, 'Bob') ← UPDATE_AFTER
//
// Manifest Entry: ADD file_new.parquet
//
// 注意:这是一个新文件,它的 Manifest Entry 是 ADD
// 文件内部包含 -U 和 +U 记录,但文件作为整体是 ADD 状态
// 这个文件不会在当前 Snapshot 过期时被删除
场景 2:Compaction 合并多个文件
// 输入文件:
// file_1.parquet: +I(1, 'Alice')
// file_2.parquet: -U(1, 'Alice'), +U(1, 'Bob')
//
// Compaction 后生成:
// file_3.parquet: +I(1, 'Bob') ← 合并后的最终状态
//
// Manifest Entries:
// ADD file_3.parquet
// DELETE file_1.parquet ← 可以删除
// DELETE file_2.parquet ← 可以删除
//
// 物理删除时机:
// 当引用 file_1 和 file_2 的 Snapshot 过期后
// 这两个文件会被整体删除
场景 3:数据文件包含删除标记(Deletion Vector)
// 对于启用 Deletion Vector 的表:
//
// 原始文件:file_1.parquet (包含 1000 条记录)
// 删除操作:DELETE FROM table WHERE id IN (1, 2, 3)
//
// 不会重写整个文件,而是生成:
// file_1.parquet (原文件保持不变)
// file_1.deletion (Deletion Vector,标记删除的行号)
//
// Manifest Entry:
// ADD file_1.parquet (extraFiles: ["file_1.deletion"])
//
// 删除时:
// 当 file_1.parquet 被标记为 DELETE 时
// 会同时删除 file_1.parquet 和 file_1.deletion
源码确认:
case DELETE:
List<Path> extraFiles = new ArrayList<>(entry.extraFiles().size());
for (String file : entry.extraFiles()) {
extraFiles.add(dataFilePathFactory.toAlignedPath(file, entry));
}
dataFileToDelete.put(dataFilePath, Pair.of(entry, extraFiles));
// ↑ extraFiles 包括 Deletion Vector 等附加文件
break;
5.4.5 为什么不能部分删除文件?
原因 1:文件格式限制
- Parquet/ORC 等列式存储格式不支持随机删除记录
- 要删除记录,只能重写整个文件
原因 2:性能考虑
- 重写文件成本高(需要读取、过滤、重新写入)
- Compaction 已经负责合并和清理数据
- Snapshot 过期只需要删除不再使用的文件
原因 3:一致性保证
- 文件不可变保证了快照的一致性
- 多个 Snapshot 可以安全地共享同一个文件
- 删除操作不会影响其他 Snapshot
5.4.6 总结
| 问题 | 答案 |
|---|---|
| Snapshot 过期时删除整个文件吗? | 是的,删除整个物理文件 |
| 如何判断文件是否可以删除? | 基于 Manifest Entry 的状态(ADD/DELETE) |
| 文件内部有插入和删除记录会影响吗? | 不会,文件内部记录类型不影响删除决策 |
| 什么时候文件会被物理删除? | 当所有引用该文件的 Snapshot 都过期后 |
| Deletion Vector 如何处理? | 与主文件一起删除(extraFiles) |
| 是否支持部分删除文件? | 不支持,只能整体删除 |
核心原则:
- 文件是不可变的,删除是整体删除
- 删除决策基于 Manifest Entry 状态,而不是文件内容
- 只有当文件的最终状态是 DELETE 且没有被任何 Snapshot 或 Tag 引用时,才会物理删除
- Compaction 负责数据的合并和优化,Snapshot 过期负责清理不再使用的文件
六、文件删除流程
6.1 删除顺序(重要)
源码位置: paimon-core/src/main/java/org/apache/paimon/table/ExpireSnapshotsImpl.java
public int expireUntil(long earliestId, long endExclusiveId) {
// 范围:[beginInclusiveId, endExclusiveId)
// 1. 删除数据文件(merge tree files)
for (long id = beginInclusiveId + 1; id <= endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
Predicate<ExpireFileEntry> skipper = snapshotDeletion.createDataFileSkipperForTags(taggedSnapshots, id);
snapshotDeletion.cleanUnusedDataFiles(snapshot, skipper);
}
// 2. 删除 Changelog 文件
if (!expireConfig.isChangelogDecoupled()) {
for (long id = beginInclusiveId; id < endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
if (snapshot.changelogManifestList() != null) {
snapshotDeletion.deleteAddedDataFiles(snapshot.changelogManifestList());
}
}
}
// 3. 清理空目录
snapshotDeletion.cleanEmptyDirectories();
// 4. 删除 Manifest 文件
List<Snapshot> skippingSnapshots = findSkippingTags(taggedSnapshots, beginInclusiveId, endExclusiveId);
skippingSnapshots.add(snapshotManager.tryGetSnapshot(endExclusiveId));
Set<String> skippingSet = snapshotDeletion.manifestSkippingSet(skippingSnapshots);
for (long id = beginInclusiveId; id < endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
snapshotDeletion.cleanUnusedManifests(snapshot, skippingSet);
}
// 5. 删除 Snapshot 文件
for (long id = beginInclusiveId; id < endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
if (expireConfig.isChangelogDecoupled()) {
commitChangelog(new Changelog(snapshot)); // 保存 Changelog
}
snapshotManager.deleteSnapshot(id);
}
// 6. 更新 Earliest Hint
writeEarliestHint(endExclusiveId);
return (int) (endExclusiveId - beginInclusiveId);
}
6.2 数据文件删除详解
为什么是 beginInclusiveId + 1?
关键注释:
// deleted merge tree files in a snapshot are not used by the next snapshot,
// so the range of id should be (beginInclusiveId, endExclusiveId]
原因:
- Snapshot N 中标记为 DELETE 的文件,在 Snapshot N+1 中已经不再使用
- 因此,删除 Snapshot N 时,应该删除 Snapshot N+1 中的 DELETE 文件
- 范围是
(beginInclusiveId, endExclusiveId],即从beginInclusiveId + 1开始
示例:
- 要过期 Snapshot 1-5(beginInclusiveId=1, endExclusiveId=6)
- 数据文件删除范围:Snapshot 2, 3, 4, 5, 6
- Snapshot 2 的 deltaManifest 中包含 Snapshot 1 中被删除的文件
删除执行:
@Override
public void cleanUnusedDataFiles(Snapshot snapshot, Predicate<ExpireFileEntry> skipper) {
if (changelogDecoupled && !produceChangelog) {
// Changelog 解耦模式:跳过 APPEND 类型的数据文件
Predicate<ExpireFileEntry> enriched =
manifestEntry ->
skipper.test(manifestEntry)
|| (manifestEntry.fileSource().orElse(FileSource.APPEND) == FileSource.APPEND);
cleanUnusedDataFiles(snapshot.deltaManifestList(), enriched);
} else {
cleanUnusedDataFiles(snapshot.deltaManifestList(), skipper);
}
}
protected void doCleanUnusedDataFile(
Map<Path, Pair<ExpireFileEntry, List<Path>>> dataFileToDelete,
Predicate<ExpireFileEntry> skipper) {
List<Path> actualDataFileToDelete = new ArrayList<>();
dataFileToDelete.forEach((path, pair) -> {
ExpireFileEntry entry = pair.getLeft();
// 应用 Skipper(Tag 保护)
if (!skipper.test(entry)) {
actualDataFileToDelete.add(path); // 主数据文件
actualDataFileToDelete.addAll(pair.getRight()); // 额外文件(如 DV 文件)
recordDeletionBuckets(entry); // 记录已删除的 Bucket
}
});
// 并行删除文件
deleteFiles(actualDataFileToDelete, fileIO::deleteQuietly);
}
6.3 Changelog 文件删除
条件: !expireConfig.isChangelogDecoupled()
if (!expireConfig.isChangelogDecoupled()) {
for (long id = beginInclusiveId; id < endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
if (snapshot.changelogManifestList() != null) {
snapshotDeletion.deleteAddedDataFiles(snapshot.changelogManifestList());
}
}
}
Changelog 解耦模式:
- 当
changelog.lifecycle-decoupled = true时 - Changelog 文件有独立的生命周期管理
- 由
ExpireChangelogImpl单独处理 - 数据文件过期时会跳过 APPEND 类型的文件
6.4 Manifest 文件删除
protected void cleanUnusedManifests(
Snapshot snapshot,
Set<String> skippingSet,
boolean deleteDataManifestLists,
boolean deleteChangelog) {
// 1. 删除 Data Manifest List 和 Manifest 文件
if (deleteDataManifestLists) {
cleanUnusedManifestList(snapshot.baseManifestList(), skippingSet);
cleanUnusedManifestList(snapshot.deltaManifestList(), skippingSet);
}
// 2. 删除 Changelog Manifest List 和 Manifest 文件
if (deleteChangelog && snapshot.changelogManifestList() != null) {
cleanUnusedManifestList(snapshot.changelogManifestList(), skippingSet);
}
// 3. 删除 Index Manifest 和 Index 文件
cleanUnusedIndexManifests(snapshot, skippingSet);
// 4. 删除 Statistics 文件
cleanUnusedStatisticsManifests(snapshot, skippingSet);
}
public void cleanUnusedManifestList(String manifestName, Set<String> skippingSet) {
List<String> toDeleteManifests = new ArrayList<>();
List<ManifestFileMeta> toExpireManifests = tryReadManifestList(manifestName);
for (ManifestFileMeta manifest : toExpireManifests) {
String fileName = manifest.fileName();
if (!skippingSet.contains(fileName)) {
toDeleteManifests.add(fileName);
skippingSet.add(fileName); // 避免其他 Snapshot 重复删除
}
}
if (!skippingSet.contains(manifestName)) {
toDeleteManifests.add(manifestName); // 删除 Manifest List 本身
}
deleteFiles(toDeleteManifests, manifestFile::delete);
}
6.5 Snapshot 文件删除
for (long id = beginInclusiveId; id < endExclusiveId; id++) {
Snapshot snapshot = snapshotManager.tryGetSnapshot(id);
// Changelog 解耦模式:保存 Changelog
if (expireConfig.isChangelogDecoupled()) {
commitChangelog(new Changelog(snapshot));
}
// 删除 Snapshot 文件
snapshotManager.deleteSnapshot(id);
}
最后执行的原因:
- 确保数据文件和元数据文件都删除后,再删除 Snapshot 文件
- 避免 Snapshot 文件删除后,其他进程仍然引用已删除的数据文件
- 保证删除的原子性和一致性
6.6 空目录清理
public void cleanEmptyDirectories() {
if (!cleanEmptyDirectories || deletionBuckets.isEmpty()) {
return;
}
// 1. 尝试删除 Bucket 目录
for (Map.Entry<BinaryRow, Set<Integer>> entry : deletionBuckets.entrySet()) {
List<Path> toDeleteEmptyDirectory = new ArrayList<>();
for (Integer bucket : entry.getValue()) {
toDeleteEmptyDirectory.add(pathFactory.bucketPath(entry.getKey(), bucket));
}
deleteFiles(toDeleteEmptyDirectory, this::tryDeleteEmptyDirectory);
// 2. 获取分层的分区路径
List<Path> hierarchicalPaths = pathFactory.getHierarchicalPartitionPath(entry.getKey());
int hierarchies = hierarchicalPaths.size();
if (hierarchies > 0 && tryDeleteEmptyDirectory(hierarchicalPaths.get(hierarchies - 1))) {
// 3. 去重高层分区目录
for (int hierarchy = 0; hierarchy < hierarchies - 1; hierarchy++) {
Path path = hierarchicalPaths.get(hierarchy);
deduplicate.computeIfAbsent(hierarchy, i -> new HashSet<>()).add(path);
}
}
}
// 4. 从最深到最浅删除分区目录
for (int hierarchy = deduplicate.size() - 1; hierarchy >= 0; hierarchy--) {
deduplicate.get(hierarchy).forEach(this::tryDeleteEmptyDirectory);
}
deletionBuckets.clear();
}
private boolean tryDeleteEmptyDirectory(Path path) {
try {
fileIO.delete(path, false); // 非递归删除,只删除空目录
return true;
} catch (IOException e) {
LOG.debug("Failed to delete directory '{}'. Check whether it is empty.", path);
return false;
}
}
执行时机:
- 配置
snapshot.clean-empty-directories = true时启用 - 在数据文件和 Changelog 文件删除后执行
- 从 Bucket 目录到分区目录,从深到浅依次尝试删除
6.7 并行删除机制
protected <F> void deleteFiles(Collection<F> files, Consumer<F> deletion) {
if (files.isEmpty()) {
return;
}
// 创建异步删除任务
List<CompletableFuture<Void>> deletionFutures = new ArrayList<>(files.size());
for (F file : files) {
deletionFutures.add(
CompletableFuture.runAsync(() -> deletion.accept(file), deleteFileExecutor));
}
try {
// 等待所有删除任务完成
CompletableFuture.allOf(deletionFutures.toArray(new CompletableFuture[0])).get();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
线程池配置: file.operation.thread-num(默认值取决于 CPU 核数)
七、特殊场景处理
7.1 Changelog 解耦模式
配置: changelog.lifecycle-decoupled = true
影响:
- 数据文件删除:
if (changelogDecoupled && !produceChangelog) {
// 跳过 APPEND 类型的数据文件
Predicate<ExpireFileEntry> enriched =
manifestEntry ->
skipper.test(manifestEntry)
|| (manifestEntry.fileSource().orElse(FileSource.APPEND) == FileSource.APPEND);
cleanUnusedDataFiles(snapshot.deltaManifestList(), enriched);
}
- Manifest 删除:
@Override
public void cleanUnusedManifests(Snapshot snapshot, Set<String> skippingSet) {
cleanUnusedManifests(
snapshot,
skippingSet,
!changelogDecoupled || produceChangelog, // 延迟删除 base/delta manifest list
!changelogDecoupled); // 延迟删除 changelog manifest
}
- Snapshot 删除:
if (expireConfig.isChangelogDecoupled()) {
commitChangelog(new Changelog(snapshot)); // 保存为独立的 Changelog
}
snapshotManager.deleteSnapshot(id);
目的:
- Changelog 文件有独立的保留策略
- 允许 Changelog 保留时间长于 Snapshot
- 支持长时间的增量读取场景
7.2 FileSource 过滤
FileSource 类型:
APPEND: 来自 Append 操作的文件COMPACT: 来自 Compaction 操作的文件
过滤逻辑:
manifestEntry.fileSource().orElse(FileSource.APPEND) == FileSource.APPEND
- 在 Changelog 解耦模式下,APPEND 文件由
ExpireChangelogImpl管理 - 避免重复删除或误删
7.3 并发安全
机制:
-
文件版本化:
- Snapshot 文件名:
snapshot-{id} - 使用原子的文件操作(overwrite, delete)
- Snapshot 文件名:
-
乐观并发:
- 多个进程可以同时触发过期
- 通过文件系统的原子性保证安全
- 删除不存在的文件会被静默忽略(
deleteQuietly)
-
异常处理:
try {
snapshot = snapshotManager.tryGetSnapshot(id);
} catch (FileNotFoundException e) {
beginInclusiveId = id + 1; // Snapshot 已被其他进程删除,跳过
continue;
}
八、关键配置详解
8.1 配置项总览
| 配置项 | 默认值 | 类型 | 说明 |
|---|---|---|---|
snapshot.num-retained.min | 10 | Integer | 最少保留快照数(硬性保护) |
snapshot.num-retained.max | Integer.MAX_VALUE | Integer | 最多保留快照数(触发过期) |
snapshot.time-retained | 1 h | Duration | 时间保留窗口 |
snapshot.expire.limit | 10 | Integer | 单次最多过期数量 |
snapshot.expire.execution-mode | sync | Enum | 执行模式(sync/async) |
snapshot.clean-empty-directories | false | Boolean | 是否清理空目录 |
consumer.expire-time | null | Duration | 消费者过期时间 |
file.operation.thread-num | CPU 核数 | Integer | 文件操作线程数 |
changelog.lifecycle-decoupled | false | Boolean | Changelog 解耦模式 |
write-only | false | Boolean | 只写模式(禁用过期) |
8.2 配置建议
8.2.1 高频写入场景
# 减少保留数量,加快过期
snapshot.num-retained.min=5
snapshot.num-retained.max=20
snapshot.time-retained=30 min
# 使用异步模式,避免阻塞写入
snapshot.expire.execution-mode=async
# 增加单次过期数量
snapshot.expire.limit=20
# 增加并行度
file.operation.thread-num=16
8.2.2 长时间查询场景
# 增加保留数量和时间
snapshot.num-retained.min=100
snapshot.num-retained.max=500
snapshot.time-retained=24 h
# 使用消费者保护
# 在查询时设置 consumer-id
8.2.3 Changelog 读取场景
# 启用 Changelog 解耦
changelog.lifecycle-decoupled=true
# Changelog 独立保留策略
changelog.num-retained.min=50
changelog.num-retained.max=200
changelog.time-retained=7 d
# Snapshot 可以更快过期
snapshot.num-retained.min=10
snapshot.time-retained=1 h
8.2.4 存储优化场景
# 启用空目录清理
snapshot.clean-empty-directories=true
# 减少保留时间
snapshot.time-retained=30 min
# 增加过期频率
snapshot.expire.limit=50
九、性能优化
9.1 并行删除
配置: file.operation.thread-num
原理:
- 使用线程池并行删除文件
- 每个文件的删除操作在独立线程中执行
- 通过
CompletableFuture等待所有删除完成
建议:
- 默认值通常足够(CPU 核数)
- 存储 IOPS 高时可以增大(如 16-32)
- 避免设置过大导致存储压力
9.2 批量删除限制
配置: snapshot.expire.limit
为什么需要限制?
-
避免长时间阻塞:
- 删除大量文件需要时间
- 同步模式下会阻塞 Commit
- 限制单次删除数量,分批执行
-
存储压力控制:
- 大量并发删除可能影响存储性能
- 分批删除更平滑
-
故障恢复:
- 如果删除过程中失败
- 下次只需重试未完成的部分
建议:
- 同步模式:10-20(默认 10)
- 异步模式:50-100
- 根据文件数量和删除耗时调整
9.3 避免重复扫描
Earliest Hint 文件:
private void writeEarliestHint(long earliest) {
try {
snapshotManager.commitEarliestHint(earliest);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
作用:
- 记录最早的 Snapshot ID
- 下次过期时直接从该 ID 开始
- 避免扫描已经删除的 Snapshot
文件位置: snapshot/EARLIEST
9.4 Tag 缓存机制
private long cachedTag = 0;
private final Map<BinaryRow, Map<Integer, Set<String>>> cachedTagDataFiles = new HashMap<>();
public Predicate<ExpireFileEntry> createDataFileSkipperForTags(...) {
if (previousTag.id() != cachedTag) {
cachedTag = 0;
cachedTagDataFiles.clear();
addMergedDataFiles(cachedTagDataFiles, previousTag);
cachedTag = previousTag.id();
}
return entry -> containsDataFile(cachedTagDataFiles, entry);
}
优化点:
- 缓存 Tag 引用的数据文件列表
- 避免重复读取相同 Tag 的 Manifest
- 对于连续过期多个 Snapshot 的场景效果明显
十、完整示例
10.1 时间线示例
假设有以下场景:
- 表配置:
snapshot.num-retained.min=3,snapshot.num-retained.max=10,snapshot.time-retained=1h - 每 10 分钟写入一次数据,生成一个 Snapshot
时间线:
T0:00 - Snapshot 1 创建
T0:10 - Snapshot 2 创建
T0:20 - Snapshot 3 创建
T0:30 - Snapshot 4 创建
...
T1:40 - Snapshot 11 创建
- 触发过期检查
- retainMax=10: min = 11 - 10 + 1 = 2
- retainMin=3: maxExclusive = 11 - 3 + 1 = 9
- 过期范围: [2, 9) = Snapshot 2-8
- 时间检查: Snapshot 2 (T0:10) 距今 1h30m > 1h,满足条件
- 实际过期: Snapshot 1 (earliest=1, endExclusive=2)
T1:50 - Snapshot 12 创建
- 触发过期检查
- min = 12 - 10 + 1 = 3
- maxExclusive = 12 - 3 + 1 = 10
- 过期范围: [3, 10) = Snapshot 3-9
- 时间检查: Snapshot 3 (T0:20) 距今 1h30m > 1h,满足条件
- 实际过期: Snapshot 2 (earliest=2, endExclusive=3)
10.2 Tag 保护示例
Snapshot 1-20 存在
Tag 'v1.0' 在 Snapshot 10
Tag 'v2.0' 在 Snapshot 15
当前 Snapshot 25,要过期 Snapshot 1-15:
1. 过期 Snapshot 1-10:
- 没有 Tag 保护(Tag 10 >= 10)
- 正常删除数据文件
2. 过期 Snapshot 11-15:
- Tag 10 保护(findPreviousSnapshot(tags, 11-15) = Tag 10)
- Tag 10 引用的文件不会被删除
- 只删除 Snapshot 11-15 新增的文件
结果:
- Snapshot 1-15 的 Snapshot 文件被删除
- Tag 10 引用的数据文件保留
- Tag 15 引用的数据文件保留
- 其他数据文件被删除
10.3 消费者保护示例
Snapshot 1-30 存在
Consumer 'job-1' 正在读取 Snapshot 20 (nextSnapshot=20)
Consumer 'job-2' 正在读取 Snapshot 25 (nextSnapshot=25)
当前 Snapshot 30,配置 retainMin=5:
1. 计算过期范围:
- maxExclusive = 30 - 5 + 1 = 26
- minNextSnapshot = min(20, 25) = 20
- maxExclusive = min(26, 20) = 20
2. 实际过期:
- 过期 Snapshot 1-19
- Snapshot 20-30 被保护
3. 消费者完成后:
- 删除消费者文件
- 下次过期时可以删除更多 Snapshot
十一、源码关键方法总结
11.1 过期流程
| 类 | 方法 | 作用 |
|---|---|---|
TableCommitImpl | maintain() | 触发过期和维护操作 |
ExpireSnapshotsImpl | expire() | 计算过期范围 |
ExpireSnapshotsImpl | expireUntil() | 执行过期删除 |
11.2 条件检查
| 类 | 方法 | 作用 |
|---|---|---|
ExpireSnapshotsImpl | expire() | 计算 min 和 maxExclusive |
ConsumerManager | minNextSnapshot() | 获取消费者保护的最小 Snapshot |
ConsumerManager | expire() | 过期消费者文件 |
11.3 文件扫描
| 类 | 方法 | 作用 |
|---|---|---|
FileDeletionBase | cleanUnusedDataFiles() | 扫描和删除数据文件 |
FileDeletionBase | getDataFileToDelete() | 构建待删除文件列表 |
FileDeletionBase | manifestSkippingSet() | 构建需要保留的 Manifest 集合 |
11.4 保护机制
| 类 | 方法 | 作用 |
|---|---|---|
FileDeletionBase | createDataFileSkipperForTags() | 创建 Tag 保护的 Skipper |
FileDeletionBase | addMergedDataFiles() | 读取 Tag 引用的数据文件 |
TagManager | taggedSnapshots() | 获取所有 Tagged Snapshots |
11.5 文件删除
| 类 | 方法 | 作用 |
|---|---|---|
SnapshotDeletion | cleanUnusedDataFiles() | 删除数据文件 |
SnapshotDeletion | cleanUnusedManifests() | 删除 Manifest 文件 |
FileDeletionBase | cleanEmptyDirectories() | 清理空目录 |
FileDeletionBase | deleteFiles() | 并行删除文件 |
SnapshotManager | deleteSnapshot() | 删除 Snapshot 文件 |
十二、常见问题与排查
12.1 为什么文件没有被删除?
可能原因:
-
保留策略限制:
- 检查
snapshot.num-retained.min和snapshot.time-retained - 确认 Snapshot 数量和时间是否超过阈值
- 检查
-
Tag 保护:
- 查询 Tag:
SELECT * FROM sys.tags - Tag 引用的文件不会被删除
- 查询 Tag:
-
消费者保护:
- 查询消费者:
SELECT * FROM sys.consumers - 正在被消费的 Snapshot 不会被删除
- 查询消费者:
-
配置了 write-only:
write-only=true时禁用所有维护操作- 包括 Snapshot 过期
-
异步模式未完成:
snapshot.expire.execution-mode=async时- 过期在后台执行,可能还未完成
排查步骤:
-- 1. 查看 Snapshot 数量
SELECT COUNT(*) FROM sys.snapshots;
-- 2. 查看最早和最新的 Snapshot
SELECT MIN(snapshot_id), MAX(snapshot_id) FROM sys.snapshots;
-- 3. 查看 Tag
SELECT * FROM sys.tags;
-- 4. 查看消费者
SELECT * FROM sys.consumers;
-- 5. 查看表配置
SHOW CREATE TABLE your_table;
12.2 如何查看过期进度?
方法 1:查看日志
INFO ExpireSnapshotsImpl - Finished expire snapshots, duration 1234 ms, range is [1, 10)
方法 2:查看 Snapshot 数量变化
-- 定期执行
SELECT COUNT(*) FROM sys.snapshots;
方法 3:查看 EARLIEST 文件
# 查看最早的 Snapshot ID
cat <table-path>/snapshot/EARLIEST
12.3 如何手动触发过期?
方法 1:Flink SQL
-- 过期到只保留最新 5 个 Snapshot
CALL sys.expire_snapshots(`table` => 'db.table', retain_max => 5);
-- 过期指定时间之前的 Snapshot
CALL sys.expire_snapshots(`table` => 'db.table', older_than => '2024-01-01 00:00:00');
方法 2:Flink Action
flink run paimon-flink-action.jar expire_snapshots \
--warehouse hdfs:///path/to/warehouse \
--database mydb \
--table mytable \
--retain_max 5
方法 3:Spark SQL
CALL sys.expire_snapshots(table => 'db.table', retain_max => 5);
12.4 Tag 如何影响过期?
影响机制:
-
数据文件保护:
- Tag 引用的数据文件不会被删除
- 即使 Snapshot 已经过期
-
Manifest 文件保护:
- Tag 引用的 Manifest 文件不会被删除
- 包括 Manifest List、Data Manifest、Index Manifest
-
Snapshot 文件:
- Tag 本身保存了 Snapshot 的完整信息
- 原始 Snapshot 文件可以删除
示例:
-- 创建 Tag
CALL sys.create_tag('db.table', 'tag1', 10);
-- 过期 Snapshot
CALL sys.expire_snapshots(`table` => 'db.table', retain_max => 5);
-- 结果:
-- - Snapshot 1-5 的文件被删除
-- - Snapshot 6-10 保留
-- - Tag1 (Snapshot 10) 引用的数据文件保留
-- - 可以通过 Tag1 读取 Snapshot 10 的数据
12.5 如何优化过期性能?
优化建议:
- 使用异步模式:
snapshot.expire.execution-mode=async
- 增加并行度:
file.operation.thread-num=32
- 增加单次过期数量:
snapshot.expire.limit=50
- 减少保留数量:
snapshot.num-retained.min=5
snapshot.num-retained.max=20
- 定期清理 Tag:
-- 删除不需要的 Tag
CALL sys.delete_tag('db.table', 'old_tag');
- 清理过期消费者:
consumer.expire-time=7 d
12.6 过期失败如何处理?
常见错误:
-
FileNotFoundException:
- 文件已被其他进程删除
- 自动跳过,不影响整体流程
-
IOException:
- 存储系统故障
- 取消本次过期,下次重试
-
OutOfMemoryError:
- 文件数量过多
- 减少
snapshot.expire.limit - 增加 JVM 内存
恢复方法:
- 重新触发过期:
CALL sys.expire_snapshots(`table` => 'db.table', retain_max => 10);
-
检查存储系统:
- 确认存储系统正常
- 检查权限配置
-
清理孤立文件(谨慎使用):
# 扫描孤立文件
flink run paimon-flink-action.jar delete_orphan_files \
--warehouse <warehouse> \
--database <database> \
--table <table>
十三、总结
13.1 核心要点
-
过期触发:
- 自动:每次 Commit 后触发
- 手动:通过 SQL 或 Action 触发
-
过期范围:
- 由
retainMax、retainMin、timeRetain共同决定 - 受消费者保护和过期限制约束
- 由
-
删除顺序:
- 数据文件 → Changelog 文件 → 空目录 → Manifest 文件 → Snapshot 文件
- 顺序很重要,确保一致性
-
保护机制:
- Tag 保护:Tag 引用的文件不删除
- 消费者保护:正在消费的 Snapshot 不删除
-
性能优化:
- 并行删除
- 批量限制
- 缓存机制
13.2 最佳实践
-
合理配置保留策略:
- 根据查询模式设置
retainMin和timeRetain - 避免过度保留导致存储浪费
- 根据查询模式设置
-
使用 Tag 管理重要快照:
- 为重要版本创建 Tag
- 定期清理不需要的 Tag
-
使用 Consumer ID 保护流式读取:
- 流式作业使用 Consumer ID
- 避免 Snapshot 被过早删除
-
监控过期进度:
- 定期检查 Snapshot 数量
- 关注过期日志和错误
-
选择合适的执行模式:
- 批作业:同步模式
- 流作业:异步模式
13.3 注意事项
-
不要过度减少 retainMin:
- 可能导致查询失败
- 至少保留查询所需的时间窗口
-
注意 Tag 的影响:
- Tag 会阻止文件删除
- 定期清理不需要的 Tag
-
Changelog 解耦模式:
- 需要单独配置 Changelog 保留策略
- 注意存储空间占用
-
并发写入场景:
- 多个作业可能同时触发过期
- 通过文件系统原子性保证安全
-
故障恢复:
- 过期失败会自动重试
- 不会影响数据正确性