
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 运行后这样连接它ATTACHwarehouseASlake(TYPEiceberg,ENDPOINThttp://localhost:8181,AUTHORIZATION_TYPEnone);这会打开一个名为 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,DATE2026-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,DATE2026-10-04,Den Haag HS,(i%5)::INTEGERFROMrange(9_001,12_001)t(i);DELETEFROMlake.demo.servicesWHEREservice_dateDATE2026-10-02ANDdelay_min4;两次插入和删除各自独立提交。一次写入不会原地改变表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));SELECTformat-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-numberASseq,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-filesFROM(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 层清单清单列出实际文件每个文件一个条目带有分区值、行数和每列统计。它们也是 AvroSELECTstatus,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_date2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet │ 3000 │ {identity_service_date_2: 2026-10-01} │ │ 1 │ 0 │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 3000 │ {identity_service_date_2: 2026-10-02} │ │ 1 │ 1 │ service_date2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │ 1000 │ {identity_service_date_2: 2026-10-02} │ │ 1 │ 0 │ service_date2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet │ 3000 │ {identity_service_date_2: 2026-10-03} │ │ 1 │ 0 │ service_date2026-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_date2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet │ 3000 │ │ DATA │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 3000 │ │ DELETE │ service_date2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │ 1000 │ │ DATA │ service_date2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet │ 3000 │ │ DATA │ service_date2026-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_nameservice_idORDERBYfile;┌─────────────┬──────────────────────────────────────────────────────────────────────────────┬─────────────┬─────────────┐ │ column_name │ file │ lower_bound │ upper_bound │ │ varchar │ varchar │ varchar │ varchar │ ├─────────────┼──────────────────────────────────────────────────────────────────────────────┼─────────────┼─────────────┤ │ service_id │ service_date2026-10-01/01a12bb2-ad80-7cf4-8f63-beee9619f7c0.parquet │ 3 │ 9000 │ │ service_id │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 1 │ 8998 │ │ service_id │ service_date2026-10-02/58a752c7-9925-4add-99ca-785505e17589-deletes.parquet │ NULL │ NULL │ │ service_id │ service_date2026-10-03/01a12bb2-ad80-71f7-b4cd-58684f05b129.parquet │ 2 │ 8999 │ │ service_id │ service_date2026-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_partitioningfalse)ORDERBYposLIMIT5;┌──────────────────────────────────────────────────────────────────────┬───────┐ │ data_file │ pos │ │ varchar │ int64 │ ├──────────────────────────────────────────────────────────────────────┼───────┤ │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 1 │ │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 4 │ │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 7 │ │ service_date2026-10-02/01a12bb2-ad7f-7690-bda8-690b622b119a.parquet │ 10 │ │ service_date2026-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 中的写入浏览器中的 Icebergv1.5.3 中新的 DuckDB-Iceberg 功能