DD 数据开发学习站DATA ENGINEERING
模块 06 · OLAP 与湖仓

数据湖与湖仓一体

Iceberg/Hudi/Paimon、表格式、ACID、时间旅行与湖仓选型。

难度 · 高阶 约 40 分钟 选学 练习 7 项 计划内
建议先学:Doris 与 StarRocks
本页目录 · 22 节

本章目标:能讲清数据湖、湖仓一体和表格式,理解 Iceberg 的 ACID、时间旅行、schema 演化和增量读,并能为订单数据设计一张入湖表。 前置知识:读过 数仓分层、Hive 分区;了解 Parquet 和对象存储更顺。

1. 为什么需要它

离线数仓通常把数据放在 Hive 表里:先定义 schema,再按分区写 Parquet,查询时用 Hive/Spark。它适合结构化数据和 T+1 批处理,但遇到下面几类需求会别扭:

  • 原始日志是 JSON、埋点字段经常变,先建严格 schema 太重;
  • 数据要能被 Spark、Flink、Trino 同时读写,不想被一个引擎绑死;
  • 需要按主键更新、删除,而不只是追加分区;
  • 需要查“昨天 10 点的数据长什么样”,或者误删后回滚;
  • 只想读新增数据,不想每次全表扫描。

数据湖先解决“便宜地存各种原始数据”:HDFS、S3、OSS 上放 Parquet/ORC/JSON,读的时候再解析,也就是 schema-on-read。但裸文件只是“文件堆”,没有事务、没有快照、没有统一元数据,多个作业一起写还可能互相覆盖。

湖仓一体在数据湖的文件之上加一层表格式,让这批文件具备表的能力:ACID、时间旅行、schema 演化、增量读。典型代表是 Iceberg、Hudi、Paimon。

面试高频:湖仓一体不是“数据湖取代数据仓库”,而是“对象存储 + 开放表格式 + 多种计算引擎”,让湖上的数据能按数仓的方式管理。


2. 核心概念

2.1 数仓、数据湖、湖仓一体

维度 数据仓库 数据湖 湖仓一体
存储 HDFS/MPP 数据库 HDFS/S3/OSS 对象存储为主
数据 结构化为主 结构化、半结构化、非结构化 以开放文件为主
schema 写时定义 读时解析 表格式管理 schema
事务 支持 裸文件不支持 表格式提供 ACID
更新删除 支持 难 支持行级更新和删除
成本 较高 低 低
典型 Hive、Doris HDFS + Parquet Iceberg、Hudi、Paimon

湖仓的分层可以画成这样:

Spark / Flink / Trino / Doris(计算引擎)
                 ↓
Iceberg / Hudi / Paimon(表格式:元数据、事务、快照)
                 ↓
Parquet / ORC / Avro(开放文件格式)
                 ↓
HDFS / S3 / OSS(廉价存储)

计算和存储分离:数据放在对象存储,引擎按需拉起;表格式负责“哪些文件构成这张表、当前版本是什么”。

2.2 表格式解决了什么

把一张 Iceberg 表拆开看,它不是一个目录里的散文件,而是有层级的元数据:

catalog(表名 → 当前 metadata 文件)
   ↓
metadata.json(schema、分区规则、快照列表)
   ↓
snapshot → manifest list → manifest(数据文件清单 + 列统计)
   ↓
data files(Parquet)

由此得到几个能力:

  • ACID:一次写入生成新的数据文件和 manifest,最后原子地切换 snapshot 指针。要么全部可见,要么全部不可见,不会读到写了一半的数据。
  • 时间旅行:每个 snapshot 都保留当时的文件清单,可以按 snapshot ID 或时间戳查询历史版本。
  • schema 演化:加列、改列名、调整字段顺序时只改 metadata,历史 Parquet 文件不动;读旧文件时按字段 ID 映射。
  • 增量读:对比两个 snapshot 的 manifest,只读新增或变更的文件,适合增量同步和 CDC 入湖。
  • 隐藏分区:分区由列表达式派生,例如按 days(dt) 分区,查询写 WHERE dt = ... 即可裁剪,不必额外维护分区列。
  • 分区演化:业务变化时可以改分区策略,旧数据仍按旧规则读,不需要重写全表。

注意:时间旅行依赖历史文件还在。快照过期(snapshot expiration)和文件清理会删掉旧数据,保留多久要按审计和回滚需求配置。

2.3 Copy-on-Write 和 Merge-on-Read

行级更新有两种实现:

方式 做法 写 读 适合
Copy-on-Write 重写受影响的数据文件 慢、写放大 快 读多写少、批处理
Merge-on-Read 先写删除文件/增量文件,读时合并 快 慢、要合并 高频 upsert、CDC

Merge-on-Read 读的时候要把基础文件和删除文件合并,长期不 compaction 会越来越慢;Copy-on-Write 每次更新重写整个文件,写放大明显。没有绝对好坏,看读写比例。

2.4 Iceberg、Hudi、Paimon

表格式 特点 常见场景
Iceberg 引擎中立、开放规范、隐藏分区、快照管理成熟 多云数据湖、批流共用、Trino/Spark/Flink 混用
Hudi 起家于 upsert 和增量处理,COW/MOR 成熟 需要频繁更新、增量拉取的湖表
Paimon Flink 生态亲密,流式写入和 CDC 入湖顺手 实时湖仓、Flink 流批一体

三者都在解决“湖上建表”,差异在生态和写入模型。选型先看团队主力引擎:Spark 为主可以优先 Iceberg/Hudi,Flink 实时链路为主可以重点评估 Paimon。

2.5 小文件、compaction 和 catalog

流式写入如果每分钟提交一次,一天会产生大量小文件,元数据和读取都会变慢。表格式不会自动消灭小文件,需要:

  • 控制提交频率,先攒批再写;
  • 配置 compaction,定期把小文件合并成大文件;
  • 清理过期快照和孤儿文件,避免存储无限增长。

catalog 负责保存“表名到当前 metadata 文件”的映射,常见实现有 Hive Metastore、JDBC、REST Catalog、Nessie。catalog 是元数据入口,生产要考虑高可用和备份;丢了 catalog,数据文件还在,但表可能找不回来。


3. 动手示例

3.1 用 PyIceberg 建一张本地表

数据湖的真实环境通常是 Spark/Flink + 对象存储,本章用 PyIceberg + SQLite catalog + 本地目录做本地模拟,验证表格式的基本行为。

pip install "pyiceberg[sql-sqlite]" pyarrow

新建 lake_demo/iceberg_demo.py:

from decimal import Decimal
from pathlib import Path

import pyarrow as pa
from pyiceberg.catalog.sql import SqlCatalog

catalog = SqlCatalog(
    "local",
    **{
        "uri": "sqlite:///lake_demo/catalog.db",
        "warehouse": Path("lake_demo/warehouse").resolve().as_uri(),
    },
)
catalog.create_namespace_if_not_exists("ecom")

schema = pa.schema([
    ("order_id", pa.string()),
    ("category", pa.string()),
    ("amount", pa.decimal128(18, 2)),
    ("dt", pa.date32()),
])

table = catalog.create_table_if_not_exists("ecom.dwd_order", schema=schema)

batch = pa.table({
    "order_id": ["O1001", "O1002"],
    "category": ["手机", "图书"],
    "amount": [Decimal("2999.00"), Decimal("59.00")],
    "dt": pa.array(["2018-08-01", "2018-08-01"], type=pa.date32()),
})
table.append(batch)
print(table.scan().to_arrow())

warehouse 是数据文件目录,catalog.db 存表名和当前 metadata 位置。append 会生成新的 Parquet 文件和 snapshot,而不是改旧文件。

3.2 时间旅行

history = list(table.history())
for snapshot in history:
    print(snapshot.snapshot_id, snapshot.timestamp_ms, snapshot.summary)

first_id = history[0].snapshot_id
old = table.scan(snapshot_id=first_id).to_arrow()
print(old)

误删、误更新后,可以按快照读回旧版本,再写回当前表。前提是对应快照和文件还没被清理。

3.3 Spark SQL 写法

生产更常见的是 Spark 或 Flink 读写 Iceberg。下面用 Spark SQL 展示 DDL、更新和时间旅行(需要 Iceberg Spark runtime,本地练习可用 Docker 镜像,属于本地模拟):

CREATE TABLE local.ecom.dwd_order (
  order_id STRING,
  category STRING,
  amount DECIMAL(18, 2),
  dt DATE
) USING iceberg
PARTITIONED BY (days(dt));

INSERT INTO local.ecom.dwd_order VALUES
('O1001', '手机', 2999.00, DATE '2018-08-01'),
('O1002', '图书', 59.00, DATE '2018-08-01');

MERGE INTO local.ecom.dwd_order t
USING (SELECT 'O1001' AS order_id, '家电' AS category, 3099.00 AS amount) s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET category = s.category, amount = s.amount;

SELECT * FROM local.ecom.dwd_order VERSION AS OF 1234567890;
ALTER TABLE local.ecom.dwd_order ADD COLUMN channel STRING;

MERGE INTO 的更新在 COW 表上重写文件,在 MOR 表上写删除文件;VERSION AS OF 用 snapshot ID 查历史。实际语法和连接器版本有关,跑之前先确认 Iceberg 与 Spark 的版本匹配。


4. 常见坑

  1. 把数据湖当“随便放文件”。没有表格式和 catalog,时间一长没人知道哪些文件属于哪张表。
  2. 只建表不管 compaction。流式小文件越积越多,查询越来越慢,元数据也膨胀。
  3. 以为时间旅行永久有效。快照过期和文件清理后旧版本就没了,保留策略要提前定。
  4. schema 演化以为什么都能改。改类型通常只支持安全的向上兼容,删列、改语义要评估下游。
  5. 分区过细。按小时甚至按分钟分区,小文件爆炸;按数据量选天/月,或依赖隐藏分区。
  6. 并发写不做冲突处理。两个作业同时提交 snapshot 可能冲突,要配置重试和提交隔离。
  7. 把湖仓当高并发查询引擎。湖表适合批查和明细,报表接口仍应同步到 Doris/ClickHouse/MySQL。
  8. catalog 不做备份。元数据服务挂了或丢了,恢复成本很高。

5. 练习

练习 1:选存储方案

三个场景分别选 Hive 数仓、数据湖裸文件还是湖仓表:每天 10 亿条 JSON 埋点长期留存;订单表要按主键频繁更新并被多个引擎查询;财务日报只读、结构稳定、要求强 schema。

参考答案

埋点长期留存选数据湖,存储便宜、schema 灵活,但最好加 Iceberg/Paimon 表格式管理;订单频繁更新、多引擎查询选湖仓表,利用 ACID、upsert 和开放格式;财务日报结构稳定、强 schema、以批处理为主,Hive 数仓或湖仓表都可以,团队已有 Hive 体系就先用 Hive。选型看数据形态、更新频率、引擎生态和成本,不是“湖仓一定替代数仓”。

练习 2:设计订单入湖表

订单 CDC 要入湖,字段有 order_id、customer_id、order_status、pay_amount、update_time。给出分区、文件格式、表格式和读写方式。

参考答案

用 Iceberg 或 Paimon,Parquet 文件,按 days(update_time) 或按月分区,主键 order_id。CDC 高频更新适合 Merge-on-Read,定期 compaction;如果下游以全量批查为主、更新不频繁,可以用 Copy-on-Write 换更快的读。写入用 Flink CDC,读取用 Spark/Trino;对外高并发接口再同步到 Doris。分区不宜过细,按数据量选天或月。

练习 3:误删数据怎么恢复

一张 Iceberg 表误执行了删除,如何利用时间旅行恢复?说出步骤和前提。

参考答案

先查 history 找到删除前的 snapshot ID,按该 snapshot 读出旧数据;再把旧数据写回当前表,或用表的回滚能力切回旧快照。前提是旧快照和对应数据文件还在,没有被 snapshot expiration 清理。生产要配置合理的保留期,并在回滚后确认下游读到的是正确版本。

练习 4:小文件治理

Flink 每分钟往湖表提交一次,运行一周后查询变慢。分析原因并给出至少三个处理办法。

参考答案

每分钟提交会生成大量小 Parquet 文件和 manifest,查询要打开很多文件、读大量元数据。办法:提高写入攒批时间或 checkpoint 间隔,减少提交次数;开启或定时执行 compaction,把小文件合并;清理过期快照和孤儿文件;按数据量调整分区,避免分区过细;监控文件数量和平均大小,设置告警。


6. 面试延伸

面试题:什么是湖仓一体?

是什么:在数据湖的廉价存储上增加开放表格式,让文件具备 ACID、schema 管理、时间旅行和增量读能力,计算引擎可以多样。

解决什么问题:裸数据湖缺少事务和元数据,难更新、难回滚;传统数仓成本高、格式封闭。湖仓把两者折中。

我的实操边界:我用 PyIceberg 在本地建过表、追加数据、按快照读历史,理解 metadata/manifest/snapshot 的结构;没有在生产环境运维过 Iceberg 集群和 PB 级数据湖。答题时强调“存储 + 表格式 + 引擎”三层,不把湖仓说成单一产品。

面试题:Iceberg 为什么能支持 ACID 和时间旅行?

是什么:Iceberg 用 metadata.json、snapshot、manifest 管理数据文件,写入先生成新文件和新快照,再原子切换指针。

解决什么问题:裸文件写入没有原子性,读者可能看到写了一半的数据;也没有版本概念,无法查历史。

我的实操边界:我验证过 append 后 snapshot 增加、按旧 snapshot 查询;没有做过并发提交冲突和高可用 catalog 的实战。回答时讲清“数据文件不可变、元数据原子提交、旧快照保留”三点即可。

面试题:Iceberg、Hudi、Paimon 怎么选?

是什么:三者都是湖仓表格式,Iceberg 引擎中立,Hudi 强在 upsert 和增量,Paimon 与 Flink 流式生态结合紧。

解决什么问题:不同团队的主力引擎、写入模式和实时性要求不同。

我的实操边界:我只在本地用过 Iceberg,Hudi 和 Paimon 了解定位;没有做过生产选型和压测。选型看主力计算引擎、是否需要高频 upsert、CDC 链路、社区和运维成本,不能只背“Iceberg 最流行”。


7. 本章验收

  • 能用自己的话区分数据仓库、数据湖和湖仓一体,并说出各自适合的场景。
  • 能画出 catalog、metadata、snapshot、manifest、数据文件的层级关系。
  • 能解释 Iceberg 的 ACID、时间旅行、schema 演化和增量读分别靠什么实现。
  • 能说出 Copy-on-Write 和 Merge-on-Read 的读写代价与适用场景。
  • 能独立运行本地 PyIceberg 示例,追加数据并按旧快照查询。
  • 能为订单 CDC 设计分区、文件格式、表格式和 compaction 策略。
  • 能说出湖仓不适合直接承接高并发点查的原因,并给出替代方案。
本章任务0 / 7
本页目录