上一章(对象存储语义与代价)确立了底座的硬约束:没有原子 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 大致是:
- 根据分区列谓词,确定要进哪些分区子目录(这一步还算便宜,前缀拼接)。
- 列出每个命中分区下的全部文件——这是 O(每分区文件数) 的 list。
- 对每个文件,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 = 42。user_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=.../)。这带来两个演进难题:
- 改分区方式要重排数据。原来按
date分区,想改成按date+hour,意味着目录结构变了,历史数据得按新结构重新落盘或至少重新组织目录——一次大规模 copy。 - schema 演进靠位置/名称约定,易错。加列、删列、改名在目录式表里没有统一的字段身份(field identity),不同文件的 schema 对齐靠列名或顺序,改名或重排容易读错列。
这两点的根因都是「表的结构信息没有一个权威、可演进的元数据中心,而是散在目录布局和各文件里」。
五、表格式补上的四件事
把三处缺口翻过来,就是开放表格式(Iceberg / Delta / Hudi)共同提供的四个能力。它们不是各家独有的卖点,而是「在对象存储上把一堆文件当成一张可靠的表」的最小必要集合。
5.1 原子提交(atomic commit)
表格式给「提交」一个明确的、原子的瞬间:一次提交要么整体生效,要么完全不生效,中间状态对 reader 不可见。 实现方式是「先把这次要新增/删除的数据文件全写好(它们此刻还不属于表),最后一步原子地切换一个元数据指针,让表从旧文件集合跳到新文件集合」。
- 切换之前:reader 读到的是旧版本,看不到任何新文件——消除了部分提交。
- 切换是单点原子操作:要么成功(新版本可见),要么失败(仍是旧版本),不存在「切了一半」。
- 这个原子切换的支点,就是第 6 章的两种原语之一:catalog
的事务/锁,或对象存储的条件写(
If-None-Matchput-if-absent /If-MatchCAS)。
各家的「指针」长得不一样: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 影响:
- reader 永远看到一个自洽的版本,不会跨版本抖动。
- writer 提交新 snapshot 不影响正在读旧 snapshot 的 reader(旧文件不被原地删除,第 6 章的「不可改写」让这天然成立)。
- 按 snapshot/版本/时间读,就是时间旅行(第 16 章);按 snapshot 固定数据版本,就是可复现的训练集(第 21 章)。
这与 MVCC 的思路同源:用「不可变版本 + 读时选版本」换并发读写不互相阻塞。区别是这里的「版本」是文件集合的快照,不是行级 tuple 版本。
5.3 文件级统计裁剪(metadata pruning)
表格式把「表当前有哪些数据文件」从「靠 list 目录推断」变成「显式记在元数据里」,并且给每个数据文件携带列级统计:每列的 min/max、null 数、值数等。planning 时读这些元数据(固定几个对象,不 list 数据前缀),就能:
- 不靠 list 拿到完整文件清单——把第三节那条 O(文件数) 的 list 成本,换成读少量元数据文件。
- 按文件统计直接裁掉无关文件——
WHERE user_id=42可以跳过所有user_id范围不含 42 的文件,连 footer 都不用打开。
裁剪链条因此变成多层级联:分区裁剪 →(元数据里的)文件级统计裁剪 → Parquet row-group/page 裁剪 → 字典过滤(第 18 章把这条链讲全)。中间那层「文件级裁剪」正是目录式表缺的。
5.4 schema 与分区演进
表格式把 schema 和分区方式放进权威元数据,并用字段 ID(field ID)而非列名/位置来标识列,于是:
- schema 演进(加列、删列、改名、重排)按 field ID 解析,老文件用老 schema 写、新文件用新 schema 写,读时按 ID 对齐,不会读错列(第 16 章)。
- 分区演进 不重写历史数据:分区方式(partition spec)本身可以演进,每个数据文件记录自己是按哪个 spec 写的,新旧文件共存,查询按各自 spec 裁剪(Iceberg 的隐藏分区与分区演进,第 9 章)。
根因解决了:表结构有了一个权威、可演进、与目录布局解耦的元数据中心。
| 缺口(目录式表) | 表格式补丁 | 后续章节 |
|---|---|---|
| 部分提交 / 并发覆盖 | 原子提交(指针单点切换 + 冲突检测) | 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
- 下层:不可变数据文件。 一律 Parquet/ORC 这类写完不改的文件(第 2–5 章)。更新/删除不原地改文件,而是写新数据文件或 delete 文件(第 10 章)。对应第 6 章的「对象不可改写」。
- 上层:不可变元数据 + 一个可变指针。 每次提交写出新的不可变元数据(记录这个版本的完整文件集合与统计),再把唯一的可变指针原子切到新元数据。对应第 6 章的「不能 rename,但能原子换一个对象/引用」。
「提交」永远是「把指针从旧版本原子切到新版本」这一个动作。无论底下数据文件有多少、搬了多少字节,提交本身只动这一个指针——这就把第 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 表格式不解决什么
划清边界,避免把表格式当银弹:
- 表格式不是查询引擎。 它定义「表由哪些文件构成、怎么原子提交、怎么裁剪」,真正读数据、做 join/聚合的是 Trino/Spark/DuckDB 等(第 18 章)。
- 表格式不替代 catalog。 「表名 → 当前元数据指针」这一映射和提交的原子点,往往落在 catalog(HMS / REST / Glue / Nessie / Polaris / Unity,第 15 章)。
- 表格式不改变对象存储语义。 它是在第 6 章那组约束之上的协议层,不会让 list 变快或让对象可改写,只是用「指针 + 不可变文件」把这些约束变得可用。
- 表格式不自动消除小文件。 频繁提交照样产生小文件,要靠 compaction 维护(第 17 章)。
七、一次提交的端到端对照
把目录式表和表格式的「写一批数据」全流程并排,收束本章。
目录式表(在 S3 上):
- 往分区前缀逐个
PUT数据文件——逐个变可见,部分提交风险全程存在。 - 没有提交点,没有冲突检测——并发 writer 可能互相覆盖。
- 别的查询此刻 list 目录——可能读到半成品。
表格式:
- 把新数据文件全部写到存储(此刻还不属于表,reader 看不到)。
- 写出新的不可变元数据,记录新版本的完整文件集合与统计。
- 原子切换指针:catalog 事务 / 条件写 put-if-absent / CAS。成功则新版本整体可见;与并发提交冲突则按隔离级别重试或失败(第 11 章)。
- 别的查询绑定某个 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 列表对比。
十一、小结
目录式分区表在对象存储上的三处缺口:
- 并发写竞争与部分提交——可见性等于「文件在不在目录里」,没有原子边界,崩溃留半个表,并发无冲突检测。
- list-based planning 太贵——planning 靠 O(N) 的 list,且文件级无统计,裁不掉无关文件。
- 没有快照隔离与一致演进——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
上,每天有一个批作业重写当天分区,同时一个补数作业偶尔也写同一分区。
- 批作业 J1 开始重写
date=2026-06-30:先DELETE该前缀下旧文件,再逐个PUT200 个新文件。 - J1 写到第 120 个文件时 executor OOM 崩溃。此刻该前缀下有 120 个新文件 + 已被删的旧文件。
- 一个 BI 查询 Q 此刻
list该分区 → 读到 120 个文件 = 当天 60% 的数据,且没有任何报错提示数据不全。报表口径凭空少了四成。 - 运维重跑 J1;与此同时补数作业 J2 也在往该分区
PUT文件。两者对不同 key 各写各的,谁也不知道对方存在(第 6 章:不同 key 并发各写各的)。 - 最终该前缀下混了 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):
- staging committer:task
数据先写本地磁盘,用 multipart 上传但延迟
CompleteMultipartUpload,job 提交时统一 complete,避开 rename。 - magic committer:直接写 S3,用特殊路径与延迟 complete 实现「提交时才可见」。
这些 committer 缓解了「提交可见性」,但它们仍是「让一批文件出现」的机制,不提供快照隔离、文件级统计、并发冲突检测、演进——这正是为什么还需要表格式这一层,而不是停在 committer。
隔离级别预告:serializable vs snapshot
表格式提交不是「写就赢」,而是按隔离级别检测冲突(第 11 章详述):
- snapshot 隔离:基于读到的 snapshot 生成新 snapshot,提交时若基线已被他人推进则重试;冲突判定相对宽松。
- serializable:更强,要求等价于某个串行顺序,对「读到的数据是否被并发写改动」也敏感。
两者都建立在「每次提交是单点原子切换 + 可检测的基线版本」之上——也就是本章的骨架。OLTP 读者可对照 PostgreSQL 的隔离级别,区别是这里冲突的粒度是「文件集合 / 分区」,不是行。
冲突检测看什么
乐观并发提交失败的典型原因(第 11 章展开):两个提交都想删除/重写同一批数据文件,或一个删除了另一个依赖的文件。检测维度通常是「本次提交涉及的文件集合 / 分区」与「基线之后他人已提交的变更」是否重叠。检测得以进行,前提是元数据里显式记着每个版本动了哪些文件——又一次回到「显式文件清单」这个补丁。
为什么用 field ID 而不是列名
schema 演进若靠列名或列位置对齐,改名 = 丢列、插列 = 错位。表格式给每列分配稳定的 field ID,schema 变化只改「ID↔︎名字/类型」的映射,数据文件里按 ID 存取(第 16 章)。这让「加列、删列、改名、重排」都能安全演进,老文件不必重写。这是「演进不动数据」能成立的关键设计,目录式表没有这层身份。
元数据也会膨胀
表格式把文件清单与统计显式化,代价是 元数据本身会增长:提交越频繁、文件越多,manifest/日志越大,planning 读元数据也变慢。于是又需要维护操作:合并 manifest、写 checkpoint、过期老 snapshot(第 16、17 章)。表格式把「list 数据目录的成本」换成「读 + 维护元数据的成本」,不是消灭成本,而是把它挪到可控、可裁剪、可压缩的一层。
文件级裁剪如何与 Parquet 内部裁剪级联
表格式补的「文件级统计」不是孤立的,它是裁剪链最外面一层。一次扫描的裁剪从粗到细级联(完整版第 18 章):
- 分区裁剪:按分区谓词排除整批文件(元数据层)。
- 文件级裁剪:按元数据里每文件的 min/max/null 等,排除范围不命中的文件(元数据层,本章补的就是这层)。
- row-group / page 裁剪:打开幸存文件的 Parquet footer,按 column index 的 page 级 min/max 跳过 row group 和 page(文件内,第 2 章)。
- 字典过滤:用 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 章 |
参考资料
- Apache Iceberg, Table Spec(snapshot、manifest、原子提交与文件级统计的规范定义)。
- Apache Iceberg Documentation, Reliability(对 Hive 表依赖文件系统 list、在 S3 上一致性与性能问题的陈述)。
- Delta Lake,
PROTOCOL.md(
_delta_log有序提交、actions、checkpoint 与原子提交语义)。 - Apache Hudi Documentation(timeline、instant 状态、并发控制与文件组织)。
- Apache Hadoop, Committing work to S3 with the S3A Committers(目录式表用 rename 提交在对象存储上的问题)。
- 本系列 对象存储语义与代价(强一致、LIST O(N)、无原子 rename、条件写,本章约束来源)。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【数据湖与开放表格式】Parquet · Iceberg · Delta · Hudi 内核拆解
拆解 lakehouse 的两层基础:列式文件格式(Parquet/ORC/Arrow)与开放表格式(Iceberg/Delta/Hudi)。讲清没有数据库进程时,如何在对象存储上做 ACID、行级更新、快照与并发,以及 catalog、查询引擎、流式入湖如何拼成可运维的湖仓。面向数据平台工程师与从 OLAP/数仓转型的开发者。
【数据湖与开放表格式】Lakehouse 全景:从 Hive 表到开放表格式
Hive 目录式分区表把『表』等同于『一组目录加 metastore 里的分区行』,于是没有原子提交、planning 要 LIST 目录、schema 与分区演进常要重写。本文用这三个硬伤切入,讲清 lakehouse 把表拆成『不可变数据文件 + 可变元数据指针 + catalog』三层后各自解决了什么,并给出全系列的分层地图。
【数据湖与开放表格式】Iceberg、Delta、Hudi 对照与互通
把前面 08–13 章拆过的 Iceberg、Delta、Hudi 放在一个坐标系里对照:元数据模型、行级更新、并发控制、引擎生态四维,每维标清口径。再讲两条互通路线——Delta UniForm(写时同步生成 Iceberg/Hudi 元数据)与 Apache XTable(事后转换元数据),以及它们的边界。最后给一棵按写入模式/引擎栈/更新频率展开的选型决策树,不做排名。
【数据库研究前沿】Iceberg vs Hudi vs Delta:湖仓表格式的事务边界与选型
把 Apache Iceberg、Apache Hudi、Delta Lake 放在同一张表上比较:metadata layout、snapshot isolation、多写者 OCC 协议、schema 与 partition evolution,最后给出 iceberg vs hudi 选型矩阵与对象存储上的事务边界。