返回

Iceberg 表内部长什么样?用 DuckDB 逐层探查

RSS DuckDB Engineering Blog AI 逐段翻译 发布 2026-10-13 08:00 收录于 10-15
DataHot 速览

Apache Iceberg 表在查询时像普通表,但底层是由 JSON、Avro 和 Parquet 文件构成的树。元数据文件在顶层,中间是 manifest list 和 manifest,底部是数据文件与删除文件;每次变更都会写入新的 metadata.json,由 catalog 保存当前版本指针。DuckDB 可读取这三种格式,因此可以用 SQL 逐层检查。文中用本地 Iceberg REST catalog fixture 创建并修改表,演示从 metadata 到数据文件的排查方法。

为什么值得关注:对数据平台从业者,理解 Iceberg 元数据、清单与数据文件结构有助于排查湖仓表的性能与正确性问题,并获得可复用的 DuckDB SQL 检查路径。

本文目录 12 节
  1. Iceberg 表内部是什么?
  2. 入门
  3. 一个带有历史的表
  4. 这棵树
  5. 第 1 层:元数据文件
  6. 第 2 层:清单列表
  7. 第 3 层:清单
  8. 列统计信息
  9. 分区统计信息
  10. 第 4 层:数据文件和删除文件
  11. 为什么要查看这些?
  12. 进一步阅读

译文

AI 逐段翻译

Iceberg 表内部是什么?

2026-10-13 12 分钟阅读

DuckDB 团队

TL;DR:一个 Iceberg 表是一棵由 JSON、Avro 和 Parquet 文件组成的树。DuckDB 可以读取其中每一个文件,所以你可以自己用 SQL 走遍这棵树:从元数据文件,经过 manifest list 和 manifests,一直到数据文件和删除文件。

一个 Apache Iceberg 表在你查询它时看起来像一张普通表,但在底层它是一棵由文件组成的小树:顶部是一个 JSON 文件,中间有几层 Avro 文件,底部是 Parquet 文件。当某些东西很慢或看起来不对时,了解这棵树里有什么会很有帮助。

DuckDB 可以读取全部三种格式,所以你可以用普通 SQL 检查每一层。在这篇文章中,我们创建一个小型 Iceberg 表,对它修改几次,然后自上而下地走遍这些文件。

入门

你需要带有 iceberg 扩展 的 DuckDB,以及一个要附加的 Iceberg REST catalog。catalogs 页面包含针对诸如 Apache Polaris、Lakekeeper 和 Amazon S3 Tables 等 catalog 的说明。

需要 catalog,是因为这些文件本身无法告诉你哪个版本的表是当前版本。一个 Iceberg 表在每次变更时都会写入一个新的 metadata.json,并且从不覆盖旧的。catalog 保存着唯一可变的状态:一个指向当前元数据文件的指针。当你查询该表时,DuckDB 首先向 catalog 询问该指针,然后从那里沿着文件树向下追踪。

在本文中,我们在本地运行一个 catalog,使用 Iceberg REST catalog fixture,这是 Iceberg 项目作为 Docker 镜像发布的一个小型参考 catalog。它是最容易设置的 catalog:它从单个镜像运行,不需要云账户,也不需要身份验证,并且把所有内容保存在本地磁盘上,你可以自己打开这些文件。

我们这样启动它:

docker run -p 8181:8181 \-v /data/lake:/data/lake \-eCATALOG_WAREHOUSE=/data/lake \
    apache/iceberg-rest-fixture

该命令的每一部分都对应 DuckDB 之后所依赖的某些内容:

  • -p 8181:8181 将 catalog 的 REST API 发布到 localhost:8181,这是 DuckDB 连接的地址。
  • -v /data/lake:/data/lake 将宿主目录挂载到容器中,这样元数据、manifest 和 Parquet 文件在容器停止后仍然存在,你可以直接读取它们。
  • -e CATALOG_WAREHOUSE=/data/lake 告诉 catalog 将这些文件写入何处,这就是为什么下面输出中的每个路径都以 /data/lake 开头。
注意 请在本地 DuckDB CLI 中运行下面的示例,而不是在基于浏览器的 DuckDB Shell 中,因为从 Web 提供的页面无法访问你自己 localhost 上的 catalog。

fixture 运行后,像这样附加它:

ATTACH'warehouse'ASlake(TYPEiceberg,ENDPOINT'http://localhost:8181',AUTHORIZATION_TYPE'none');

这会打开一个名为 lake 的 catalog 连接,你可以像查询任何已附加的数据库一样查询它。

  • TYPE iceberg 告诉 DuckDB 使用 Iceberg REST 协议。
  • ENDPOINT 是上面由 -p 标志发布的地址。
  • AUTHORIZATION_TYPE 'none' 与 fixture 相匹配,它不期望任何凭据。
  • 第一个参数是 warehouse 名称。该 fixture 只提供一个 warehouse,所以任何名称都可以。
  • 使用对象存储时,下面输出中的路径以 s3:// 开头,而不是 /data/lake,但其他一切都相同。

一个带有历史的表

我们创建一个按日期分区的列车服务表,并对它修改三次:插入三天的数据、插入第四天的数据,以及一次删除。

CREATESCHEMAlake.demo;CREATETABLElake.demo.services(service_idBIGINT,service_dateDATE,stationVARCHAR,delay_minINTEGER)PARTITIONEDBY(service_date);INSERTINTOlake.demo.servicesSELECTi,DATE'2026-10-01'+(i%3)::INTEGER,['Utrecht Centraal','Delft','Leiden Centraal'][1+(i%3)::INTEGER],(i%9)::INTEGERFROMrange(1,9_001)t(i);INSERTINTOlake.demo.servicesSELECTi,DATE'2026-10-04','Den Haag HS',(i%5)::INTEGERFROMrange(9_001,12_001)t(i);DELETEFROMlake.demo.servicesWHEREservice_date=DATE'2026-10-02'ANDdelay_min=4;

两次插入和删除各自独立提交。写入不会就地更改表:DuckDB 读取当前快照,在旧文件旁边写入新的数据和元数据文件,然后要求 catalog 将表指针从该快照移动到新快照。这一移动就是提交。

提交会指明它期望替换的快照,因此从同一快照开始的两个写入者不能都成功。第一次提交移动指针,第二次会被拒绝,因为它打算替换的快照已不再是当前快照。被拒绝的写入随后会在新快照之上重试。当从单个会话按顺序运行时,这里的三个语句永远不会冲突,因为每个语句都从前一个提交的快照开始。

该表现在有 11,000 行:第一次插入增加了 9,000 行,第二次增加了 3,000 行,删除移除了 10 月 2 日延迟为 4 分钟的 1,000 行。这三个语句中的每一个都创建了一个新的 snapshot,iceberg_snapshots 如下列出:

SELECTsequence_number,snapshot_id,operationFROMiceberg_snapshots(lake.demo.services)ORDERBYsequence_number;
┌─────────────────┬─────────────────────┬───────────┐
│ sequence_number │     snapshot_id     │ operation │
│     uint64      │       uint64        │  varchar  │
├─────────────────┼─────────────────────┼───────────┤
│               1 │ 7523747767380427861 │ append    │
│               2 │ 3062515218093523051 │ append    │
│               3 │ 6456433563311493009 │ delete    │
└─────────────────┴─────────────────────┴───────────┘

这棵树

在逐层查看之前,这里是整体结构。每一层都指向其下一层中的文件:

metadata.json                    one per table version
└── manifest list (Avro)         one per snapshot
    └── manifests (Avro)         one per write (for simplicity)
        ├── data files (Parquet)
        └── delete files (Parquet)

所有这些文件都是不可变的。每次变更都会写入新文件和一个新的 metadata.json,然后通过将 catalog 指针移动到它来提交。

第 1 层:元数据文件

catalog 为每个表存储一个指针:当前元数据文件的位置。iceberg_load_table_response 返回 DuckDB 加载该表时 catalog 发回的内容,包括该位置:

SELECTmetadata_locationFROMiceberg_load_table_response(lake.demo.services);
┌────────────────────────────────────────────────────────────────────────────────────────────┐
│                                     metadata_location                                      │
│                                          varchar                                           │
├────────────────────────────────────────────────────────────────────────────────────────────┤
│ /data/lake/demo/services/metadata/00003-b79b5ffd-f118-44fd-9d8e-3551c147ef3b.metadata.json │
└────────────────────────────────────────────────────────────────────────────────────────────┘

文件名以 00003 开头,因为这是元数据的第四个版本,从表创建时的 00000 开始计数。

它是一个 JSON 文档,所以 read_json 可以读取它。我们将该位置存储在一个 变量 中以保持查询简短:

SETVARIABLEmetadata_file=(SELECTmetadata_locationFROMiceberg_load_table_response(lake.demo.services));SELECT"format-version","current-snapshot-id",len(snapshots)ASsnapshots,len("metadata-log")ASprevious_metadata_filesFROMread_json(getvariable('metadata_file'));
┌────────────────┬─────────────────────┬───────────┬─────────────────────────┐
│ format-version │ current-snapshot-id │ snapshots │ previous_metadata_files │
│     int64      │        int64        │   int64   │          int64          │
├────────────────┼─────────────────────┼───────────┼─────────────────────────┤
│              2 │ 6456433563311493009 │         3 │                       3 │
└────────────────┴─────────────────────┴───────────┴─────────────────────────┘

除了 schema 和 partition spec 之外,元数据文件还保存着仍然存在的所有快照的列表,每个快照都有一个带有计数器的 summary。这些计数器是一种无需读取任何数据就能查看每次变更做了什么的经济方式:

SELECTs."sequence-number"ASseq,s.summary.operation,s.summary."added-records",s.summary."added-position-deletes",s.summary."total-records",s.summary."total-data-files",s.summary."total-delete-files"FROM(SELECTunnest(snapshots)ASsFROMread_json(getvariable('metadata_file')))ORDERBYseq;
┌───────┬───────────┬───────────────┬────────────────────────┬───────────────┬──────────────────┬────────────────────┐
│  seq  │ operation │ added-records │ added-position-deletes │ total-records │ total-data-files │ total-delete-files │
│ int64 │  varchar  │    varchar    │        varchar         │    varchar    │     varchar      │      varchar       │
├───────┼───────────┼───────────────┼────────────────────────┼───────────────┼──────────────────┼────────────────────┤
│     1 │ append    │ 9000          │ NULL                   │ 9000          │ 3                │ 0                  │
│     2 │ append    │ 3000          │ NULL                   │ 12000         │ 4                │ 0                  │
│     3 │ delete    │ NULL          │ 1000                   │ 12000         │ 4                │ 1                  │
└───────┴───────────┴───────────────┴────────────────────────┴───────────────┴──────────────────┴────────────────────┘

在最后一行中,删除之后,total-records 仍然是 12,000,并且仍然有四个数据文件。删除没有触及数据文件。它添加了一个包含 1,000 个条目的 delete file。我们稍后会讲到那个文件。

注意 Iceberg 规范将这些 summary 字段视为可选,因此引擎可以自由省略其中任何一个。上面的计数器由 DuckDB 填充,但由另一个引擎写入的表可能缺少其中一些,在这种情况下该字段会读作 NULL。将它们视为方便的提示,而不是保证。
提示 元数据文件会随每次提交而增长,因为它会保留快照列表以及先前元数据文件的日志。在接收数千次小提交的表上,它可能会变得足够大,从而拖慢加载表的速度。过期旧快照可以将其控制在合理范围内,大多数目录和引擎都能做到这一点。

第 2 层:清单列表

每个快照都指向一个 清单列表,这是一个 Avro 文件,列出了构成该快照的各个清单。avro 扩展通过 read_avro 读取它。我们取最新快照的清单列表:

SETVARIABLEmanifest_list=(SELECTmanifest_listFROMiceberg_snapshots(lake.demo.services)ORDERBYsequence_numberDESCLIMIT1);SELECTregexp_replace(manifest_path,'.*/metadata/','')ASmanifest,content,sequence_number,added_files_count,added_rows_countFROMread_avro(getvariable('manifest_list'));
┌──────────────────────────────────────────────┬─────────┬─────────────────┬───────────────────┬──────────────────┐
│                   manifest                   │ content │ sequence_number │ added_files_count │ added_rows_count │
│                   varchar                    │  int32  │      int64      │       int32       │      int64       │
├──────────────────────────────────────────────┼─────────┼─────────────────┼───────────────────┼──────────────────┤
│ ffbf7076-8d36-46f0-aaa2-76e7d95442f0-m0.avro │       0 │               1 │                 3 │             9000 │
│ 74fa5b1f-0908-4e34-964a-504d946671de-m0.avro │       0 │               2 │                 1 │             3000 │
│ 5282235d-42b4-4b5d-a190-147a867994b7-m0.avro │       1 │               3 │                 1 │             1000 │
└──────────────────────────────────────────────┴─────────┴─────────────────┴───────────────────┴──────────────────┘

每次写入都会创建一个清单。content 对于跟踪数据文件的清单为 0,对于跟踪删除文件的清单为 1。清单列表还存储每个清单中的分区值范围(此处未显示),因此当查询按分区列进行过滤时,读取器可以跳过整个清单。

最新快照仍包含由先前两个快照写入的清单。为了使提交的写入成本低廉,引擎可以为每次写入添加一个新清单,就像这里发生的那样。这样做的代价是读取性能,因为小清单会迅速累积。另一种方法是将新增内容合并到现有清单中,这对写入器来说成本更高,但对读取器更优化。

第 3 层:清单

清单列出实际文件,每个文件一条条目,包含分区值、行数和各列统计信息。它们也是 Avro:

SELECTstatus,data_file.content,regexp_replace(data_file.file_path,'.*/data/','')ASfile,data_file.record_count,data_file.partitionFROMread_avro('/data/lake/demo/services/metadata/*-m*.avro')ORDERBYfile;
┌────────┬─────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────┬─────────────────────────────────────────┐
│ status │ content │                                     file                                     │ record_count │                partition                │
│ int32  │  int32  │                                   varchar                                    │    int64     │  struct(identity_service_date_2 date)   │
├────────┼─────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────┼─────────────────────────────────────────┤
│      1 │       0 │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │         3000 │ {'identity_service_date_2': 2026-10-01} │
│      1 │       0 │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │         3000 │ {'identity_service_date_2': 2026-10-02} │
│      1 │       1 │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │         1000 │ {'identity_service_date_2': 2026-10-02} │
│      1 │       0 │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │         3000 │ {'identity_service_date_2': 2026-10-03} │
│      1 │       0 │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │         3000 │ {'identity_service_date_2': 2026-10-04} │
└────────┴─────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────┴─────────────────────────────────────────┘

status = 1 表示该文件是在写入该清单的快照中添加的。content 对于数据文件为 0,对于位置删除文件为 1。分区值按文件存储,这正是读取器无需打开文件即可跳过文件的原因。

该扩展有一个函数可以为你执行此操作。iceberg_metadata 会沿当前快照遍历清单列表和清单,并为每个文件返回一行:

SELECTmanifest_content,regexp_replace(file_path,'.*/data/','')ASfile,record_countFROMiceberg_metadata(lake.demo.services)ORDERBYfile;
┌──────────────────┬──────────────────────────────────────────────────────────────────────────────┬──────────────┐
│ manifest_content │                                     file                                     │ record_count │
│     varchar      │                                   varchar                                    │    int64     │
├──────────────────┼──────────────────────────────────────────────────────────────────────────────┼──────────────┤
│ DATA             │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │         3000 │
│ DATA             │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │         3000 │
│ DELETE           │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │         1000 │
│ DATA             │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │         3000 │
│ DATA             │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │         3000 │
└──────────────────┴──────────────────────────────────────────────────────────────────────────────┴──────────────┘

当你想要查看该函数未返回的字段,或者当你查看较早的快照或不在目录中的表时,直接读取 Avro 文件很有用。

列统计信息

清单还为每个数据文件存储每一列的下界和上界。iceberg_column_stats 以可读形式返回它们:

SELECTcolumn_name,regexp_replace(file_path,'.*/data/','')ASfile,lower_bound,upper_boundFROMiceberg_column_stats(lake.demo.services)WHEREcolumn_name='service_id'ORDERBYfile;
┌─────────────┬──────────────────────────────────────────────────────────────────────────────┬─────────────┬─────────────┐
│ column_name │                                     file                                     │ lower_bound │ upper_bound │
│   varchar   │                                   varchar                                    │   varchar   │   varchar   │
├─────────────┼──────────────────────────────────────────────────────────────────────────────┼─────────────┼─────────────┤
│ service_id  │ service_date=2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet         │ 3           │ 9000        │
│ service_id  │ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet         │ 1           │ 8998        │
│ service_id  │ service_date=2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │ NULL        │ NULL        │
│ service_id  │ service_date=2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet         │ 2           │ 8999        │
│ service_id  │ service_date=2026-10-04/01a12bb2-adbe-790f-bde9-50db3acba4b2.parquet         │ 9001        │ 12000       │
└─────────────┴──────────────────────────────────────────────────────────────────────────────┴─────────────┴─────────────┘

当你在非分区列上进行过滤时,DuckDB 会使用这些边界来跳过文件。带有 WHERE service_id > 10000 的查询只需打开 10 月 4 日的文件,因为其他三个文件的边界将它们排除了。

前三天的范围几乎完全重叠,因为行是按轮转方式分散到各天的。按常用过滤列的顺序写入的数据会获得更窄的边界和更多的跳过。

分区统计信息

上面的边界是按列、按数据文件的。对于分区列,有一个更粗略的视图,每个清单一行,清单列表会保留它,以便读取器在打开任何文件之前跳过整个清单。iceberg_partition_stats 返回它:

SELECTpartition_field_name,partition_field_transform,lower_bound,upper_boundFROMiceberg_partition_stats(lake.demo.services)ORDERBYlower_bound;
┌─────────────────────────┬───────────────────────────┬─────────────┬─────────────┐
│  partition_field_name   │ partition_field_transform │ lower_bound │ upper_bound │
│         varchar         │          varchar          │   varchar   │   varchar   │
├─────────────────────────┼───────────────────────────┼─────────────┼─────────────┤
│ identity_service_date_2 │ identity                  │ 2026-10-01  │ 2026-10-03  │
│ identity_service_date_2 │ identity                  │ 2026-10-02  │ 2026-10-02  │
│ identity_service_date_2 │ identity                  │ 2026-10-04  │ 2026-10-04  │
└─────────────────────────┴───────────────────────────┴─────────────┴─────────────┘

每个清单一行,因此这些边界描绘了三次写入:第一次插入将三天分散在一个清单中,第二次插入添加了 10 月 4 日,删除只涉及 10 月 2 日。partition_field_transform 是 identity,因为我们直接按日期列进行分区。带有 WHERE service_date = DATE '2026-10-04' 的查询仅凭这些边界就可以排除第一个和第三个清单,而无需读取任何清单条目。

第 4 层:数据文件和删除文件

树的最底层是 Parquet 文件。数据文件是任何读取器都可以打开的常规 Parquet。删除文件的工作方式不同。它是一个 位置删除 文件,它按文件和行位置列出要忽略的行:

SELECTregexp_replace(file_path,'.*/data/','')ASdata_file,posFROMread_parquet('/data/lake/demo/services/data/*/*-deletes.parquet',hive_partitioning=false)ORDERBYposLIMIT5;
┌──────────────────────────────────────────────────────────────────────┬───────┐
│                              data_file                               │  pos  │
│                               varchar                                │ int64 │
├──────────────────────────────────────────────────────────────────────┼───────┤
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     1 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     4 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │     7 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │    10 │
│ service_date=2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │    13 │
└──────────────────────────────────────────────────────────────────────┴───────┘

这就是该扩展实现 DELETE 和 UPDATE 的方式:它不修改数据文件,而是记录被删除行的位置,这种策略称为 读时合并。

然后每个读取器都必须合并这两个文件才能获得正确的行。写入成本低,读取则付出少量代价,该代价随删除文件的数量而增长。我们设置 hive_partitioning = false,因为否则 service_date=... 目录会被添加为额外的列。

对于 format-version 为 3 的表,相同的信息会以紧凑的二进制删除向量存储在 Puffin 文件中,而不是 Parquet 文件。

为什么要查看这些?

大多数时候,你不需要。FROM lake.demo.services 会返回正确的 11,000 行,扩展会处理这棵树。当某些事情没有按预期运行时,它就会变得有用:

  • 查询读取的文件比你预期的多。 将清单中的分区值和列边界与你的过滤条件进行比较。
  • 表随时间变慢。 统计清单和删除文件的数量。许多小提交和许多删除都会添加每个查询都必须处理的文件。
  • 行数看起来不对。 快照摘要会显示每次提交添加和删除了多少行。
  • 另一个引擎写入了该表。 清单会显示它写入了什么,包括 DuckDB 不使用的属性。

由于每一层都是 DuckDB 已经能读取的格式的文件,你可以用查询数据所用的同一工具来回答这些问题。

进一步阅读

这篇内容对你有用吗?

反馈只用于改善内容筛选,不等同于收藏

分享这条资讯
分享海报
保存图片
iOS 也可以长按图片保存