全文共 5,131 字 预计阅读 15 分钟
bg

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 慢":把"向对象存储发成千上万次 LIST HTTP 调用"换成"读几个 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/hourbucket(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,一样都不能少。
Back to Blog