StreamTableScan
StreamTableScan
是 Paimon 中为流式读取设计的核心接口。它定义了流式作业所必需的关键能力,比如从某个状态点 (restore
) 开始消费、在 Flink Checkpoint 时保存当前消费进度 (checkpoint
)、以及在 Checkpoint 完成后通知 (notifyCheckpointComplete
) 等。
DataTableStreamScan
、AuditLogStreamScan
和 LookupDataTableScan
是这个接口的三个关键实现,它们各自承担着不同的职责,形成了一个功能分层的体系。
DataTableStreamScan
:通用的数据表流式扫描器
这是最基础、最核心的实现类。你可以把它看作是对一张普通 Paimon 数据表进行流式读取的“主力军”。
它的主要职责是:
- 管理扫描生命周期:它内部组合了
StartingScanner
和FollowUpScanner
。StartingScanner
:负责作业启动时的首次扫描。根据不同的启动模式(如latest
,latest-full
,from-snapshot
),它决定是从最新的快照全量读,还是从某个历史快照增量读。FollowUpScanner
:在首次扫描结束后,负责后续的增量扫描。它会周期性地检查是否有新的快照生成,并读取这些新快照带来的变更数据(Deltas 或 Changelogs)。
- 状态管理:它实现了
checkpoint
和restore
方法,通过记录和恢复nextSnapshotId
(下一个要读取的快照 ID)来与 Flink 的 Checkpoint 机制对齐,保证 Exactly-Once 语义。 - 生成执行计划:调用
plan()
方法时,它会根据当前的状态(nextSnapshotId
)去扫描对应的快照,并生成需要被读取的数据文件切片(Splits)。
// ... existing code ...
public class DataTableStreamScan extends AbstractDataTableScan implements StreamDataTableScan {
// ... existing code ...
private StartingScanner startingScanner;
private FollowUpScanner followUpScanner;
// ... existing code ...
@Nullable private Long nextSnapshotId;
// ... existing code ...
@Override
public void restore(@Nullable Long nextSnapshotId) {
this.nextSnapshotId = nextSnapshotId;
}
@Nullable
@Override
public Long checkpoint() {
// ... a lot of logic to determine the next snapshot id ...
return nextSnapshotId;
}
@Override
public Plan plan() {
initScanner();
if (nextSnapshotId == null) {
// 首次 plan, 使用 startingScanner
Plan plan = tryFirstPlan();
// ...
return plan;
}
// 后续 plan, 使用 followUpScanner
FollowUpScanner.Result result = followUpScanner.scan(nextSnapshotId, snapshotReader);
// ...
}
// ... existing code ...
总结:DataTableStreamScan
是所有对 Paimon 数据表进行流读的基础,提供了完整的状态管理和增量消费逻辑。
LookupDataTableScan
:为 Lookup Join 优化的流式扫描器
这个类继承自 DataTableStreamScan
,是一个特化版本,专门用于 Flink 的 Lookup Join 场景。
在 Lookup Join 中,Paimon 表通常作为维表(Dimension Table)。这个 Scan 的主要目标是:
- 首次加载:在作业启动时,高效地读取维表的全量或部分数据,填充到 Flink 的 Lookup Cache 中。
- 变更捕获:持续地消费维表的变更数据(CDC),并用这些变更来更新 Lookup Cache,确保维表数据是最新的。
它通过继承 DataTableStreamScan
复用了大部分的流式扫描和状态管理逻辑,但会根据 Lookup Join 的特定需求调整其行为,例如它可能会有特殊的 lookupScanMode
来控制如何生成变更流。
// ... existing code ...
public class LookupDataTableScan extends DataTableStreamScan {
private static final Logger LOG = LoggerFactory.getLogger(LookupDataTableScan.class);
private final StartupMode startupMode;
private final LookupStreamScanMode lookupScanMode;
public LookupDataTableScan(
CoreOptions options,
SnapshotReader snapshotReader,
SnapshotManager snapshotManager,
ChangelogManager changelogManager,
boolean supportStreamingReadOverwrite,
DefaultValueAssigner defaultValueAssigner) {
super(
options,
snapshotReader,
snapshotManager,
changelogManager,
supportStreamingReadOverwrite,
defaultValueAssigner);
this.startupMode = options.startupMode();
this.lookupScanMode = options.lookupStreamScanMode();
}
}
总结:LookupDataTableScan
是一个为维表关联场景定制的、优化的 DataTableStreamScan
。
AuditLogStreamScan
:审计日志表的流式扫描器(装饰器模式)
这个类是一个典型的装饰器(Decorator)。它本身不实现复杂的扫描逻辑,而是 包装(wrap) 了一个 DataTableStreamScan
实例。
它的作用是为 Paimon 的系统表 audit_log
提供流式读取的能力。audit_log
表记录了对主表的所有提交记录(Commit),包括 Append
和 Compact
等。
AuditLogStreamScan
将对 audit_log
表的查询请求,巧妙地转换成对底层 StreamDataTableScan
的调用。例如,它会将 audit_log
的 schema 字段(如 commit_kind
, snapshot_id
)的过滤条件,转换为对内部快照元数据的过滤。
// ... existing code ...
private class AuditLogStreamScan implements StreamDataTableScan {
private final StreamDataTableScan streamScan;
private AuditLogStreamScan(StreamDataTableScan streamScan) {
this.streamScan = streamScan;
}
@Override
public Plan plan() {
return streamScan.plan();
}
@Nullable
@Override
public Long checkpoint() {
return streamScan.checkpoint();
}
@Override
public void restore(@Nullable Long nextSnapshotId) {
streamScan.restore(nextSnapshotId);
}
// ... 其他方法也都是直接调用 streamScan 的同名方法 ...
}
// ... existing code ...
总结:AuditLogStreamScan
是一个适配器或装饰器,它通过包装一个标准的 DataTableStreamScan
,使得用户可以像查询普通数据表一样,流式地查询 audit_log
这张特殊的系统表。
关系图谱
这三者之间的关系可以这样理解:
+-----------------------+
| StreamTableScan | (接口)
+-----------------------+
^
| (实现)
+-----------------------+
| DataTableStreamScan | (通用核心实现)
+-----------------------+
^
| (继承)
+-----------------------+
| LookupDataTableScan | (为 Lookup Join 特化的实现)
+-----------------------+
+-----------------------+
| AuditLogStreamScan | (装饰器, 内部持有) -------> DataTableStreamScan
+-----------------------+
DataTableStreamScan
中 Snapshot
是如何被获取的
这个过程可以分为两个阶段:首次规划(First Plan) 和 后续规划(Next Plan)。
1. 首次规划(First Plan):tryFirstPlan()
方法
当一个流式作业启动时,plan()
方法会首次被调用,此时 nextSnapshotId
为 null
,会进入 tryFirstPlan()
的逻辑。这里的核心是 StartingScanner
。
a. StartingScanner
的创建
在 initScanner()
方法中,会调用 createStartingScanner(true)
来创建一个 StartingScanner
的实例。StartingScanner
有多种实现,Paimon 会根据用户配置的启动模式(scan.startup.mode
)来决定使用哪一种。常见的模式有:
latest-full
(默认): 从最新的快照开始,先读取全量数据,然后消费增量。latest
: 直接从最新的快照开始,只消费增量数据。from-snapshot
: 从用户指定的某个快照开始消费。from-timestamp
: 从指定的时间戳开始消费。
b. StartingScanner.scan()
的执行
tryFirstPlan()
方法会调用 startingScanner.scan(snapshotReader)
。这个方法的目标就是定位到第一个应该被处理的 Snapshot
。
以默认的 LatestStartingScanner
为例,它的逻辑大致如下:
- 调用
snapshotManager.latestSnapshotId()
获取当前最新的快照 ID。 - 如果不存在任何快照,它会返回一个
StartingScanner.NoSnapshot
或StartingScanner.NextSnapshot
结果,告诉上层现在没有数据,应该从snapshot-1
开始等。 - 如果存在最新的快照,它会基于这个快照进行规划,读取全量数据,并返回一个
ScannedResult
。
tryFirstPlan()
方法会处理 scan()
返回的结果:
ScannedResult
: 这说明成功找到了一个起始快照,并且已经规划好了读取这个快照数据的Plan
。方法会从ScannedResult
中提取出currentSnapshotId
,然后计算出nextSnapshotId
(通常是currentSnapshotId + 1
),为下一次规划做准备。NextSnapshot
: 这说明当前没有合适的起始快照(比如表是空的),但它告诉了我们下一次应该从哪个 ID (nextSnapshotId
) 开始扫描。NoSnapshot
: 说明表是空的,并且也没有正在进行的提交,暂时无事可做。
总结:第一个 Snapshot 是由 StartingScanner
根据用户配置的启动模式,通过 SnapshotManager
查询文件系统上的 snapshot
目录来定位的。SnapshotManager
知道如何找到 LATEST
hint 文件或者遍历目录来确定最新的快照。
2. 后续规划(Next Plan):nextPlan()
方法
当 tryFirstPlan()
执行完毕后,nextSnapshotId
就被赋值了。从第二次调用 plan()
开始,就会进入 nextPlan()
的逻辑。
nextPlan()
的核心是一个 while(true)
循环,它不断地尝试获取并处理下一个快照。
a. NextSnapshotFetcher.getNextSnapshot()
在循环内部,它首先调用 nextSnapshotProvider.getNextSnapshot(nextSnapshotId)
。这个 NextSnapshotFetcher
是一个工具类,它的逻辑很简单:
- 调用
snapshotManager.snapshotExists(nextSnapshotId)
检查ID对应的snapshot-xxx
文件是否存在。 - 如果存在,就调用
snapshotManager.snapshot(nextSnapshotId)
读取文件内容并反序列化成Snapshot
对象返回。 - 如果不存在,就返回
null
。
b. FollowUpScanner.scan()
如果成功获取到了 Snapshot
对象,nextPlan()
会继续调用 followUpScanner.scan(snapshot, snapshotReader)
。FollowUpScanner
负责处理增量数据。根据配置(changelog-producer
),它可能是:
DeltaFollowUpScanner
: 只读取快照中的增量文件(deltaManifestList
)。ChangelogFollowUpScanner
: 读取快照中专门生成的 Changelog 文件(changelogManifestList
)。
scan()
方法会返回读取这个增量快照所需的数据切片 Plan
。
c. 循环与推进
nextPlan()
在成功处理一个快照后,会执行 nextSnapshotId++
,然后继续 while
循环,尝试获取下一个快照。如果 getNextSnapshot()
返回 null
(意味着下一个快照还没生成),nextPlan()
就会返回一个 SnapshotNotExistPlan.INSTANCE
,告知 Flink 当前没有新数据,可以稍后再试。
整体流程图
+----------------------+
| plan() is called |
+----------------------+
|
(nextSnapshotId == null?)
|
+---------+---------+
| Yes | No
v v
+-----------------+ +-----------------+
| tryFirstPlan() | | nextPlan() |
+-----------------+ +-----------------+
| |
v |
+-----------------+ +--------------------------------+
| StartingScanner | | while(true) { |
| .scan() | | snapshot = getNextSnapshot() |
+-----------------+ | if (snapshot != null) { |
| | plan = followUpScanner.scan()|
v | nextSnapshotId++ |
+-----------------+ | return plan |
| Set initial | | } else { |
| nextSnapshotId | | return NoNewSnapshot |
+-----------------+ | } |
| } |
+--------------------------------+
通过这个机制,DataTableStreamScan
实现了从一个明确的起点(由 StartingScanner
确定)开始,不断地、顺序地消费后续所有新生成的 Snapshot
,从而构成了完整的流式读取链路。
SnapshotManager
SnapshotManager
负责所有与快照相关的操作,包括定位、读取、查找、遍历和管理快照文件的生命周期。可以把它理解为 Paimon 表的“历史记录管理员”。
下面我们分模块来解析它的设计和工作原理。
SnapshotManager
的核心职责是提供一个统一的接口来访问和管理存储在文件系统上的快照文件。
构造函数与核心属性
// ... existing code ...
private final FileIO fileIO;
private final Path tablePath;
private final String branch;
@Nullable private final SnapshotLoader snapshotLoader;
@Nullable private final Cache<Path, Snapshot> cache;
public SnapshotManager(
FileIO fileIO,
Path tablePath,
@Nullable String branchName,
@Nullable SnapshotLoader snapshotLoader,
@Nullable Cache<Path, Snapshot> cache) {
this.fileIO = fileIO;
this.tablePath = tablePath;
this.branch = BranchManager.normalizeBranch(branchName);
this.snapshotLoader = snapshotLoader;
this.cache = cache;
}
// ... existing code ...
从构造函数可以看出它的几个关键依赖:
fileIO
: 文件系统I/O接口,用于实际读写文件,这使得 Paimon 可以对接 HDFS, S3, OSS 等多种存储。tablePath
: 表的根路径。branch
: 分支名称。Paimon 支持多分支,SnapshotManager
的所有操作都是在指定的分支下进行的。它通过branchPath()
方法来获取分支的实际路径(例如/path/to/table/branch-my_branch
)。snapshotLoader
: 一个可选的加载器。在某些场景下(如集成外部 catalog),快照的最新信息可能由外部系统管理,SnapshotLoader
提供了一个扩展点来从外部源加载最新快照信息。cache
: 一个可选的 Caffeine 缓存。为了避免重复读取和反序列化同一个快照文件,SnapshotManager
可以使用缓存来提升性能。
SnapshotManager
最基础的功能:根据 ID 获取快照。
它通过 snapshotDirectory()
和 snapshotPath(long snapshotId)
这两个方法来管理快照的物理路径。
snapshotDirectory()
: 返回快照目录的路径,例如/path/to/table/snapshot
。snapshotPath(long snapshotId)
: 返回特定 ID 的快照文件路径,例如/path/to/table/snapshot/snapshot-5
。
读取快照
snapshot(long snapshotId)
是最常用的读取方法。
// ... existing code ...
public Snapshot snapshot(long snapshotId) {
Path path = snapshotPath(snapshotId);
Snapshot snapshot = cache == null ? null : cache.getIfPresent(path);
if (snapshot == null) {
snapshot = Snapshot.fromPath(fileIO, path);
if (cache != null) {
cache.put(path, snapshot);
}
}
return snapshot;
}
// ... existing code ...
它的逻辑很清晰:
- 先尝试从缓存中获取
Snapshot
对象。 - 如果缓存未命中,则调用
Snapshot.fromPath(fileIO, path)
从文件系统中读取 JSON 文件并反序列化成Snapshot
对象。 - 如果配置了缓存,将新读取的对象放入缓存。
- 返回
Snapshot
对象。
snapshotExists(long snapshotId)
则用于检查某个快照文件是否存在,避免不必要的读取。
snapshotPath
如何决定路径?
// ... existing code ...
public Path snapshotPath(long snapshotId) {
return new Path(
branchPath(tablePath, branch) + "/snapshot/" + SNAPSHOT_PREFIX + snapshotId);
}
public Path snapshotDirectory() {
return new Path(branchPath(tablePath, branch) + "/snapshot");
}
// ... existing code ...
这个方法的逻辑非常清晰:
- 它首先通过
branchPath(tablePath, branch)
获取当前分支的根路径。对于主分支(main
),这通常就是表的根路径tablePath
。 - 然后,它在这个路径下拼接了固定的
"/snapshot/"
目录。 - 最后,它拼接了快照文件的前缀
SNAPSHOT_PREFIX
(即 "snapshot-")和快照的ID。
所以,一个ID为 N
的快照文件,其最终的物理路径会是: <table_path>/snapshot/snapshot-N
从 snapshotPath
的实现中我们可以得出几个关键信息:
- 路径中不包含分区信息:路径的构造只依赖于
tablePath
和branch
,完全没有分区键(Partition Keys)的任何信息。 - 路径中不包含分桶信息:路径中也没有任何关于分桶(Bucket)的信息。
这意味着,无论数据被写入哪个分区、哪个桶,所有的提交最终都会生成一个位于同一个 snapshot
目录下的、全局唯一的、递增的快照文件。
这个快照文件内部的 Manifest List
会进一步指向 Manifest
文件,而 Manifest
文件中才包含了具体的文件元数据,这些元数据里才记录了每个数据文件属于哪个分区(DataFileMeta.partition()
)和哪个桶(DataFileMeta.bucket()
)。
可以这样理解 Paimon 的元数据结构:
Snapshot
(快照): 相当于一次数据库事务的提交记录 (Commit Log)。它是表级别的,记录了“在某个时间点,我对整个表做了一次变更”。每一次变更(无论涉及多少分区和桶)都会产生一条新的、全局的提交记录。Manifest List
(清单列表): 相当于这次提交的详细摘要。它指向了本次提交涉及的所有变更清单。Manifest
(清单): 这是具体的变更清单。它详细记录了“在这次提交中,分区A的桶1新增了文件X,分区B的桶3删除了文件Y”。Data File
(数据文件): 这是真正存储数据的物理文件,位于分区和分桶对应的目录下。
因此,SnapshotManager
管理的是表级别的“提交历史”。当任何一个读取器(无论是批读还是流读)想要查询表的数据时,它首先会找到一个 Snapshot
(比如最新的快照),然后顺着 Snapshot -> Manifest List -> Manifest -> Data File
这条链,就能找到它需要读取的所有数据文件的完整列表,无论这些文件分布在哪个分区或哪个桶。这种设计保证了任何一次读取都能获得表在某个时间点上的一个完整、一致的视图。
查找最早和最新的快照
这是流式读取和批式读取的起点,也是 SnapshotManager
的关键能力。
latestSnapshotId()
和 earliestSnapshotId()
这两个方法负责找到当前表中最新和最早的快照ID。
// ... existing code ...
public @Nullable Long latestSnapshotId() {
try {
if (snapshotLoader != null) {
// ... 尝试从外部加载器获取
}
return findLatest(snapshotDirectory(), SNAPSHOT_PREFIX, this::snapshotPath);
} catch (IOException e) {
throw new RuntimeException("Failed to find latest snapshot id", e);
}
}
// ... existing code ...
public @Nullable Long earliestSnapshotId() {
try {
return findEarliest(snapshotDirectory(), SNAPSHOT_PREFIX, this::snapshotPath);
} catch (IOException e) {
throw new RuntimeException("Failed to find earliest snapshot id", e);
}
}
// ... existing code ...
它们内部都委托给了 HintFileUtils.findLatest
和 HintFileUtils.findEarliest
。Paimon 为了优化查找性能,会在 snapshot
目录下维护 LATEST
和 EARLIEST
两个提示文件 (hint file),里面直接记录了最新和最早的快照ID。
- 查找逻辑:优先读取 hint 文件。如果 hint 文件不存在或不准确,则会回退到遍历整个
snapshot
目录,通过列举所有snapshot-*
文件并解析文件名中的数字来确定最早和最新的ID。
latestSnapshot()
和 earliestSnapshot()
方法则是在获取到 ID 后,进一步调用 snapshot(id)
来返回完整的 Snapshot
对象。
高级查找与遍历
除了简单的最早/最新查找,SnapshotManager
还提供了更复杂的查找能力,这对于时间旅行(Time Travel)和基于 Watermark 的消费至关重要。
按时间戳查找
earlierOrEqualTimeMills(long timestampMills)
laterOrEqualTimeMills(long timestampMills)
这两个方法用于根据给定的时间戳查找快照。它们的实现非常高效,采用了二分查找算法。
// ... existing code ...
public @Nullable Snapshot earlierOrEqualTimeMills(long timestampMills) {
Long latest = latestSnapshotId();
// ... 边界检查 ...
long earliest = earliestSnapShot.id();
Snapshot finalSnapshot = null;
while (earliest <= latest) {
long mid = earliest + (latest - earliest) / 2; // 防止溢出
Snapshot snapshot = snapshot(mid);
long commitTime = snapshot.timeMillis();
if (commitTime > timestampMills) {
latest = mid - 1; // 在左半部分查找
} else if (commitTime < timestampMills) {
earliest = mid + 1; // 在右半部分查找
finalSnapshot = snapshot;
} else {
finalSnapshot = snapshot; // 精确匹配
break;
}
}
return finalSnapshot;
}
// ... existing code ...
它们首先确定查找范围(earliestId
到 latestId
),然后在这个范围内对快照ID进行二分查找,每次取出中间的快照,比较其提交时间与目标时间戳,然后缩小查找范围,直到找到满足条件的快照。
按 Watermark 查找
-
earlierOrEqualWatermark(long watermark)
-
laterOrEqualWatermark(long watermark)
这组方法逻辑与按时间戳查找类似,但更复杂一些,因为并非所有快照都带有 Watermark。它们的算法同样基于二分查找,但增加了额外的逻辑来处理快照中 watermark
字段为 null
的情况,它会向前或向后寻找最近的一个带有 Watermark 的快照来进行比较。
遍历
snapshots()
: 返回一个迭代器,可以按ID从小到大的顺序遍历所有快照。traversalSnapshotsFromLatestSafely(Filter<Snapshot> checker)
: 从最新到最老安全地遍历快照。这个方法主要用在写入端,因为它考虑到了在遍历过程中,旧的快照可能会被清理程序删除导致FileNotFoundException
,它能够优雅地处理这种情况并继续遍历。
总结
SnapshotManager
通过以下几个层次的设计,实现了对快照的高效、健壮和灵活的管理:
- 抽象层:通过
FileIO
接口解耦了底层存储,使其具备跨平台能力。 - 路径管理:提供了统一、规范的快照文件路径生成规则。
- 性能优化:
- 利用
Caffeine
缓存Snapshot
对象,减少I/O和反序列化开销。 - 利用
LATEST
和EARLIEST
hint 文件,避免了昂贵的目录遍历操作。 - 对于范围查找,使用高效的二分查找算法。
- 利用
- 功能完备性:提供了从基础的ID查询,到复杂的按时间、按Watermark查找,再到安全的遍历等全方位的快照访问能力。
- 扩展性:通过
SnapshotLoader
接口,允许与外部元数据服务集成。
可以说,SnapshotManager
是 Paimon 实现 ACID 事务、流批一体和时间旅行等高级特性的基石。所有对表历史状态的查询,最终都会汇集到这个类中来完成。