Delta Lake增量存储成本分析与治理 - 第3节

增量数据写入机制:Append 与 Overwrite 模式

Delta Lake 的写入模式,说白了就两种:Append(追加)和 Overwrite(覆盖)。

我刚开始接触 Delta Lake 时,觉得这俩不就是「加数据」和「换数据」嘛,有啥好研究的?后来在线上环境踩了几次坑才发现——选错模式,成本能差出一个数量级。

Append 模式:增量写入的默认选择

Append 模式,就是往表里追加新数据。每次写入都会生成新的数据文件和对应的事务日志条目。

核心特征:

  • 不修改已有文件
  • 只新增文件
  • 事务日志记录新增文件的路径和统计信息

举个例子,你每天跑一次增量数据同步:

# 每天追加当天的订单数据
df.write \
  .mode("append") \
  .format("delta") \
  .save("/data/orders")

每次执行,Delta Lake 会生成一个新的 Parquet 文件。第一天 1 个文件,第二天 2 个,第 30 天就是 30 个。嗯,这里要注意——文件数量会线性增长。

Overwrite 模式:全量替换的代价

Overwrite 模式,是把表里所有数据都替换掉。Delta Lake 会先标记旧文件为「删除」,再写入新文件。

注意:Overwrite 不是「原地修改」,而是「先删后写」。旧文件不会立即物理删除,只是被标记为 tombstone(墓碑)。

# 全量覆盖订单表
df.write \
  .mode("overwrite") \
  .format("delta") \
  .save("/data/orders")

我在项目中遇到过一种情况:业务方要求每天全量刷新一张 500GB 的表。用 Overwrite 模式跑了三个月,存储成本翻了一倍。为什么?因为旧文件还在事务日志里保留着,VACUUM 没跑干净。

两种模式的成本对比

对比维度 Append 模式 Overwrite 模式
文件生成方式 新增文件 删除旧文件 + 生成新文件
历史版本保留 天然保留所有版本 旧版本被标记删除
存储开销趋势 线性增长 周期性峰值(新旧文件共存期)
事务日志大小 每条记录新增少量条目 每条记录大量条目(删除+新增)
适合场景 增量同步、日志采集 全量刷新、数据重跑

事务日志的存储开销

事务日志(Transaction Log)是 Delta Lake 的核心机制。每次写入、删除、更新操作,都会在 _delta_log 目录下生成一个 JSON 文件。

你想想看,一天跑 100 次写入,一年就是 36500 个 JSON 文件。每个文件虽然不大(几 KB 到几十 KB),但架不住数量多。

我的经验:有个客户的数据湖跑了两年,_delta_log 目录占了 200GB 存储空间。虽然相比数据文件不算大,但文件数量超过 50 万,导致文件系统 inode 耗尽,Spark 读取时列出目录都超时。

事务日志的开销主要体现在三个方面:

  • 存储空间:每个 JSON 文件包含操作类型、文件路径、统计信息等元数据
  • 读取性能:Spark 读取表时需要回放事务日志,日志越长,启动越慢
  • 文件数量:大量小 JSON 文件对对象存储(如 S3、OSS)不友好

我曾经踩过一个坑:一个高频写入的表,每秒写入一次,事务日志每小时生成 3600 个文件。一周后,读取这个表需要 5 分钟才能完成日志回放。后来加了 delta.logRetentionDuration 配置,才把问题压下去。

小文件问题的根源

小文件问题,是 Delta Lake 存储成本管控里最头疼的问题之一。说白了,就是文件太小、太多。

小文件是怎么产生的?

根源其实很简单:每次写入都生成新文件。

Append 模式下,每次写入的数据量可能很小。比如:

  • 实时流写入,每条记录一个批次
  • 定时任务,每次只更新几百条数据
  • 分区写入,每个分区只写少量数据

这些场景都会产生大量小文件。我见过最夸张的情况:一个表有 10 万个小文件,每个文件只有几十 KB。

小文件的危害:

  • 元数据膨胀:事务日志记录大量文件路径
  • 读取性能差:Spark 需要打开大量文件,I/O 开销巨大
  • 存储成本高:对象存储按请求次数计费,小文件请求量巨大
  • 压缩效率低:小文件无法有效利用列式存储的压缩优势

小文件问题的演进过程

我画了一张图,帮你理解小文件问题是怎么一步步恶化的:

小文件问题演进过程 阶段1:正常写入 文件大小合理 阶段2:频繁写入 文件数量增多 阶段3:小文件泛滥 性能急剧下降 崩溃 每个阶段的特征 阶段1:每次写入 100MB+,文件数量可控 阶段2:写入频率增加,每次写入 1-10MB,文件数开始增长 阶段3:文件数超过 1 万,每个文件几十 KB,读取性能下降 10 倍 阶段4:文件数超过 10 万,事务日志膨胀,Spark 作业频繁 OOM 避坑建议: 1. 控制写入频率,合并小批次 2. 定期执行 OPTIMIZE 合并文件 3. 设置合理的分区策略,避免过度分区

如何判断小文件问题?

我一般用这个命令快速检查:

# 查看表文件统计信息
DESCRIBE DETAIL /data/orders;

# 查看文件数量
SELECT count(*) FROM (
  SELECT input_file_name() as file_name
  FROM delta.`/data/orders`
) t;

如果文件数量超过 1 万,或者平均文件大小小于 64MB,就该警惕了。

我曾经踩过的坑:有个实时流任务,每 5 秒写入一次,每次写入几十条数据。跑了三天,生成了 5 万个小文件。读取时 Spark 直接报错:

org.apache.spark.SparkException: 
  Job aborted due to stage failure: 
  Task 0 in stage 1.0 failed 4 times, 
  most recent failure: Lost task 0.3 in stage 1.0:
  java.lang.OutOfMemoryError: GC overhead limit exceeded

后来加了 coalesce(1) 和 OPTIMIZE 才解决。

小文件问题的本质

你想想看,小文件问题的本质是什么?

不是文件小,而是文件数量和写入频率不匹配。每次写入都生成新文件,写入越频繁,文件越多。而 Delta Lake 的事务日志又忠实地记录了每一个文件,导致元数据也跟着膨胀。

所以,解决小文件问题的核心思路就两个:

  • 减少写入次数:合并小批次,降低写入频率
  • 合并已有文件:用 OPTIMIZE 命令把小文件合并成大文件

这两个方法,我会在后面的章节里详细展开。这里先记住一句话:小文件不是病,文件太多真要命。