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 配置时,工厂方法会:
- 读取fallback分支配置
- 验证fallback分支是否存在
- 创建fallback分支的 FileStoreTable 实例
- 用 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到流式数据
实现方案:
- 创建分区表
CREATE TABLE T (
dt STRING NOT NULL,
name STRING NOT NULL,
amount BIGINT
) PARTITIONED BY (dt);
- 为流式作业创建分支
-- 创建流式分支
CALL sys.create_branch('default.T', 'streaming_branch');
-- 配置流式分支
ALTER TABLE `T$branch_streaming_branch` SET (
'primary-key' = 'dt,name',
'bucket' = '2',
'changelog-producer' = 'lookup'
);
- 设置fallback分支
-- 批处理分支(主分支)设置fallback到流式分支
ALTER TABLE T SET ('scan.fallback-branch' = 'streaming_branch');
- 数据写入
-- 流式作业持续写入实时数据
INSERT INTO `T$branch_streaming_branch`
VALUES ('20240726', 'apple', 4), ('20240726', 'peach', 10);
-- 批处理作业修正历史数据
INSERT INTO T
VALUES ('20240725', 'apple', 5), ('20240725', 'banana', 7);
- 查询行为
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 机制通过以下设计实现了灵活的跨分支数据读取:
- 分区级fallback:仅对主分支缺失的分区从fallback分支读取
- 透明合并:在扫描阶段自动合并两个分支的数据分片
- 快照对齐:通过时间戳在不同分支间映射快照
- Schema 兼容性:严格的类型和主键验证确保数据一致性
- 批处理专用:明确限制在批处理场景,避免流式语义冲突