作者|霁谦
数据说明:本文示例均为通用化、参数化示例,不包含客户、账号、实例标识或实际业务数据;文中的 ID、路径、地址和值均为占位符。
在订单事件、用户行为、设备日志等场景中,数据通常同时包含两部分:一部分是 order_id、event_time、amount 等结构稳定的字段;另一部分是商品属性、营销信息、客户端参数等持续变化的扩展字段。
稳定字段适合使用明确的数据类型,但扩展字段很难提前定义完整 Schema。把它们保存成 JSON 简单灵活,可一旦需要频繁分析 JSON 内部字段,文本解析和类型转换就会成为持续的查询开销。
Variant 为这类数据提供了新的选择:它保留半结构化数据的灵活性,同时通过类型化二进制表示和 Parquet Shredding,让动态字段也能更好地进入列式分析链路。
一、Variant 是什么?它和普通 Parquet JSON 有什么区别?
本文所说的"普通 Parquet JSON",是指将 JSON Payload 以 STRING、BINARY 或 Parquet JSON Logical Type 的方式保存在 Parquet 中,并不是指 StarRocks 的原生 JSON 类型。
普通 Parquet JSON:灵活,但内部结构对 Parquet 不透明
例如,一条通用事件包含如下 Payload:
json
{
"sku": "<sku>",
"channel": "<channel>",
"campaign_id": "<campaign_id>",
"paid": true,
"items": [
{
"sku": "<sku>",
"qty": "<quantity>"
}
]
}
当它以普通 JSON 保存到 Parquet 时,底层通常仍由一个 BYTE_ARRAY 承载。Parquet 知道这一列保存了 JSON,但 sku、channel、campaign_id 等内部路径并不是独立的 Parquet 类型列。
因此,查询 campaign_id 时通常需要经历:读取完整 JSON Payload、解析 JSON 文本、查找目标路径,再将结果转换成需要的数据类型。如果多个查询反复访问相同路径,这些解析和类型转换成本也会被反复支付。
Variant:结构灵活,但不再只是文本
Variant 是一种面向半结构化数据的类型化二进制表示。对象、数组和标量在写入时会被编码为:
metadata:保存字段名称、编码信息等元数据;value:保存值的类型、位置和实际内容。
逻辑上,不同行仍然可以拥有不同字段;物理上,数据不再是一段只能从头解析的 JSON 文本。例如:
json
{"campaign_id": "<numeric_id>"}
{"campaign_id": "unknown"}
{"coupon": "<coupon_code>"}
这些结构不同、甚至同一路径类型不同的数据,都可以保存在同一个 Variant 列中。Apache Parquet 已定义 Variant 的二进制编码和 Shredding布局。
图 1:普通 JSON 与 Variant 的读取差异(配图可在平台编辑器中补充)
普通 Parquet JSON 与 Parquet Variant 对比
| 对比维度 | 普通 Parquet JSON | Parquet Variant |
|---|---|---|
| 物理表示 | JSON 文本或二进制 Payload | 带类型信息的二进制编码 |
| Schema 灵活性 | 高,不同行可以有不同结构 | 高,不同行可以有不同结构 |
| 类型保真 | 查询时经常需要 CAST | 编码中保存具体类型信息 |
| 路径访问 | 需要解析 JSON 文本并查找路径 | 根据二进制结构定位路径 |
| 重复查询成本 | 每次查询都可能重新解析 | 避免反复解析 JSON 文本语法 |
| 内部字段列式化 | 内部路径不是独立 Parquet 列 | 可通过 Shredding 将热点路径保存为类型化子列 |
| 写入成本 | 写入相对简单 | 写入时需要进行 Variant 编码 |
| 适用场景 | 原文归档、内部字段很少查询 | Schema 持续变化,同时需要反复分析内部路径 |
Variant 的核心优势可以概括为三点:保留半结构化数据的 Schema 灵活性;减少查询时重复解析 JSON 文本的成本;通过 Shredding,让热点路径具备类型化、列式化的物理表示。
Variant 并不意味着文件一定更小,也不代表所有查询都会更快。如果数据只写入一次、读取一次,或者查询总是返回完整 Payload,普通 JSON 仍然可能是更简单的选择。
二、StarRocks 如何读取 Paimon Variant?
在 StarRocks 查询 Paimon Variant 的过程中,Paimon 负责管理表的 Snapshot、Schema 和数据文件,StarRocks 负责读取 Parquet 文件并执行 Variant 查询。
图 2:StarRocks 查询 Paimon Variant 的读取链路(配图可在平台编辑器中补充)
整个读取过程可以分为三个阶段。
1. 获取 Paimon 表和 Split 信息
StarRocks 通过 Paimon Catalog 获取表结构和当前 Snapshot,再由 Paimon 完成数据文件的 Split 规划。StarRocks 抽取 Split 文件信息,使用 StarRocks Parquet Reader 读取 Variant。
2. 将 Parquet Variant 转换为列式数据
Parquet Reader 根据文件中的物理 Schema 读取:
- Plain Variant 中的
metadata和value; - Shredded Variant 中的
metadata、value和typed_value。
读取结果被组织为 StarRocks 的 Variant 列向量,继续参与过滤、投影、聚合等向量化计算。
3. 通过 Variant 函数访问内部字段
用户可以使用不同函数访问 Variant:
get_variant_string:提取字符串;get_variant_int:提取整数;get_variant_double:提取浮点数;get_variant_bool:提取布尔值;variant_query:返回指定路径下的 Variant;variant_typeof:查看 Variant 值的类型;CAST:将 Variant 转换为目标 SQL 类型。
三、Shredding 如何优化 Variant 读取?
Plain Variant 的物理结构可以简化为:
text
payload
├── metadata
└── value
它已经避免了 JSON 文本语法的重复解析,但所有内部字段仍集中在 Variant 的二进制结构中。如果长期反复访问少数热点路径,例如 $.sku、$.channel、$.campaign_id、$.paid,可以通过 Shredding 将这些路径保存为类型化子列:
text
payload
├── metadata
├── value
└── typed_value
├── sku STRING
├── channel STRING
├── campaign_id BIGINT
└── paid BOOLEAN
图 3:Plain Variant 与 Shredded Variant 的物理布局(配图可在平台编辑器中补充)
Shredding 并没有把 Variant 变成固定 Schema 的 STRUCT。没有被 Shredding 的长尾字段仍然保存在 value 中,类型不符合预期的数据也可以通过 value 保留。
例如,campaign_id 大部分时候是整数,但某一行写入了字符串 unknown。整数值可以进入 typed_value.campaign_id,类型冲突的值则继续保存在 Variant 的通用 value 中,保证原始数据语义不会丢失。
Shredding 的读取优势主要来自:
- 热点路径拥有明确的数据类型,减少运行时类型判断和转换;
- 热点路径形成独立的 Parquet 类型化子列,可以使用 Parquet 的编码和压缩能力;
- 查询热点字段时,可以直接利用
typed_value,减少对通用 Variant 内容的解码; - 类型化子列为路径级列裁剪和 Parquet 统计信息过滤提供物理基础。
一张 Paimon 表的 Snapshot 会引用多个 Parquet 数据文件。在表结构演进过程中,早期文件中的 payload 可以采用 Plain Variant 物理布局,后续文件可以采用 Shredded Variant 物理布局。两者都是 Parquet 文件,区别仅在于 Variant 列的物理 Schema。StarRocks Parquet Reader 会根据每个文件的实际 Schema 读取数据,并向查询层提供一致的 Variant 语义。
四、使用 StarRocks 查询 Paimon Variant
下面以通用事件表为例。假设 DLF REST Catalog 中已经存在一张 Paimon 表:
text
<database_name>.<table_name>
表结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
event_id |
BIGINT | 事件 ID |
event_time |
TIMESTAMP | 事件时间 |
amount |
DECIMAL(18,2) | 示例数值字段 |
status |
STRING | 状态字段 |
payload |
VARIANT | 动态扩展信息 |
1. 创建 Paimon DLF REST Catalog
sql
CREATE EXTERNAL CATALOG paimon_dlf
PROPERTIES (
"type" = "paimon",
"paimon.catalog.type" = "rest",
"uri" = "<dlf_rest_endpoint>",
"paimon.catalog.warehouse" = "<dlf_catalog_name>",
"token.provider" = "dlf"
);
paimon.catalog.warehouse 表示 DLF Catalog 名称,服务地址和地域参数需要替换为对应环境的配置。创建 Catalog 后即可查看表结构:
sql
DESC paimon_dlf.<database_name>.<table_name>;
2. 查看完整 Variant
sql
SELECT
event_id,
payload,
variant_typeof(payload) AS payload_type
FROM paimon_dlf.<database_name>.<table_name>
LIMIT <row_limit>;
对于示例中的对象数据,payload_type 通常为 Object。
3. 按类型提取字段
sql
SELECT
event_id,
get_variant_string(payload, '$.sku') AS sku,
get_variant_string(payload, '$.channel') AS channel,
get_variant_int(payload, '$.campaign_id') AS campaign_id,
get_variant_bool(payload, '$.paid') AS paid
FROM paimon_dlf.<database_name>.<table_name>;
如果路径不存在,或者实际值无法转换为目标类型,对应的 get_variant_* 函数返回 NULL。
4. 读取嵌套对象和数组
sql
SELECT
event_id,
variant_query(payload, '$.items[0]') AS first_item,
get_variant_string(payload, '$.items[0].sku') AS first_item_sku,
get_variant_int(payload, '$.items[0].qty') AS first_item_qty
FROM paimon_dlf.<database_name>.<table_name>;
variant_query 返回的仍然是 Variant,适合继续访问嵌套结构;如果需要明确的 SQL 类型,可以使用 get_variant_* 或 CAST。
sql
SELECT
event_id,
CAST(variant_query(payload, '$.campaign_id') AS BIGINT) AS campaign_id
FROM paimon_dlf.<database_name>.<table_name>;
5. 使用 Variant 字段进行过滤和聚合
sql
SELECT
event_id,
get_variant_string(payload, '$.sku') AS sku
FROM paimon_dlf.<database_name>.<table_name>
WHERE get_variant_string(payload, '$.channel') = '<channel>'
AND get_variant_bool(payload, '$.paid') = true;
也可以先抽取动态字段,再参与聚合:
sql
WITH extracted AS (
SELECT
get_variant_string(payload, '$.channel') AS channel,
get_variant_bool(payload, '$.paid') AS paid
FROM paimon_dlf.<database_name>.<table_name>
)
SELECT
channel,
COUNT(*) AS event_count,
SUM(CASE WHEN paid THEN 1 ELSE 0 END) AS paid_count
FROM extracted
GROUP BY channel;
这样,Paimon 表中的动态 Payload 就可以像普通类型列一样参与 StarRocks 的过滤、聚合和分析。
五、如何选择 JSON、Variant 和正式类型列?
图 4:半结构化数据建模选择(配图可在平台编辑器中补充)
| 数据特征 | 推荐方式 |
|---|---|
| 需要保留 JSON 原文,几乎不查询内部字段 | 普通 Parquet JSON |
| Schema 经常变化,需要反复访问内部路径 | Plain Variant |
| 文档较宽,长期反复访问少数热点路径 | Shredded Variant |
| 字段参与分区、Join、排序或强 SLA 查询 | 正式类型列 |
一种更合理的建模方式是:
- 稳定 ID、事件时间、数值和状态字段使用正式类型列;
- 变化频繁的属性和扩展信息放入 Variant;
- 稳定热点路径使用 Shredding;
- 如果某个动态字段逐渐成为核心过滤、Join 或分区字段,再将它提升为正式类型列。
Variant 的价值不是把所有字段都塞进一个 Payload,而是在"固定 Schema"和"完全动态 JSON"之间提供更合适的平衡。
总结
普通 Parquet JSON 解决了动态数据的存储问题,但频繁分析内部字段时,需要持续承担文本解析和类型转换成本。Variant 将半结构化数据编码为带类型信息的二进制结构,在保留 Schema 灵活性的同时,让动态字段重新进入列式计算体系。
在此基础上,Shredding 可以进一步将热点路径保存为类型化 Parquet 子列,减少通用 Variant 解码和类型转换,并为列裁剪、编码压缩和统计过滤提供物理基础。
通过 Paimon DLF REST Catalog,StarRocks 用户可以直接使用 get_variant_*、variant_query、variant_typeof 和 CAST 查询 Paimon Variant 数据,将动态 Payload 与普通结构化字段放在同一套 SQL 分析链路中。