【Paimon 学习笔记 三】工作流程:Paimon 的批写、流写、批读与流读

第二篇讲了存储架构------快照、manifest、LSM 树、bucket,是"零件"。结尾把写入/批读/流读的路径串了一遍,但每一步都是一句话带过。这篇把每一步拆开:谁负责写、谁负责提交、失败怎么办、changelog 从哪来。

继续用订单场景:orders 表(主键表,2 个 bucket),order_001 在 10:00 到达、10:01 改状态。

1 writer 和 committer:写数据和提交是两拨人

第二篇说"Checkpoint 触发:内存数据 flush 成 sorted run"------这只说了 writer 干的活。实际 Flink Sink 端有两个角色:

先回顾 Flink 架构 (Flink 系列第一篇的内容,忘了可以快速过一眼):一个 Flink 作业(job)跑起来后,JobManager 把任务分发到多个 TaskManager(进程)上。每个 TaskManager 里有多个 slot(资源槽位),每个 slot 同一时间跑一个 subtask(算子的并行实例)。这里说的"算子"专指 Sink 算子 ------Paimon 的写入就是 Sink 算子在干活。Sink 算子的并行度 = Writer 数量

  • Writer(每个并行子任务一个):负责把数据写进对应 bucket 的内存 sorted table,Checkpoint 触发时 flush 成数据文件。Writer 只管写文件,不管提交
  • Committer (单并行度):等所有 Writer 都 flush 完,收集它们写出的文件列表,生成 manifest 和 snapshot,做 rename 提交。snapshot 写入 snapshot/ 目录这一步,是 Committer 干的

为什么要分两个角色?因为 snapshot 提交必须看到所有 bucket 的文件------这是全局操作,不能让单个 Writer 自己干。Writer 是局部的(只看自己那个 bucket),Committer 是全局的(看所有 bucket)。

用 order_001 举例:

  1. 10:00,order_001 进 Flink,hash 路由到 bucket-0 的 Writer-0
  2. Writer-0 把 order_001=待支付 写进内存
  3. Checkpoint 触发:Writer-0 把内存数据 flush 成 data-0-1.parquet
  4. Writer-0 告诉 Committer:"我写了 data-0-1"
  5. Committer 等所有 Writer 都报告完毕,生成 manifest-1(记录 data-0-1),创建 snapshot-1,rename 提交
  6. snapshot-1 出现 → order_001 可见

2 可见性延迟 = Checkpoint 间隔

从上面流程可以看出:数据可见的时机不是"写入时",而是"Committer 提交 snapshot 时"。Committer 在 Checkpoint 完成时才提交------所以:

可见性延迟 ≈ Checkpoint 间隔

Checkpoint 设 1 分钟,数据最多 1 分钟后可见。设 10 秒,最多 10 秒。这不是 Paimon 的限制,是流批一体的代价------要攒批提交保证原子性,就得接受延迟。

那能不能设 1 秒一个 Checkpoint 追求极致实时?能,但 Checkpoint 太频繁有代价:

  • 每次 Checkpoint 都要 flush + 提交,小文件增多(每次一个 sorted run,不管数据多少)
  • Committer 压力大(每秒生成 manifest + snapshot)
  • Flink Checkpoint 本身有开销(barrier 对齐、state snapshot)

反过来,Checkpoint 设太长(如 10 分钟):

  • 可见性延迟大,下游等不起
  • 一次 Checkpoint 攒的数据多,单次 flush 的文件大------好处是文件少,坏处是失败重做代价大

权衡点:Checkpoint 间隔 = 可见性延迟 vs 小文件频率 + Checkpoint 开销。生产常见 1~5 分钟,第四篇给具体调参框架。

3 Checkpoint 失败:数据文件已经写了,snapshot 没提交

这是个真实问题。Checkpoint 流程:

复制代码
Checkpoint N 开始
  → barrier 传播到所有 Writer
  → Writer flush 数据文件到磁盘(这一步可能已经完成)
  → Writer 报告给 Committer
  → Committer 生成 manifest + snapshot
  → rename snapshot 文件 ← 这一步如果失败呢?

如果 Committer 提交失败(比如 S3 rename 超时),会发生两件事:

1. Flink 重试 Checkpoint N

Checkpoint 是原子操作------要么全成功要么全失败。rename 失败意味着 Checkpoint N 没完成,Flink 回滚到上一个成功的 Checkpoint(N-1),重新跑 Checkpoint N:

复制代码
Checkpoint N rename 失败
  → 整个 Checkpoint N 标记为失败
  → Flink 从 Checkpoint N-1 恢复
  → Writer 重新 flush(数据还在内存里,没丢)
  → Committer 重新生成 manifest + snapshot
  → 再次 rename ← 重试

Writer 之前 flush 的数据文件成了孤儿文件------物理上存在于 bucket 目录里,但没有任何 snapshot 引用它们,对读者不可见。

2. 孤儿文件等过期清理

孤儿文件不影响正确性(读者只看 snapshot 引用的文件),只是浪费空间。Paimon 通过快照过期机制定期清理:过期旧 snapshot 时,检查哪些数据文件不被任何 snapshot 引用,直接删掉。

所以 rename 失败不丢数据------数据还在 Writer 内存里,重试时重新 flush。孤儿文件是重试的副产品,等过期清理。这是原子提交机制的保证:要么 snapshot 提交成功(所有文件可见),要么没提交(所有文件不可见,重试 + 孤儿文件等清理)。

二 批写:Spark 一次性写完提交

1 批写怎么做

Spark 批写 Paimon 表,流程直白:

复制代码
Spark 读取数据源 → 按 bucket 分配 → 写数据文件 → 生成 manifest → 提交 snapshot

没有 Writer/Committer 分离------Spark Driver 统一协调:所有 Executor 写完文件后,Driver 生成一份 manifest 和一个 snapshot,一次性提交。

sql 复制代码
-- Spark SQL 批写:把一张 Hive 表的数据灌进 Paimon
INSERT INTO orders
SELECT * FROM hive_orders;

2 批写适合什么场景

场景 为什么用批写
初始化数据导入 建表后先灌历史数据,再切流写
T+1 批量回灌 每天补一批修正数据
维表全量刷新 维表定期全量覆盖

流写和批写的本质区别:

流写(Flink) 批写(Spark)
提交频率 每个 Checkpoint 一次 一次 job 一次
可见性 秒~分钟级 job 跑完才可见
失败处理 孤儿文件自动清理 重跑整个 job
角色分离 Writer + Committer Driver 统一

一句话:流写是"攒批增量提交",批写是"一把梭全量提交"

三 批读:Spark 从 Paimon 表读数据

第二篇讲了批读的概要路径(读 snapshot → manifest → 裁剪 → merge),这里补充三种读取模式。

1 读最新(默认)

sql 复制代码
SELECT * FROM orders;

Spark 默认读最新 snapshot------拿到最新 snapshot 的 manifest 列表,裁剪出需要的数据文件,主键表 merge 取最新版本,append 表直接读。第二篇第五节已经讲过,不重复。

2 时间旅行:读历史版本

Paimon 的快照机制让"时间旅行"变得轻量------不用备份恢复,直接改 snapshot 引用:

sql 复制代码
-- 读指定快照版本
SELECT * FROM orders VERSION AS OF 1;

-- 读指定时间点的版本
SELECT * FROM orders TIMESTAMP AS OF '2026-08-29 10:00:00';

为什么轻量?因为 snapshot-1 指向的 manifest 和数据文件一直都在磁盘上(只要快照没过期)。读 snapshot-1 就是"用旧版本的 manifest 找旧版本的文件"------数据不动,只是换了一份清单。对比 Hive 要恢复历史数据得翻 T+1 备份目录,Paimon 直接查。

限制:时间旅行能回到多早,取决于快照保留策略。snapshot 被过期清理后,对应的历史版本就读不了了。快照保留多少个/多少天------第四篇讲。

3 增量读:两个快照之间发生了什么

Spark 也能做增量读------读两个快照之间的变更:

sql 复制代码
-- 读 snapshot-1 到 snapshot-2 之间的增量
SELECT * FROM orders /*+ OPTIONS('incremental-between' = '1,2') */;

这条语句返回 snapshot-1 到 snapshot-2 之间新增和变更的行。实现原理:对比两个 snapshot 的 manifest,找出新增的数据文件,只读这些文件。

但注意:Spark 增量读拿到的是"文件级别的增量"------主键表的数据文件里存的是追加的新版本,Spark 读出来需要自己判断哪些是新增、哪些是更新。真正的增量流读(带 +I/-U/+U/-D 语义)要靠 Flink 流读,下一节讲。

这是第二篇埋的最大伏笔------"下游 Flink 作业从 Paimon 表新 snapshot 的 changelog 读增量"。changelog 从哪来?+I/-U/+U/-D(+I 新增、-U 更新前值、+U 更新后值、-D 删除)怎么产生?这节拆开。

1 下游怎么订阅

下游 Flink 作业把 Paimon 表当源表,持续监听新 snapshot:

sql 复制代码
-- 下游 Flink 作业:把 Paimon 表当源表读
CREATE TABLE orders_source (
    order_id STRING,
    order_status STRING,
    city STRING,
    amount DECIMAL(10,2),
    order_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'paimon',
    'warehouse' = 's3://warehouse',
    'scan.mode' = 'from-snapshot',
    'scan.snapshot-id' = '1'
);

工作机制:

  1. Flink 作业启动,先读当前指定 snapshot 的全量数据(初始化)
  2. 然后持续轮询 snapshot/ 目录,等新 snapshot 出现
  3. 新 snapshot 出现 → 读它的 changelog → 输出变更流

注意:下游 Flink 作业和写入 Paimon 的 Flink 作业不是同一个。写入端把数据写进 Paimon 表,读取端从 Paimon 表读 changelog------中间隔着 Paimon 的存储层,两端解耦。

写入端(Flink 作业 A)只管把数据写进 Paimon 表;读取端(Flink 作业 B)持续监听 snapshot/ 目录,等新 snapshot 出现就读 changelog。两端互不感知------写入端不知道谁在读,读取端不知道谁在写。读取端可以任意时刻启动(从任意 snapshot 开始),也可以停掉再恢复(接着上次位置继续)。

2 changelog 在 SQL 里怎么用:写 SQL 不用关心底层

上面订阅的 SQL 只是"接到"了 changelog。接进来之后怎么用?答案:怎么用普通流就怎么用,SQL 层完全无感。完整链路三段:

sql 复制代码
-- 1. 建 Paimon catalog(一次性,之后表直接引用,不用每张表写 connector)
CREATE CATALOG paimon WITH (
    'type' = 'paimon',
    'warehouse' = 's3://warehouse'
);

-- 2. 流读 orders:hint 指定 scan.mode,包成视图方便引用
CREATE TEMPORARY VIEW orders_stream AS
SELECT * FROM paimon.`default`.orders /*+ OPTIONS('scan.mode' = 'latest-full') */;

scan.mode 决定从哪开始读:

行为
latest-full 先读最新快照全量(初始化)+ 之后持续读增量(最常用)
latest 跳过存量,只读启动之后的新增量
from-snapshot scan.snapshot-id 指定快照读全量 + 持续增量(恢复/回溯用)
from-timestamp scan.timestamp-millis 时间点开始读增量

用法 A:changelog 透传,写下游 Paimon 主键表

sql 复制代码
CREATE TABLE paimon.`default`.orders_risk (
    order_id STRING,
    order_status STRING,
    amount DECIMAL(10,2),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH ('bucket' = '8');

INSERT INTO paimon.`default`.orders_risk
SELECT order_id, order_status, amount
FROM orders_stream
WHERE amount > 10000;   -- 风控规则:大额订单

注意 INSERT 里没有任何"changelog 语法"------orders_stream 带着 RowKind 进来,Paimon sink 收到 +U 就 upsert、收到 -D 就删除。订单改状态,下游表自动跟着改,不会变成两行。

用法 B:changelog 进聚合,统计自动修正

sql 复制代码
SELECT city, SUM(amount) AS pay_amount, COUNT(*) AS pay_cnt
FROM orders_stream
WHERE order_status = '已支付'
GROUP BY city;

这是 changelog 价值最明显的地方:order_001 从"待支付"改"已支付"时,流里来的是 -U(待支付) + +U(已支付)------聚合算子先撤回 旧状态的影响、再累加新状态。如果读不到 -U/+U(比如 changelog-producer 配了 none),状态变更后这条订单会被当成新数据再计一次,城市成交额直接算重。

两个前提记住:

  1. changelog-producer 不能是 none------none 模式下流读主键表只能拿到近似 +I,没有 -U/+U,用法 B 的"自动修正"不成立
  2. 用法 A 的下游表必须有主键------没主键的 append 表消费不了 -U/-D(无从删除),只能接 append 流

和流写对称 :流写时你只写 INSERT INTO,底下 Checkpoint、Writer/Committer、rename 提交全自动;流读时你只写 SELECT / GROUP BY,底下 changelog 生成、RowKind 透传全自动。SQL 层无感------但建表参数决定底层语义:参数配错(比如 changelog-producer=none),SQL 写得再对结果也是错的。语法上无感,语义上必须懂。

3 changelog 从哪来:changelog-producer 四种模式

关键问题:Paimon 表的数据文件里存的是追加的 sorted run(新版本),不是变更日志。下游要的 +I/-U/+U/-D 变更流从哪来?

答案取决于 changelog-producer 配置------它决定 Paimon 怎么生成 changelog:

模式一:none(默认)

不主动生成 changelog。下游流读时只能拿到"新追加的数据文件"------对于主键表,这些文件里的行是新版本,但下游不知道旧版本是什么,无法生成 -U(旧值)。

实际效果:下游只能近似读出 +I(新增行),无法准确识别更新和删除。适合 append 表(本来就没有更新),主键表不推荐。

模式二:input

直接保存上游输入的 changelog。要求上游是 CDC 源(如 MySQL CDC、Flink CDC),自带 +I/-U/+U/-D 语义。Paimon 原样把 changelog 存下来,下游读时直接取。

  • 上游发 +I(order_001, 待支付) → Paimon 存 +I → 下游读 +I
  • 上游发 -U(order_001, 待支付), +U(order_001, 已支付) → Paimon 存这对变更 → 下游读 -U+U

优点:changelog 最准确、最完整(直接来自源头)。缺点:要求上游必须是 CDC 源,且额外存储 changelog 文件。

changelog.time-retained 控制保留多长时间的 changelog------短链路(如订单状态变更)设几小时够用,长了浪费空间。这个参数第四篇展开。

模式三:lookup

上游不是 CDC 源(比如就是普通 Kafka 数据流),但下游需要 changelog。Paimon 在 Committer 提交时主动查找旧版本:

  • order_001 新版本"已支付"写进来 → Committer 查找 order_001 的旧版本 → 找到"待支付" → 生成 -U(order_001, 待支付), +U(order_001, 已支付)
  • order_002 是全新订单 → Committer 查找旧版本 → 没找到 → 生成 +I(order_002, 待支付)

优点:不要求上游是 CDC 源,Paimon 自己补全旧值。缺点:Committer 提交时多一次查找,增加提交延迟。

模式四:full-compaction

通过全量 compaction 生成 changelog。每次 full compaction 后,对比前后两次全量结果,输出差异作为 changelog。

  • compaction 前的全量:order_001=待支付
  • compaction 后的全量:order_001=已支付
  • 差异:-U(order_001, 待支付), +U(order_001, 已支付)

优点:实现最简单,不依赖上游、不额外查找。缺点:延迟大------只有 compaction 后才有 changelog,compaction 频率决定 changelog 延迟。

4 四种模式对比

模式 changelog 来源 上游要求 延迟 准确性 适合
none 不生成 --- 无 -U/-D append 表
input 上游 CDC CDC 源 低(随 Checkpoint) 完整 MySQL CDC → Paimon
lookup Committer 查旧值 中(提交时查找) 完整 Kafka → Paimon,要 changelog
full-compaction compaction 对比 高(等 compaction) 完整 对延迟不敏感

选型逻辑:

  • 上游是 CDC → input(最准最省力)
  • 上游不是 CDC 但要 changelog → lookup(补全旧值)
  • 对 changelog 延迟不敏感 → full-compaction(最省事)
  • append 表 / 不需要 changelog → none

5 +I/-U/+U/-D 怎么产生(以 lookup 为例)

用 order_001 走一遍 lookup 模式下 changelog 的产生过程:

第一次 Checkpoint(10:00):order_001=待支付 是新数据

  1. Writer-0 flush 出 data-0-1(order_001=待支付)
  2. Committer 提交时查找 order_001 旧版本 → 没找到(第一次写)
  3. 生成 changelog:+I(order_001, 待支付)
  4. 创建 snapshot-1

第二次 Checkpoint(10:01):order_001 改成 已支付

  1. Writer-0 flush 出 data-0-2(order_001=已支付)
  2. Committer 提交时查找 order_001 旧版本 → 在 data-0-1 找到"待支付"
  3. 生成 changelog:-U(order_001, 待支付), +U(order_001, 已支付)
  4. 创建 snapshot-2

下游 Flink 作业收到:

复制代码
+I(order_001, 待支付)       ← snapshot-1 的 changelog
-U(order_001, 待支付)       ← snapshot-2 的 changelog(前半:旧值)
+U(order_001, 已支付)       ← snapshot-2 的 changelog(后半:新值)

下游拿到这组变更流,就可以做 Temporal Join------按事件时间翻版本,这正是 Flink 系列维表 join 篇里"维表按版本变化"的来源。Paimon 做 Temporal Join 的维表时,必须以 changelog 流模式读取(不能读 latest 快照),否则拿不到 -U/+U,维表更新就丢语义------这是 Paimon 维表的硬约束。

五 验证

sql 复制代码
-- 1. 建表(Spark SQL),指定 changelog-producer = lookup
CREATE TABLE orders (
    order_id STRING,
    order_status STRING,
    city STRING,
    amount DECIMAL(10,2),
    order_time TIMESTAMP(3),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'paimon',
    'bucket' = '2',
    'warehouse' = 's3://warehouse',
    'changelog-producer' = 'lookup'
);

写两条数据(Spark 批写,模拟初始化):

sql 复制代码
INSERT INTO orders VALUES
    ('order_001', '待支付', '北京', 100.00, TIMESTAMP '2026-08-29 10:00:00'),
    ('order_002', '待支付', '上海', 200.00, TIMESTAMP '2026-08-29 10:00:00');

改一条状态(Spark 批写,模拟修正):

sql 复制代码
INSERT INTO orders VALUES
    ('order_001', '已支付', '北京', 100.00, TIMESTAMP '2026-08-29 10:00:00');

时间旅行验证------查写入前的版本:

sql 复制代码
SELECT * FROM orders VERSION AS OF 1;
-- order_001=待支付(改之前的状态)

SELECT * FROM orders;
-- order_001=已支付(最新)

增量读验证------查两次写入之间的变化:

sql 复制代码
SELECT * FROM orders /*+ OPTIONS('incremental-between' = '1,2') */;
-- 返回 order_001=已支付(snapshot-1 到 snapshot-2 的增量)

查快照清单验证:

sql 复制代码
SELECT * FROM orders$snapshots;
-- snapshot_id | commit_user  | commit_time           | record_count
-- 1           | spark        | 2026-08-29T10:00:00Z  | 2
-- 2           | spark        | 2026-08-29T10:01:00Z  | 2

六 小结

  • 流写:Writer 写数据文件,Committer 生成 manifest + snapshot 做 rename 提交------两步分离保证原子性;可见性延迟 ≈ Checkpoint 间隔;失败的孤儿文件不影响正确性,过期自动清理
  • 批写:Spark 一次性写完提交,没有 Writer/Committer 分离,适合初始化导入和批量回灌
  • 批读:默认读最新 snapshot;时间旅行通过改 snapshot 引用实现(轻量,不动数据);增量读通过对比两个 snapshot 的 manifest 找新文件
  • 流读:下游 Flink 作业持续监听新 snapshot;changelog-producer 决定 changelog 怎么生成------input(上游 CDC 直存)、lookup(Committer 查旧值补全)、full-compaction(compaction 对比)、none(不生成)

下一篇是系列最后一篇:选型与参数配置------主键表 vs append 怎么选、bucket 数量怎么定、compaction 频率怎么调、快照保留多久、Paimon 全参数分类表。把前三篇的概念变成生产决策。

相关推荐
老当益壮梁奶奶1 小时前
Linux软件编程学习笔记(八):进程间通信详解(1)
linux·c语言·笔记·学习·算法
weixin_466068113 小时前
《十分钟冥想》精读笔记(上):每天10分钟,给大脑做一次“系统减负”
笔记
凯尔萨厮3 小时前
Java学习笔记十(注解)
java·笔记·学习
JoannaJuanCV4 小时前
VLM学习-SFT(监督微调)
深度学习·学习·机器学习·大模型·视觉大模型·vlm·视觉编码器
程序员大雄学编程5 小时前
微积分43. 无穷积分入门:从概念到Python实战可视化
开发语言·python·学习·微积分
一条破秋裤5 小时前
STM32 学习笔记:GPIO 输出实验——LED 闪烁、流水灯与蜂鸣器
笔记·stm32·学习
2601_962073975 小时前
【MySQL】全面学习数据库查询技巧:查询指令深度学习指南
数据库·学习·mysql
存在morning5 小时前
【Paimon 学习笔记 二】存储架构:快照、manifest、LSM 树、bucket
笔记·学习·架构
双眼鈹6 小时前
周报8.31
笔记·学习