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 开销巨大
- 存储成本高:对象存储按请求次数计费,小文件请求量巨大
- 压缩效率低:小文件无法有效利用列式存储的压缩优势
小文件问题的演进过程
我画了一张图,帮你理解小文件问题是怎么一步步恶化的:
如何判断小文件问题?
我一般用这个命令快速检查:
# 查看表文件统计信息
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 命令把小文件合并成大文件
这两个方法,我会在后面的章节里详细展开。这里先记住一句话:小文件不是病,文件太多真要命。