日志、埋点、第三方接口返回值,几乎每个数据仓库都会遇到 JSON 这种半结构化数据。很多团队最开始的处理方式都很直接:拿到 JSON,塞进一个字符串列,等到用的时候再让应用层去解析。这个方案在数据量小的时候看不出问题,但一旦规模上来,就会变成查询慢、维护难、数据质量没保障的源头。

这里说的“半结构化”,是指数据本身有结构,但不是固定的表结构。JSON 可以嵌套、可以变长、可以缺字段,同一个 key 在不同的记录里甚至可能是不同类型。传统关系型数据库在设计上就不擅长处理这种形态,而数据仓库最初也是围绕关系模型建立起来的。所以,怎么把 JSON 塞进数仓,以及怎么让它能被高效查询,就成了一个长期存在的工程问题。
第一阶段:把 JSON 塞进字符串列
最早的解决方案很朴素:既然不知道 JSON 里有哪些字段,那就把它当做一个整体存进字符串列。以 Hive 为例,通常是一个 STRING 类型字段,比如事件表的 event_data。存储时,直接把 JSON 序列化后的文本丢进去。这样做的好处是接入成本几乎为零,只要提供一个 JSON 字段,任何上游数据都能先落进来。
但代价很快就暴露出来。一个电商业务的埋点事件表,event_data 里存了事件类型、商品 ID、来源渠道、用户属性等。早期需求只是统计每天订单量,用 COUNT(*) 就行。后来业务方想看不同渠道的转化率,你的 SQL 会变成下面这样:
SELECT event_type, COUNT(*)
FROM events
WHERE event_data LIKE '%channel%'
GROUP BY event_type;
这段 SQL 靠 LIKE 去匹配 JSON 文本,能不能得到正确结果,取决于 JSON 的序列化格式和 key 的命名。比如 channel 出现在其他字段的值里,就会产生误匹配。而且,数据量一旦到了亿级,这种写法基本是全表扫描,列式存储的压缩优势和谓词下推完全用不上。很多团队会想:那就把 JSON 拉到应用层解析,用 Java/Python 处理。可是这种方式需要把大量原始数据通过网络传输到计算节点,解析一次的 CPU 开销也不小,数据量大了以后同样扛不住。
字符串列方式并不是不能用,它适合那些不需要参与复杂查询、只是作为原始存档的场景。但如果 JSON 是你分析的核心输入,这条路很快就会走不下去。
第二阶段:用 ETL 把 JSON 拍平
既然 JSON 不能直接高效查询,那就把它“翻译”成一行一列。这是数仓建模中最常见的处理方式:通过 ETL 将 JSON 中的字段抽取出来,映射成独立的列,甚至根据 JSON 中的数组嵌套关系,拆分成多张维表、事实表。
以一个渠道接入为例。假设你需要把 event_data 中的 channel、product_id、user_id 作为常查询的字段,可以这样处理:
INSERT INTO events_structured
SELECT
event_type,
JSON_EXTRACT(event_data, '$.channel') AS channel,
JSON_EXTRACT(event_data, '$.product_id') AS product_id,
JSON_EXTRACT(event_data, '$.user_id') AS user_id
FROM events;
把 JSON 拆成结构化列后,查询性能明显提升:列存可以发挥压缩优势,排序键和 min/max 索引也能用上,物化视图也能建立在具体的列上。如果你的查询路径非常明确,这种方式的性价比很高。
但“明确”两个字往往很难保证。真实业务中,JSON 数据来自多个系统,每个系统的字段都有微妙的差异。比如 A 渠道用 user_id,B 渠道用 uid,C 渠道用 acct_id,接进来的字段名都不一样。更麻烦的是嵌套数组:比如商品列表 items,每个 item 带有多个属性,要拆成明细行,ETL 的复杂度和维护成本会直线上升。
还有一个被很多人忽视的问题:拆列以后,原始 JSON 被丢弃了。如果未来发现某个当年没拆的字段其实很重要,你可能需要回到上游日志重新消费并回刷。对离线数仓来说,这往往是一场耗时数天的数据重构。
所以,ETL 拍平更适合 schema 相对稳定、分析路径明确的核心交易类数据。对于探索型、变化快的半结构化数据,它并不友好。
Variant 类型的诞生:把灵活性下推给存储引擎
为了在“灵活”和“高效”之间取得平衡,现代云数据仓库开始提供原生的半结构化数据类型。最有代表性的就是 Snowflake 的 VARIANT,以及 BigQuery 的 JSON 类型、ClickHouse 的 JSON / Dynamic 类型、Redshift 的 SUPER。它们的核心设计不再是“把文本扔进去”,而是以二进制结构存储 JSON,并在引擎内部建立索引或路径映射,让查询可以按需访问某个字段,而不是解析整个 JSON。
以 Snowflake 为例,一个 VARIANT 列可以直接用冒号路径访问内部字段:
SELECT
event_data:channel AS channel,
event_data:product_id::INT AS product_id
FROM events
WHERE event_data:event_type = 'view';
这里不需要预定义 JSON 的 schema,也不需要 ETL 拆列。写入时,Snowflake 会解析并优化存储布局;查询时,优化器能够识别路径访问,并尽可能利用裁剪机制,只读取相关的数据切片。
这种设计带来的直接好处有几点:一是接入速度极快,上游只需要把 JSON 原样写入即可;二是数据完整性好,原始 JSON 始终保留,不会因为拆列而丢失;三是 schema 演进灵活,新增字段不需要改表结构。
但 Variant 也不是没有代价。根据经验,它的存储空间可能比纯文本略大或差不多,因为内部需要维护结构信息;而且路径访问虽然比文本扫描快,但和真正的原生列相比,仍然有更大的解析开销。如果你对 JSON 字段大量做 GROUP BY 或 JOIN,性能依然可能不理想。
因此,很多数据仓库社区形成了新的最佳实践:将高频访问、稳定存在的字段物化为普通列,将低频或易变的字段保留在 Variant 中。两者结合,既保证了性能,又保留了灵活性。
主流数据仓库 JSON 类型横向对比
不同数据仓库对半结构化的实现方式各有侧重。选择前,有必要先了解它们之间的关键差异。下面的表格列出了几个主流数仓的 JSON 类型特点:
| 数据仓库 | JSON 类型 | 典型访问方式 | 存储与优化 | 适用场景 | 注意事项 |
|---|---|---|---|---|---|
| Snowflake | VARIANT | 冒号路径 + :: 类型转换 | 自动分列优化,路径裁剪 | 灵活 schema,混合摄入 | 高频字段需物化 |
| BigQuery | JSON | JSON_VALUE / JSON_QUERY | 支持部分路径优化 | 分析宽松 schema 数据 | 路径访问性能有限 |
| ClickHouse | String / JSON / Dynamic | JSONExtract / subcolumns | 依赖物化列或 subcolumns 索引 | 海量日志实时分析 | 原生 JSON 类型仍在演进 |
| Redshift | SUPER | PartiQL 点访问 | 支持远端扫描优化 | 与 S3 数据湖集成 | 复杂嵌套查询需谨慎 |
这个表格里,ClickHouse 的情况比较特殊。早期版本其实没有真正的 JSON 类型,大家用的是 String 加函数解析,或者 Nested 结构。最近版本虽引入了 JSON 类型,但从社区反馈看,它的成熟度和查询优化还在不断改进中。如果你的团队正打算在 ClickHouse 上处理 JSON,最好先做一轮 benchmark,别直接进入生产。
怎么选:三个问题、四个误区
落到具体项目,我认为选型前先回答三个问题:
- schema 的变化频率有多高?如果一周变几次,那么结构化拆解的成本会很高,应该优先选原生 JSON 类型。
- 80% 的查询是否集中在少数几个字段?如果是,就把这些字段物化出来,其余丢进 VARIANT。
- 团队是否有能力维护复杂 ETL?小团队更适合“少量物化列 + 原生 JSON”的方式,减少开发和运维负担。
同时,我整理了四个常见的误区:
- 以为 VARIANT 能完全替代建模。VARIANT 只是存储层面的灵活性,没有合理的物化列和查询裁剪,性能依然不可控。
- 在 SQL 里用 LIKE 处理 JSON。这是最糟糕的做法,无法利用索引,且很容易误匹配。即便有 VARIANT,也不要写这样的查询。
- 拆列后扔掉原始 JSON。一旦后续需求超出已拆字段,重建过程极其痛苦,应该至少保留一份原始 JSON 的归档。
- 把所有 JSON 都塞进原生类型。如果 JSON 里有大量高频访问的稳定字段,全部放在 VARIANT 里会导致解析开销放大,最好物化。
这些误区背后其实是一条原则:半结构化数据的处理方案,永远要基于真正的查询模式来设计,而不是基于“用什么类型最方便”来设计。
落地时,需要踩过的细节坑
即使选定了 Variant 类型,实际工程中还是有不少细节需要处理。
第一个是压缩与排序键。以 Snowflake 为例,VARIANT 列无法直接作为 cluster key,必须物化出少量高频字段作为 cluster key。如果你用 ClickHouse,JSON 路径索引和主键设计之间的关系也需要反复测试。
第二个是类型转换。JSON 中的数字、布尔、日期都以字符串形式存储,查询时如果频繁做类型转换,开销不可忽视。建议在写入时就明确字段的 data type,或者用物化列保证类型准确。
第三个是嵌套层级。VARIANT 访问路径通常支持多层,但层级过深会让查询语句难以维护,也会增加解析成本。对于超过三层且频繁访问的路径,更值得用结构化子表或物化列来替代。
第四个是数据质量控制。VARIANT 类型本身不会校验 JSON 的 schema,所以脏数据同样能被写进去。你仍然需要在上游或 ETL 阶段加上必要的校验和告警,否则下游分析会在不知不觉中被异常值污染。
很多团队在从字符串列迁移到 VARIANT 时,往往会忽略这些细节,结果发现性能并没有想象中提升。其实不是 Variant 的问题,而是你的数据布局和查询结构没有跟上。
一个小结,也是对建模思路的反思
从 JSON 字符串列到 ETL 拍平,再到原生的 Variant/JSON 类型,这个演进过程反映了一个趋势:数据仓库正在把“反规范化”的灵活性交给存储引擎,而不是全部压给建模和 ETL。这样做的价值在于,当业务发生变化时,你可以用很低的成本去调整对数据的访问方式,而不是先花两周改表、刷数据。
但反过来说,这种灵活性并不能消除建模。真正的工程智慧仍然在于:知道哪些部分需要严格约束,哪些部分可以保持宽松。你可以让 80% 的核心字段按结构化方式建模,同时让 20% 的长尾字段保留为 Variant。这个比例并不是固定的,要由团队的数据规模、数据源稳定性、分析深度共同决定。
最后想说,没有一种数据仓库内置的 JSON 类型能让你一劳永逸。任何一个方案都有它适用的边界,而这些边界,需要在你的数据、你的查询、你的运维资源中去验证。这篇文章希望帮你把几种方案的演进脉络和取舍逻辑理清楚,具体怎么选,还是得回到你手头的业务上。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/874/