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 存储体系中的位置:

Iceberg 存储清理流程 数据写入 / 提交 生成新 Snapshot 写入数据文件 对象存储 (S3/HDFS) 写入 过期 Snapshot 堆积 Orphan 文件残留 未清理 expireSnapshots removeOrphanFiles 清理策略建议 • Snapshot 保留 3-7 天 • Orphan 文件清理间隔 > 3 天 • 定期执行(每天/每周)

3.6 实战避坑指南

最后,分享几个我踩过的坑,希望能帮你少走弯路:

  1. 不要在生产环境直接跑清理——先在测试表上验证 dry_run = true,确认不会误删数据再执行。
  2. 清理频率要匹配写入频率——如果每小时写入一次,每天清理一次就够了。如果每分钟写入一次,建议每 4-6 小时清理一次。
  3. 注意对象存储的 API 限流——一次性删除几万个文件,S3 可能会限流。建议分批删除,每次不超过 1000 个。
  4. 监控清理任务的执行时间——如果清理时间越来越长,说明你的表可能已经积累了太多垃圾,需要调整清理策略。
  5. 保留至少 2-3 个 Snapshot——别为了省钱把 Snapshot 全删了,万一需要时间旅行回溯数据,你就傻眼了。

一句话总结:过期 Snapshot 和 Orphan 文件就像你家里的旧报纸和空瓶子——不扔吧,占地方;扔了吧,又怕哪天要用。Iceberg 给了你工具,但用不用、怎么用,决定了你的存储账单是「合理」还是「爆炸」。