3. 隐藏的定时炸弹:过期Snapshot与Orphan文件清理不及时的成本陷阱
说实话,很多团队刚开始用 Iceberg 的时候,最关注的是「ACID 事务」和「时间旅行」这些炫酷特性。但很少有人会去想一个问题:每次写入、每次提交,背后到底留下了多少垃圾?
我见过不止一个案例——业务跑得好好的,突然某天收到云厂商的账单预警,存储费用翻了两倍。查了半天,发现是 Iceberg 表里堆积了成千上万个过期 Snapshot 和 Orphan 文件。说白了,这些就是你的存储账单里最隐蔽的定时炸弹。
3.1 过期Snapshot:你以为删了,其实还在
Iceberg 的每次写入都会生成一个新的 Snapshot。这个 Snapshot 本质上是一个元数据快照,指向当前表的所有数据文件。好处是你可以随时回退到任意历史版本,坏处是——旧版本的数据文件并不会自动删除。
举个例子:
-- 查看当前表的 Snapshot 数量
SELECT snapshot_id, committed_at, parent_id
FROM my_table.snapshots
ORDER BY committed_at DESC;
-- 结果可能让你吓一跳
-- 明明只做了 10 次写入,却可能有 50 个 Snapshot
-- 因为每次 compaction、每次 rewrite 都会产生新 Snapshot
为什么会这样?因为 Iceberg 的设计哲学是「安全第一」。它不会主动删任何文件,怕你哪天想回溯数据。但问题来了——如果你不主动清理,这些文件就会一直躺在 S3 或 HDFS 上,按月计费。
核心结论:过期 Snapshot 占用的存储空间,可能占你表总存储的 30%-60%。而且它们通常是小文件,碎片化严重,删除效率低,成本却一点不少。
我在项目中遇到过一家做实时数仓的公司,他们的 Iceberg 表每天写入 200 次,但 Snapshot 保留策略设成了 7 天。你算算——200 × 7 = 1400 个 Snapshot。每个 Snapshot 哪怕只多保留 1 个数据文件,1400 个文件就是额外的存储开销。他们的月存储费从 800 美元直接飙到了 4500 美元。
3.2 Orphan文件:那些没人要的「孤儿」
Orphan 文件比过期 Snapshot 更隐蔽。它们不是 Snapshot 的一部分,而是因为写入失败、任务重试、或者手动操作中断而留下的「孤儿」。
常见的产生场景:
- 写入中途失败:Spark/Flink 任务写到一半挂了,临时文件没来得及清理
- Compaction 残留:合并任务执行过程中,旧文件标记删除但实际没删干净
- 手动删除元数据:有人直接删了 metadata 目录下的某些文件,但数据文件还在
- 并发写入冲突:多个任务同时写同一张表,部分文件被遗弃
这些文件有什么特点?它们不在 Iceberg 的元数据里,所以 Iceberg 根本不知道它们存在。你查 Snapshot、查 Manifest,都看不到它们。但它们确确实实占着存储空间,每个月给你贡献一笔「隐形账单」。
注意:Orphan 文件通常是小文件(几 KB 到几 MB),数量却可能成千上万。在对象存储(如 S3)上,即使文件再小,只要数量多,List 操作和 Delete 操作都会产生 API 费用。这笔钱虽然单次不高,但积少成多,一个月下来也是一笔不小的开支。
3.3 成本陷阱的量化分析
我们来算一笔账,让你更直观地感受一下这个陷阱有多大。
| 项目 | 正常状态 | 未清理状态 | 成本差异 |
|---|---|---|---|
| Snapshot 数量 | 50 个 | 500 个 | 10 倍 |
| 数据文件总数 | 1,000 个 | 8,000 个 | 8 倍 |
| 总存储量 | 500 GB | 1.2 TB | 2.4 倍 |
| 月存储费用(S3) | $115 | $276 | +$161 |
| 月 API 费用 | $5 | $35 | +$30 |
| 总计 | $120 | $311 | +$191/月 |
你看,仅仅因为没清理,每个月多花将近 200 美元。一年就是 2400 美元。这还只是一张表。如果你的数仓有 50 张表呢?100 张呢?
我的建议:不要等到账单出来才后悔。把清理策略纳入日常运维,就像定期倒垃圾一样。Iceberg 提供了 expireSnapshots 和 removeOrphanFiles 两个核心操作,用好了,这笔钱就能省下来。
3.4 清理机制的核心逻辑
Iceberg 的清理其实不复杂,但很多人用错了。我简单梳理一下:
过期 Snapshot 清理:
-- Spark SQL 方式
CALL catalog.system.expire_snapshots(
table = 'my_db.my_table',
older_than = TIMESTAMP '2024-01-01 00:00:00',
retain_last = 5
);
-- 或者用 Java API
Table table = catalog.loadTable(TableIdentifier.of("my_db", "my_table"));
table.expireSnapshots()
.expireOlderThan(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
.retainLast(3)
.commit();
这里有两个参数很关键:older_than 和 retain_last。前者指定时间阈值,后者保留最近 N 个 Snapshot。我个人习惯是「保留最近 3 天 + 最近 5 个 Snapshot」,这样既能保证时间旅行能力,又不会积压太多。
Orphan 文件清理:
-- Spark SQL 方式
CALL catalog.system.remove_orphan_files(
table = 'my_db.my_table',
older_than = TIMESTAMP '2024-01-01 00:00:00'
);
-- 注意:这个操作默认只做「扫描并列出」,不会真的删除
-- 需要加参数才执行删除
CALL catalog.system.remove_orphan_files(
table = 'my_db.my_table',
older_than = TIMESTAMP '2024-01-01 00:00:00',
dry_run = false
);
我曾经踩过的坑:第一次跑 remove_orphan_files 时,我忘了设 dry_run = false,结果扫了半天,一个文件都没删。后来才发现默认是只读模式。另外,older_than 的时间要设得保守一点——我建议至少设 3 天以上,避免误删正在写入的临时文件。
3.5 一张图看懂清理流程
下面这张图展示了过期 Snapshot 和 Orphan 文件的清理流程,以及它们在整个 Iceberg 存储体系中的位置:
3.6 实战避坑指南
最后,分享几个我踩过的坑,希望能帮你少走弯路:
- 不要在生产环境直接跑清理——先在测试表上验证
dry_run = true,确认不会误删数据再执行。 - 清理频率要匹配写入频率——如果每小时写入一次,每天清理一次就够了。如果每分钟写入一次,建议每 4-6 小时清理一次。
- 注意对象存储的 API 限流——一次性删除几万个文件,S3 可能会限流。建议分批删除,每次不超过 1000 个。
- 监控清理任务的执行时间——如果清理时间越来越长,说明你的表可能已经积累了太多垃圾,需要调整清理策略。
- 保留至少 2-3 个 Snapshot——别为了省钱把 Snapshot 全删了,万一需要时间旅行回溯数据,你就傻眼了。
一句话总结:过期 Snapshot 和 Orphan 文件就像你家里的旧报纸和空瓶子——不扔吧,占地方;扔了吧,又怕哪天要用。Iceberg 给了你工具,但用不用、怎么用,决定了你的存储账单是「合理」还是「爆炸」。