Iceberg 表里面有什么?
2026-10-13 12 分钟阅读
DuckDB 团队
TL;DR:一个 Iceberg 表是一棵由 JSON、Avro 和 Parquet 文件组成的树。DuckDB 可以读取其中每一种文件,所以你可以用 SQL 自己遍历这棵树:从元数据文件,经过清单列表和清单,一直到数据文件和删除文件。
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:它从单个镜像运行,不需要 Cloud 账户,不需要认证,并把所有东西保存在本地磁盘上,你可以自己打开这些文件。
我们这样启动它:
dockerrun-p8181:8181\-v/data/lake:/data/lake\-eCATALOG_WAREHOUSE=/data/lake\apache/iceberg-rest-fixture该命令的每一部分都对应 DuckDB 之后依赖的某个东西:
-p 8181:8181在 localhost:8181 上发布 catalog 的 REST API,这是 DuckDB 连接的地址。-v /data/lake:/data/lake把主机目录挂载到容器中,这样元数据、清单和 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,它不需要凭据。- 第一个参数是仓库名称。fixture 提供单个仓库,所以任何名称都行。
- 使用对象存储时,下面输出中的路径以
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 行。三个语句各自创建了一个新快照,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 每个表版本一个 └── manifest list (Avro) 每个快照一个 └── manifests (Avro) 在我们的情况下每次写入一个 ├── 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 可以读取它。我们把位置存到一个变量中以保持查询简短:
SETVARIABLE metadata_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 和分区规范,元数据文件还持有所有仍然存在的快照列表,每个快照都有一个带计数器的摘要。这些计数器是一种廉价的方式,可以看到每次变更做了什么,而无需读取任何数据:
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 个条目的删除文件。我们稍后会讲到那个文件。
注意
Iceberg 规范把这些摘要字段视为可选的,所以引擎可以自由地省略其中任何一个。上面的计数器由 DuckDB 填充,但由另一个引擎写入的表可能缺少其中一些,在这种情况下该字段读作 NULL。把它们当作方便的提示而不是保证。
提示
元数据文件随着每次提交增长,因为它保留快照列表和先前元数据文件的日志。在一个接收数千次小提交的表上,它可能变得大到足以减慢表的加载。过期旧快照——大多数 catalog 和引擎都能做——会让它保持受控。
第 2 层:清单列表
每个快照指向一个清单列表,一个 Avro 文件,列出构成该快照的清单。avro 扩展用 read_avro 读取它。我们取最新快照的清单列表:
SETVARIABLE manifest_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 文件在你想看到函数不返回的字段时很有用,或者在你看一个较旧快照或一个不在 catalog 中的表时。
列统计
清单还为每个数据文件存储每一列的下界和上界。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 的方式:它不碰数据文件,而是记录被删除行的位置,这种策略叫做 merge-on-read。
然后每个读取器都必须合并两个文件才能得到正确的行。写入廉价,读取付出一个小成本,它随删除文件数量增长。我们设置 hive_partitioning = false,因为否则 service_date=… 目录会被添加为一个额外的列。
对于 format-version 3 的表,同样的信息存储为 Puffin 文件中的紧凑二进制删除向量,而不是 Parquet 文件。
到底为什么要看这个?
大多数时候,你不需要。FROM lake.demo.services 返回正确的 11,000 行,扩展处理这棵树。当某些东西不像你预期的那样表现时,它就有用了:
- 一个查询读取的文件比你预期的多。把清单中的分区值和列边界与你的过滤器比较。
- 一个表随时间变慢。统计清单和删除文件。许多小提交和许多删除都会添加每个查询都必须处理的文件。
- 行数看起来不对。快照摘要显示每次提交添加和删除了多少行。
- 另一个引擎写了这个表。清单显示它写了什么,包括 DuckDB 不使用的属性。
由于每一层都是 DuckDB 已经能读取的格式的文件,你可以用查询数据的同一个工具回答这些问题。
进一步阅读
- DuckDB-Iceberg 中的写入
- 浏览器中的 Iceberg
- v1.5.3 中新的 DuckDB-Iceberg 功能