2. Lakehouse 架构基础:数据湖与数据仓库的融合
聊实时特征工程,绕不开底层存储架构。我见过不少团队,特征上线后才发现数据对不上、回溯时发现历史被改过、批流两套存储搞得运维想骂人。这些问题,说白了就是没选对底座。
Lakehouse 不是新概念,但它确实解决了传统架构的痛点。我把它拆成三个部分来讲:为什么需要融合、Delta Lake 和 Iceberg 怎么做到 ACID、以及 ACID 在特征存储里到底有多重要。
2.1 数据湖与数据仓库:为什么非要融合?
先看两张老面孔。
| 维度 | 数据仓库 | 数据湖 |
|---|---|---|
| 存储格式 | 结构化,Schema-on-Write | 原始格式,Schema-on-Read |
| 事务支持 | 强 ACID | 无(或很弱) |
| 典型场景 | BI 报表、固定维度分析 | 机器学习、日志存储、非结构化数据 |
| 成本 | 高(专用硬件、商业许可) | 低(对象存储 + 开源引擎) |
| 数据新鲜度 | 通常 T+1 | 可实时 |
你想想看,做实时特征的时候,我们既要数据湖的低成本存储和灵活性,又要数据仓库的事务一致性和查询性能。以前的做法是两套系统:实时特征写到 Redis 或 HBase,离线特征存 Hive 表,中间靠一堆 ETL 脚本同步。结果呢?特征版本对不上、回溯时数据被覆盖、线上和离线特征分布不一致——我踩过这个坑,代价是整整两周的模型效果回退排查。
Lakehouse 的核心理念很简单:把数据湖的存储层加上事务层。底层还是廉价的对象存储(S3、OSS、HDFS),上层通过表格式(Table Format)提供 ACID、时间旅行、Schema 演化等能力。这样,实时和离线特征可以共用同一份存储,不再需要两套管道。
核心结论:Lakehouse 不是推翻重来,而是在数据湖上「长出」仓库的能力。对特征工程来说,这意味着批流存储统一、历史可回溯、写入不冲突。
2.2 Delta Lake 与 Apache Iceberg:两个主流方案
目前最成熟的两个 Lakehouse 表格式,一个是 Databricks 推出的 Delta Lake,另一个是 Netflix 开源的 Apache Iceberg。我两个都用过,各有侧重。
2.2.1 Delta Lake 的核心原理
Delta Lake 的核心是一个 事务日志(Transaction Log),存在 _delta_log 目录下,每个 JSON 文件记录一次原子提交。读数据时,引擎会从日志中重建最新快照。
-- Delta Lake 表结构示例(Spark SQL)
CREATE TABLE feature_store.user_features (
user_id STRING,
feature_name STRING,
feature_value DOUBLE,
event_time TIMESTAMP
) USING DELTA
LOCATION 's3://feature-bucket/user_features/'
TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true');
Delta Lake 的几个关键特性:
- ACID 事务:并发写入不会互相覆盖,失败自动回滚。
- 时间旅行:通过
VERSION AS OF或TIMESTAMP AS OF查询历史版本。 - Schema 演化:新增列不会破坏已有数据。
- Change Data Feed:实时捕获变更,直接对接流处理。
我的经验:Delta Lake 在 Spark 生态里集成度最高,尤其是配合 Structured Streaming 做实时特征写入,几乎零额外配置。但如果你用的是 Flink,Iceberg 的 Flink 连接器更成熟。
2.2.2 Apache Iceberg 的核心原理
Iceberg 的设计思路类似,但更强调 表格式的开放性。它把元数据分成三层:
- Catalog:记录当前快照指针(Hive Metastore、Hadoop、REST 等)。
- Metadata File:每个快照对应一个 JSON 文件,记录 Manifest 列表。
- Manifest File:记录数据文件列表、分区信息、列统计。
-- Iceberg 表创建示例(Spark SQL)
CREATE TABLE feature_store.item_features (
item_id BIGINT,
feature_name STRING,
feature_value DOUBLE,
ts TIMESTAMP
) USING ICEBERG
PARTITIONED BY (days(ts))
LOCATION 's3://feature-bucket/item_features/'
TBLPROPERTIES ('write.format.default' = 'parquet');
Iceberg 的优势在于:
- 隐藏分区:用户不需要手动管理分区列,Iceberg 自动推导。
- 文件级统计:查询时通过 Manifest 中的 min/max 值做文件剪枝,性能更好。
- 多引擎兼容:Spark、Flink、Trino、Presto 都能直接读写。
2.2.3 对比总结
| 特性 | Delta Lake | Apache Iceberg |
|---|---|---|
| 事务日志 | JSON 文件 + Checkpoint Parquet | Metadata + Manifest 分层 |
| 时间旅行 | 版本号 / 时间戳 | 快照 ID / 时间戳 |
| Schema 演化 | 支持(ADD / DROP / RENAME) | 支持(更细粒度) |
| Flink 集成 | 一般(需额外配置) | 原生支持(DataStream / SQL) |
| Spark 集成 | 原生(Databricks 主导) | 原生 |
| 文件剪枝 | 依赖分区 + Z-order | Manifest 统计 + 分区 |
注意:不要盲目选型。如果你的实时管道以 Flink 为主,Iceberg 更省心;如果团队 Spark 技术栈深厚,Delta Lake 的运维成本更低。我曾经在一个项目里强行用 Delta Lake 对接 Flink,结果 CDC 延迟高了 3 倍,后来切回 Iceberg 才解决。
2.3 ACID 事务在特征存储中的重要性
特征存储不是普通的数据库。它要支撑模型训练时的批量回溯、线上推理时的实时写入、以及特征工程中的多步更新。没有 ACID,你会遇到三个典型问题。
2.3.1 问题一:并发写入冲突
实时特征管道通常有多个任务同时写同一张表。比如一个任务写用户画像特征,另一个任务写实时行为特征。如果没有事务隔离,两个任务同时写同一个分区,可能互相覆盖,导致特征值错乱。
Lakehouse 的 ACID 通过乐观并发控制(OCC)解决:每个写入任务先尝试提交,如果发现冲突(比如目标文件被其他任务改了),就重试。Delta Lake 和 Iceberg 都支持这个机制。
2.3.2 问题二:回溯训练时数据不一致
模型训练需要某个时间点的特征快照。如果特征表在回溯期间还在被写入,传统方案只能「读时加锁」或「复制一份」。前者影响线上写入,后者浪费存储。
有了时间旅行,你可以直接写:
-- Delta Lake 回溯到 2024-01-15 08:00:00 的特征快照
SELECT * FROM feature_store.user_features
TIMESTAMP AS OF '2024-01-15 08:00:00'
WHERE user_id = 'u12345';
Iceberg 类似:
-- Iceberg 回溯到快照 ID
SELECT * FROM feature_store.item_features
FOR SYSTEM_VERSION AS OF 1234567890;
这样,训练和推理用的特征版本完全一致,不会出现「训练时特征 A 是 v2,上线时变成 v3」的尴尬。
2.3.3 问题三:失败写入导致数据损坏
实时管道难免会挂。如果写入一半时任务失败,传统文件系统会留下不完整的 Parquet 文件。下次读表时,可能读到半截数据,特征值变成 NULL 或乱码。
ACID 事务保证写入的原子性:要么全部提交,要么全部回滚。失败的任务不会留下脏数据。我遇到过最惨的一次,因为网络抖动导致 Hive 表写入了 200 个不完整文件,排查了整整一天。后来换成 Delta Lake,再没出过这种问题。
一句话总结:特征存储的 ACID 不是锦上添花,而是底线。没有它,实时特征工程的稳定性就是空中楼阁。
2.4 本章知识体系总览
下面这张图概括了 Lakehouse 架构在特征工程中的位置和核心组件。你可以把它当作本章的「地图」。
从这张图可以看得很清楚:底层是廉价的对象存储,中间层通过 Delta Lake 或 Iceberg 提供事务能力,上层是特征存储,再往上对接实时推理、模型训练和离线分析。每一层各司其职,但核心的「粘合剂」就是中间的表格式。
我的建议:刚开始接触 Lakehouse 时,不要贪多。先选一个表格式(Delta Lake 或 Iceberg),把特征存储的批流统一跑通。等团队熟悉了事务日志和时间旅行的概念,再考虑更高级的特性,比如 Schema 演化自动兼容、CDC 实时入湖。一步一步来,比一次性上全栈要稳得多。