4. 快照过期机制:过期定义、保留策略、过期流程

Iceberg 表的数据文件之所以能够被安全删除,依赖的是一套严谨的快照过期(Snapshot Expire)机制。理解这一机制,是治理孤儿文件、控制存储成本的前提。本节将围绕“什么是过期”“保留多少”“如何执行”三个核心问题展开。

4.1 过期定义:快照的生命周期终点

在 Iceberg 中,快照(Snapshot)是表在某一时刻的完整元数据视图,它通过清单列表(Manifest List)指向一组清单文件(Manifest),进而指向数据文件。只要一个快照存在,它引用的所有数据文件就都被视为“活跃数据”,不能被物理删除。

所谓快照过期,是指将超过保留阈值、且不再被任何读取需求引用的快照从表的元数据中移除。过期操作并不直接删除数据文件,而是先切断元数据对数据文件的引用关系,使这些数据文件变成“孤儿文件”,等待后续的 RemoveOrphanFiles 动作进行物理清理。

关键判断标准:一个快照是否“过期”,取决于它是否同时满足以下两个条件——
  • 时间条件:快照的提交时间早于当前时间减去保留期限(如 7 天);
  • 引用条件:该快照不是表当前版本(current snapshot),也不在“分支(branch)或标签(tag)”的保留引用列表中。

需要特别指出的是,Iceberg 的 expire_snapshots 过程默认只删除那些“比指定时间戳更早”且“不在保留集合中”的快照。如果某张表只有一个快照,即使它已经存在了 100 天,也不会被过期删除,因为它是当前快照。

快照过期机制核心流程 t0 t_now Snapshot 1 提交: t0 状态: 过期 Snapshot 2 提交: t1 状态: 过期 Snapshot 3 提交: t2 保留(branch) Snapshot 4 提交: t3 当前版本 保留窗口(如7天) 可过期区间 → 数据文件变为孤儿 → 数据文件仍被引用 → 数据文件活跃

4.2 保留策略:默认值与配置项

Iceberg 提供了两种维度的保留策略:基于时间的保留和基于数量的保留。两者可以同时生效,只要满足任一保留条件,快照就不会被过期。

配置项 默认值 说明
write.metadata.delete-after-commit.enabled false 提交后是否自动删除旧元数据文件
write.metadata.previous-versions-max 100 保留的元数据版本数量(含当前)
history.expire.max-snapshot-age-ms 5天(432000000 ms) 快照最大存活时间
history.expire.min-snapshots-to-keep 1 最少保留的快照数量
history.expire.max-ref-age-ms 10天(864000000 ms) 分支/标签引用的最大存活时间

在实际生产环境中,推荐使用 Spark 或 Flink 定期执行过期任务,而不是依赖自动提交后的元数据清理。以下是一个典型的 Spark 过期命令:

-- 使用 Spark SQL 过期快照
CALL spark_catalog.system.expire_snapshots(
  table = 'db.user_events',
  older_than = TIMESTAMP '2024-06-01 00:00:00',
  retain_last = 5,
  stream_results = false
);

其中 older_than 指定时间阈值,retain_last 指定无论时间如何都保留的最近快照数量。两者是“或”的关系——只要快照年龄超过阈值,或者它不是最近 N 个快照之一,就会被标记为可过期。

4.3 过期流程:从元数据到数据文件的级联

快照过期的执行过程可以拆解为以下四个阶段,理解每个阶段有助于排查“为什么空间没有释放”的问题。

  1. 候选快照筛选:Iceberg 读取表元数据中所有快照,根据 older_than 和 retain_last 条件筛选出候选集合。同时检查每个快照是否被分支(branch)或标签(tag)引用,被引用的快照会被排除。
  2. 元数据引用清理:从表的历史记录中移除候选快照的条目,更新 current-snapshot-id 和 snapshot-log。此步骤只修改元数据,不触碰数据文件。
  3. 数据文件引用计数递减:Iceberg 遍历被移除快照的清单列表,对其中的每个数据文件执行引用计数减一。只有当引用计数降为零时,该数据文件才会被加入“待删除”列表。
  4. 物理删除与统计:对待删除列表中的数据文件执行 FileIO.deleteFile() 操作。删除完成后,返回统计信息(如删除了多少个数据文件、释放了多少字节)。
重要提示:在 Iceberg 1.4 及以上版本中,expire_snapshots 默认使用“引用计数”机制,而不是简单的“快照遍历”。这意味着如果两个不同快照引用了同一个数据文件(例如 UPDATE 操作未重写所有文件),该文件只有在所有引用它的快照都过期后才会被真正删除。

4.4 案例:一个典型的过期执行与验证

假设有一张电商订单表 ods.orders,每天有约 2000 万条新增数据,每次写入生成一个新快照。运维策略要求保留最近 3 天的快照用于时间旅行查询,同时保留最近 10 个快照用于故障回溯。

执行过期操作:

CALL spark_catalog.system.expire_snapshots(
  table = 'ods.orders',
  older_than = TIMESTAMP '2024-06-10 00:00:00',  -- 3天前
  retain_last = 10
);

执行结果示例:

----------------------------------------------
Result
----------------------------------------------
Deleted 12 snapshots
Deleted 47 data files (1.2 GB)
Deleted 8 manifest files
Total files removed: 55
----------------------------------------------

验证是否还有孤儿文件残留:

CALL spark_catalog.system.remove_orphan_files(
  table = 'ods.orders',
  older_than = TIMESTAMP '2024-06-10 00:00:00'
);

如果第二次调用返回“No orphan files found”,说明过期操作已经完整清理了所有不再被引用的数据文件。如果仍有孤儿文件,通常是因为这些文件是在快照过期之后才变成无引用的(例如并发写入导致元数据竞争),或者文件修改时间晚于 older_than 阈值。

4.5 误区与复盘:快照过期不等于空间立即释放

常见误区一:认为执行了 expire_snapshots 后存储空间会立刻下降。实际上,如果底层文件系统(如 HDFS)开启了回收站,删除的文件会先进入回收站,需要等待回收站清理策略生效后空间才会真正释放。此外,云存储(如 S3)上的删除是最终一致的,可能会有短暂的延迟。
常见误区二:忽略分支和标签对快照的“保活”作用。如果为某个历史快照创建了 tag(例如 CREATE TAG monthly_archive),该快照即使超过 older_than 阈值也不会被过期。这在某些场景下是期望行为,但也可能导致数据文件长期无法清理。

一个值得复盘的典型场景是:某团队对一张大表执行了频繁的 UPDATE 操作,每次 UPDATE 都会生成新快照并重写大量数据文件。由于没有及时执行过期操作,快照数量在两周内从 20 个增长到 500 多个,存储占用从 2 TB 膨胀到 8 TB。执行过期后,虽然快照数量降到了 15 个,但存储只回落到 5 TB——原因是部分数据文件被多个快照引用,引用计数未归零。最终通过结合 rewrite_data_files 合并小文件,才将存储压缩到 2.5 TB。

这个案例说明:快照过期是治理的第一步,但不是全部。合理的写入频率控制、定期过期策略、以及必要时的数据文件重写,三者缺一不可。建议将过期操作纳入日常自动化运维,例如每天凌晨 2 点执行一次,并配合监控告警,观察每次过期的删除文件数和释放空间量,及时发现异常增长趋势。