Paimon StreamTableScan 和 snapshot Manager

StreamTableScan

StreamTableScan 是 Paimon 中为流式读取设计的核心接口。它定义了流式作业所必需的关键能力,比如从某个状态点 (restore) 开始消费、在 Flink Checkpoint 时保存当前消费进度 (checkpoint)、以及在 Checkpoint 完成后通知 (notifyCheckpointComplete) 等。

DataTableStreamScanAuditLogStreamScan 和 LookupDataTableScan 是这个接口的三个关键实现,它们各自承担着不同的职责,形成了一个功能分层的体系。

DataTableStreamScan:通用的数据表流式扫描器

这是最基础、最核心的实现类。你可以把它看作是对一张普通 Paimon 数据表进行流式读取的“主力军”

它的主要职责是:

  • 管理扫描生命周期:它内部组合了 StartingScanner 和 FollowUpScanner
    • StartingScanner:负责作业启动时的首次扫描。根据不同的启动模式(如 latestlatest-fullfrom-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 的主要目标是:

  1. 首次加载:在作业启动时,高效地读取维表的全量或部分数据,填充到 Flink 的 Lookup Cache 中。
  2. 变更捕获:持续地消费维表的变更数据(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_kindsnapshot_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 为例,它的逻辑大致如下:

  1. 调用 snapshotManager.latestSnapshotId() 获取当前最新的快照 ID。
  2. 如果不存在任何快照,它会返回一个 StartingScanner.NoSnapshot 或 StartingScanner.NextSnapshot 结果,告诉上层现在没有数据,应该从 snapshot-1 开始等。
  3. 如果存在最新的快照,它会基于这个快照进行规划,读取全量数据,并返回一个 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 是一个工具类,它的逻辑很简单:

  1. 调用 snapshotManager.snapshotExists(nextSnapshotId) 检查ID对应的 snapshot-xxx 文件是否存在。
  2. 如果存在,就调用 snapshotManager.snapshot(nextSnapshotId) 读取文件内容并反序列化成 Snapshot 对象返回。
  3. 如果不存在,就返回 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 ...

它的逻辑很清晰:

  1. 先尝试从缓存中获取 Snapshot 对象。
  2. 如果缓存未命中,则调用 Snapshot.fromPath(fileIO, path) 从文件系统中读取 JSON 文件并反序列化成 Snapshot 对象。
  3. 如果配置了缓存,将新读取的对象放入缓存。
  4. 返回 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 ...

这个方法的逻辑非常清晰:

  1. 它首先通过 branchPath(tablePath, branch) 获取当前分支的根路径。对于主分支(main),这通常就是表的根路径 tablePath
  2. 然后,它在这个路径下拼接了固定的 "/snapshot/" 目录。
  3. 最后,它拼接了快照文件的前缀 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 通过以下几个层次的设计,实现了对快照的高效、健壮和灵活的管理:

  1. 抽象层:通过 FileIO 接口解耦了底层存储,使其具备跨平台能力。
  2. 路径管理:提供了统一、规范的快照文件路径生成规则。
  3. 性能优化
    • 利用 Caffeine 缓存 Snapshot 对象,减少I/O和反序列化开销。
    • 利用 LATEST 和 EARLIEST hint 文件,避免了昂贵的目录遍历操作。
    • 对于范围查找,使用高效的二分查找算法。
  4. 功能完备性:提供了从基础的ID查询,到复杂的按时间、按Watermark查找,再到安全的遍历等全方位的快照访问能力。
  5. 扩展性:通过 SnapshotLoader 接口,允许与外部元数据服务集成。

可以说,SnapshotManager 是 Paimon 实现 ACID 事务、流批一体和时间旅行等高级特性的基石。所有对表历史状态的查询,最终都会汇集到这个类中来完成。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值