paimon 表 snapshot 过期流程深度解析

46 阅读23分钟

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 自动创建和过期
    }
}

关键点

  1. 执行模式:通过 snapshot.expire.execution-mode 配置控制

    • sync(默认):同步执行,在 Commit 线程中直接执行
    • async:异步执行,在独立线程池中执行
  2. 执行顺序:先过期消费者 → Snapshot 过期 → 分区过期 → Tag 操作

  3. 触发条件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.min10最少保留快照数(硬性保护)
snapshot.num-retained.maxInteger.MAX_VALUE最多保留快照数(触发过期)
snapshot.time-retained1 h时间保留窗口
snapshot.expire.limit10单次最多过期数量

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 = 10
  • snapshot.num-retained.max = 50
  • snapshot.expire.limit = 5
  • 当前时间 = T,snapshot.time-retained = 1h

计算过程:

  1. min = max(100 - 50 + 1, 1) = 51(从 51 开始可以考虑过期)
  2. maxExclusive = 100 - 10 + 1 = 91(必须保留 90-100)
  3. 假设消费者正在读取 Snapshot 60,则 maxExclusive = min(91, 60) = 60
  4. 应用限制:maxExclusive = min(60, 1 + 5) = 6
  5. 最终过期范围:[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);
    }
}

工作原理

  1. 消费者在读取 Snapshot 时,会在 consumer/consumer-{id} 目录下记录 nextSnapshot
  2. minNextSnapshot() 返回所有消费者中最小的 nextSnapshot 值
  3. 小于该值的 Snapshot 不能被删除,因为可能正在被消费
  4. 消费者文件本身也会过期(通过 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();
}

保护逻辑

  1. 获取所有 Tagged Snapshots(按 Snapshot ID 排序)
  2. 对于要过期的 Snapshot ID,找到小于等于它的最近的 Tag
  3. 读取该 Tag 引用的所有数据文件
  4. 在删除时,跳过被 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 核心原则:文件是整体删除的

关键概念

  1. 数据文件是不可变的(Immutable)

    • 一旦写入,文件内容永不修改
    • 删除时是删除整个物理文件,而不是文件中的某些记录
  2. 文件状态 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;
        }
    }
}

关键逻辑

  1. 遍历 Manifest Entries 时,维护一个 dataFileToDelete Map
  2. 遇到 ADD entry:从 Map 中移除该文件(表示文件仍在使用)
  3. 遇到 DELETE entry:将文件加入 Map(表示文件可以删除)
  4. 最终 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<PathextraFiles = 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)
是否支持部分删除文件?不支持,只能整体删除

核心原则

  1. 文件是不可变的,删除是整体删除
  2. 删除决策基于 Manifest Entry 状态,而不是文件内容
  3. 只有当文件的最终状态是 DELETE 且没有被任何 Snapshot 或 Tag 引用时,才会物理删除
  4. 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

影响

  1. 数据文件删除
if (changelogDecoupled && !produceChangelog) {
    // 跳过 APPEND 类型的数据文件
    Predicate<ExpireFileEntry> enriched =
            manifestEntry ->
                    skipper.test(manifestEntry)
                            || (manifestEntry.fileSource().orElse(FileSource.APPEND) == FileSource.APPEND);
    cleanUnusedDataFiles(snapshot.deltaManifestList(), enriched);
}
  1. Manifest 删除
@Override
public void cleanUnusedManifests(Snapshot snapshot, Set<String> skippingSet) {
    cleanUnusedManifests(
            snapshot,
            skippingSet,
            !changelogDecoupled || produceChangelog,  // 延迟删除 base/delta manifest list
            !changelogDecoupled);  // 延迟删除 changelog manifest
}
  1. 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 并发安全

机制

  1. 文件版本化

    • Snapshot 文件名:snapshot-{id}
    • 使用原子的文件操作(overwrite, delete)
  2. 乐观并发

    • 多个进程可以同时触发过期
    • 通过文件系统的原子性保证安全
    • 删除不存在的文件会被静默忽略(deleteQuietly
  3. 异常处理

try {
    snapshot = snapshotManager.tryGetSnapshot(id);
} catch (FileNotFoundException e) {
    beginInclusiveId = id + 1;  // Snapshot 已被其他进程删除,跳过
    continue;
}

八、关键配置详解

8.1 配置项总览

配置项默认值类型说明
snapshot.num-retained.min10Integer最少保留快照数(硬性保护)
snapshot.num-retained.maxInteger.MAX_VALUEInteger最多保留快照数(触发过期)
snapshot.time-retained1 hDuration时间保留窗口
snapshot.expire.limit10Integer单次最多过期数量
snapshot.expire.execution-modesyncEnum执行模式(sync/async)
snapshot.clean-empty-directoriesfalseBoolean是否清理空目录
consumer.expire-timenullDuration消费者过期时间
file.operation.thread-numCPU 核数Integer文件操作线程数
changelog.lifecycle-decoupledfalseBooleanChangelog 解耦模式
write-onlyfalseBoolean只写模式(禁用过期)

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

为什么需要限制?

  1. 避免长时间阻塞

    • 删除大量文件需要时间
    • 同步模式下会阻塞 Commit
    • 限制单次删除数量,分批执行
  2. 存储压力控制

    • 大量并发删除可能影响存储性能
    • 分批删除更平滑
  3. 故障恢复

    • 如果删除过程中失败
    • 下次只需重试未完成的部分

建议

  • 同步模式: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=3snapshot.num-retained.max=10snapshot.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(2025) = 20
   - maxExclusive = min(2620) = 20

2. 实际过期:
   - 过期 Snapshot 1-19
   - Snapshot 20-30 被保护

3. 消费者完成后:
   - 删除消费者文件
   - 下次过期时可以删除更多 Snapshot

十一、源码关键方法总结

11.1 过期流程

方法作用
TableCommitImplmaintain()触发过期和维护操作
ExpireSnapshotsImplexpire()计算过期范围
ExpireSnapshotsImplexpireUntil()执行过期删除

11.2 条件检查

方法作用
ExpireSnapshotsImplexpire()计算 min 和 maxExclusive
ConsumerManagerminNextSnapshot()获取消费者保护的最小 Snapshot
ConsumerManagerexpire()过期消费者文件

11.3 文件扫描

方法作用
FileDeletionBasecleanUnusedDataFiles()扫描和删除数据文件
FileDeletionBasegetDataFileToDelete()构建待删除文件列表
FileDeletionBasemanifestSkippingSet()构建需要保留的 Manifest 集合

11.4 保护机制

方法作用
FileDeletionBasecreateDataFileSkipperForTags()创建 Tag 保护的 Skipper
FileDeletionBaseaddMergedDataFiles()读取 Tag 引用的数据文件
TagManagertaggedSnapshots()获取所有 Tagged Snapshots

11.5 文件删除

方法作用
SnapshotDeletioncleanUnusedDataFiles()删除数据文件
SnapshotDeletioncleanUnusedManifests()删除 Manifest 文件
FileDeletionBasecleanEmptyDirectories()清理空目录
FileDeletionBasedeleteFiles()并行删除文件
SnapshotManagerdeleteSnapshot()删除 Snapshot 文件

十二、常见问题与排查

12.1 为什么文件没有被删除?

可能原因

  1. 保留策略限制

    • 检查 snapshot.num-retained.min 和 snapshot.time-retained
    • 确认 Snapshot 数量和时间是否超过阈值
  2. Tag 保护

    • 查询 Tag:SELECT * FROM sys.tags
    • Tag 引用的文件不会被删除
  3. 消费者保护

    • 查询消费者:SELECT * FROM sys.consumers
    • 正在被消费的 Snapshot 不会被删除
  4. 配置了 write-only

    • write-only=true 时禁用所有维护操作
    • 包括 Snapshot 过期
  5. 异步模式未完成

    • 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 [110)

方法 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 如何影响过期?

影响机制

  1. 数据文件保护

    • Tag 引用的数据文件不会被删除
    • 即使 Snapshot 已经过期
  2. Manifest 文件保护

    • Tag 引用的 Manifest 文件不会被删除
    • 包括 Manifest List、Data Manifest、Index Manifest
  3. 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 如何优化过期性能?

优化建议

  1. 使用异步模式
snapshot.expire.execution-mode=async
  1. 增加并行度
file.operation.thread-num=32
  1. 增加单次过期数量
snapshot.expire.limit=50
  1. 减少保留数量
snapshot.num-retained.min=5
snapshot.num-retained.max=20
  1. 定期清理 Tag
-- 删除不需要的 Tag
CALL sys.delete_tag('db.table''old_tag');
  1. 清理过期消费者
consumer.expire-time=7 d

12.6 过期失败如何处理?

常见错误

  1. FileNotFoundException

    • 文件已被其他进程删除
    • 自动跳过,不影响整体流程
  2. IOException

    • 存储系统故障
    • 取消本次过期,下次重试
  3. OutOfMemoryError

    • 文件数量过多
    • 减少 snapshot.expire.limit
    • 增加 JVM 内存

恢复方法

  1. 重新触发过期
CALL sys.expire_snapshots(`table` => 'db.table', retain_max => 10);
  1. 检查存储系统

    • 确认存储系统正常
    • 检查权限配置
  2. 清理孤立文件(谨慎使用):

# 扫描孤立文件
flink run paimon-flink-action.jar delete_orphan_files \
  --warehouse <warehouse> \
  --database <database> \
  --table <table>

十三、总结

13.1 核心要点

  1. 过期触发

    • 自动:每次 Commit 后触发
    • 手动:通过 SQL 或 Action 触发
  2. 过期范围

    • 由 retainMaxretainMintimeRetain 共同决定
    • 受消费者保护和过期限制约束
  3. 删除顺序

    • 数据文件 → Changelog 文件 → 空目录 → Manifest 文件 → Snapshot 文件
    • 顺序很重要,确保一致性
  4. 保护机制

    • Tag 保护:Tag 引用的文件不删除
    • 消费者保护:正在消费的 Snapshot 不删除
  5. 性能优化

    • 并行删除
    • 批量限制
    • 缓存机制

13.2 最佳实践

  1. 合理配置保留策略

    • 根据查询模式设置 retainMin 和 timeRetain
    • 避免过度保留导致存储浪费
  2. 使用 Tag 管理重要快照

    • 为重要版本创建 Tag
    • 定期清理不需要的 Tag
  3. 使用 Consumer ID 保护流式读取

    • 流式作业使用 Consumer ID
    • 避免 Snapshot 被过早删除
  4. 监控过期进度

    • 定期检查 Snapshot 数量
    • 关注过期日志和错误
  5. 选择合适的执行模式

    • 批作业:同步模式
    • 流作业:异步模式

13.3 注意事项

  1. 不要过度减少 retainMin

    • 可能导致查询失败
    • 至少保留查询所需的时间窗口
  2. 注意 Tag 的影响

    • Tag 会阻止文件删除
    • 定期清理不需要的 Tag
  3. Changelog 解耦模式

    • 需要单独配置 Changelog 保留策略
    • 注意存储空间占用
  4. 并发写入场景

    • 多个作业可能同时触发过期
    • 通过文件系统原子性保证安全
  5. 故障恢复

    • 过期失败会自动重试
    • 不会影响数据正确性