Lakehouse 核心概念:开放表格式深度解析
聊 Lakehouse,绕不开一个核心问题:数据到底怎么存、怎么管、怎么保证一致性?
传统 Hive 表,说白了就是个目录加一堆文件。你写个 Spark 任务跑一半挂了,数据可能就处于“半死不活”的状态。我早年做数仓迁移时,就吃过这个亏——凌晨跑批,ETL 中途失败,结果第二天报表数据对不上,排查了整整两天。
后来 Delta Lake、Iceberg、Hudi 这些开放表格式(Open Table Format)陆续出现,才真正把“事务”、“版本”、“模式演进”这些能力带进了数据湖。今天我就带你拆开看看,它们到底是怎么工作的。
为什么需要开放表格式?
你想想看,传统数据湖(Hive 表)有什么痛点:
- 没有事务:并发写的时候,数据可能读到一半的结果
- 没有版本:数据错了,想回滚?不好意思,你得自己写脚本恢复
- 性能差:哪怕只读一行,也得扫整个分区目录
- 模式僵化:加个字段?删个字段?得重建表
开放表格式就是来解决这些问题的。它们本质上是一层元数据管理层,架在数据文件(Parquet/ORC/Avro)之上,用日志或清单文件来记录每一次变更。
核心思想:把数据库的 ACID 事务能力,带到廉价的对象存储(S3、OSS、HDFS)上。
三大主流格式:Delta Lake、Iceberg、Hudi
目前市面上最活跃的就是这三家。我分别讲讲它们的原理和我的使用感受。
Delta Lake
Delta Lake 是 Databricks 开源的,也是 Spark 生态里最成熟的方案。它的核心是一个 事务日志(Transaction Log),存在 _delta_log 目录下,每个 JSON 文件代表一次提交。
-- 数据目录结构示例
/mnt/events/
├── _delta_log/
│ ├── 00000000000000000000.json
│ ├── 00000000000000000001.json
│ └── 00000000000000000002.json
├── part-00000-xxx.snappy.parquet
└── part-00001-xxx.snappy.parquet
每次写操作,Delta Lake 都会生成一个新的 JSON 日志条目。读数据时,引擎会从日志里找到最新版本,只读那些“活着的”文件。这就是时间旅行(Time Travel)的基础。
我的经验:Delta Lake 的日志文件如果积累太多(比如几万个),读取性能会下降。我建议定期执行 VACUUM 清理旧版本,保留最近 7 天就够了。
Delta Lake 的另一个亮点是 Schema Enforcement。写入的数据如果和表结构不匹配,默认会直接报错。这在生产环境里非常有用——我曾经遇到过上游改了字段类型,下游没通知,结果 Delta Lake 直接拒绝了写入,避免了脏数据扩散。
Apache Iceberg
Iceberg 是 Netflix 开源的,后来捐给了 Apache。它的设计哲学和 Delta Lake 不太一样——它用 元数据树(Metadata Tree) 来管理版本。
Iceberg 的元数据分三层:
- Catalog:指向当前最新的元数据文件
- Metadata File:记录表结构、分区信息、快照列表
- Manifest List / Manifest File:记录具体的数据文件列表
这种分层设计,让 Iceberg 在大规模分区表的场景下表现非常出色。你想想看,如果一张表有几千个分区,Delta Lake 的日志文件会膨胀得很厉害,但 Iceberg 的 Manifest 文件可以高效地跳过不相关的分区。
-- Iceberg 的元数据文件结构
/warehouse/events/
├── metadata/
│ ├── v1.metadata.json
│ ├── v2.metadata.json
│ ├── snap-12345-1-xxx.avro
│ └── snap-12345-2-xxx.avro
└── data/
└── ...
注意:Iceberg 的元数据文件是不可变的,每次更新都会生成新文件。如果你的表更新非常频繁(比如每分钟一次),元数据文件会增长很快。建议配合 expireSnapshots 定期清理。
我个人比较喜欢 Iceberg 的一点是它的计算引擎无关性。Delta Lake 和 Spark 绑定得比较紧,而 Iceberg 原生支持 Spark、Flink、Trino、Presto 等多种引擎。如果你团队里混用多种计算框架,Iceberg 会更友好。
Apache Hudi
Hudi 是 Uber 开源的,它的强项是增量处理和近实时写入。Hudi 引入了两个核心概念:
- Copy-on-Write (COW):写入时直接重写整个文件,读性能好
- Merge-on-Read (MOR):写入时只写增量日志,读时合并,写性能好
Hudi 的增量查询能力非常强。你可以只读取某个时间点之后变更的数据,这对于 CDC(变更数据捕获)场景特别有用。我做过一个实时数仓项目,用 Hudi 的 MOR 表接收 Kafka 的 CDC 流,延迟控制在 1 分钟以内。
-- Hudi 的增量查询示例(Spark SQL)
SELECT * FROM hudi_table
WHERE `_hoodie_commit_time` > '20240101000000'
AND `_hoodie_commit_time` <= '20240102000000'
避坑指南:Hudi 的 MOR 表在读取时会有合并开销。如果查询频繁,建议用 COW 表。我曾经在生产环境里把一张高频查询的表设成了 MOR,结果查询延迟从 200ms 飙到了 3s,后来改成 COW 才恢复正常。
三大格式对比
我把它们的关键差异整理成了表格,方便你快速参考:
| 特性 | Delta Lake | Apache Iceberg | Apache Hudi |
|---|---|---|---|
| 开源方 | Databricks | Netflix → Apache | Uber → Apache |
| 元数据管理 | 事务日志(JSON) | 元数据树(Avro/JSON) | 时间线(Timeline) |
| 事务能力 | ACID,乐观并发 | ACID,乐观并发 | ACID,基于时间线 |
| 时间旅行 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| Schema 演进 | ✅ 支持(增删改字段) | ✅ 支持(增删改字段、重命名) | ✅ 支持(增删改字段) |
| 增量查询 | 需通过 Change Data Feed | 需通过 Incremental Read | 原生支持(强项) |
| 存储模式 | COW(默认) | COW(默认) | COW + MOR |
| 引擎支持 | Spark(最佳)、Flink、Presto | Spark、Flink、Trino、Presto、Hive | Spark、Flink、Presto、Hive |
| 适用场景 | Spark 生态、批处理、ETL | 多引擎混用、大规模分区表 | 近实时写入、CDC、增量处理 |
核心原理:它们是怎么做到 ACID 的?
说白了,这三种格式都用了同一个套路——乐观并发控制(Optimistic Concurrency Control)。
流程大致是这样的:
- 读取当前元数据(日志或清单)
- 在本地执行写入操作,生成新的数据文件
- 尝试提交新的元数据(原子操作)
- 如果提交时发现元数据已经被别人更新了,就重试
这个“原子提交”是怎么实现的?Delta Lake 依赖 文件系统 rename 的原子性(比如 S3 的 putObject),Iceberg 和 Hudi 则依赖 Catalog 的原子操作(比如 Hive Metastore 的锁,或者 AWS Glue Catalog 的条件更新)。
关键点:没有全局锁,没有两阶段提交。靠的是“先检查、后写入、再验证”的乐观策略。这在云存储上非常高效,但并发冲突高的时候,重试成本也会上升。
SVG 架构图:开放表格式的核心逻辑
下面这张图展示了三种格式的元数据管理方式对比:
怎么选?我的建议
没有银弹。我根据实际项目经验,给你几个参考方向:
- 如果你团队 Spark 技术栈很重,选 Delta Lake。它和 Spark 的集成最丝滑,性能调优也最方便。
- 如果你需要多引擎混用(比如 Spark 做 ETL,Trino 做 Ad-hoc 查询),选 Iceberg。它的引擎兼容性最好。
- 如果你有近实时写入或 CDC 需求,选 Hudi。它的增量查询和 MOR 模式是独门绝技。
我的个人习惯:新项目我一般先用 Iceberg 起步,因为它最灵活。如果后续发现性能瓶颈,再针对性地调整格式。别一开始就纠结“选哪个最好”,先跑起来再说。
嗯,开放表格式的内容就讲到这里。记住一句话:它们不是替代关系,而是工具选择。理解它们的原理,你才能在合适的场景里做出正确的决策。