S1 第 7 期:快照删了,文件为什么还在?
Apache Paimon 源码学习
作者 X老师(DaemonforY),Paimon 2.0 / master 源码,按 CC BY-NC-SA 4.0 发布。配套实验和代码在 GitHub。

TL;DR:快照只是一份“文件清单”,同一个数据文件会被多个快照共享。过期一个快照时,Paimon 不会去扫描所有快照找引用,而是只看后面的快照把哪些文件标记成了
DELETE,再排除仍被 Tag 引用的,剩下的才物理删除。所以在实验里,第一次过期删掉了一个快照,数据文件一个都没少。
实验准备
在一张主键表 orders_tt 上做 4 次提交(和第 4 期的操作完全一样):
| 快照 | 类型 | 做了什么 |
|---|---|---|
| 1 | APPEND | 插入订单 1~5 |
| 2 | APPEND | 订单 1 改为 PAID,新增订单 6 |
| 3 | APPEND | 删除订单 3 |
| 4 | COMPACT | 全量合并 |
然后给快照 1 打一个 Tag:
CALL sys.create_tag(`table` => 'default.orders_tt', tag => 'v1', snapshot_id => 1);完整 SQL 在仓库
labs/sql/lab02/,一条命令复现:cd labs && ./run.sh sql/lab02/step1-setup.sql
第一步:先看清“哪个快照用了哪些文件”

Paimon 的 $files 系统表也支持时间旅行,分别查 4 个快照,得到下面这张表:

几个关键点:
- 一共 8 个数据文件,但每个快照只引用其中一部分。
- 快照 1 的 3 个文件,快照 2、3 也在用——更新和删除都是追加新文件(第 4 期讲过),旧文件原封不动。
- 快照 4 是合并的结果,只引用 2 个新文件;对它来说,前面 6 个文件都已经“没用了”。
快照不是数据的拷贝,只是一份“此刻哪些文件有效”的清单。
第二步:过期快照的三道保险
过期快照用 expire_snapshots 存储过程。先试一下最直接的写法:
CALL sys.expire_snapshots(`table` => 'default.orders_tt', retain_max => 2);IllegalArgumentException: retainMax (2) must not be less than retainMin (10).报错了。原因是快照保留有三个参数,互相约束:

| 参数 | 默认值 | 作用 |
|---|---|---|
snapshot.num-retained.min | 10 | 最近 N 个快照永远保留,无论多老 |
snapshot.num-retained.max | 不限 | 最多保留 N 个,超出的旧快照直接过期,不看时间 |
snapshot.time-retained | 1 小时 | 介于两者之间的快照,要“足够老”才过期 |
只传 retain_max => 2,retain_min 仍是默认的 10,违反了 “max ≥ min”。
源码(ExpireSnapshotsImpl.expire())里这几行写得很清楚:
long min = Math.max(latestSnapshotId - retainMax + 1, earliest); // 第 143 行:超出 max 的强制过期
long maxExclusive = latestSnapshotId - retainMin + 1; // 第 148 行:最近 min 个不动
maxExclusive = Math.min(maxExclusive,
consumerManager.minNextSnapshot().orElse(Long.MAX_VALUE)); // 第 154 行:consumer 还在读的不动
maxExclusive = Math.min(maxExclusive, earliest + maxDeletes); // 第 159 行:单次最多过期 50 个
...
if (olderThanMills <= nextSnapshot.timeMillis()) { // 第 167 行:不够老就停注意第 167 行判断的是下一个快照的时间:一个快照在被下一个快照取代之前一直是“最新的”,所以要从下一个快照出现时开始计时。
第三步:第一次过期——删了快照,文件一个没少
CALL sys.expire_snapshots(`table` => 'default.orders_tt', retain_max => 3, retain_min => 1);返回 1:过期了 1 个快照。
- 快照目录:
snapshot-1没了,剩下snapshot-2/3/4。 - 数据文件:还是 8 个。
为什么只过期了快照 1?最多保留 3 个,快照 1 超出了,直接过期;快照 2、3 在 3 个以内,但它们才创建了几秒钟,不满足 1 小时的 time-retained,受保护。
为什么文件一个都没删?答案在下一节的源码里。
第四步:再过期两个,删掉了 3 个文件
把 time-retained 临时调成 1 毫秒:
CALL sys.expire_snapshots(`table` => 'default.orders_tt', retain_min => 1,
options => 'snapshot.time-retained=1ms');返回 2:快照 2、3 被过期,只剩快照 4。数据文件从 8 个变成 5 个。
被删掉的 3 个是 3148963f(订单 1 的新版本)、bd45851e(订单 6)、86db2fdb(订单 3 的删除标记)——它们只被快照 2、3 引用。
而快照 1 的 3 个文件还在,尽管快照 1 早就过期了。

源码:到底哪些文件会被删?

很多人以为过期快照时,Paimon 会检查“每个文件还有没有被任何快照引用”。实际上不是这样的。看 ExpireSnapshotsImpl.cleanDataFiles(第 275 行)和 FileDeletionBase.dataFilesToDelete(第 239 行):

- 过期区间是 [最早快照, 保留的第一个快照)。
- Paimon 只看这个区间之后的快照的 delta manifest,找出被标记为
DELETE的文件——也就是“后来的提交明确说不再需要”的文件,比如合并时被替换掉的旧文件。 - 这些文件里,还被某个 Tag 引用的跳过,其余物理删除。
用这个规则对照实验:
| 看哪个快照的 delta | 其中的 DELETE | 被 Tag v1 引用 | 实际删除 | |
|---|---|---|---|---|
| 第一次过期(快照 1) | 快照 2 | 无(快照 2 只新增了 2 个文件) | — | 0 个 |
| 第二次过期(快照 2、3) | 快照 3、4 | 快照 4(合并)标记了 6 个旧文件 | 其中 3 个 | 3 个 |
完全吻合。这个设计的好处是:过期时不需要扫描整张表的全部快照,只看增量,代价和要过期的快照数成正比。
第五步:Tag 是怎么保护数据的
快照 1 已经过期,按快照号读:
SELECT * FROM orders_tt /*+ OPTIONS('scan.snapshot-id' = '1') */;The specified scan snapshotId 1 is out of available snapshotId range [4, 4].但按 Tag 读:
SELECT * FROM orders_tt /*+ OPTIONS('scan.tag-name' = 'v1') */ ORDER BY order_id;5 行全在,连已经被删除的订单 3 也在——这就是快照 1 当时的样子。
Tag 本质上是把快照的 JSON 复制一份放到 tag/ 目录(实验 2 中,tag/tag-v1 和 snapshot/snapshot-1 的 uuid 完全相同),它不受快照过期影响,它引用的文件也就不会被删。
最后删掉 Tag:
CALL sys.delete_tag(`table` => 'default.orders_tt', tag => 'v1');数据文件从 5 个变成 2 个:Tag 独占的 3 个文件被删除,只剩快照 4 合并出来的 2 个。
生产上的几点提醒
- 想释放空间,要等快照过期:删除数据、合并之后,旧文件还被历史快照引用,磁盘不会立刻变小。默认至少保留 10 个快照、1 小时以内的快照不过期。
- Tag 会“钉住”文件:长期保留的 Tag 越多,能删的文件越少。用
time_retained给 Tag 设过期时间,或定期清理不用的 Tag。 - 流式读取要配 consumer-id:快照被过期后,停了很久的流作业可能读不到它需要的快照。
consumer-id能让快照过期避开正在被读取的快照(第 154 行那个consumerManager.minNextSnapshot()),后面专门一期讲。 - 过期是谁触发的:Flink 写入作业每次提交后会在 committer 里自动执行(
TableCommitImpl.maintain());也可以用expire_snapshots存储过程手动执行。
小结
- 快照 = 文件清单,文件被多个快照共享。
- 过期快照 ≠ 删除文件。只有被后续快照标记为 DELETE、且没有 Tag 再引用的文件才会被物理删除。
- 快照保留由
num-retained.min(默认 10)、num-retained.max(默认不限)、time-retained(默认 1 小时)共同决定。
下期预告:Tag 到底是什么?怎么用它做“每日数据版本”?
自测题:如果没有给快照 1 打 Tag,第二次过期会删掉几个文件?(答案:6 个——快照 4 标记 DELETE 的 6 个旧文件都没有 Tag 保护,最终只剩快照 4 的 2 个文件。)