Apache Iceberg Variant类型:半结构化数据新解法
译文 AI 逐段翻译
Apache Iceberg 中的半结构化数据:认识 Variant 类型
半结构化数据(例如各行字段不同的 JSON 类文档)一直难以适应围绕固定模式构建的表格式。Iceberg v3 正是为此类数据添加了 Variant 类型:单个列可以保存任意、不断变化的形状的值,并以引擎一致读写紧凑的二进制形式存储。
这是关于 Apache Iceberg 中 Variant 的系列文章的第一篇。本文涵盖 Variant 是什么、为何存在,以及如何融入 Iceberg 表。下一篇文章将介绍切碎(shredding),即以类型化、列式数据存储频繁访问的 Variant 字段的技术。Variant 以 Parquet、Avro 和 ORC 格式存储;目前仅 Parquet 支持切碎功能。
Variant 解决的问题🔗
考虑形状随时间变化的事件数据:
{"event":"view","page":"/pricing","session":"s-8f21"}{"event":"signup","session":"s-8f21","plan":"pro","price":29.00}{"event":"view","page":"/docs","session":"s-3c07","referrer":{"source":"search","term":"iceberg variant"}}两种传统方法可以处理这种情况,但各有缺点:
- 将 JSON 存储为字符串。 这种方式灵活,但读取单个字段意味着解析整个文本。JSON 的类型系统也很薄弱:时间戳只是字符串,数字的精度也不明确。
- 严格、扁平化的模式。 这种方式查询速度快,但每个新字段都需要模式迁移,而且稀疏或一次性字段会浪费空间。
Variant 与 JSON 一样灵活,但以紧凑、类型化的二进制形式存储数据。值保留其原生类型:时间戳保持为时间戳,小数保持为精确小数,而不会退化为 JSON 的字符串和数字。在值内部,字段名被收集到字典中,并通过 id 引用,因此名称不会在每次出现时都完整写出。无需预先声明模式,因此不同形状的文档可以共存于一个列中,新字段也无需迁移。
Apache Iceberg 中的 Variant🔗
Variant 在 v3 规范中已添加到 Iceberg 类型系统。规范将其归为单独类别:variant “既非原始类型,也非嵌套类型”。它比原始类型更丰富,但又不像结构体或列表那样具有固定的、声明的形状。
Variant 值类似于 JSON,但具有更广泛的原始类型,包括 date、timestamp、timestamptz、binary 和 decimal。它还可以嵌套:
- 一个Variant 数组 是 Variant 值的有序集合。与 Iceberg 列表不同,其元素不限于单一元素类型。
- 一个Variant 对象 是字符串键字段的集合,其值本身也是 Variant 值。与 Iceberg 模式中的结构体列不同,其字段不是一组固定的、命名的类型化列。
存储方式🔗
Iceberg 并未为 Variant 定义自己的二进制编码。该类型及其编码来自 Apache Parquet 项目,Iceberg 直接使用该编码,因此 variant 列映射到 Parquet 组,包含两个二进制字段:
optional group payload (VARIANT(1)) {
required binary metadata;
required binary value;
}
metadata保存值中使用的字段名字典,因此value字节通过整数 id 引用每个名称,而不是重复名称字符串。value保存编码数据:标量、数组或对象。数组和对象为每个元素存储field_offset(该元素值起始的字节偏移量),对象还为每个字段存储field_id(元数据字典中的索引)。
Variant 列本身像其他 Iceberg 列一样通过字段 ID 寻址,但其 metadata 和 value 子字段通过名称访问,这对于切碎功能很重要。
跨引擎读写🔗
- Apache Spark 4.0 和 4.1 可以创建包含
VARIANT列的表并进行读写。读取功能在 Iceberg 1.10.0 中 Spark 4.0 上提供;Iceberg 1.11.0 增加了 Spark 4.1 支持,并在 Spark 4.0 和 4.1 上支持写入切碎的 Variant。 - Apache Flink 2.1 在 Iceberg 1.11.0 中添加了 Variant 支持。这仅涵盖未切碎的 Variant;切碎写入支持已合并,预计在后续版本中提供。
由于这些引擎都使用相同的 Iceberg Variant 类型,一个引擎写入的值在其他引擎中读取时完全相同。
使用 Variant🔗
使用 Spark SQL,您可以将异构事件存储在单个 VARIANT 列中,并使用 variant_get 读回字段。Variant 列需要 Iceberg v3 表:
-- Variant is a v3 type, so the table must be format version 3CREATETABLEevents(idBIGINT,payloadVARIANT)USINGicebergTBLPROPERTIES('format-version'='3');-- Insert events of different shapes into the same columnINSERTINTOeventsVALUES(1,parse_json('{"event": "login", "country": "US"}')),(2,parse_json('{"event": "purchase", "country": "UK", "amount": 99}')),(3,parse_json('{"event": "login"}'));-- Read fields out of the Variant with variant_get(column, path, type)SELECTid,variant_get(payload,'$.event','string')ASevent,variant_get(payload,'$.country','string')AScountry,variant_get(payload,'$.amount','int')ASamountFROMevents;查询返回每事件一行,字段缺失处为 null:
| id | event | country | amount |
|---|---|---|---|
| 1 | login | US | null |
| 2 | purchase | UK | 99 |
| 3 | login | null | null |
第 3 行仅包含 event,因此 country 和 amount 读回为 null。Variant 不要求每行共享相同结构,因此异构事件可以存在于一个列中。
何时使用 Variant🔗
当您无法控制数据形状,或数据变化速度超过您希望演进模式的速度时,Variant 是正确的选择:
- 事件和点击流数据,每种事件类型携带不同的字段集,并且新字段会随时间出现。
- 应用程序和服务日志,具有结构化但异构的负载。
- 物联网和传感器遥测数据,每个设备型号报告自己的读数。
- 第三方 API 响应和 Webhook,其模式由他人所有,并且可能在没有通知的情况下更改。
- 稀疏属性,否则会变成大部分为 null 列的宽表。
Variant 不能替代已知模式。当字段出现在每行并且您经常查询时,常规类型化列或用于固定嵌套形状的结构体更简单高效。一种常见模式是将稳定、频繁查询的字段保留为普通列,并将可变部分放入单个 Variant 列。
下一步:切碎🔗
到目前为止,Variant 列是单个 metadata + value 对。读取一个字段意味着解码 metadata 字典,然后从 value blob 中反序列化该字段(如果存在)。这很灵活,但灵活性以性能为代价:由于整个文档驻留在单个 value列中,引擎无法像普通类型列那样利用每字段的最小/最大统计信息来裁剪数据。分片(Shredding)解决了这一问题:它将频繁访问的字段存储为单独的、有类型的Parquet列,因此仅涉及这些字段的查询只需读取这些列,并可以利用其统计信息进行数据跳过,而任何不适合的内容则回退到无类型value。本系列下一篇文章将解释分片的工作原理及其带来的好处。
参与贡献🔗
Iceberg中的Variant支持仍在发展,欢迎贡献。 Variant跟踪问题(#10392) 追踪进行中的工作,您可以通过邮件列表和Slack联系社区。
资源🔗
- Iceberg表规范,Variant类型: 半结构化类型
- Apache Parquet中的Variant: Apache Parquet中用于半结构化数据的Variant类型