第二篇讲了存储架构------快照、manifest、LSM 树、bucket,是"零件"。结尾把写入/批读/流读的路径串了一遍,但每一步都是一句话带过。这篇把每一步拆开:谁负责写、谁负责提交、失败怎么办、changelog 从哪来。
继续用订单场景:orders 表(主键表,2 个 bucket),order_001 在 10:00 到达、10:01 改状态。
一 流写:Flink Checkpoint 提交的完整机制
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 举例:
- 10:00,order_001 进 Flink,hash 路由到 bucket-0 的 Writer-0
- Writer-0 把 order_001=待支付 写进内存
- Checkpoint 触发:Writer-0 把内存数据 flush 成 data-0-1.parquet
- Writer-0 告诉 Committer:"我写了 data-0-1"
- Committer 等所有 Writer 都报告完毕,生成 manifest-1(记录 data-0-1),创建 snapshot-1,rename 提交
- 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 怎么持续读增量
这是第二篇埋的最大伏笔------"下游 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'
);
工作机制:
- Flink 作业启动,先读当前指定 snapshot 的全量数据(初始化)
- 然后持续轮询
snapshot/目录,等新 snapshot 出现 - 新 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),状态变更后这条订单会被当成新数据再计一次,城市成交额直接算重。
两个前提记住:
- changelog-producer 不能是 none------none 模式下流读主键表只能拿到近似 +I,没有 -U/+U,用法 B 的"自动修正"不成立
- 用法 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=待支付 是新数据
- Writer-0 flush 出 data-0-1(order_001=待支付)
- Committer 提交时查找 order_001 旧版本 → 没找到(第一次写)
- 生成 changelog:+I(order_001, 待支付)
- 创建 snapshot-1
第二次 Checkpoint(10:01):order_001 改成 已支付
- Writer-0 flush 出 data-0-2(order_001=已支付)
- Committer 提交时查找 order_001 旧版本 → 在 data-0-1 找到"待支付"
- 生成 changelog:-U(order_001, 待支付), +U(order_001, 已支付)
- 创建 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 全参数分类表。把前三篇的概念变成生产决策。