bg8.hive -> iceberg
时间回到 SQL-on-Hadoop 的起点。Hive 由 Facebook 开发、2008 年前后开源(本系列取整称 2009 年这一代),它解决的痛点很实在:HDFS 上是一堆文件,分析师却只会写 SQL。Hive 的做法朴素得惊人:
- 一张表 = HDFS 上的一个目录;
- 一个分区 = 目录下的一个子目录,比如
/warehouse/orders/dt=2026-07-28/; - 目录里的文件(Parquet / ORC / 文本)就是表的数据;
- 另设一个 Hive Metastore(HMS)——一个跑在 MySQL / PostgreSQL 上的元数据库,记录"这张表叫什么、有哪些列、有哪些分区目录、数据在哪"。
于是查询引擎(Hive / 后来的 Spark SQL / Presto)拿到一条 SQL,先问 HMS "表 orders 的分区目录有哪些",再去对象存储/HDFS LIST 这些目录列出文件,然后读文件。SQL 就这样跑在了 Hadoop 上。这是一个了不起的抽象:它让"目录 + 文件 + 一份外部元数据"看起来像一张表。
术语一致约定:下文 HMS = Hive Metastore;表格式 = table format;文件格式 = file format。
Hive 的模型有一个根本假设:表的边界 = 目录的边界,分区的枚举 = 目录的 LIST,写入的可见 = 文件 rename 进目录。这套假设在 2009 年的 HDFS 上勉强成立,但每一条都埋着坑。逐条来看:
痛点 1:没有 ACID,并发写不安全。 Hive 怎么"提交"一批新数据?把文件写到临时目录,再 rename 进表/分区目录,同时更新 HMS。问题是:一次写入往往是几十上百个文件,rename 是逐个文件做的,中间没有原子边界。读的人可能正好看到"一半文件已进、一半还没进"的中间态。两个作业同时写同一分区,更是互相覆盖。Hive 表没有"一次提交要么全见要么全不见"的语义。
痛点 2:在对象存储上,连"勉强原子"都没了。(承接第 03 篇)
在 HDFS 上,rename 是 NameNode 上的一次元数据操作,是原子的,所以"整个目录换一批文件"还能凑合。可当底座换成对象存储(S3 / TOS / OSS)——也就是今天云数仓/数据湖的常态——rename 根本不是原子操作,它是 COPY + DELETE 两步(第 03 篇讲过)。中途失败、并发交错,都会把表读坏。Hive 的提交模型在对象存储上直接失去了地基。
痛点 3:分区靠目录,LIST 慢、小文件更慢。(承接第 03 篇)
要知道扫哪些文件,引擎得 LIST 分区目录。一张表几万个分区、每个分区又是几百个小文件时,查询规划阶段(planning) 就要打成千上万次 LIST。而对象存储的 LIST 是分页 HTTP 调用(一次上限约 1000 个 key),又慢延迟又高(第 03 篇的坑)。结果是:SQL 还没开始读数据,光"数清楚有哪些文件"就耗掉大量时间。
痛点 4:没有 schema 演进,改列就要重写。
Hive 主要靠列名/列序对应数据。想把 user_id 改名、想在中间插一列、想调列序,轻则读出错列,重则要把历史数据全部重写一遍。
痛点 5:分区方案定死,改一次搬一次家。
分区键定了(dt),想从按天改成按小时?数据的目录结构变了,几乎等于把整表数据搬家重写。而且你还得在表里冗余存一个 dt 列,查询时必须记得写 WHERE dt=...,忘了就全表扫描。
2015 年前后,数据从 HDFS 大规模迁往对象存储,上面 5 个坑集中爆发。Netflix 的 Ryan Blue、Dan Weeks 为此造了 Apache Iceberg(2017 年首个版本,2018 年 11 月捐给 Apache,2020 年 5 月成为顶级项目——来源:Apache/Wikipedia 项目史)。同期还有 Databricks 的 Delta Lake、Uber 的 Apache Hudi。它们是同一类东西——表格式(table format)。
- 解决什么:在廉价、不可变、rename 非原子、list 慢的对象存储之上,重新为"表"补上 ACID 事务、快照隔离、schema/分区演进、时间旅行、快速文件定位。
- 代价是什么:多了一层元数据(metadata)。表不再是"目录 + HMS 一条记录",而是"数据文件 + 一棵会不断长大的元数据树"。这层元数据需要额外写入、额外维护(compaction、清理过期快照),也带来了新的运维负担。天下没有免费的 ACID。
关键区分:文件格式 vs 表格式(务必分清)
这是最容易混的一点,单独拎出来讲。你在第 04 篇学的 Parquet 是文件格式,本篇的 Iceberg 是表格式,它们不在一个层次,不是替代关系,而是上下层叠加。
- 文件格式(file format,第 04 篇):管的是一个文件内部怎么摆字节。Parquet 决定了列怎么按列存、怎么压缩、footer 里放哪些统计(每列 min/max/null 数)。它的视野到单个文件为止,不知道自己旁边还有别的文件,更不知道"表""事务"。
- 表格式(table format,本篇):管的是一堆文件怎么组成一张有事务语义的表。它不关心某个文件内部的字节布局(那是 Parquet 的事),它关心的是:这一刻,这张表由哪些文件构成?哪些文件是上一版本的?怎么原子地从旧版本切到新版本?
┌───────────────────────────────────────────────┐
│ 表格式 Iceberg │
│ "哪些文件 = 这张表的这个版本 + 事务/快照" │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 文件格式 │ │ 文件格式 │ │ 文件格式 │ ← 第04篇 │
│ │ Parquet │ │ Parquet │ │ Parquet │ │
│ │ 单文件内 │ │ 单文件内 │ │ 单文件内 │ │
│ │ 列式布局 │ │ 列式布局 │ │ 列式布局 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└───────────────────────────────────────────────┘
全部躺在对象存储上
一句话:Parquet 让"一个文件"高效;Iceberg 让"一堆文件"成为一张能事务、能回滚的表。 你完全可以(也几乎总是)用 Iceberg 表来管理一堆 Parquet 文件——两层各司其职。
表格式的定义(简洁版):一套在对象存储上用元数据文件把一批数据文件组织成"带事务语义的表" 的开放规范——它规定了"表的当前状态由哪些文件构成"以及"如何原子地演进到下一个状态"。
Iceberg 把这套元数据组织成一棵分层的树。自顶向下四层(术语以官方 spec 为准,括号内是中文习惯译名):
graph TD
C["Catalog(元数据目录服务)<br/>表名 → 指向'当前 metadata 文件'的指针"]
C -->|指针指向| M["metadata file(元数据文件, *.metadata.json)<br/>表 schema / 分区规格 / 全部快照列表 / current-snapshot-id"]
M -->|current 快照指向| ML["manifest list(清单列表, snap-*.avro)<br/>每个快照一个,列出本快照包含哪些 manifest"]
ML --> MF1["manifest(清单文件, *.avro) #1<br/>列出一批文件 + 每个文件的分区值/行数/各列 min-max/null 数"]
ML --> MF2["manifest(清单文件, *.avro) #2"]
MF1 --> D1["data file a.parquet"]
MF1 --> D2["data file b.parquet"]
MF2 --> D3["data file c.parquet"]
MF2 --> DEL["delete file *.parquet(v2+,行级删除)"]
各层职责(对照官方 spec 的定义):
- Catalog(元数据目录服务):只干一件核心事——保存"表名 → 当前 metadata 文件位置"这个指针,并保证能原子地更换它。可选实现有 Hive Metastore、JDBC、AWS Glue、Nessie、以及 REST Catalog。它是整个事务能力的"支点",下一节详述。
- metadata file(元数据文件):一个 JSON。记录表的 schema、分区规格(partition spec)、所有历史快照的列表、以及当前快照 id。每次提交都会生成一个新的 metadata 文件(旧的保留)。
- manifest list(清单列表):每个快照对应一个。它列出"本快照由哪些 manifest 组成",并为每个 manifest 记了分区取值范围等汇总统计,便于整片跳过。
- manifest(清单文件):列出一批具体的数据文件/删除文件,并为每个文件记录:它属于哪个分区、有多少行、各列的 min/max、null 数等。这是取代目录
LIST的关键——文件清单和统计都在这里,不用去列目录。 - data file(数据文件):真正的数据,就是第 04 篇的 Parquet(也可 ORC/Avro)。delete file 是 v2 起为"行级删除"引入的(见 3.1)。
格式版本(format version,标注版本):据 Iceberg 官方 spec——v1:仅数据文件(适合只追加/整体覆盖的分析表);v2:引入 delete file,支持行级删除/更新(merge-on-read);v3:扩展类型(variant、纳秒时间戳、geometry 等)、列默认值、行血缘(row lineage)、二进制删除向量(deletion vectors)、表加密。v4 仍在开发、尚未正式采纳。你云上用的具体是哪个版本,以你 catalog/引擎的实现为准,可查表属性
format-version。
快照(snapshot)的定义:某一时刻,这张表"由哪些数据文件构成"的一个完整、不可变的记录。
现在回答那个核心问题:在 rename 都不原子的对象存储上,Iceberg 凭什么做到"一次写入要么全见、要么全不见"?
关键洞察是——把难题缩小。Hive 需要"让一整个目录的几十个文件同时原子出现",这在对象存储上不可能。Iceberg 换了个思路:
数据文件、manifest、manifest list、新的 metadata 文件,全都先悄悄写好(它们是全新文件,不覆盖任何东西,读的人根本看不到)。最后,只需要做一个动作:把 Catalog 里"当前 metadata 指针"从旧文件原子地换成新文件。
于是"让一堆文件原子出现"这个大难题,被缩小成"原子替换一个指针"这个小操作。而"原子替换一个指针"是可以做到的——用 CAS(compare-and-swap,比较并交换)。官方 spec 的原话是:"An atomic swap of one table metadata file for another provides the basis for serializable isolation"(用一个 metadata 文件原子替换另一个,提供了可串行化隔离的基础)。
sequenceDiagram
participant W as 写入引擎(Spark/Flink)
participant OS as 对象存储(TOS/S3)
participant CAT as Catalog(支持 CAS)
Note over W,OS: 阶段①~③ 全是"写新文件",读者完全看不见
W->>OS: ① 写新的 data files
W->>OS: ② 写新的 manifest / manifest list
W->>OS: ③ 写新的 metadata.json(含新快照)
W->>CAT: ④ CAS:把指针从 base 换成 new(校验 base 仍是当前)
alt base 仍是当前(没人抢先)
CAT-->>W: 提交成功 —— 新快照瞬间原子生效
else base 已被别的作业改掉
CAT-->>W: 冲突 —— 基于新的当前版本重试(乐观并发)
end
这条链把 ACID 逐字拆开了:
- 原子性(A):指针切换是单个 CAS,要么切成功(全见),要么没切(全不见)。中间那些新文件在切换前对读者不可见。
- 隔离性(I) 与 MVCC 类比:读的人"load 表"时拿到的是当时的当前快照,之后写入产生的新快照不影响正在进行的读——读到的是一个一致的历史版本。这与你在 PostgreSQL 用过的 MVCC 是同一类思想:写不覆盖旧版本、而是产生新版本,读锚定在某个一致快照上。近似之处:MVCC 是数据库进程内行级的多版本 + 事务管理器;Iceberg 是文件级、不可变快照 + 一次外部 CAS,粒度粗得多,没有行锁。
- 一致性/持久性(C/D):新文件先落对象存储(持久),最后一步指针切换才让它们"成为表的一部分"。
这一步为什么必须是 Catalog、而不是随便 rename 一下?(承接第 03 篇的坑) 因为整个方案的原子性全压在"换指针"这一个动作上,它必须是真原子的 CAS。而对象存储不提供原子 rename——所以不能靠"改个文件名/写个版本文件"来完成切换。真正的 CAS 得由 Catalog 提供:
- Hive Metastore / JDBC / Glue / Nessie:在数据库/服务端用条件更新(乐观锁)实现 CAS——"仅当当前指针still等于 base 时,才更新为 new"。
- REST Catalog:把这次 CAS 提交为一个服务端事务,由服务端保证原子。
- HadoopCatalog(仅 HDFS):靠 HDFS 的原子 rename 换版本文件——在对象存储上不安全,官方不推荐用于生产对象存储。
类比(锚定你写过的 Java CAS,精确):这一步本质就是
currentMetadataPtr.compareAndSet(base, new)——base被别的线程改了,compareAndSet返回 false,你重试。Iceberg 的"乐观并发提交"就是把这句 CAS 从 JVM 堆里搬到了 Catalog 服务上。
代价:每次提交都要写"数据文件 + manifest + manifest list + metadata"一整串新文件;高并发写同一张表时,CAS 冲突会触发重试,重试要重读当前快照、重算——竞争激烈时提交会变慢甚至失败
回到痛点 3:Hive 规划查询要 LIST 成千上万个分区目录。Iceberg 怎么做?
它根本不 LIST 目录。 规划一次查询,引擎只做:读当前 metadata → 读该快照的 manifest list → 读少量 manifest。而每个文件的路径、所属分区、行数、各列 min/max 都已经写在 manifest 里了。
因果链:
- 解决"list 慢":把"向对象存储发成千上万次
LISTHTTP 调用"换成"读几个 manifest 文件(Avro,通常已在同一存储、可批量/并行读)"。文件清单从"运行时去列目录"变成"提交时就写死在元数据里",规划从 O(分区数×目录调用) 降到 O(读少量 manifest)。 - 解决"小文件定位/裁剪":manifest 里每个文件都带分区值和各列 min/max。查询带
WHERE amount > 1000时,引擎直接用 manifest 里的 min/max 在文件级别裁剪——min/max 不与条件相交的文件连读都不用读。这叫文件级裁剪(file pruning / data skipping),发生在打开 Parquet 之前,比第 04 篇讲的 Parquet 内部 row-group 裁剪更靠前一层。
代价:文件清单是"提交时物化"的,所以写入越频繁、文件越多,manifest 也越多越大——这正是第六节"元数据膨胀"的来源,需要 rewrite_manifests 定期合并。
痛点 5 的一半是:Hive 要你冗余存一个 dt 列,查询必须手写 WHERE dt='2026-07-28',还得保证 dt 和真实时间戳 ts 一致,忘写就全表扫。
Iceberg 的做法:分区不再是"目录名",而是元数据里记录的一个变换(transform)。建表时写 PARTITIONED BY (days(ts)),表示"按 ts 的天分区"——但表里不需要多一个 dt 列。查询时你直接写 WHERE ts >= '2026-07-28' AND ts < '2026-07-29',Iceberg 会自动把这个对 ts 的过滤,经由 days(ts) 变换,推导出该扫哪些分区。
因果链:分区值是引擎按变换算出来、写进 manifest 的;裁剪也由引擎按同一个变换完成。用户不必知道分区键,也不会因为"忘了写分区过滤"而全表扫。 支持的变换有 year/month/day/hour、bucket(N, col)、truncate(len, col) 等。
代价/边界:变换必须是 Iceberg 支持的那几种;能否裁剪还依赖查询引擎实现了对应的下推(Spark/Trino/Flink 支持度不完全一致,以引擎版本文档为准)。
痛点 4 和痛点 5 的另一半:改列、改分区就要重写历史。Iceberg 靠两个设计避免:
① 列用唯一 field-id 追踪,不靠列名/列序。 Iceberg 给每列分配一个永不复用的整数 id,读写都按 id 对应(id 也写进 Parquet footer)。于是:
- 改名:只改元数据里"id→名字"的映射,数据文件一个字节都不用动。
- 加列:老文件没有这个 id 的数据,读时按规则返回
NULL(或 v3 的列默认值)——不重写老文件。 - 删/重排列:同理,都是元数据操作。
对比 Hive"按名/按序对应"——改名就错位、必须重写,差别就在这个 id。
② 分区规格(partition spec)是带版本的,按文件生效。
把分区从 day(ts) 改成 hour(ts),Iceberg 不搬历史数据:老数据文件在 manifest 里记着"我按 day 分区",新写入的按 hour 分区。查询规划时,引擎对不同 spec 的文件分别裁剪(split planning 能处理多套 spec)。所以演进是元数据变更,不是数据重写。
代价:引擎在规划时要同时处理多套分区规格,实现更复杂;老数据仍是老粒度,不会因为你改了 spec 就自动变细——想让老数据也按新粒度,还是得重写(compaction)。
因为每次提交都新增一个快照、旧 metadata/manifest/数据文件都保留(在过期清理之前),"读历史版本"几乎是免费副产品:metadata 文件里存着所有历史快照,你只要让引擎"读某个旧快照 id / 旧时间点对应的快照"即可。
代价与边界:历史不是永久免费的——旧快照占着旧数据文件不能删。你必须靠 expire_snapshots 清理过期快照来回收存储,而一旦过期,对应的时间旅行也就没了。保留时长由表属性控制(如 history.expire.max-snapshot-age-ms,Iceberg 文档默认 5 天,以官方 Configuration 文档为准)。
前面拆了一堆机制,这里把第 1.2 节的五个 Hive 痛点和它们的解法对齐一次——注意最右列都指向"元数据操作",这正是表格式的统一思路:能不动数据文件就不动,把变化压进元数据。
| Hive 痛点 | 根因 | Iceberg 的机制 | 本质 |
|---|---|---|---|
| 无 ACID、并发写不安全 | 逐个 rename 文件进目录,无原子边界 | 写新文件 + Catalog 一次 CAS 换指针(4.1) | 一个原子指针切换 |
| 对象存储上连"勉强原子"都没了 | rename = COPY+DELETE 非原子(03) | 原子性只压在"换指针",交给 Catalog | 缩小原子操作的范围 |
LIST 慢、小文件定位难 |
靠目录 LIST 枚举文件(03) |
manifest 预存文件清单 + 各列 min/max(4.2) | 提交时物化清单,免运行时 list |
| 无 schema 演进,改列要重写 | 按列名/列序对应数据 | field-id 追踪列(4.4) | 只改元数据映射 |
| 分区定死、要手写分区过滤 | 分区 = 目录名 | 隐藏分区 + 带版本的分区规格(4.3/4.4) | 变换写进元数据,按文件生效 |
以下语法以 Spark SQL 3.3+ + Iceberg 1.x 为例(不同引擎语法略有差异,以引擎文档为准)。
① 时间旅行 —— 读表的历史版本
-- 先看这张表都有哪些历史快照(snapshots 是 Iceberg 自动提供的元数据表)
SELECT snapshot_id, committed_at, operation
FROM db.orders.snapshots -- 表名.snapshots:读的是元数据树,不是数据
ORDER BY committed_at;
-- 读"2026-07-28 12:00 那一刻"的表状态(不是现在)
SELECT count(*) FROM db.orders TIMESTAMP AS OF '2026-07-28 12:00:00';
-- 或按快照 id 精确回到某一版本
SELECT count(*) FROM db.orders VERSION AS OF 4567890123456789012;
模拟输出(第一条):
snapshot_id | committed_at | operation
----------------------+-------------------------+-----------
4567890123456789012 | 2026-07-28 11:58:03.120 | append
5567890123456789012 | 2026-07-28 13:10:44.007 | overwrite
一句话解释:snapshots 里每一行就是 4.1 说的一个快照;TIMESTAMP AS OF 让引擎在 metadata 的快照列表里挑出"那个时间点的当前快照",然后只读该快照 manifest 指向的文件——所以你能读到过去,正因为旧快照和旧文件都还没被 expire_snapshots 清掉。
② schema 演进 —— 改名不重写数据
-- 把列 user_id 改名为 uid;瞬间返回,不碰任何数据文件
ALTER TABLE db.orders RENAME COLUMN user_id TO uid;
模拟输出:
-- 命令毫秒级返回,无数据被扫描/重写
Time taken: 0.12 s
一句话解释:这条为什么是毫秒级?因为按 4.4,列由 field-id 追踪,改名只改元数据里"id→名字"的映射,几 TB 的历史 Parquet 一个字节都没动——这正是 Hive 做不到、必须重写的地方。
边界
1. 小文件问题没消失,只是换了位置——仍需 compaction。
Iceberg 解决的是"怎么快速找到文件",不是"文件别太多太小"。用 Flink 分钟级流式写入(第 07 篇),每次提交都产生一批小 Parquet,时间一长就是海量小文件:读放大、每个文件都有打开开销。必须周期性 compaction:CALL catalog.system.rewrite_data_files(...) 把小文件合并成大文件。这是实打实的运维成本,不做则查询越来越慢。
2. 元数据会膨胀。
每次提交都新增 metadata / manifest list / manifest。高频写入下,元数据文件本身变多变大,规划时要读的 manifest 也变多——4.2 省下来的规划开销会被吃回去。需配套:rewrite_manifests(合并 manifest)、expire_snapshots(删过期快照+其独占的数据文件)、remove_orphan_files(清理提交失败留下的孤儿文件)。结论:Iceberg 不是"建完就不管"的,它需要一套维护作业。
3. 多引擎/高并发并发写有约束。 乐观并发(4.1)意味着:两个作业改同一张表,后提交的那个若发现 base 已过期就得重试;写热点表、长事务并存时,重试可能反复失败。更硬的约束是:所有写入方必须共用同一个能做 CAS 的 Catalog。若一方用 HadoopCatalog(靠对象存储 rename)、另一方用 HMS,CAS 语义对不齐,就可能丢更新。别在对象存储上用 HadoopCatalog 做生产多写。
4. 何时不该用它。
- 数据量很小、又不需要事务/演进/时间旅行——直接查一堆 Parquet 或用云数仓表就行,别引入元数据层和维护作业的复杂度。
- 需要高频点更新/低延迟点查(类似 OLTP)——表格式面向"大规模分析 + 批量提交",不是给你当 MySQL 用;点查/高并发点写请看第 10 篇 OLAP 或直接上关系库/StarRocks。
5. 和 Hudi / Delta 的差异(点到即止)。 三者都是表格式、都用"日志/快照 + 原子提交"实现 ACID,差异主要在侧重与实现细节(相近、易混,故用表对比):
| 维度 | Iceberg | Delta Lake | Hudi |
|---|---|---|---|
| 起源 | Netflix | Databricks | Uber |
| 元数据形态 | 快照树(metadata→manifest list→manifest) | 有序事务日志 _delta_log(JSON+checkpoint) |
时间线 timeline + 记录级索引 |
| 最擅长 | 隐藏分区、schema/分区演进、多引擎中立 | 与 Spark/Databricks 生态深度整合 | 强 upsert / 增量拉取(CDC 场景) |
| 原子提交支点 | Catalog 的 CAS(换 metadata 指针) | 日志文件的版本号排他写入 | timeline 上 commit 的原子完成 |
小结
- 文件格式管一个文件内部,表格式管一堆文件 + 元数据 = 一张有事务的表——两层叠加,不是替代。
- Iceberg 的核心魔法是把"让一堆文件原子出现"缩小成"原子换一个指针",再用 Catalog 的 CAS 完成——ACID、快照隔离、时间旅行都从这一步长出来;思想同 MVCC,粒度是文件级。
- 它用 manifest 取代目录
LIST,同时治好了"list 慢"和"小文件定位";用 field-id 和带版本的分区规格让演进变成元数据操作、免重写。 - 代价是多了一层要维护的元数据:compaction、清理过期快照、合并 manifest,一样都不能少。