Parquet Variant × DuckDB:列存半结构化数据的正确打开方式(上)
Parquet Variant × DuckDB:列存半结构化数据的正确打开方式
半结构化数据的存储困局
如果你的数据长这样:
1{"event": "page_view", "user_id": 1001, "page": "/home", "duration_ms": 3420}
2{"event": "purchase", "user_id": 1002, "items": ["sku_a", "sku_b"], "total": 129.99, "coupon": "NEW10"}
3{"event": "error", "user_id": 1003, "error_code": 500, "stack": "RuntimeError: ...", "retry_count": 3}
三条日志,三个 schema。传统做法无非几种选择:
- 全部存 JSON 字符串:查询时每次都要 parse,列存优势全丢
- 全部展平为 STRUCT:需要所有行 schema 一致,做不到
- 拆成三张表:查询时 UNION ALL,维护成本高
- MAP<string,string>:值类型全变成字符串,数值和时间的类型信息丢失
过去十年,数据湖上的半结构化数据始终没有一个"正确的答案"。
2025 年 8 月,Apache Parquet 社区给出的答案来了:Variant。
什么是 Parquet Variant?
Variant 是 Parquet 2.12.0 正式定稿的逻辑类型。它不是"把 JSON 塞进 Parquet",而是一个精心设计的二进制编码格式。
物理结构
Variant 在 Parquet 中是一个 group 类型,包含两个 binary 字段:
1Variant Group
2├── metadata ← 共享字符串字典(去重字段名)
3└── value ← 紧凑二进制编码的实际数据
它支持四种基本类型:
| 类型 | 编码 |
|---|---|
| Primitive | 21 种:null, bool, int8~64, float, double, decimal, date, timestamp, string, uuid 等 |
| Short String | 长度 <64 字节的字符串,类型字节直接携带长度 |
| Object | 无序 key/value,字段名用字典索引,排序后支持二分查找 |
| Array | 有序 Variant 值列表 |
两个关键设计
1. 偏移量导航
Object 和 Array 使用偏移量数组存储。读取某个字段时,直接跳到偏移对应位置——不需要完整 parse 整条数据。这是 Variant 比 JSON 字符串快的第一个原因:
1读 JSON 字符串: parse 整条 → 构建 AST → 定位字段
2读 Variant 编码: metadata 查字典 → value 跳偏移 → 直接读取
2. 共享字段名字典
传统 JSON 存储中,字段名 ("event", "user_id", "page") 在每一行都重复。Variant 的 metadata 字段只存一份字典,行内存字典索引。行数越多,节省越显著。
Shredding:真正改变游戏规则的设计
Shredding(切分)是 Variant 规格中最重要创新。它的思路很朴素:
既然我们知道用户最常查哪些字段——那就把它们提取成独立的 typed column。
1写入前判断 写入时存储
2┌─────────────────┐ ┌──────────────────────────┐
3│ {"event":"view",│ │ event: VARCHAR (typed) │
4│ "user":1001, │ ──→ │ user: INT32 (typed) │
5│ "page":"/home"}│ │ value: BINARY (原始) │
6└─────────────────┘ └──────────────────────────┘
Shredding 带来的三个关键收益:
- 列剪枝:只查 event 和 user 时,不需要读原始 value 列
- 谓词下推:
WHERE user_id = 1001直接在 Parquet 统计信息层过滤 - 向量化执行:提取后的 typed column 走 DuckDB 的向量化执行路径
Databricks 实测数据:
- Shredded Variant 比 JSON 字符串 快 30 倍
- 比未 shred 的 Variant 快 8 倍
DuckDB 的 Variant 支持
DuckDB 是第一个原生支持 Variant 全链路(读写 + shredding)的开源引擎。
版本演进
| 时间 | 里程碑 |
|---|---|
| 2025-07 | 读取支持(非 shredded + shredded) |
| 2025-10 | 写入支持 + 自动 shredding |
| 2026-03 | DuckDB v1.5.0 "Variegata" 正式发布 |
| 2026-04 | Snowflake Variant Parquet 兼容 |
SQL 语法
最直观的变化:不再需要 JSON 函数。
1-- 建表
2CREATE TABLE events (
3 id INTEGER,
4 payload VARIANT
5);
6
7-- 插入
8INSERT INTO events VALUES
9 (1, '{"event":"view", "user":1001, "page":"/home"}'::VARIANT),
10 (2, '{"event":"purchase","user":1002,"total":129.99}'::VARIANT);
11
12-- 查询:点号表达式!
13SELECT id, payload.event, payload.user FROM events;
14-- 不需要 json_extract_string(payload, '$.event')
15
16-- 类型检查:每行单独返回底层类型
17SELECT id, variant_typeof(payload) FROM events;
18-- → OBJECT, OBJECT
19
20-- 写入 Parquet(自动 shred 第一 row group 中推断的字段)
21COPY events TO 'events.parquet' (FORMAT PARQUET);
22
23-- 显式指定 shredding schema
24COPY events TO 'events_shred.parquet'
25 (FORMAT PARQUET,
26 SHREDDING {'payload': 'STRUCT(event VARCHAR, user INTEGER)'});
自动 Shredding 机制
DuckDB 的自动 shredding 分析第一个 row group 的结构,推断哪些字段可以提取。用户写 COPY events TO 'file.parquet' 时不需要额外配置——它会自动做最优选择。
例外:DECIMAL 类型不自动 shred,因为精度和标度匹配逻辑复杂,需要显式指定。
性能表现
实验室基准(10GB, 10M 行)
| 查询 | JSON 文本 | VARIANT | 加速比 |
|---|---|---|---|
| 全扫描 + 嵌套提取 | 8.4s | 0.9s | 9.3x |
| 过滤 + 投影 | 5.1s | 0.6s | 8.5x |
| GROUP BY 嵌套字段 | 12.3s | 1.4s | 8.8x |
| 存储大小 | 2.0 GB | 1.2 GB | 40% 节省 |
MotherDuck 的数据更激进:对于只查少数字段的 JSON 查询,shredding + 列剪枝可达 100x 提升。
真实世界表现
社区开发者 marending 在 2026 年 5 月用真实可观测性数据做了一组对比:
| 格式 | 大小 |
|---|---|
| JSON 文本 | 2.2 MB |
| MAP | 1.3 MB |
| Variant | 1.1 MB |
| 全物化列 | 1.0 MB |
文件大小非常理想,但查询方面 Variant 却不如预期——原因是一个未优化代码路径(GitHub #22024,已在追踪修复中)。社区评价:这个实现在 v1.5 阶段"对生产使用来说还太年轻"。
当前限制
虽然方向正确,但 Variant × DuckDB 在 v1.5 阶段有几个明确的短板:
- 无原生 DuckDB Variant 类型:目前内部转为 JSON 处理,真正的原生类型列为未来工作
- JSON Path 的 projection pushdown 尚未实现
- Variant 列没有统计信息发射:无法利用 min/max/null count 做数据跳过
- DECIMAL 不在自动 shredding 范围
- #22024 引起的性能退化:特定数据模式下的查询比预期慢
与其他方式的对比
| 存储方式 | 性能 | Schema 灵活性 | 类型保真 | 适用场景 |
|---|---|---|---|---|
| JSON 字符串 | ❌ 最慢 | ⭐⭐⭐⭐⭐ | ❌ | 临时分析 |
| STRUCT | ⭐⭐⭐⭐⭐ | ⭐ | ✅ | Schema 固定的数据 |
| MAP | ⭐⭐⭐⭐ | ⭐⭐⭐ | ❌ | 值类型统一 |
| Variant | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 动态 schema、异构数据 |
| Variant + Shredding | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ | 推荐的生产模式 |
(本文为上下篇之一,下篇将继续讨论实战建议、生态对比与总结。敬请期待。)