3. 湖仓一体架构
好,我们进入第三章。这一章聊的是湖仓一体,也就是 Lakehouse。说实话,这个概念刚出来的时候,我第一反应是——这不就是数据湖加了个事务层吗?后来深入用了才发现,事情没那么简单。
3.1 为什么需要 Lakehouse?
先说说背景。传统架构里,数据湖和数据仓库是分开的。数据湖存原始数据,便宜但乱;数据仓库做分析,干净但贵。你想想看,一个团队要维护两套系统,数据还得来回搬,ETL 写起来又臭又长。
我在 2019 年帮一家电商公司做架构升级时,就遇到了这个痛点。他们的数据湖里存了几百 TB 的点击流日志,但要做个简单的用户留存分析,得先跑两小时的 Spark 作业把数据清洗进仓库。业务方等不及,天天催。那时候我就想,要是能直接在数据湖上做 ACID 事务,该多好。
Lakehouse 就是干这个的。它把数据仓库的可靠性、事务能力,直接搬到了数据湖上。底层还是对象存储(比如 S3、ADLS),但上层加了一层事务引擎。说白了,就是让数据湖也能像仓库一样,支持更新、删除、并发读写。
核心思想: 一份数据,既能跑机器学习,又能做 BI 报表。不需要来回拷贝。
3.2 Databricks 的 Lakehouse 理念
Databricks 是 Lakehouse 概念的提出者。他们的思路很直接:用开放格式(Parquet)存数据,用事务日志(Transaction Log)管变更,用 Spark 做计算引擎。这三者一组合,就成了 Delta Lake。
我个人习惯把 Lakehouse 拆成三层来看:
- 存储层: 对象存储(S3/ADLS/GCS),存 Parquet 文件 + Delta 日志
- 事务层: Delta Lake 引擎,提供 ACID、版本控制、Schema 管理
- 计算层: Spark / Presto / Flink 等引擎,读取 Delta 表
这里有个关键点:Delta Lake 不是存储系统,它是个事务存储层。它不存数据,它管数据。每次写入、更新、删除,都会在 _delta_log 目录下生成一个 JSON 文件,记录这次操作做了什么。
我的经验: 刚开始用 Delta Lake 时,我习惯把 _delta_log 目录忽略掉。后来排查数据不一致问题,才发现这个目录才是 Delta 的灵魂。每次查问题,先看日志,比看数据本身还管用。
为了让你更直观地理解 Lakehouse 的分层结构,我画了一张图:
3.3 Delta Lake 核心特性:ACID 事务
ACID 是 Lakehouse 的基石。没有它,数据湖就是个文件堆。Delta Lake 通过乐观并发控制(OCC)来实现 ACID。什么意思呢?就是多个作业同时写同一个表时,不会锁表,而是每个作业先写自己的临时文件,最后提交时检查冲突。
我记得有一次,一个生产环境同时跑了 6 个流式写入作业,都往同一个 Delta 表写数据。要是用传统 Hive,早就乱套了。但 Delta Lake 的 OCC 机制自动处理了冲突——只有一个作业提交成功,其他的自动重试。数据一条没丢,也没重复。
ACID 的四个特性在 Delta Lake 里是这样实现的:
| 特性 | Delta Lake 实现方式 | 实际效果 |
|---|---|---|
| 原子性 | 事务日志记录每次提交,提交成功才可见 | 写入中途失败,数据不会部分可见 |
| 一致性 | Schema 强制 + 约束检查 | 写入数据必须符合表结构,否则拒绝 |
| 隔离性 | 乐观并发控制 + 快照隔离 | 读写互不阻塞,读的是提交时的快照 |
| 持久性 | 写入对象存储 + 日志持久化 | 即使集群挂了,数据也不会丢 |
避坑指南: 我曾经遇到过一个问题——并发写入量太大时,OCC 的重试次数会飙升。默认情况下,Delta 会重试无限次,但实际生产中建议设置 spark.databricks.delta.maxRetryMs,比如 10 分钟。超过这个时间还没提交成功,就报错,避免死循环。
3.4 Schema Enforcement:强制表结构
数据湖最让人头疼的问题之一,就是脏数据。比如某个字段本来是整型,突然某天来了个字符串,传统数据湖直接写进去,下游查询就崩了。
Delta Lake 的 Schema Enforcement 就是干这个的。它会在写入时检查数据的 Schema 是否与表定义一致。不一致?直接拒绝写入,并抛出异常。
举个例子:
-- 创建一个 Delta 表
CREATE TABLE sales (
id INT,
amount DOUBLE,
dt DATE
) USING DELTA;
-- 正常写入
INSERT INTO sales VALUES (1, 100.5, '2024-01-01'); -- 成功
-- 错误写入:amount 传了字符串
INSERT INTO sales VALUES (2, 'abc', '2024-01-02'); -- 失败!
-- 报错:Cannot cast string 'abc' to double
这个特性在流式写入场景下特别有用。我做过一个实时数仓项目,Kafka 里的数据偶尔会混入格式异常的字段。如果没有 Schema Enforcement,这些脏数据会直接污染整个表,排查起来非常痛苦。有了它,异常数据直接被拦截,我们只需要在写入前加个异常处理逻辑就行。
小技巧: 如果你确实需要允许 Schema 变化(比如新增字段),可以用 ALTER TABLE ... ADD COLUMNS 显式修改表结构。Delta Lake 支持 Schema Evolution,但需要你主动声明,不会偷偷改。
3.5 Schema Evolution:灵活应对变化
说到 Schema Evolution,这里展开一下。实际业务中,数据源经常变。今天多一个字段,明天少一个字段,后天字段类型变了。Delta Lake 提供了两种模式:
- 严格模式(默认): 写入 Schema 必须与表 Schema 完全匹配,多一个字段都不行
- 演化模式: 允许自动合并 Schema,新增字段会自动添加到表中
启用演化模式很简单:
-- 写入时允许 Schema 自动演化
INSERT INTO sales VALUES (3, 200.0, '2024-01-03', '新字段')
-- 需要设置:
-- spark.databricks.delta.schema.autoMerge.enabled = true
但我建议你谨慎使用这个功能。我在一个项目中吃过亏——自动合并 Schema 后,表里多了十几个莫名其妙的字段,下游 BI 报表直接崩了。后来我改成手动管理 Schema 变更,虽然麻烦点,但可控。
3.6 时间旅行(Time Travel)
时间旅行是 Delta Lake 的另一个杀手锏。你可以查询表在任意历史版本的数据。原理很简单——Delta Lake 的每次提交都会生成一个新版本,旧版本的数据文件不会被立即删除,而是保留在存储上。
用法也很直观:
-- 查询表在版本 5 时的数据
SELECT * FROM sales VERSION AS OF 5;
-- 查询表在某个时间点的数据
SELECT * FROM sales TIMESTAMP AS OF '2024-01-01 10:00:00';
这个功能在数据回滚、审计、调试时特别有用。我记得有一次,一个同事误操作删了一批数据,我直接用时间旅行把表恢复到删除前的版本,数据全回来了。那一刻,他看我的眼神就像看救世主。
注意: 时间旅行依赖历史数据文件。如果你运行了 VACUUM 命令清理旧文件,历史版本就查不到了。默认保留 7 天,你可以通过 spark.databricks.delta.retentionDurationCheck.enabled 调整。
3.7 本章小结
Lakehouse 不是银弹,但它解决了数据湖最核心的几个痛点:事务、Schema 管理、版本控制。Delta Lake 作为它的实现,把 ACID 带到了数据湖的世界。我个人觉得,对于新项目,直接上 Delta Lake 是值得的。对于老项目,可以逐步迁移,先从核心表开始。