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

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

文章导航

分类入口
databasestorage
标签入口
#table-format#acid#snapshot-isolation#iceberg#delta-lake#hudi#lakehouse

目录

上一章(对象存储语义与代价)确立了底座的硬约束:没有原子 rename、LIST 是 O(N)、对象不可改写、强一致但无快照隔离、并发互斥只能靠条件写。这一章回答一个直接问题:既然数据就是一堆 Parquet 文件躺在 S3 上,为什么不能像 Hive 那样「用目录当表」,非要再加一层表格式?

答案是:目录式分区表在对象存储上有三处会真正出事故的缺口——并发写互相覆盖、读到写一半的数据、planning 慢到不可用。Iceberg、Delta、Hudi 形态各异,但都在补同样四件事,而且都用同一个骨架去补。本文先把缺口讲透,再拆补丁,最后抽象出三家共有的结构。

不写具体表格式的元数据细节(Iceberg 留给第 8 章,Delta 第 12 章,Hudi 第 13 章),只讲「为什么需要」和「共性抽象」。版本口径:以 Iceberg 表规范、Delta 协议规范、Hudi 官方文档对各自机制的陈述为准。


一、目录式分区表是什么

先说清被取代的对象。Hive 风格的表把「表」定义成「一个目录下、按分区列分子目录、每个子目录放数据文件」的约定:

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

表的「schema、分区列、存储位置」记在一个外部的 Hive Metastore(HMS) 里,但 「这张表当前到底有哪些数据文件」没有任何地方显式记录——它隐含在「目录里现在躺着哪些文件」这一事实里。读表 = 列出目录下的文件再读;写表 = 往目录里加文件;分区裁剪 = 根据分区列谓词只进对应子目录。

这套约定在 HDFS 上能用很多年,靠的是两个 HDFS 性质:目录 rename 原子、list 相对便宜。一旦底座换成对象存储,这两个前提都不成立(第 6 章),缺口就暴露了。

flowchart TB
  HMS["Hive Metastore<br/>(schema / 分区列 / location)"] --> LOC["表 location 前缀"]
  LOC --> P1["date=2026-06-29/"]
  LOC --> P2["date=2026-06-30/"]
  P1 --> F1["列出目录里现有文件 = 表内容"]
  P2 --> F2["没有显式文件清单"]

二、缺口一:并发写竞争与部分提交

目录式表「写 = 往目录加文件」,这件事没有任何原子边界,于是有两个并发与故障问题。

2.1 部分提交(partial commit)

一个写作业要往分区里加 200 个文件。它一个个 PUT 上去,写到第 120 个时 executor 崩了。此刻目录里有 120 个新文件——而任何 reader 来列目录,都会把这 120 个半成品当成表的正式数据读进去。没有「全部成功才可见」的开关,因为可见性等于「文件在不在目录里」,而文件是逐个出现的。

对象存储让这更糟:第 6 章讲过,HDFS 上可以「写进 _temporary/ 再原子 rename 到最终目录」来逼近原子提交(FileOutputCommitter v1),但 S3 没有原子 rename,那个「rename 提交」本身退化成逐对象 copy+delete,既慢又非原子,复制到一半失败同样留下半个表。S3A committer 为此做了专门设计(用 multipart 的延迟 complete 把可见时机后移),但仍解决不了「多个对象一起出现」的原子性。

2.2 并发写互相覆盖

两个作业同时改同一张表:作业 A 在重写分区 date=2026-06-30(先删旧文件再写新文件),作业 B 同时往这个分区追加数据。目录里没有任何协调点,两者的 PUT/DELETE 交错,最终目录状态取决于操作到达顺序——可能 A 删掉了 B 刚写的文件,也可能留下 A、B 各一半。第 6 章说过,S3 对同 key 并发写是「last writer wins」,没有冲突报错;对不同 key(不同文件)的并发更是各写各的,谁也不知道对方存在。目录式表没有「提交」这个概念,自然也没有「冲突检测」。

sequenceDiagram
  participant A as 作业 A (重写分区)
  participant DIR as 目录 (无协调点)
  participant B as 作业 B (追加)
  A->>DIR: DELETE 旧文件
  B->>DIR: PUT new-b.parquet
  A->>DIR: PUT new-a.parquet
  Note over DIR: 最终内容 = 操作到达顺序的产物<br/>无人检测冲突,可能丢数据

读者若来自 OLTP 背景,可以对照 PostgreSQL MVCC 的隔离:那里每条记录的可见性由事务快照决定;目录式表里「可见性 = 文件在不在」,没有任何事务边界。


三、缺口二:list-based planning 太贵

目录式表的查询规划(planning)必须回答「这次查询要读哪些文件」,而它唯一的信息来源是 列目录。这把第 6 章那条 O(N) 的 LIST 代价直接搬到了每一次查询的关键路径上。

3.1 planning = 递归 list

一次带分区谓词的查询,planning 大致是:

  1. 根据分区列谓词,确定要进哪些分区子目录(这一步还算便宜,前缀拼接)。
  2. 列出每个命中分区下的全部文件——这是 O(每分区文件数) 的 list。
  3. 对每个文件,planning 层不知道它的内容统计(min/max 等不在任何索引里),只能把文件整个交给扫描层,靠读 Parquet footer 再做 row-group 裁剪。

把每个文件的元数据获取记为一次往返,分区数 \(P\)、每分区文件数 \(f\),planning 的 list 往返量级是:

\[ T_{\text{plan}} \approx \sum_{i=1}^{P} \left\lceil \frac{f_i}{1000} \right\rceil \cdot t_{\text{rtt}} \]

文件越多、分区越细,planning 越慢,而且这段时间查询还没开始读一个字节的数据。流式写入和频繁提交会制造海量小文件(第 17、19 章),让这一项爆炸。

3.2 没有文件级统计 = 裁不掉文件

更深的问题是:目录式表 在文件这一层没有任何统计信息。它能做的裁剪只有两层——分区目录裁剪(粗)和单文件内的 Parquet row-group 裁剪(要先打开文件)。中间「不打开文件就判断这个文件要不要读」这一层是缺失的。

举例:表按 date 分区,但查询条件是 WHERE user_id = 42user_id 不是分区列,分区裁剪帮不上忙;目录式表只能把命中日期下的所有文件都打开、读 footer、看 row-group 统计才能判断有没有 user_id=42。如果有个地方预先记着「每个文件的 user_id 取值范围」,绝大多数文件可以在 planning 阶段直接跳过,连 footer 都不用读。这正是表格式要补的「文件级统计裁剪」。

flowchart LR
  subgraph DIR["目录式表 planning"]
    Q1["查询 user_id=42"] --> L1["list 命中分区全部文件"]
    L1 --> O1["逐个打开 Parquet 读 footer"]
    O1 --> R1["row-group 裁剪"]
  end
  subgraph TF["表格式 planning"]
    Q2["查询 user_id=42"] --> M2["读元数据(含每文件 min/max)"]
    M2 --> S2["按文件统计直接跳过无关文件"]
    S2 --> R2["只对少数文件读 footer"]
  end

四、缺口三:没有快照隔离与一致演进

前两节是「写」和「读规划」的问题,第三处缺口是「读到的是什么版本」。

4.1 没有一致的时间点视图

目录式表的 reader 看到的永远是「此刻目录里有哪些文件」。如果在它列目录的同时有 writer 在加/删文件,reader 看到的就是一个抖动的、可能自相矛盾的文件集合:list 第一个分区时是旧状态,list 到第三个分区时 writer 已经提交了,于是这次查询跨了两个版本。没有「快照(snapshot)」概念,就没有「这次查询基于表的某个确定版本」的保证,也就谈不上可重复读、时间旅行、按版本固定训练数据(第 16、21 章)。

4.2 schema 与分区演进要动数据

目录式表把分区信息编码进目录路径date=.../)。这带来两个演进难题:

这两点的根因都是「表的结构信息没有一个权威、可演进的元数据中心,而是散在目录布局和各文件里」。


五、表格式补上的四件事

把三处缺口翻过来,就是开放表格式(Iceberg / Delta / Hudi)共同提供的四个能力。它们不是各家独有的卖点,而是「在对象存储上把一堆文件当成一张可靠的表」的最小必要集合。

5.1 原子提交(atomic commit)

表格式给「提交」一个明确的、原子的瞬间:一次提交要么整体生效,要么完全不生效,中间状态对 reader 不可见。 实现方式是「先把这次要新增/删除的数据文件全写好(它们此刻还不属于表),最后一步原子地切换一个元数据指针,让表从旧文件集合跳到新文件集合」。

各家的「指针」长得不一样:Iceberg 是 catalog 里指向当前 metadata.json 的引用(第 8、11 章);Delta 是 _delta_log 里下一个 N.json 日志条目的创建(第 12 章);Hudi 是 timeline 上 instant 从 inflight 到 completed 的状态转移(第 13 章)。但语义一致:提交 = 一个可线性化的单点切换。

5.2 快照隔离(snapshot isolation)

每次提交产生一个不可变的、完整的表版本(snapshot / 版本号 / instant)。reader 在开始时绑定一个 snapshot,整个查询都基于这个确定的文件集合,不受并发 writer 影响

这与 MVCC 的思路同源:用「不可变版本 + 读时选版本」换并发读写不互相阻塞。区别是这里的「版本」是文件集合的快照,不是行级 tuple 版本。

5.3 文件级统计裁剪(metadata pruning)

表格式把「表当前有哪些数据文件」从「靠 list 目录推断」变成「显式记在元数据里」,并且给每个数据文件携带列级统计:每列的 min/max、null 数、值数等。planning 时读这些元数据(固定几个对象,不 list 数据前缀),就能:

裁剪链条因此变成多层级联:分区裁剪 →(元数据里的)文件级统计裁剪 → Parquet row-group/page 裁剪 → 字典过滤(第 18 章把这条链讲全)。中间那层「文件级裁剪」正是目录式表缺的。

5.4 schema 与分区演进

表格式把 schema 和分区方式放进权威元数据,并用字段 ID(field ID)而非列名/位置来标识列,于是:

根因解决了:表结构有了一个权威、可演进、与目录布局解耦的元数据中心。

缺口(目录式表) 表格式补丁 后续章节
部分提交 / 并发覆盖 原子提交(指针单点切换 + 冲突检测) 8、11、12
list planning O(N) 元数据显式文件清单 8、18
裁不掉文件(无文件级统计) 文件级 min/max 等统计裁剪 8、18
读到写一半 / 无版本 快照隔离 + 时间旅行 11、16
演进要重排数据 field ID + 分区 spec 演进 9、16

六、共性抽象:元数据指针 + 不可变数据文件

把三家放在一起看,会发现一个完全一致的骨架。它不是巧合,而是第 6 章那组对象存储约束逼出来的唯一可行解。

6.1 两层结构

flowchart TB
  PTR["可原子切换的元数据指针<br/>(catalog 引用 / 日志末端 / timeline 末端)"]
  PTR --> META["不可变元数据<br/>(版本快照: 文件清单 + 列统计 + schema)"]
  META --> D1["不可变数据文件 part-*.parquet"]
  META --> D2["不可变数据文件 part-*.parquet"]
  META --> D3["不可变 delete 文件 (MoR)"]
  style PTR fill:#388bfd,color:#fff

「提交」永远是「把指针从旧版本原子切到新版本」这一个动作。无论底下数据文件有多少、搬了多少字节,提交本身只动这一个指针——这就把第 6 章那个「copy-rename 慢十万倍」的问题彻底绕开了:提交不搬文件,只换指针。

6.2 三家如何映射到这个骨架

抽象角色 Iceberg Delta Lake Hudi
可变指针 catalog 指向当前 metadata.json _delta_log 的最新版本号 timeline 最新 completed instant
不可变元数据 metadata.json → manifest list → manifest 有序 JSON commit + parquet checkpoint .hoodie/ 下的 instant 文件
文件级统计 manifest 里每个 data file 的 lower/upper bound 等 commit 的 add action 中的 stats 元数据表 / 文件级索引
不可变数据文件 Parquet/ORC/Avro data files Parquet data files base file + log file
原子提交支点 catalog 事务/锁 或对象存储条件写 put-if-absent 创建下一个日志条目 instant 状态转移 + 并发控制锁

差异在「指针放在哪、元数据怎么组织、冲突怎么检测」,这些是后续各章的主题;共性在「不可变文件 + 原子切换的指针」这一条,三家无一例外。理解了这条骨架,再看 Iceberg 的 snapshot 树、Delta 的事务日志、Hudi 的 timeline,就是同一思想的三种数据结构实现。

6.3 表格式不解决什么

划清边界,避免把表格式当银弹:


七、一次提交的端到端对照

把目录式表和表格式的「写一批数据」全流程并排,收束本章。

目录式表(在 S3 上):

  1. 往分区前缀逐个 PUT 数据文件——逐个变可见,部分提交风险全程存在
  2. 没有提交点,没有冲突检测——并发 writer 可能互相覆盖。
  3. 别的查询此刻 list 目录——可能读到半成品。

表格式:

  1. 把新数据文件全部写到存储(此刻还不属于表,reader 看不到)。
  2. 写出新的不可变元数据,记录新版本的完整文件集合与统计。
  3. 原子切换指针:catalog 事务 / 条件写 put-if-absent / CAS。成功则新版本整体可见;与并发提交冲突则按隔离级别重试或失败(第 11 章)。
  4. 别的查询绑定某个 snapshot,全程看到自洽版本。
flowchart LR
  W1["写数据文件<br/>(对表不可见)"] --> W2["写新元数据<br/>(完整文件清单+统计)"]
  W2 --> W3{"原子切指针<br/>CAS / put-if-absent"}
  W3 -->|成功| OK["新版本整体可见"]
  W3 -->|冲突| RETRY["读新状态后重试"]
  RETRY --> W2

同一组对象存储约束,目录式表「逐文件可见、无提交点、靠 list」处处踩坑;表格式「不可变文件 + 原子指针 + 元数据裁剪」把每一处都补上。


八、planning 代价的量化直觉

把第三节的公式代入一组具体数字,体会「list planning」和「元数据 planning」差在哪。下面是按模型代入的示例估算,不是实测——绝对延迟取决于真实部署,这里只看量级与斜率。

设一张表有 \(P = 365\) 个日分区,每个分区 \(f = 500\) 个数据文件,总文件数 \(N = P \cdot f = 182500\)。查询条件命中 90 天,即 \(P' = 90\) 个分区。单次 S3 往返按 \(t_{\text{rtt}} = 10\ \text{ms}\)(同 region 的保守量级)估。

目录式表(list planning,第三节公式):要列出 90 个分区下全部文件,

\[ T_{\text{plan}} \approx \sum_{i=1}^{P'} \left\lceil \frac{f_i}{1000} \right\rceil \cdot t_{\text{rtt}} = 90 \cdot \lceil 500/1000 \rceil \cdot 10\ \text{ms} = 90 \cdot 1 \cdot 10\ \text{ms} = 900\ \text{ms} \]

若分区更细、每分区文件更多(流式写入常见),这个值线性放大;而且这只是「拿到文件清单」,还没做任何文件级裁剪——WHERE user_id=42 这种非分区列谓词,目录式表只能把 90 × 500 = 45000 个文件全交给扫描层逐个开 footer。

表格式(元数据 planning):读固定的几个元数据对象(当前 metadata + 命中的 manifest),往返数与分区数、文件数 不成正比,而是与「命中的 manifest 数」相关,通常是个位数到几十次往返。拿到元数据后,用文件级 min/max 直接裁掉 user_id 范围不含 42 的文件,可能 45000 个里只剩几十个要真正读。

维度 目录式表 表格式
拿文件清单的往返 O(命中分区的文件数 / 1000) O(命中 manifest 数)
非分区列裁剪 不能(逐文件开 footer) 能(文件级统计)
示例命中文件数 45000 全开 footer 文件级裁剪后剩少量

结论不靠绝对值:目录式表的 planning 随文件数线性增长且裁不掉非分区列;表格式把 planning 压成读少量元数据 + 文件级裁剪。 这就是为什么大表、宽谓词、小文件多的场景,表格式的优势随规模拉开。


九、把对象存储约束逐条映射到设计决定

本章的所有补丁,根上都来自第 6 章那组对象存储约束。把它们一一对上,表格式的设计就不再是「一堆特性」,而是「在给定底座上的唯一合理解」。

对象存储约束(第 6 章) 直接后果 表格式的应对
没有原子 rename 不能用「rename 目录」当提交 提交 = 原子切换一个元数据指针,不搬文件
对象不可改写 数据/元数据不能原地改 每次提交写新文件 + 旧文件不动;更新走 CoW/MoR
旧对象不被原地删 历史版本天然留存 snapshot 链 + 时间旅行几乎免费
LIST 是 O(N) planning 靠 list 太贵 元数据显式存文件清单,planning 不 list 数据
无文件级索引 裁不掉非分区列 元数据携带每文件列统计做裁剪
并发写无互斥(last writer wins) 并发提交会互相覆盖 条件写 / catalog 事务做单点原子提交 + 冲突检测
强一致但无快照隔离 reader 看抖动视图 提交后立即可读(靠强一致)+ reader 绑定 snapshot(靠表格式)
跨对象无事务 多文件提交可能部分可见 指针切换前新文件不属于表,切换后整体可见

读法:左列是不可改的物理事实,右列是被它逼出来的协议设计。 Iceberg/Delta/Hudi 在右列的具体实现不同,但每一行都得回答。这张表也是判断「某个 S3 兼容存储能不能直接跑某表格式」的清单——尤其「并发写无互斥」那一行,取决于底座是否提供条件写(第 6 章第五节、第 11 章)。


十、常见问题

10.1 直接在 Spark 里写 Parquet 到 S3 目录,不就是一张表吗?

能读,但不是可靠的表。它正好是本章说的「目录式表」:没有原子提交(崩溃留半张表)、没有并发冲突检测(并发写互相覆盖)、planning 靠 list(大表慢)、没有文件级裁剪和快照。小规模、单写、能接受偶发不一致时凑合可用;多写、流式、要时间旅行或要稳定 planning 就会出事故。

10.2 表格式是不是就是「把文件清单存进一个 manifest 文件」?

文件清单只是其中一件事。完整骨架是「不可变数据文件 + 不可变元数据(含清单与列统计)+ 一个可原子切换的指针」,再加上基于版本基线的冲突检测。少了原子指针,就没有原子提交;少了列统计,就没有文件级裁剪;少了版本基线,就没有并发冲突检测。

10.3 有了强一致的 S3,是不是就不需要表格式了?

不是。强一致解决的是「单个对象写完立即可读」(第 6 章),它让原子提交有可能实现,但本身不提供原子多文件提交、快照隔离、文件级裁剪、演进。这些仍要表格式在强一致之上构造。强一致是必要条件,不是充分条件。

10.4 三家选哪个?

本章只讲「为什么都需要表格式」和共性骨架,不做选型。差异(元数据组织、更新模型、并发、生态)在第 8–14 章逐一拆,第 14 章给对照与互通、第 15 章讲 catalog。选型结论应建立在理解这些机制之后,而不是 feature 列表对比。


十一、小结

目录式分区表在对象存储上的三处缺口:

  1. 并发写竞争与部分提交——可见性等于「文件在不在目录里」,没有原子边界,崩溃留半个表,并发无冲突检测。
  2. list-based planning 太贵——planning 靠 O(N) 的 list,且文件级无统计,裁不掉无关文件。
  3. 没有快照隔离与一致演进——reader 看抖动视图,演进要重排数据。

开放表格式补上的四件事——原子提交、快照隔离、文件级统计裁剪、schema/分区演进——共用一个由对象存储约束逼出的骨架:不可变数据文件 + 可原子切换的元数据指针。提交永远是「换一个指针」,不搬文件;读永远绑定一个不可变快照;裁剪永远先读元数据再碰数据。

Iceberg、Delta、Hudi 是这个骨架的三种实现。下一章进入主线:拆开 Iceberg 的元数据树,看一次读如何从 catalog 指针逐层收敛到最终要扫的那批文件。


返回 系列目录

上一篇:对象存储语义与代价

下一篇:Iceberg 元数据树


附录、概念与工程注记

补一组与「为什么需要表格式」直接相关、但放进正文会打断主线的点,作为读后续章节的铺垫。

Hive 表 vs 表格式:一张对照

维度 Hive 目录式表 开放表格式
「表内容」从哪来 list 目录现有文件 元数据显式记录的文件清单
提交 无提交点(逐文件可见) 元数据指针原子切换
并发 无冲突检测 乐观并发 + 冲突检测(第 11 章)
planning O(文件数) 的 list 读少量元数据 + 文件级裁剪
文件级统计 无(只能开 footer) 有(min/max/null 等)
隔离 读抖动视图 快照隔离
演进 改分区要重排数据 field ID + 分区 spec 演进
时间旅行 按 snapshot/版本读

一个目录式表事故的具体过程

把缺口落到一条具体时间线,体会它为什么是事故而不是理论问题。场景:一张按天分区的 Hive 表 events 在 S3 上,每天有一个批作业重写当天分区,同时一个补数作业偶尔也写同一分区。

  1. 批作业 J1 开始重写 date=2026-06-30:先 DELETE 该前缀下旧文件,再逐个 PUT 200 个新文件。
  2. J1 写到第 120 个文件时 executor OOM 崩溃。此刻该前缀下有 120 个新文件 + 已被删的旧文件。
  3. 一个 BI 查询 Q 此刻 list 该分区 → 读到 120 个文件 = 当天 60% 的数据,且没有任何报错提示数据不全。报表口径凭空少了四成。
  4. 运维重跑 J1;与此同时补数作业 J2 也在往该分区 PUT 文件。两者对不同 key 各写各的,谁也不知道对方存在(第 6 章:不同 key 并发各写各的)。
  5. 最终该前缀下混了 J1 重跑的全量 + J2 的增量,可能重复计数,也可能 J1 的「先删后写」删掉了 J2 刚写的文件。

每一步都对应本章一处缺口:第 2 步是部分提交,第 3 步是无快照隔离(读到写一半),第 4–5 步是并发无冲突检测。换成表格式:J1 的新文件在指针切换前不属于表(第 3 步 Q 读到的是切换前的完整旧版本);J1 崩溃则指针从未切换,等于这次提交没发生;J1 与 J2 并发提交会在指针切换处被检测为冲突,一个成功一个重试(第 11 章)。同一组操作,表格式把每一步事故都堵死在「指针没切换 = 提交没发生」这一条上。

FileOutputCommitter 与 S3A committer

Hadoop 的 FileOutputCommitter 有两个算法版本(来源:Hadoop 文档):v1 把每个 task 输出先写到 task 临时目录,job 提交时把它们 rename 到最终目录;v2 在 task 提交时就 rename。两者都依赖 目录 rename 原子且廉价——在 HDFS 成立,在 S3 不成立(第 6 章)。

为此 Hadoop 提供 S3A committers(来源:Hadoop 文档,Committing work to S3 with the S3A Committers):

这些 committer 缓解了「提交可见性」,但它们仍是「让一批文件出现」的机制,不提供快照隔离、文件级统计、并发冲突检测、演进——这正是为什么还需要表格式这一层,而不是停在 committer。

隔离级别预告:serializable vs snapshot

表格式提交不是「写就赢」,而是按隔离级别检测冲突(第 11 章详述):

两者都建立在「每次提交是单点原子切换 + 可检测的基线版本」之上——也就是本章的骨架。OLTP 读者可对照 PostgreSQL 的隔离级别,区别是这里冲突的粒度是「文件集合 / 分区」,不是行。

冲突检测看什么

乐观并发提交失败的典型原因(第 11 章展开):两个提交都想删除/重写同一批数据文件,或一个删除了另一个依赖的文件。检测维度通常是「本次提交涉及的文件集合 / 分区」与「基线之后他人已提交的变更」是否重叠。检测得以进行,前提是元数据里显式记着每个版本动了哪些文件——又一次回到「显式文件清单」这个补丁。

为什么用 field ID 而不是列名

schema 演进若靠列名或列位置对齐,改名 = 丢列、插列 = 错位。表格式给每列分配稳定的 field ID,schema 变化只改「ID↔︎名字/类型」的映射,数据文件里按 ID 存取(第 16 章)。这让「加列、删列、改名、重排」都能安全演进,老文件不必重写。这是「演进不动数据」能成立的关键设计,目录式表没有这层身份。

元数据也会膨胀

表格式把文件清单与统计显式化,代价是 元数据本身会增长:提交越频繁、文件越多,manifest/日志越大,planning 读元数据也变慢。于是又需要维护操作:合并 manifest、写 checkpoint、过期老 snapshot(第 16、17 章)。表格式把「list 数据目录的成本」换成「读 + 维护元数据的成本」,不是消灭成本,而是把它挪到可控、可裁剪、可压缩的一层。

文件级裁剪如何与 Parquet 内部裁剪级联

表格式补的「文件级统计」不是孤立的,它是裁剪链最外面一层。一次扫描的裁剪从粗到细级联(完整版第 18 章):

  1. 分区裁剪:按分区谓词排除整批文件(元数据层)。
  2. 文件级裁剪:按元数据里每文件的 min/max/null 等,排除范围不命中的文件(元数据层,本章补的就是这层)。
  3. row-group / page 裁剪:打开幸存文件的 Parquet footer,按 column index 的 page 级 min/max 跳过 row group 和 page(文件内,第 2 章)。
  4. 字典过滤:用 page 字典快速判定值是否存在。

关键是前两层在不打开数据文件的情况下完成。目录式表缺第 2 层,所有命中分区的文件都要进到第 3 层(开 footer)才能裁——这就是第三节那个「45000 个文件全开 footer」的来源。表格式把绝大多数文件挡在第 2 层之外。

manifest / 日志复用降低写放大

既然提交是「写新元数据」,会不会每次提交都把全表文件清单重写一遍?不会。三家都做增量复用:Iceberg 新 snapshot 复用未变化的 manifest,只新增/替换涉及变化的 manifest(第 8 章);Delta 用「日志追加 + 周期 checkpoint」,单次提交只写本次的 action(第 12 章);Hudi 在 timeline 上只追加本次 instant(第 13 章)。所以提交的写放大与「本次变更量」相关,而不是与「全表大小」相关——否则大表每次提交都重写全清单,频繁提交会拖垮元数据层。

与 MVCC 的异同

维度 OLTP MVCC(如 PG) 表格式快照
版本单位 行(tuple 版本) 文件集合(snapshot)
可见性 事务快照选 tuple 版本 reader 绑定 snapshot 选文件集合
回收 VACUUM 清旧版本 expire snapshot + 删孤儿文件
写冲突 行锁 / 串行化检测 文件/分区级乐观并发检测

思想同源(不可变版本 + 读时选版本),粒度从行抬到文件,因为底座是「不可变文件 + 慢 list」的对象存储,行级原地更新本就不可行(第 6 章)。

这层抽象对三家后续章节的指引

读后面 Iceberg(第 8–11 章)、Delta(第 12 章)、Hudi(第 13 章)时,可以始终用本章的三个问题对齐:指针放哪、元数据怎么组织、冲突怎么检测。三家答案不同,但都在回答同一组由对象存储约束提出的问题。第 14 章会把三者按这套坐标做对照与互通。

读路径:从指针到要扫的文件

reader 的解析顺序与提交相反:先从 catalog / 日志末端 / timeline 末端拿到当前元数据指针,读到对应版本的元数据,得到「这个版本的完整文件清单 + 每文件统计」,再用查询谓词在元数据层做分区裁剪和文件级裁剪,最后只对幸存文件读 Parquet(再做 row-group/page 裁剪)。整条链从指针出发、逐层收敛到文件集合,不 list 数据前缀。Iceberg 的具体四层收敛在第 8 章,引擎侧的完整下推链在第 18 章。

为什么不把数据全塞进一个数据库

既然要 ACID,为什么不直接用数仓/数据库管所有数据,而要「文件 + 表格式」这套?核心是存储与计算解耦:数据以开放列式文件(第 2–5 章)躺在廉价对象存储上,可被任意引擎(Trino/Spark/DuckDB/Flink,第 18 章)并发读写,不锁定到某个数据库进程;表格式只提供「让这堆文件像一张可靠的表」的元数据协议,本身轻量、无常驻进程。代价是要自己补 ACID、并发、裁剪——也就是本章这四件事。数仓与湖仓的概念边界在第 1 章展开,本章只解释「为什么需要表格式这层」。

提交频率与小文件的张力

原子提交把「提交」变廉价(只换指针),副作用是鼓励频繁提交:流式入湖可能每几秒一次提交,每次写少量小文件(第 19 章)。小文件多 → 元数据膨胀、planning 读元数据变慢、扫描时每文件固定开销占比上升。于是表格式必须配套维护:compaction 合并小文件、合并 manifest、过期 snapshot(第 17 章)。「提交便宜」和「读高效」之间的张力,是 lakehouse 运维的长期主题,不是一次性配置。

术语表

术语 含义
目录式表 / Hive 表 用「目录 + 分区子目录 + 数据文件」约定表示一张表,文件清单靠 list 推断
表格式(table format) 在数据文件之上定义「表由哪些文件构成、如何原子提交、如何裁剪」的元数据协议
部分提交(partial commit) 多文件写入中途失败,半成品被 reader 当正式数据读到
原子提交 一次提交整体生效或完全不生效,中间状态对 reader 不可见
快照 / snapshot 一次提交产生的不可变、完整的表版本
快照隔离 reader 绑定某个 snapshot,全程基于确定文件集合,不受并发写影响
文件级统计 元数据中记录的每个数据文件的列 min/max、null 数等,用于不开文件即裁剪
metadata pruning planning 时读元数据并按统计裁掉无关文件,替代 list + 逐文件开 footer
field ID 列的稳定身份标识,schema 演进按 ID 而非列名/位置对齐
元数据指针 指向「当前表版本元数据」的唯一可变引用,提交即原子切换它
CoW / MoR copy-on-write(重写整文件)/ merge-on-read(写 delete 文件读时合并),第 10 章

参考资料

  1. Apache Iceberg, Table Spec(snapshot、manifest、原子提交与文件级统计的规范定义)。
  2. Apache Iceberg Documentation, Reliability(对 Hive 表依赖文件系统 list、在 S3 上一致性与性能问题的陈述)。
  3. Delta Lake, PROTOCOL.md_delta_log 有序提交、actions、checkpoint 与原子提交语义)。
  4. Apache Hudi Documentation(timeline、instant 状态、并发控制与文件组织)。
  5. Apache Hadoop, Committing work to S3 with the S3A Committers(目录式表用 rename 提交在对象存储上的问题)。
  6. 本系列 对象存储语义与代价(强一致、LIST O(N)、无原子 rename、条件写,本章约束来源)。

同主题继续阅读

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

2026-06-29 · database / storage

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

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

2026-06-30 · database / storage

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

Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。

2026-06-30 · database / storage

【数据湖与开放表格式】Iceberg、Delta、Hudi 对照与互通

把前面 08–13 章拆过的 Iceberg、Delta、Hudi 放在一个坐标系里对照:元数据模型、行级更新、并发控制、引擎生态四维,每维标清口径。再讲两条互通路线——Delta UniForm(写时同步生成 Iceberg/Hudi 元数据)与 Apache XTable(事后转换元数据),以及它们的边界。最后给一棵按写入模式/引擎栈/更新频率展开的选型决策树,不做排名。


By .