Paimon源码解读 -- Fallback Branch

7 阅读9分钟

Paimon Fallback Branch 机制分析

一.概述

Fallback Branch(fallback分支)是 Apache Paimon 提供的一种分支读取机制,允许批处理作业在当前分支缺少某些分区时,自动从备用分支读取这些分区的数据。这一机制特别适用于需要结合流式写入和批处理修正的场景。

核心特性:

  • 仅支持批处理读取,不支持流式读取
  • 按分区级别进行fallback
  • 自动合并主分支和fallback分支的数据
  • 支持跨分支的快照时间戳转换

相关配置选项

配置键: scan.fallback-branch

定义位置: org.apache.paimon.CoreOptions

public static final ConfigOption<String> SCAN_FALLBACK_BRANCH =
    key("scan.fallback-branch")
        .stringType()
        .noDefaultValue()
        .withDescription(
            "When a batch job queries from a table, if a partition does not exist in the current branch, "
            + "the reader will try to get this partition from this fallback branch.");

配置方式:

-- Flink SQL 设置fallback分支
ALTER TABLE T SET ('scan.fallback-branch' = 'branch_name');

-- 重置fallback分支配置
ALTER TABLE T RESET ('scan.fallback-branch');

验证机制:

  • 配置的fallback分支必须存在,否则会抛出异常
  • 验证在 SchemaValidation.validateFallbackBranch() 方法中执行

二.核心实现原理

1. 核心类:FallbackReadFileStoreTable

文件位置: org.apache.paimon.table.FallbackReadFileStoreTable

这是一个包装类,继承自 DelegatedFileStoreTable,持有两个 FileStoreTable 实例:

  • wrapped:主分支表(当前分支)
  • fallback:fallback分支表
public class FallbackReadFileStoreTable extends DelegatedFileStoreTable {
    private final FileStoreTable fallback;

    public FallbackReadFileStoreTable(FileStoreTable wrapped, FileStoreTable fallback) {
        super(wrapped);
        this.fallback = fallback;

        // 确保不会嵌套包装
        Preconditions.checkArgument(!(wrapped instanceof FallbackReadFileStoreTable));
        Preconditions.checkArgument(!(fallback instanceof FallbackReadFileStoreTable));
    }
}

2. 表创建流程

文件位置: org.apache.paimon.table.FileStoreTableFactory

当检测到 scan.fallback-branch 配置时,工厂方法会:

  1. 读取fallback分支配置
  2. 验证fallback分支是否存在
  3. 创建fallback分支的 FileStoreTable 实例
  4. 用 FallbackReadFileStoreTable 包装主表和fallback表
String fallbackBranch = options.get(CoreOptions.SCAN_FALLBACK_BRANCH);
if (!StringUtils.isNullOrWhitespaceOnly(fallbackBranch)) {
    Options branchOptions = new Options(dynamicOptions.toMap());
    branchOptions.set(CoreOptions.BRANCH, fallbackBranch);

    // 验证fallback分支存在
    Optional<TableSchema> schema =
        new SchemaManager(fileIO, tablePath, fallbackBranch).latest();
    checkArgument(schema.isPresent(),
        "Cannot set '%s' = '%s' because the branch '%s' isn't existed.",
        CoreOptions.SCAN_FALLBACK_BRANCH.key(), fallbackBranch, fallbackBranch);

    // 创建fallback表并包装
    FileStoreTable fallbackTable =
        createWithoutFallbackBranch(fileIO, tablePath, schema.get(),
                                    branchOptions, catalogEnvironment);
    table = new FallbackReadFileStoreTable(table, fallbackTable);
}

3. Schema 兼容性验证

在创建扫描操作时,会验证两个分支的 Schema 兼容性:

private void validateSchema() {
    // 获取主分支名字
    String mainBranch = wrapped.coreOptions().branch();
    // 获取fallback分支名字
    String fallbackBranch = fallback.coreOptions().branch();
    // 1. 验证这俩分支的schema类型是否兼容(忽略可空性)
    RowType mainRowType = wrapped.schema().logicalRowType();
    RowType fallbackRowType = fallback.schema().logicalRowType();
    Preconditions.checkArgument(
        sameRowTypeIgnoreNullable(mainRowType, fallbackRowType),
        "Branch %s and %s does not have the same row type...");

    // 2. 验证主键兼容
    List<String> mainPrimaryKeys = wrapped.schema().primaryKeys();
    List<String> fallbackPrimaryKeys = fallback.schema().primaryKeys();
    // 如果主分支有主键,fallback分支也必须有相同的主键
    if (!mainPrimaryKeys.isEmpty()) {
        if (fallbackPrimaryKeys.isEmpty()) {
            throw new IllegalArgumentException("Branch ...");
        }
        Preconditions.checkArgument(mainPrimaryKeys.equals(fallbackPrimaryKeys),
            "Primary keys must be the same...");
    }
}

4. 动态选项重写机制

fallback分支的选项需要特殊处理,通过 rewriteFallbackOptions() 方法实现:

private Map<String, String> rewriteFallbackOptions(Map<String, String> options) {
    Map<String, String> result = new HashMap<>(options);

    // (1) fallback分支名不应该变化
    String branchKey = CoreOptions.BRANCH.key();
    if (options.containsKey(branchKey)) {
        result.put(branchKey, fallback.options().get(branchKey));
    }

    // (2) 快照 ID 跨分支转换
    if (options.containsKey(scanSnapshotIdOptionKey)) {
        long id = Long.parseLong(options.get(scanSnapshotIdOptionKey));
        // 主分支快照 ID -> 主分支快照对应的时间戳
        // wrapped是主分支
        long millis = wrapped.snapshotManager().snapshot(id).timeMillis();
        // 根据主分支快照对应的时间戳 -> 找到最近/相同的fallback分支快照 ID
        Snapshot fallbackSnapshot =
            fallback.snapshotManager().earlierOrEqualTimeMills(millis);
        // 找到了,则用fallback最近的快照ID;没找到,则用默认值1
        long fallbackId = (fallbackSnapshot == null)
            ? Snapshot.FIRST_SNAPSHOT_ID
            : fallbackSnapshot.id();
        result.put(scanSnapshotIdOptionKey, String.valueOf(fallbackId));
    }

    // (3) 移除 bucket 配置,使用fallback分支的 bucket 数
    result.remove(CoreOptions.BUCKET.key());

    return result;
}

关键逻辑说明:

  • 分支名固定:确保fallback表始终指向配置的fallback分支
  • 快照时间对齐:通过时间戳将主分支的快照映射到fallback分支的快照
  • Bucket 数独立:允许两个分支使用不同的 bucket 数量

三.工作流程

1.扫描阶段(Scan)

FallbackReadFileStoreTable 内部实现了 Scan 类,负责合并主分支和fallback分支的数据分片:

@Override
public TableScan.Plan plan() {
    List<DataSplit> splits = new ArrayList<>();
    Set<BinaryRow> completePartitions = new HashSet<>();

    // 步骤 1: 先收集主分支的所有分片和分区,比如截止20260102前的全部分区
    for (Split split : mainScan.plan().splits()) {
        DataSplit dataSplit = (DataSplit) split;
        splits.add(dataSplit);
        completePartitions.add(dataSplit.partition());
    }

    // 步骤 2: 再找出主分支没有,但fallback分支有的分区,比如20260103分区
    List<BinaryRow> remainingPartitions =
        fallbackScan.listPartitions().stream()
            .filter(p -> !completePartitions.contains(p))
            .collect(Collectors.toList());

    // 步骤 3: 从fallback分支读取缺失的分区和分片,添加到splits中
    // 最终splits分区为:[...、20260102、20260103]
    if (!remainingPartitions.isEmpty()) {
        fallbackScan.withPartitionFilter(remainingPartitions);
        for (Split split : fallbackScan.plan().splits()) {
            splits.add((DataSplit) split);
        }
    }

    return new DataFilePlan(splits);
}

核心原则:Main 分支优先

  • 如果 main 分支和 fallback 分支都有相同的分区,只读取 main 分支的数据
  • 只有 main 分支完全缺失的分区,才会从 fallback 分支读取
  • 通过 completePartitions.contains(p) 检查确保分区不重复

流程图:

主分支扫描
    ↓
收集已有分区 (Set<Partition>)  ← main 分支优先
    ↓
fallback分支分区列表 - main已有分区 = main缺失分区
    ↓
扫描fallback分支中筛选出来的的main缺失分区
    ↓
合并所有分片 (List<DataSplit>)

示例:

main 分支分区: [2024-07-25, 2024-07-26]
fallback 分支分区: [2024-07-25, 2024-07-27]

查询结果:
- 2024-07-25: 从 main 读取(虽然 fallback 也有,但 main 优先)
- 2024-07-26: 从 main 读取
- 2024-07-27: 从 fallback 读取(main 没有此分区)

2.读取阶段(Read)

FallbackReadFileStoreTable 实现了 Read 类,根据分片特征选择合适的 Reader:

@Override
public RecordReader<InternalRow> createReader(Split split) throws IOException {
    DataSplit dataSplit = (DataSplit) split;

    // 根据数据文件的 minKey 字段数量判断使用哪个 Reader
    if (!dataSplit.dataFiles().isEmpty()
        && dataSplit.dataFiles().get(0).minKey().getFieldCount() > 0) {
        return fallbackRead.createReader(split);
    } else {
        return mainRead.createReader(split);
    }
}

决策逻辑:

  • 检查分片的第一个数据文件的 minKey 字段数量
  • 如果字段数 > 0,使用fallback分支的 Reader
  • 否则使用主分支的 Reader

重要说明:

  • 此方法仅用于识别 split 来源,而非决定读取哪个分支
  • 分区选择已在扫描阶段完成(见上面的 plan() 方法)
  • 到达 createReader 时,split 已经明确来自 main 或 fallback
  • 不存在"同一分区同时读取两个分支"的情况

3.使用场景

(1) 典型场景:流批混合数据修正

业务需求:

  • 实时流式作业持续写入当前日期的数据(保证时效性)
  • 夜间批处理作业修正历史日期的数据(保证准确性)
  • 查询时优先读取批处理修正后的数据,未修正的分区fallback到流式数据

实现方案:

  1. 创建分区表
CREATE TABLE T (
    dt STRING NOT NULL,
    name STRING NOT NULL,
    amount BIGINT
) PARTITIONED BY (dt);
  1. 为流式作业创建分支
-- 创建流式分支
CALL sys.create_branch('default.T', 'streaming_branch');

-- 配置流式分支
ALTER TABLE `T$branch_streaming_branch` SET (
    'primary-key' = 'dt,name',
    'bucket' = '2',
    'changelog-producer' = 'lookup'
);
  1. 设置fallback分支
-- 批处理分支(主分支)设置fallback到流式分支
ALTER TABLE T SET ('scan.fallback-branch' = 'streaming_branch');
  1. 数据写入
-- 流式作业持续写入实时数据
INSERT INTO `T$branch_streaming_branch`
VALUES ('20240726', 'apple', 4), ('20240726', 'peach', 10);

-- 批处理作业修正历史数据
INSERT INTO T
VALUES ('20240725', 'apple', 5), ('20240725', 'banana', 7);
  1. 查询行为
SELECT * FROM T;
-- 结果:
-- dt=20240725: 来自主分支(批处理修正后的数据)
-- dt=20240726: 来自 streaming_branch(流式实时数据)

(2) 场景优势

  • 零停机:流式作业无需停止,批处理并行修正历史数据
  • 数据一致性:批处理修正的分区自动覆盖流式数据
  • 查询透明:应用层无需感知数据来源,统一查询接口
  • 资源隔离:流式和批处理使用不同分支,避免冲突

四.限制与注意事项

1. 流式读取不支持

限制说明:

  • Fallback Branch 机制仅支持批处理读取
  • 流式读取任务只会从当前分支读取数据,忽略 scan.fallback-branch 配置

原因: 流式读取通常基于增量消费语义,跨分支的分区fallback会破坏这种语义。

2. Schema 兼容性要求

必须满足:

  • 两个分支的行类型必须相同(忽略可空性差异)
  • 如果主分支有主键,fallback分支也必须有相同的主键
  • 主键列表必须完全一致

示例:

-- 错误:主分支有主键,fallback分支没有主键
-- 主分支: primary-key = 'id'
-- fallback分支: 无主键
-- 结果:抛出 IllegalArgumentException

-- 错误:主键不一致
-- 主分支: primary-key = 'id,name'
-- fallback分支: primary-key = 'id'
-- 结果:校验失败

3. Bucket 数量差异

行为:

  • 主分支和fallback分支可以使用不同的 bucket 数量
  • 系统会自动移除 bucket 配置项,使用fallback分支的 bucket 配置

原因: 不同分支可能采用不同的分桶策略,允许灵活配置。

4. 快照时间对齐

机制:

  • 如果设置了 scan.snapshot-id,系统会将主分支的快照 ID 转换为时间戳
  • 然后在fallback分支中查找 earlierOrEqualTimeMills 的快照
  • 如果找不到匹配快照,使用 FIRST_SNAPSHOT_ID

注意事项:

  • 确保两个分支的快照时间范围有合理重叠
  • fallback分支的快照保留策略应与主分支协调

5. 分区级别fallback

特性:

  • fallback是按分区级别进行的,不是按文件或记录级别
  • 优先从主分支读取某个分区的任何数据,main分支没有才会从fallback分支读取该分区的数据
  • Main 分支绝对优先:相同分区永远读取 main 分支的数据

影响:

  • 分区粒度设计很重要(如日期分区 vs 小时分区)
  • 较小的分区粒度可以提供更精细的fallback控制

示例:

场景:main 和 fallback 都有 dt=2024-07-25 分区

实际行为:
- 只读取 main 分支的 dt=2024-07-25 数据
- 完全忽略 fallback 分支的同名分区

注意:不会出现"部分数据来自 main,部分来自 fallback"的情况

五.源码和示例

示例 1:基本使用

-- 1. 创建表
CREATE TABLE sales (
    sale_date STRING,
    product_id STRING,
    amount DECIMAL(10, 2)
) PARTITIONED BY (sale_date);

-- 2. 创建流式分支
CALL sys.create_branch('default.sales', 'realtime');

-- 3. 设置fallback分支
ALTER TABLE sales SET ('scan.fallback-branch' = 'realtime');

-- 4. 流式写入
INSERT INTO `sales$branch_realtime`
VALUES ('2024-07-26', 'P001', 100.00);

-- 5. 批处理写入
INSERT INTO sales
VALUES ('2024-07-25', 'P001', 150.00);

-- 6. 查询(自动合并)
SELECT * FROM sales;
-- 2024-07-25 数据来自主分支(批处理)
-- 2024-07-26 数据来自 realtime 分支(流式)

示例 2:禁用fallback分支

-- 重置配置
ALTER TABLE sales RESET ('scan.fallback-branch');

-- 查询只读取主分支数据
SELECT * FROM sales;
-- 只返回 2024-07-25 的数据

示例 3:测试验证

-- 查看主分支分区
SELECT DISTINCT sale_date FROM sales;

-- 查看流式分支分区
SELECT DISTINCT sale_date FROM `sales$branch_realtime`;

-- 对比设置fallback前后的数据差异
-- 未设置fallback
ALTER TABLE sales RESET ('scan.fallback-branch');
SELECT COUNT(*) FROM sales;  -- 结果: N

-- 设置fallback
ALTER TABLE sales SET ('scan.fallback-branch' = 'realtime');
SELECT COUNT(*) FROM sales;  -- 结果: N + M (M 为fallback分支额外的分区数据)

4.相关源码文件

文件路径描述
paimon-common/src/main/java/org/apache/paimon/CoreOptions.java:1427配置选项定义
paimon-core/src/main/java/org/apache/paimon/table/FallbackReadFileStoreTable.java核心实现类
paimon-core/src/main/java/org/apache/paimon/table/FileStoreTableFactory.java表工厂创建逻辑
paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java分支存在性验证
paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BranchSqlITCase.java功能测试用例
docs/content/maintenance/manage-branches.md官方文档

六.总结

Paimon 的 Fallback Branch 机制通过以下设计实现了灵活的跨分支数据读取:

  1. 分区级fallback:仅对主分支缺失的分区从fallback分支读取
  2. 透明合并:在扫描阶段自动合并两个分支的数据分片
  3. 快照对齐:通过时间戳在不同分支间映射快照
  4. Schema 兼容性:严格的类型和主键验证确保数据一致性
  5. 批处理专用:明确限制在批处理场景,避免流式语义冲突