前阵子参加一场数据架构评审会,会上又吵起了数据仓库和数据湖的边界问题。有人说数据湖就是把文件丢到对象存储里,有人说数据湖已经过时了,湖仓一体才是未来。老实讲,数据湖架构这个概念被讲烂了,真正把它落地跑稳的团队却不多。我自己这几年参与过几套大数据平台的选型和重构,从 Hive 数仓迁到以对象存储为底座的数据湖,中间踩过不少坑。这篇文章想把数据湖架构从原理到落地完整拆一遍,包括分层设计、表格式选型、ACID 机制、迁移路径,以及那些文档里不会写的实战坑。适合正在做技术选型的数据工程师,也适合刚接触大数据体系、想系统理解数据湖是什么的读者。
1. 数据湖是什么:先说我看到的架构演化
1.1 传统数仓时期的核心矛盾
十年前提到大数据,基本就是 Hadoop,提到数据处理,基本就是 Hive 数仓。当时的标准做法是:业务数据先经过 ETL 清洗,按预先设计好的维度模型落库,查询走 Hive SQL 或 Spark SQL。这套模式有个专业说法叫 Schema-on-Write,写入之前必须先定义好表结构。听起来很严谨,实际用起来很痛苦。
我记得当时接日志类需求,业务方提了一个新埋点字段,数据团队要经历"沟通字段含义—改 Hive 表结构—重跑历史分区—通知下游消费方"一整条链路。如果哪天发现某个字段类型定义错了,历史数据全部作废,只能把整个分区删掉重刷。到了后期,数仓里堆积了大量中间表和镜像表,同一份明细数据在不同目录下存了很多份,存储成本翻倍,数据血缘也乱成一团。
更麻烦的是,传统数仓对数据形态的限制很大。结构化表好说,半结构化的 JSON 日志、图片元数据、传感器时序数据,甚至以后可能会冒出来的音频特征数据,都要强行塞进二维表里。业务走了好几轮,数据团队却在为存储格式和模型设计疲于奔命。那时候我就意识到,纯数仓模式不可能承载未来几年爆发式增长的非结构化数据。
1.2 数据湖转折点:存储和表格式解耦
数据湖理念本身不新,核心就一句话:先把原始数据廉价地存下来,形成事实的全部保留,等真正要分析时再按需定义结构。这就是 Schema-on-Read。但早期 HDFS 上的数据湖体验其实很差,NameNode 运维复杂、小文件性能差、SQL 能力弱,很多团队搭了个 Hadoop 湖,最后沦为了数据沼泽,看起来什么都有,查起来什么都查不了。
真正的转折点是对象存储加上表格式(Table Format)这套组合的成熟。对象存储把存储成本打了下来,弹性扩展也不用再管数据节点;而 Iceberg、Hudi、Delta Lake 这一类表格式,在廉价的文件存储之上补上了数据库表才有的能力——ACID 事务、快照隔离、时间旅行、模式演化。存储层和表格式一解耦,数据湖才真正从概念变成了可落地的架构。
1.3 我对数据湖架构的完整理解
如果只把数据湖理解成对象存储里的文件集合,那后面所有数据管理都会出问题。我理解的数据湖至少包括四层:
- 存储底座:对象存储或兼容对象存储协议的分布式文件系统,负责海量廉价存储。
- 表格式:把散乱文件组织成具备事务语义的表,包括元数据管理、快照机制。
- 计算引擎:批处理用 Spark,流处理用 Flink,即席查询用 Trino/Presto,需要能同时读写同一张表。
- 数据治理组件:权限、血缘、质量校验、生命周期管理。
这四层是协同工作的。存储负责"装得下",表格式负责"管得住",计算引擎负责"算得快",治理负责"信得过"。任何一层掉链子,数据湖都会变成数据沼泽。
2. 数据湖的分层架构:存储、表格、计算与元数据
2.1 存储层:对象存储成为默认选择的原因
存储层是整个数据湖的地基,现在做新的数据湖项目,我基本默认推对象存储。原因很现实:成本弹性、运维简单、吞吐量足够支撑湖上的分析负载。
对象存储适合海量小文件并发读,但不适合高并发随机写。好在表格式设计天然考虑了这一点——写入是批量提交式的,数据先写到临时目录,再把指向新快照的元数据文件原子性地提交。所以对象存储在数据湖里主要是"写少读多追加多"的模式,和它的特性正好匹配。
有一点要提醒:对象存储的"强一致性问题"在过去是痛点,早年有些厂商的对象存储存在读后写不一致的问题,需要等一段时间才能读到新写入的数据。现在主流的云厂商对象存储基本都解决了这个问题,但私有化部署的兼容系统不一定。选型时一定要做"写完立即读"的验证,不然数据实时链路会被这个坑卡死。
2.2 表格式:数据湖的心脏
存储层之上是最关键的抽象层——表格式。传统 Hive 表的元数据就是目录和文件列表,没有事务日志、没有快照、没有版本管理。你在 Hive 表上跑一个覆盖写入,查询端可能读到一半新数据一半旧数据,因为文件替换不是原子的。这在业务上完全不可接受。
Iceberg、Hudi、Delta Lake 这三类主流表格式,本质上是把数据库表的概念搬到了数据湖上。它们各自有元数据文件,记录表当前版本指向哪些数据文件,写入时通过乐观锁 CAS 来保证并发安全。简单类比就是 Git:每次提交对应一个快照,读表时指定某个快照就能读到那个时间点的完整数据。
| 能力项 | 传统 Hive 表 | Iceberg | Hudi | Delta Lake |
|---|---|---|---|---|
| 表级 ACID 事务 | 无 | 支持 | 支持 | 支持 |
| 快照隔离 / 时间旅行 | 无 | 原生支持 | 支持 | 支持 |
| 隐藏分区 | 无 | 支持分区变换 | 有限 | 有限 |
| 记录级更新删除 | 整分区重写 | 写删除文件标记 | Copy-on-Write / Merge-on-Read | Merge-on-Read |
| 并发控制 | 无 | 乐观锁 | 乐观锁 / 锁表 | 基于事务日志 |
| 典型生态 | 旧 Hive/Spark | Flink/Spark/Trino | Flink/Spark | Spark/Flink |
这三类引擎选型没有绝对答案。我现在的倾向是:如果主要诉求是构建统一明细数仓、希望多引擎都能高效查询,Iceberg 最稳;如果业务有大量更新操作、尤其希望把 MySQL 表近乎实时地镜像进湖里,Hudi 更顺手;如果是纯 Spark 栈、希望事务日志和 Spark 深度集成,Delta Lake 好上手。
2.3 计算引擎与元数据服务的协同方式
有了存储和表格式,还需要计算引擎去读写。数据湖架构的一个好处是多引擎可以共享同一张表,Spark 跑批、Flink 用流式写入、Trino 服务交互式查询。关键难点在于元数据服务得统一。Catalog 就是表的注册中心,告诉计算引擎"这张表在哪个存储桶、当前快照是哪个、元数据文件在哪"。实践中常用 Hive Metastore 充当 Catalog,也可以用 REST Catalog 独立部署,配合统一权限服务。
我的建议是,新项目尽量用 REST Catalog,不要死抱着 Hive Metastore。HMS 是很多年前为了 Hive 数仓设计的,并发能力一般,一旦湖上引擎变多、元数据操作频繁,它很容易成为瓶颈。REST Catalog 是 Iceberg 社区规格里定义的现代接口,隔离了引擎和元数据存储,扩展性和审计能力都更好。
3. 从数据仓库到数据湖:一次迁移的完整复盘
3.1 迁移前旧数仓的痛点
我在模拟项目X(某电商数据分析平台)中完整走过一次迁移。项目早期的架构就是一个典型的 Hive 数仓,底层 HDFS,离线 ETL 用 Spark 调度。规模到了几百 TB 以后,问题开始集中爆发。
第一个是模式演化。业务加字段是常态,但 Hive 表一旦有历史分区,新列在旧分区里就是 null,回刷历史数据又特别慢。第二个是 SLA 问题。汇总层依赖上游全量数据,全量跑一次需要十多个小时,T+2 已经是极限,业务方天天催当天数据。第三个是存储冗余,同一份订单明细被复制到清洗层、汇总层、临时分析层,加起来多占了两倍的存储空间。
3.2 迁移路径是怎么设计的
当时我们定了一条原则:不搞大爆炸式迁移,只做"明细层先行、分层渐进"的迁移。口径是新的湖上表作为唯一数据底座,逐步替代旧的 Hive 明细表。
第一步是搭湖基础环境。对象存储桶规划成三层,对应原始层、明细层、应用层。表格式选型最终用了 Iceberg,Catalog 先临时复用 HMS,后面再迁到独立 REST Catalog。初始建表用 Spark,例子如下:
CREATE TABLE ods_orders ( order_id BIGINT, user_id BIGINT, order_ts TIMESTAMP, amount DECIMAL(10,2), province STRING ) USING iceberg PARTITIONED BY (days(order_ts));注意 Iceberg 的分区不是简单声明一个分区列,它支持分区变换,这里的days(order_ts)表示按天做隐藏分区。查询时不需要手写order_ts >= '2024-01-01' AND order_ts < '2024-01-02'这种分区裁剪语句,引擎会自动推断,这对业务方非常友好。
第二步是新老并行。Spark 任务每天同时写 Hive 表和 Iceberg 表,然后跑数据比对任务,按主键比较记录数、关键字段的 sum 和 md5。并行跑了三周,确认一致后把下游消费方切到 Iceberg 表。
第三步是追平和回刷。历史全量数据从 HDFS 直接批量读出来,按天分区写入 Iceberg 表,同时保留旧表作为回溯备档。这里有个教训:回刷时如果开的并行度过高,容易触达对象存储的配额,也容易把小任务的文件写成碎片;合理做法是控制每批写入的数据量,比如每批 2GB 左右,再把目标文件大小参数调好。
3.3 迁移中真正伤筋动骨的地方
迁移最大的难点不是技术框架的搭建,而是数据一致性校验。我们的比对任务一开始只看记录总数,跑了一周后才发现新旧表有条数一致但单字段内容不一致的情况,原因是旧表清洗逻辑里有隐式类型转换,不同分区同一字段有的是 string 有的是 bigint。这个问题的排查链路拉得很长:
- 先按日期抽了几个分区,发现条数一致。
- 再按 province 维度抽 card 类数据,发现金额 sum 不一致。
- 定位到是历史清洗任务在不同时期有不同的币种单位,早期是分,后期是元。
- 最后只能用 Iceberg 的模式演化能力,对旧分区做单独补偿处理。
还有一个印象深刻的问题是 CDC 链路。我们把 MySQL 的 binlog 通过 Flink CDC 写入湖表,整体方案没有问题,但 MySQL 侧一有大事务,比如运营批量改价,binlog 会产生大流量,Flink 作业的背压直接把链路写慢了。后来把源端连接参数调整了,也加了写入端的反压预警,才稳定下来。
4. 数据湖的关键机制:ACID、时间旅行与增量处理
4.1 ACID 是怎么在对象存储上实现的
数据湖刚出来的时候,最大的质疑是"对象存储上怎么谈事务"?对象存储本身确实不提供事务能力,但表格式可以在元数据层面实现。以 Iceberg 为例,一张表的所有变化都由一个元数据文件树来描述:Metadata File 指向一个 Manifest List,Manifest List 包含若干 Manifest,Manifest 再指向真正的数据文件和删除文件。
每次写入,表格式做三件事:把新数据文件写到存储;生成新的 Manifest 和 Manifest List;更新表的当前元数据指针。最后一步通常用原子性操作完成,比如在 Catalog 里做 CAS 比较,检查当前版本号是不是我写入时的版本,如果不是就重试。这就是乐观锁,提交失败不会留下半截数据。
我用 Git 给团队讲这个概念:每个 commit 就是一次快照,你永远不会改掉历史上的 commit,只在它后面追加新的 commit。读数据的时候指定某个 commit,就能把那个时刻的文件列表完整取出来。想通了这一点,数据湖的 ACID 就没什么神秘的。
4.2 时间旅行与快照隔离的威力
时间旅行是我认为数据湖比传统数仓体验提升最明显的地方。传统 Hive 表如果要查三天前的历史状态,基本没有可能,只能靠有没有保留历史分区。Iceberg 表天然保存了所有快照,查历史只需要指定版本号或时间点:
SELECT count(*) FROM ods_orders FOR VERSION AS OF 5632; SELECT count(*) FROM ods_orders FOR TIMESTAMP AS OF '2024-06-01 08:00:00';这个能力在实战中太有用了。第一是数据回溯,下游任务算错了指标,直接找到出错时刻的版本就能定位影响范围。第二是任务回刷,不需要删表重建,直接把快照切到某个历史时刻重新处理。第三是审计合规,监管要求查"某天的表长什么样",不需要额外搭快照存储,表格式天然就提供了。
不过也要注意,快照不清理就会无限膨胀。每次 commit 都生成新快照,几千次提交后元数据文件会越来越多,查询性能会被拖慢。所以生产环境必须做定期快照过期和孤儿文件清理任务,这是湖上常规运维的一部分。
4.3 增量处理:CDC 与流批一体的实践
数据湖建成后,一个核心需求是批流一体:同一套表既能支持离线批量写入,也能支撑分钟级的增量更新。这里的关键技术是记录级 UPSERT。传统 Hive 表只能做覆盖式写入,全新数据写一遍,历史数据改一下就要重写整个分区。
Hudi 的 Merge-on-Read 在这种场景下表现很好。新记录先写到增量文件,读的时候把基座文件和增量文件合并。对标数据库的实现,相当于把更新的行先放在一个 delta 区域,读到的是合并后的视图。对于频繁更新、且查询侧对延迟要求高的业务,这是很自然的选型。
我们后来搭了一条近实时链路:MySQL binlog -> Kafka -> Flink CDC -> Hudi 表。数据从数据库变更到湖表可查,延迟控制在分钟级。这套链路的好处是,下游不管是批处理还是实时分析,都面向同一个湖表,不再需要维护两套数据。
5. 数据湖分层建模:青铜、白银、黄金层的设计逻辑
5.1 数据沼泽是怎么出现的
很多人以为数据湖就是把所有数据丢进去,业务想怎么查就怎么查。这种想法最终只会得到一个数据沼泽。文件堆了一堆,不知道哪些是新鲜可用的,哪些是历史垃圾;同一个业务字段,在不同表里有不同的命名和格式;问到某张表谁负责,没人说得清。
我在实践里比较推崇的分层方式是湖内分三层的模型,行业里常叫 Bronze-Silver-Gold,我用中文给团队讲就是"原样层—明细层—指标层"。核心思想是:原始数据永远保留、明细数据可复用、指标数据服务业务。
5.2 每一层的数据约定和处理重点
青铜层(原样层)的任务最简单也最容易忽略:把上游数据原封不动地落下来。不管是 MySQL 的 binlog、业务日志、还是第三方接口的响应快照,都直接以原始格式写入,不清理、不脱敏、尽量不丢失字段。压缩格式建议用 zstd,压缩率和解压速度兼顾。这一层的目的只有一个——保证任何时刻都能追溯源头数据。
白银层(明细层)做真正的清洗加工。包括字段类型统一、去重、标准化枚举值、统一单位、补齐缺失维度。这一层的数据是后续所有应用能共用的中间资产。比如订单表,不管来源是 MySQL 还是接口同步,最终在白银层都统一成一张订单明细表,字段名、字段类型、时区、金额单位全部一致。这个统一口径的过程,是数据湖资产化的核心。
黄金层(指标层)面向具体的业务场景,通常是多维分析需要的宽表或聚合指标表。这里可以用维度建模的思路,也可以直接按业务需求定义。黄金层的表要严格控制数量,避免每个业务方都建一套自己的表,最后又变成数据沼泽。
| 层级 | 数据形态 | 主要处理 | 常见延迟目标 | 成本定位 |
|---|---|---|---|---|
| 青铜层 | 原始文件 | 存储、压缩、分区 | 实时/准实时 | 低成本,保留全量 |
| 白银层 | 统一明细 | 清洗、去重、标准化 | 分钟级到小时级 | 中成本,资产复用 |
| 黄金层 | 应用宽表/指标表 | 聚合、建模 | 小时级到日级 | 高成本,服务业务 |
5.3 权限与治理落位在哪些节点
分层不只是为了建模,也是权限治理的抓手。我的建议是:青铜层默认只有数据平台团队可访问,且不允许外部直接扫全表;白银层按部门和数据域授权,敏感字段统一加密或脱敏显示;黄金层对业务方只读开放。这样每一层的权限边界清晰,敏感数据不会裸奔到分析工具里。
在表元数据上,一定要维护 owner 和业务域。我给团队定的规范是每一张白银层和黄金层的表,必须存在责任人字段,半年没人访问的表进入下线流程。这个动作看着繁琐,实际是防止数据湖膨胀成垃圾场最有效的手段。
6. 落地数据湖的七个常见坑(附排查思路)
6.1 小文件爆炸
症状:表数据量不大,但文件数上万,查询时动不动就触发几百上千个任务扫描,元数据读取慢。
排查链路:先看表里的数据文件数量和平均大小。如果平均文件只有几十 MB,说明写入并发高但文件目标太小。流式写入最容易触发这种问题,Flink 作业并行度 20,每分钟提交一次,十分钟就生成几百个文件。
解决思路:调整写入端的目标文件大小参数,比如 Iceberg 的write.target-file-size-bytes调大到 512MB;流式作业尽量用触发间隔加缓冲的方式攒批提交;定期对发生小文件问题的分区做 rewrite 合并(compaction)。我看到很多团队漏掉最后一步,compaction 在数据湖里不是可选项,是日常运维必须项。
6.2 分区策略拍脑袋
症状:分区数太多,比如按 user_id 分几千个区;或者分区数太少,单分区数据量过大。分区优化的核心是控制扫描数据量。
排查思路:SELECT语句里看实际扫描的分区数,对比全表分区数。按时间分区是最普适的方案,但时间粒度要结合数据量。日增量几 GB 的表,按天分区合理;日增量几百 GB 的表,可能需要按天分桶,或者在分区里再做二级 bucketing。
我之前遇到过一个案例:一张订单表按天分区,但下游经常做跨月汇总。查询时需要扫 30 个分区。后来加了按省份做 bucket 隐藏分区,查询裁剪效果明显好很多。分区策略没有银弹,要靠 explain 看执行计划逐步调。
6.3 元数据膨胀
症状:表本身数据量不大,但读取表结构、获取快照的动作越来越慢,查询计划要几秒钟。
原因:快照保留太多。每次 commit 都生成新快照,长时间不清理,元数据文件里堆了几百份 manifest list,查询时要扫描所有历史版本才能拿到当前版本。
解决:设置快照过期策略,保留最近 N 天或最近 N 个快照,定期清理孤儿文件。生产环境建议配一套定时任务,每天执行快照过期和孤儿文件清理。我在迁移时发现好多历史快照是当天任务重试的时候产生的,一天能产生上百个快照,清理完查询计划耗时降了数倍。
6.4 Schema 兼容性问题
症状:新写入的数据类型和旧数据不一致,比如 old 分区是 string,新分区是 bigint,联合查询报错或返回空值。
根本原因:青铜层没有做好类型规范化,或者上游字段含义变了。排查时先看表 schema 的历史变更记录,再对比不同分区实际落盘的类型。
解决:入湖时统一做一次类型推断,保证同一字段在全表范围内类型一致;对于已经在湖里的脏分区,用模式演化和数据补偿双管齐下。这个坑越早发现越好,拖到黄金层下游已经建了十几个任务的时候再修,成本翻倍。
6.5 并发写冲突
症状:多个任务同时写同一张表,日志里频繁出现提交失败、重试,偶尔还有业务抱怨数据少了。
原因:表格式的乐观锁并发控制,写任务提交时如果版本已经被别人更新,就会冲突。这不是 bug,是保护机制。但生产环境不能总靠重试解决。
解决:合理分桶,让不同任务写不同桶;对于必须同时写同一分区的任务,做任务调度互斥;如果冲突依然频繁,把表的分区粒度调细一些,降低并发单位竞争概率。Flink 写同一个 Hudi 表时尤其要注意,同一时刻只让一个 checkpoint 对应一个写入批次。
6.6 权限管理失位
症状:业务人员能在分析工具里查到青铜层的原始敏感信息,安全团队问起来数据团队答不上来权限边界。
排查思路:数据湖权限有两条链路,一个是对象存储桶级别的读写权限,一个是表格式层和 Catalog 层的表级权限,两者经常是割裂的,漏配一处就相当于开了一个后门。
解决:统一权限入口。表级权限做完之后,对象存储桶要针对湖上引擎的服务账号收敛权限,不允许个人账号直读桶。敏感字段在白银层做列级脱敏,黄金层只暴露加工后的数据。这个治理动作必须在湖刚建起来时就做,后面数据量大了再补,整改成本极高。
6.7 数据质量没人认领
症状:下游某个指标突然波动,排查半天发现数据源某张表多跑了一轮去重,没人知道谁改的。
根因是数据资产所有权缺失。湖上表一旦超过一定数量,没有 owner 和业务域标识,就意味着没人对它的准确性负责。
解决方式是把质量校验嵌到分层链路里。我司现在的做法是:白银层每张表在生成时跑一次核心字段的完整性校验,比如订单表必须满足"金额不为负、日期不能为空、主键不重复",不过校验不下发黄金层。这个动作能在源端变化的第一时间暴露问题,比业务反馈要快得多。
写到这里我又想起评审会那个场景。数据湖和数据仓库根本不是对立关系,湖仓一体就是数据湖成熟之后自然演进出来的状态。它的价值不在于"湖"这个字,而在于把存储、表格式、计算引擎、治理机制组合成一个整体。每次遇到有人问"数据湖有什么用",我的回答都是:先想清楚你的数仓在哪一层会吃不住,再回来评估湖怎么帮你补上这块短板。对我自己来说,最大的收获不是学会用某个新框架,而是理解了分层架构和元数据管理这件事比任何单个组件都重要。数据湖的复杂度是有代价的,但把底层机制和常见坑摸透之后,它确实是目前处理大规模数据最稳的一条路。