土法炼钢兴趣小组的算法知识备份

【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式

文章导航

分类入口
databasestorage
标签入口
#lakehouse#hive#table-format#iceberg#object-storage#data-lake#data-warehouse

目录

读完 列存引擎内核(ClickHouse / DuckDB 读优化)、PostgreSQL 行存 OLTPLSM 写优化引擎,你已经熟悉「数据在一个数据库进程里、由它统一管理页、索引、事务」的世界。lakehouse 换了一个前提:数据不在任何进程里,而是一堆文件躺在对象存储上,没有常驻的存储引擎进程为它们维护一致性。 那么「一张支持 ACID、能更新删除、能时间旅行的表」是怎么在这种底座上成立的?

这正是本系列要拆的内核问题。第一篇不展开任何单一格式,而是回答三个更基础的问题:

本篇是全景与地图,不依赖本机实测;涉及具体格式与提交协议的实测留到后续各章。结论锚定一手来源:Apache Iceberg 表规范与官方文档对 Hive 表问题的陈述、Apache Hive 的 Metastore 文档、以及 lakehouse 概念的原始论文(Armbrust 等,CIDR 2021)与 Delta Lake 论文(Armbrust 等,VLDB 2020)。


一、先把「表」这个词拆开

在数据库里,「表」是一个被引擎牢牢握住的对象:引擎知道它有哪些行、存在哪些页、被哪些索引覆盖、当前有哪些未提交事务。读写都经过引擎,一致性由引擎的 MVCC、锁、WAL 保证(见 PostgreSQL MVCC)。

把数据搬到对象存储上之后,这个「握住表的引擎」消失了。剩下的只有:

「表格式(table format)」要回答的就是后面这半句:给定一个表名,当前这一刻它由哪些文件组成、用什么 schema 解释、能不能并发安全地往里加减文件。 这件事在有引擎时被隐含解决了,在对象存储上必须显式设计。Hive 给出了第一代答案,开放表格式(Iceberg / Delta / Hudi)给出了第二代。要讲清第二代为什么长成那样,得先看清第一代卡在哪。

文件格式 ≠ 表格式

一个高频混淆先澄清:Parquet 是文件格式,Iceberg 是表格式,两者不在一个层次。

一张 Iceberg 表里的数据文件几乎总是 Parquet(也可以是 ORC/Avro)。所以本系列的顺序是先讲文件格式(第 2–5 章),再讲对象存储语义(第 6 章),最后才进表格式(第 7 章起)。本篇把这条链先铺出来。

flowchart TD
  Q["查询引擎<br/>Trino / Spark / DuckDB"]
  CAT["catalog 层<br/>表名 → 当前元数据指针"]
  TF["表格式层<br/>snapshot / 事务日志 / timeline"]
  OBJ["对象存储层<br/>S3 语义:不可变对象 + 条件写"]
  FF["文件格式层<br/>Parquet / ORC 内部结构"]
  Q --> CAT
  CAT --> TF
  TF --> OBJ
  TF --> FF
  FF --> OBJ

这张图是全系列的地图:自底向上是「文件怎么读 → 对象存储能给什么 → 表格式如何在其上建事务 → catalog 如何定位与原子提交 → 引擎如何下推查询」。本篇把每一层的存在理由讲清,后续各章逐层钻进去。


二、第一代答案:Hive 目录式分区表

Hadoop 时代,「在 HDFS / 对象存储上把文件当表查」的事实标准是 Hive 表:用 Hive Metastore(HMS)存表与分区的元数据,用目录结构表达分区,用目录里的文件作为数据。今天绝大多数「老数据湖」仍是这个模型,理解它是理解开放表格式的前提。

2.1 它长什么样

一张按日期分区的 Hive 表,物理上是这样一棵目录树(对象存储上则是共享前缀的 key,见 对象存储语义与代价):

s3://warehouse/db/orders/
  date=2026-06-29/
    part-00000.parquet
    part-00001.parquet
  date=2026-06-30/
    part-00000.parquet
    part-00002.parquet

元数据侧,Hive Metastore 里存着(来源:Apache Hive 文档,AdminManual MetastoreTable/Partition 模型):

注意一个关键事实:Metastore 跟踪到「分区」这一级就停了,它不记录分区目录里有哪些文件。 「这个分区由哪些文件组成」要靠运行时去 LIST 那个目录前缀得到。这一条是后面所有麻烦的根。

flowchart LR
  HMS["Hive Metastore<br/>表 schema + 分区行(目录前缀)"]
  D1["date=2026-06-29/ (目录)"]
  D2["date=2026-06-30/ (目录)"]
  HMS -->|"分区行指向前缀"| D1
  HMS -->|"分区行指向前缀"| D2
  D1 -.->|"运行时 LIST 才知道有哪些文件"| F1["part-*.parquet"]
  D2 -.->|"运行时 LIST 才知道有哪些文件"| F2["part-*.parquet"]

2.2 一次查询怎么跑

查询 SELECT sum(amount) FROM orders WHERE date = '2026-06-30' 在 Hive 模型下的 planning 大致是:

  1. 问 Metastore:表 orders 的位置、schema、分区键。
  2. 问 Metastore:满足 date = '2026-06-30' 的分区有哪些(分区裁剪在这一步,靠分区行)。
  3. 对命中的分区目录做 LIST,枚举里面的数据文件。
  4. 打开这些 Parquet 文件读取(文件内部还能按 row group / page 再裁,见 Parquet 文件格式深拆)。

第 2 步的分区裁剪是 Hive 模型唯一像样的优化:不读不相关日期的目录。但第 3 步是命门——要知道一个分区里有哪些文件,只能 LIST 目录。 在 HDFS 上 LIST 还算便宜,搬到 S3 这种对象存储上,LIST 是按前缀分页的网络 API,代价随对象数线性增长(见 对象存储语义与代价 第三节)。这是「小文件多了 planning 就慢」的直接来源。


三、Hive 表的三个硬伤

把 Hive 模型的问题归纳成三条,每一条都直接对应开放表格式后来补上的一件事。以下问题陈述锚定 Apache Iceberg 官方文档对「Hive 表的问题」的总结(Iceberg Documentation 与表规范 Overview 对设计动机的陈述)。

3.1 硬伤一:没有原子提交,依赖目录 rename

写入一张 Hive 表(比如一次 INSERT OVERWRITE 或追加一批文件),经典做法是 Hadoop 的 FileOutputCommitter:先把文件写到一个临时目录 _temporary/,全部写完后 rename 到最终目录。在 HDFS / POSIX 上,目录 rename 是原子且 O(1) 的,于是「rename 成功」≈「这批数据原子地出现了」。

这套机制在对象存储上塌掉,因为对象存储没有原子 rename(详见 对象存储语义与代价 第四节):

结果是:Hive 表在对象存储上既给不了「全有或全无」的原子提交,也给不了并发写的隔离。 工程上只能靠「同一时刻只允许一个写任务」「写完用业务约定切换分区」等外部纪律兜,脆弱且不通用。这是第 7 章「表格式为什么存在」要正面回答的问题。

3.2 硬伤二:planning 依赖 LIST,随规模线性退化

如 2.2 所述,Hive 要知道分区里有哪些文件,只能 LIST 目录。这带来两个层面的问题:

Hive 模型里没有文件级的统计信息——它不知道某个文件里 amount 的取值范围,所以无法在打开文件前按文件裁剪。能裁的只有分区目录这一粒度。要进一步裁,只能打开每个 Parquet 文件读 footer 里的统计(又是一次次往返)。

开放表格式的对策是把「表由哪些文件组成」和「每个文件的列级统计」预先写进元数据文件,planning 时读固定几个元数据对象,而不是 LIST 数据目录、也不必逐个打开数据文件。这是第 8 章(Iceberg 元数据树)的核心。

3.3 硬伤三:schema 与分区演进笨重

Hive 表的两个演进点都别扭:

开放表格式用两招破这两点(细节在第 9、16 章):

三个硬伤与解法对照

Hive 硬伤 根因 开放表格式的解法 本系列章节
无原子提交、依赖目录 rename 对象存储无原子 rename,commit-by-rename 失效 提交 = 原子切换一个元数据指针(CAS) 第 7、11 章
planning 靠 LIST,无文件级 stats Metastore 只跟踪到分区,文件靠运行时枚举 元数据预存文件清单 + 列级统计,不 LIST 第 8 章
schema/分区演进笨重 列靠名字/位置、分区是物理目录 field id 对齐 + partition spec 演进,不重写 第 9、16 章

这张表是全系列的「问题驱动」索引:每一行的右侧解法都对应后面专门的一章。


四、第二代答案:把表拆成三层

开放表格式(Iceberg / Delta / Hudi)的共同抽象,可以概括成一句话:一张表 = 一堆不可变的数据文件 + 一份可变的元数据 + 一个能原子切换元数据的 catalog。 三家在「元数据怎么组织」上各有取法(snapshot 树 / 事务日志 / timeline,第 8、12、13 章对比),但分层是共通的。

flowchart TD
  C["catalog 指针<br/>表名 → 当前元数据位置 (唯一可变量)"]
  M["元数据<br/>记录: 当前由哪些文件组成 + 每文件的列级统计 + schema/分区历史"]
  D["数据文件 (不可变)<br/>Parquet / ORC,写完不再改"]
  C -->|"提交 = 原子 swap"| M
  M -->|"引用 + 统计"| D

4.1 第一层:不可变数据文件

数据落成一个个 Parquet / ORC 文件,写完就不再修改。这不是风格选择,而是被对象存储逼出来的:对象不可局部改写(见第 6 章第六节),想「改」只能写新文件。

不可变带来一串连锁结论:

4.2 第二层:可变元数据

元数据是表格式的大脑,至少记录三件事:

  1. 当前表由哪些数据文件组成——把 Hive 要靠 LIST 才知道的事,变成读元数据就知道。
  2. 每个数据文件的列级统计(min/max、null count 等)——把 Hive 没有的文件级裁剪能力补上。
  3. schema 与分区的历史与当前指针——用 field id 与 partition spec 支撑无重写演进。

关键在于元数据本身也大多是不可变文件 + 写新换旧:每次提交生成新的元数据文件,旧的不动。这样「读一个历史版本」就是「读一份旧元数据」。Iceberg 把它组织成一棵四层树(metadata.json → manifest list → manifest → data file,第 8 章),Delta 组织成一串有序事务日志(第 12 章),Hudi 组织成 timeline(第 13 章)。

4.3 第三层:catalog 与原子提交

三层里唯一被原地改写的,是 catalog 里那一个「表名 → 当前元数据位置」的指针。提交一次写入的最后一步,就是把这个指针从「旧元数据」原子地切到「新元数据」。

这个「原子 swap」从哪来?取决于 catalog 的实现(第 15 章详谈):数据库 catalog 用一行的条件更新(行锁/事务),REST catalog 用后端服务的事务,纯对象存储则可用条件写(If-Match/If-None-Match,第 6 章第五节)。把「提交一张表」从 Hive 的「搬 N 个文件并祈祷 rename 原子」压缩成「换 1 个指针」,是开放表格式最核心的一招。

sequenceDiagram
  participant W as Writer
  participant S as 对象存储
  participant C as Catalog
  W->>S: 写新数据文件 (Parquet, 不可变)
  W->>S: 写新元数据 (引用新+旧文件, 含统计)
  W->>C: CAS: 指针 旧元数据 → 新元数据
  alt 指针未变
    C-->>W: 成功 (新文件原子可见)
  else 被别人抢先
    C-->>W: 冲突, 读新状态后重试
  end

对照第 3 章的三个硬伤:原子 swap 解决了「无原子提交」;元数据预存文件清单与统计解决了「靠 LIST、无文件级裁剪」;field id 与 partition spec 解决了「演进笨重」。三层抽象不是凭空设计,而是逐一回应 Hive 的痛点。


五、三个被用滥的词:data lake / warehouse / lakehouse

讲清了技术分层,再来界定三个市场词的技术边界。这里不做营销式定义,只按「数据放哪、谁管 schema、有没有事务」三个维度区分。

维度 数据仓库 (data warehouse) 数据湖 (data lake) 湖仓 (lakehouse)
数据位置 仓库自有存储(常为专有格式) 对象存储 / HDFS 上的开放格式文件 对象存储上的开放格式文件
schema 写时 schema(schema-on-write) 读时 schema(schema-on-read) 写时 schema(表格式强制)
事务 / ACID 基本没有(Hive 表只能弱保证) 有(表格式提供)
引擎耦合 强(数据进仓库才能查) 弱(任意引擎读文件) 弱(多引擎共享表格式)
典型问题 贵、封闭、难放半结构化数据 没有事务、易成「数据沼泽」 复杂度上移到表格式与 catalog

三者的演化逻辑:

所以 lakehouse 在技术上 = 数据湖的存储底座 + 仓库级的表语义,而把仓库语义落到对象存储上的那层东西,就是本系列要拆的开放表格式。Delta Lake 论文(Armbrust 等,Delta Lake: High-Performance ACID Table Storage over Cloud Object Stores,VLDB 2020)给出了这一思路最早的完整工程实现之一:用一份事务日志在 S3 上做 ACID。

一句话边界:lakehouse 不是某个产品,而是「开放文件格式 + 表格式元数据 + catalog + 多引擎」这套组合。本系列拆的是这套组合的内核,不是任何厂商的托管实现。


六、为什么 Iceberg 作主线

三家开放表格式都遵循第四节的三层抽象,本系列选 Apache Iceberg 作主线,Delta / Hudi 各一章对照(第 12、13 章),第 14 章做三者对比与互通。选择理由(不做排名,只说本系列的取法):

维度 Iceberg Delta Lake Hudi
元数据模型 不可变 snapshot + manifest 树 顺序事务日志 _delta_log timeline + file group
规范开放度 表规范 + REST Catalog 规范公开 协议公开,生态偏 Spark/Databricks 协议公开,生态偏流式 upsert
引擎中立性 强(Trino/Spark/Flink/DuckDB 等) 较强(UniForm 补互通) 偏自带写入栈
行级更新 position/equality delete、V3 deletion vector deletion vector CoW/MoR + record index

选 Iceberg 主要因为:表规范(V1/V2/V3)与 REST Catalog 规范完整公开、便于锚定一手来源;引擎中立性最强,适合讲「表格式与引擎解耦」这件事。这不代表 Iceberg「更好」——三家是同一问题(在对象存储上给文件加事务)的不同解法,各自的取舍在第 14 章展开。

版本锚定:本系列以 Iceberg 表规范 V2 为主线,V3 新特性(deletion vector、row lineage 等)单独标注「规范已定义、引擎支持视版本而定」;库版本 Iceberg 1.x、Delta 3.x+、Hudi 1.x,源码与规范引用都标注 release / spec 版本。


七、本系列的分层地图与阅读路径

把全系列 21 篇按第一节的分层地图归位,每一章在解决哪一层的问题:

flowchart TD
  subgraph L1["文件格式层 (第 2-5 章)"]
    P["02 Parquet"] --> O["03 ORC 对照"]
    P --> A["04 Arrow 内存格式"]
    A --> E["05 编码与压缩"]
  end
  subgraph L2["对象存储与动机 (第 6-7 章)"]
    S["06 对象存储语义"] --> WT["07 表格式为何存在"]
  end
  subgraph L3["表格式内核 (第 8-14 章)"]
    IM["08 Iceberg 元数据树"] --> HP["09 隐藏分区"]
    IM --> RD["10 行级删除/MoR"]
    IM --> CC["11 提交与并发"]
    DL["12 Delta"] --> CMP["14 三者对照"]
    HU["13 Hudi"] --> CMP
    RD --> CMP
  end
  subgraph L4["治理/引擎/前沿 (第 15-21 章)"]
    CATALOG["15 Catalog 之争"]
    QE["18 引擎如何读湖"]
  end
  L1 --> L2 --> L3 --> L4

几条推荐路径(完整路径见 系列目录):

无论走哪条路径,下一站都建议是 Parquet 文件格式深拆:表格式管的是「哪些文件组成表」,而「文件内部怎么读、谓词如何在读盘前裁页」是 Parquet 这层的事,也是整套裁剪漏斗(partition pruning → file pruning → page pruning)的最后一级。


八、小结

Hive 目录式分区表是「在对象存储上把文件当表用」的第一代答案,它把表等同于「一组目录 + Metastore 里的分区行」,于是栽在三处:

  1. 没有原子提交:依赖目录 rename,而对象存储没有原子 rename,导致 partial commit 与并发写无隔离。
  2. planning 靠 LIST:Metastore 只跟踪到分区,文件清单要运行时枚举;没有文件级统计,裁剪粒度只到分区目录。
  3. 演进笨重:列靠名字/位置、分区是物理目录,schema 与分区演进常要重写数据。

开放表格式(Iceberg / Delta / Hudi)的共同对策是把表拆成三层:不可变数据文件 + 可变元数据 + 可原子切换的 catalog 指针。提交从「搬 N 个文件」变成「换 1 个指针」(解决硬伤一),元数据预存文件清单与列级统计(解决硬伤二),field id 与 partition spec 支撑无重写演进(解决硬伤三)。这就是 lakehouse 在技术上的实质:数据湖的开放存储底座 + 仓库级的表语义

下一篇进入文件格式层的核心:Parquet 文件格式深拆,看一个 Parquet 文件如何切成 row group / column chunk / page,谓词如何在读盘前裁掉数据。


返回 系列目录

下一篇:Parquet 文件格式深拆


参考资料

  1. Apache Iceberg, Apache Iceberg Documentationiceberg.apache.org)与表规范 Overview——对 Hive 表问题(依赖目录 LIST、无文件级跟踪、原子性与演进)的设计动机陈述。
  2. Apache Hive, AdminManual Metastore 与 Hive Table / Partition 元数据模型文档——Metastore 跟踪到分区粒度、分区指向目录前缀。
  3. Apache Hadoop, Committing work to S3 with the S3A Committers——经典 FileOutputCommitter 依赖目录 rename 的原子性,对象存储不满足。
  4. M. Armbrust, A. Ghodsi, R. Xin, M. Zaharia, Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics, CIDR 2021——lakehouse 概念的原始论文。
  5. M. Armbrust 等, Delta Lake: High-Performance ACID Table Storage over Cloud Object Stores, VLDB 2020——在对象存储上用事务日志做 ACID 的工程实现。
  6. 本系列 对象存储语义与代价——S3 无原子 rename、LIST 代价、条件写的一手语义。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-29 · database / storage

【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解

拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。

2026-06-30 · database / storage

【数据湖与开放表格式】表格式为什么存在

目录式分区表(Hive 表)在对象存储上有三处硬伤:并发写部分提交、list planning 太贵、缺快照隔离与原子提交。本文拆开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema 与分区演进,并抽象出三家共有的『元数据指针 + 不可变数据文件』骨架。

2026-06-30 · database / storage

【数据湖与开放表格式】对象存储语义与代价

对象存储不是网络版 POSIX 文件系统。本文用 S3 官方语义钉住四件事:强一致模型的边界、LIST 随对象数线性增长的代价、没有原子 rename(只能 copy+delete)、条件写(If-None-Match/If-Match)对提交协议的意义,并讲清 multipart 与对象不可改写。

2026-06-30 · database / storage

【数据湖与开放表格式】Iceberg 元数据树

拆解 Iceberg 的四层元数据:catalog 指针 → metadata.json → manifest list(snapshot)→ manifest file → data file。讲清 snapshot 与 manifest 里的分区数据和列级 stats(lower/upper bound、null/value count)如何让一次查询不 list 目录就收敛到文件集合,并给出表规范 V1/V2/V3 的版本边界。基于 pyiceberg 0.11.1 真实建表逐层 dump。


By .