搞过几年SparkSQL的人,基本都跟JSON打过交道。尤其是做数仓、做数据清洗的,日常需求里一半以上都是“把这段JSON里的某个字段给我拆出来”。简单场景用get_json_object就搞定了,但一遇到数组套对象、对象套数组、动态key、多层嵌套这种组合拳,不少人的第一反应是“写UDF吧”。其实大部分场景根本轮不到UDF,SparkSQL自带的JSON函数组合起来足够应付绝大多数复杂结构。这篇就把我实际踩过的、拆过的各种JSON处理场景做一个系统梳理,从函数原理到完整案例再到性能调优,一篇讲透。
1. 先搞清楚:复杂JSON到底“复杂”在哪
1.1 真实业务里的JSON长什么样
开发文档里的JSON示例永远是干干净净的:
{"name": "张三", "age": 20}但真实业务日志里的JSON往往是这个画风:
{ "orderId": "A10001", "userId": 9527, "items": [ {"sku": "P001", "price": 29.9, "tags": ["促销", "新品"]}, {"sku": "P002", "price": 99.0, "tags": ["热卖"]} ], "address": { "province": "浙江省", "city": "杭州市", "detail": {"street": "文一西路", "number": 969} }, "ext_info": "{\"couponId\":\"C888\",\"channel\":\"app\"}" }这种结构里有数组、数组里套对象、对象里再套对象,还有字符串化的JSON。处理这种数据,难点不是“取字段”,而是“怎么把层级关系拍平成关系表”。因为数仓里最终要的是二维表:一个订单一行,商品明细展开成多行,每个字段单独成列。
1.2 SparkSQL处理JSON的优势和边界
优势很明确:SparkSQL是分布式计算引擎,一个explode就能把几十万行JSON里的数组全部炸开,不需要像单机脚本那样一行一行循环。而且内置函数从Spark 2.x到3.x逐步增强,from_json、to_json、schema_of_json这些函数配合使用,几乎能覆盖90%以上的业务场景。
但边界也清晰:它不是专门为JSON设计的数据库,没有像MongoDB那样的$unwind链式操作,也没有PostgreSQL那种丰富的JSON路径语法。所以很多需求需要组合函数来实现,这也是不少开发者觉得“麻烦”的原因。
一句话总结:复杂JSON处理的核心能力,就是“拆”和“炸”。拆是用路径表达式提取嵌套字段,炸是用explode把数组展开成多行。所有复杂的JSON需求,本质都是拆和炸的组合运用。
2. 核心函数拆解:每个都是独立武器
2.1 get_json_object:最常用的“拆”工具
这是SparkSQL里出现频率最高的JSON函数,语法极简:
SELECT get_json_object(json_col, '$.name') AS name FROM table;第二个参数是JSONPath表达式,几个常用写法:
| 路径写法 | 含义 | 示例结果 |
|---|---|---|
$.name | 取根节点下的name字段 | "张三" |
$.items[0].sku | 取items数组第一个元素的sku | "P001" |
$.items[*].price | 取items数组所有元素的price | 非标准路径,慎用 |
$..price | 递归查找所有price字段 | 非标准路径,性能差 |
两个容易翻车的点值得提前说:
第一,get_json_object一次只能取一个字段。想要取5个字段就得调用5次,JSON字符串会被反复解析5遍,数据量大时性能明显下降。后面会讲用from_json一次性解决的方案。
第二,JSONPath的[*]通配符在SparkSQL里支持不完整,经常返回NULL。我第一次用$.items[*].price试图提取所有价格,结果拿到一列NULL,排查了半天才发现是这个坑。后来改用explode炸开再取,问题才解决。
2.2 from_json + to_json:结构化转换的利器
from_json是把JSON字符串转成SparkSQL的StructType结构,这是处理嵌套JSON最关键的函数。基础用法:
SELECT from_json('{"name": "张三", "age": 20}', 'struct<name:string, age:int>') AS person;返回的是一个结构体列,可以通过person.name、person.age访问内部字段。
to_json是反向操作,把结构体或Map转回JSON字符串,常用于结果输出到消息队列或文件时统一格式。
这里有个非常实用的场景:一次性解析多个字段。不需要反复get_json_object,直接定义一个完整schema,一次转换多次引用:
SELECT person.name, person.age FROM ( SELECT from_json(json_col, 'struct<name:string, age:int>') AS person FROM table );JSON字符串只解析了一遍,性能提升明显。我后来在项目里清理过一段慢SQL,原来用5次get_json_object的写法跑了40多秒,改成一次from_json后只要12秒。
2.3 explode与posexplode:“炸”开数组的关键操作
explode把数组列展开成多行,SparkSQL里通常配合lateral view使用:
SELECT order_id, item.sku, item.price FROM orders LATERAL VIEW explode(items) AS item;如果items里有多个字段,展开后每一行就是数组里的一个对象,直接通过item.sku取字段。
posexplode比explode多返回一个索引列,适合需要保留数组元素顺序的场景,比如排序、拼接、识别第几个商品:
SELECT order_id, pos, item.sku FROM orders LATERAL VIEW posexplode(items) AS pos, item;这里返回的pos就是元素在数组里的下标,从0开始。
explode有一个著名的陷阱:当数组为空或为NULL时,整行数据会消失。因为explode的语义就是把一行变多行,空数组自然产生0行。如果业务上需要保留无明细的订单,就得用outer关键字:
SELECT order_id, item.sku FROM orders LATERAL VIEW OUTER explode(items) AS item;加了OUTER后,空数组的订单会保留,item字段为NULL。这个细节不留意就会造成数据缺失,而且是那种对不上数才发现的“隐性缺失”,非常坑。
3. 实战案例:从简单到变态的完整拆解
3.1 案例一:字符串化JSON的“二次解析”
真实场景里经常遇到JSON字符串被转义后塞在另一个JSON字段里,之前那个ext_info字段就是典型。它是字符串,不是JSON对象,直接get_json_object取内部字段会返回NULL:
-- 错误示范:ext_info本身是字符串,不是JSON结构 SELECT get_json_object(ext_info, '$.couponId') FROM table; -- 返回NULL正确的做法是先用get_json_object把整段字符串取出来,再用from_json或二次get_json_object解析:
SELECT order_id, get_json_object(ext_info, '$.couponId') AS coupon_id, get_json_object(ext_info, '$.channel') AS channel FROM table;因为ext_info虽然存储时是字符串,但内容本身是合法JSON,所以get_json_object可以直接解析。反直觉的地方在于:外面那层JSON要用路径定位到ext_info字段,里面那层JSON要从ext_info字段值开始解析,路径是重新从$开始算的。
有次我就犯了这个迷糊,路径写成了$.ext_info.$.couponId,运行结果当然全是NULL。记住一个核心认知:get_json_object的路径永远从当前字符串内容的根部开始,和字段外层的层级无关。
3.2 案例二:数组套对象的标准处理流程
处理订单明细这种“一单多商品”结构,目标是把每个商品拆成一行。完整SQL如下:
WITH order_data AS ( SELECT orderId, items, address.province, address.city FROM ( SELECT get_json_object(json_col, '$.orderId') AS orderId, get_json_object(json_col, '$.items') AS items, get_json_object(json_col, '$.address') AS address FROM raw_orders ) LATERAL VIEW OUTER explode(from_json(items, 'array<struct<sku:string, price:double, tags:array<string>>>')) AS item ) SELECT orderId, item.sku, item.price, item.tags, province, city FROM order_data;这里的关键是explode时传入了from_json的结果。items最开始是JSON字符串,explode只能处理数组类型,所以需要先用from_json把它转成array<struct<...>>,才能逐行炸开。
有个细节容易忽略:from_json的schema定义里,tags字段是array<string>,炸开item后可以通过item.tags直接拿到数组列,如果还需要把tags再展开,可以再套一次explode。嵌套多层就多炸几次,这是SparkSQL处理“套娃”结构的核心思路。
3.3 案例三:动态key对象的Map化处理
业务上经常出现key是动态生成的JSON对象,比如每个商品的库存数据:
{ "stock": { "sku_1001": {"warehouse": "华东仓", "count": 50}, "sku_1002": {"warehouse": "华北仓", "count": 30} } }sku编码是动态的,没法在schema里写死字段名。这时候from_json的map<string, struct<...>>类型派上用场:
SELECT sku, stock_info.warehouse, stock_info.count FROM ( SELECT from_json( get_json_object(json_col, '$.stock'), 'map<string, struct<warehouse:string, count:int>>' ) AS stock_map FROM stock_data ) LATERAL VIEW explode(stock_map) AS sku, stock_info;explode处理Map类型时,会生成两列:一列是key,一列是value。这在其他数据库里不常见,但在SparkSQL里却是处理动态JSON最优雅的方案。我第一次用这个技巧的时候,感叹了好一阵子,原来Map类型在from_json里有这作用。
从这里也能看到from_json的schema定义能力有多重要:它支持struct、array、map三种复杂类型,还可以互相嵌套。能定义出正确的schema,JSON解析就成功了一大半。
3.4 案例四:多层嵌套的“连环炸”
最复杂的一种场景是数组套对象、对象里再套数组。比如一个店铺有多个订单,每个订单里有多个商品:
{ "shopId": "S001", "orders": [ { "orderId": "A001", "items": [ {"sku": "P001", "price": 10}, {"sku": "P002", "price": 20} ] }, { "orderId": "A002", "items": [ {"sku": "P003", "price": 30} ] } ] }展开成明细行的SQL需要两次explode:
SELECT shopId, order_info.orderId, item.sku, item.price FROM ( SELECT get_json_object(json_col, '$.shopId') AS shopId, from_json( get_json_object(json_col, '$.orders'), 'array<struct<orderId:string, items:array<struct<sku:string, price:double>>>>' ) AS orders FROM shop_data ) LATERAL VIEW OUTER explode(orders) AS order_info LATERAL VIEW OUTER explode(order_info.items) AS item;两次explode之间用同一个order_info串起来,因为第一次炸开后,order_info.items就是当前订单的商品数组,第二次炸开就以它为输入。这个逻辑有点像两层循环,外层循环订单,内层循环商品。
写这段SQL时最容易踩的坑是:第二个explode里的路径写错。用了orders.items而不是order_info.items。前者引用的是原始数组,后者才是当前行的订单数据。只要写错,要么报错要么数据风马牛不相及。
3.5 案例五:JSON数组的“全局展开”与关联
有时JSON字符串本身就整个是一个数组,不套在任何对象外面:
[ {"id": 1, "value": "a"}, {"id": 2, "value": "b"} ]处理方式反而更直接——不需要get_json_object,直接从根上转成数组再炸开:
SELECT element.id, element.value FROM ( SELECT from_json(json_col, 'array<struct<id:int, value:string>>') AS arr FROM raw_data ) LATERAL VIEW OUTER explode(arr) AS element;还有一个高级场景:两个数组字段需要按位置对应展开。比如商品和数量分别在两个数组里:
{"skus": ["P001", "P002"], "qtys": [2, 3]}目标是得到(P001, 2)和(P002, 3)。用posexplode配合filter可以实现:
SELECT sku, qty FROM ( SELECT posexplode(from_json(skus, 'array<string>')) AS (sku_pos, sku), from_json(qtys, 'array<int>') AS qty_arr FROM raw_data ) LATERAL VIEW OUTER explode(qty_arr) AS qty WHERE sku_pos = ...;这种写法需要小心生成笛卡尔积,数据量大时建议先过滤再关联,或者改用zip函数。Spark 3.x的arrays_zip是处理这类问题的更优解:
SELECT zipped.sku, zipped.qty FROM ( SELECT explode(arrays_zip(skus, qtys)) AS zipped FROM ( SELECT from_json(skus, 'array<string>') AS skus, from_json(qtys, 'array<int>') AS qtys FROM raw_data ) );arrays_zip把多个数组按索引合成一个结构体数组,再整体炸开,既避免了笛卡尔积,又保持了位置上的一一对应。
4. 性能调优:让JSON处理不再拖慢整个任务
4.1 JSON处理慢的三个典型原因
JSON解析本质上是一个CPU密集型的字符串操作。大数据量场景下拖慢速度的情况基本只有三类:
第一类是反复解析同一段JSON。一次SQL里用5次、10次get_json_object,JSON字符串就被解析同样次数。数据量上千万行时,这个浪费非常明显。解决办法是调整逻辑,用from_json一次解析多字段,或者在子查询里先解析一次,外层直接引用。
第二类是路径写得太重。有些开发者习惯用$..field这种递归搜索的路径,性能极差。它的实现逻辑是遍历JSON树的每个节点去匹配,而不是直接定位。能用$.a.b精确路径就绝不用递归路径,这是原则。
第三类是explode之后的数据倾斜。如果某个key下挂了超大数组,比如一个用户有几十万条操作记录,炸开后这个key的分区数据量会暴涨,拖垮整个Stage。这类场景需要单独识别处理,比如对超大数组做采样拆分,或者把大key单独跑。
4.2 三个亲测有效的优化手段
第一个是用schema_of_json自动推导schema。手写复杂schema容易出错,尤其是多层嵌套时少写一个字段就解析失败。schema_of_json可以根据一条样本JSON自动生成schema定义:
SELECT schema_of_json('{"orders": [{"orderId": "A001", "items": [{"sku": "P001"}]}]}');执行结果为:
struct<orders:array<struct<orderId:string,items:array<struct<sku:string>>>>>直接把这个结果粘贴到from_json的schema参数里,省时还不会错。注意事项:这个函数基于单条样本推导,字段缺失或类型不一致会导致推断不准,生产环境要抽样多条验证。
第二个是尽量先过滤再解析。如果只需要处理特定类型的记录,可以先对原始字符串做like匹配,过滤掉大部分无关数据,再做JSON解析。JSON解析比字符串匹配贵得多,先过滤后解析的顺序能让任务快一倍都不止。
第三个是重复使用的schema定义成变量。一个复杂的schema字符串如果散落在SQL各处,后期改了JSON结构需要逐个地方改。可以把schema定义在公共配置里,通过变量传递,这样只改一处。实际上,在代码里定义好schema常量,拼SQL时引用这个常量,能有效避免“改漏了”的情况。
4.3 常见错误排查速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| get_json_object返回NULL | JSONPath写错;字段名大小写不匹配;JSON字符串本身不合法 | 用json格式化工具校验数据;逐级测试路径 |
| from_json返回NULL | schema类型定义和实际数据不符;数据中存在null但schema没配nullable | 用schema_of_json推导;检查NULL值 |
| explode后行数变少 | 数组为NULL或空数组 | 改用LATERAL VIEW OUTER explode |
| 报错“cannot resolve” | 字段名引用错误,可能拼写或层级不对 | 检查字段的限定名,看是否需要加别名前缀 |
| 数据倾斜严重 | 某个key下挂超大数组 | 大key单独处理或改用filter拆分 |
| 中文乱码 | 文件编码问题和JSON无关,但常见 | 检查spark.sql.parquet.writeLegacyFormat等相关配置 |
还有一个我常提醒自己注意的点:JSON解析的字段名大小写敏感。有一次数据里字段是OrderId,我写成orderId,排查了半天找不到原因,后来才发现是大小写的问题。
5. 关于“该不该写UDF”的选型思路
SparkSQL内置函数处理JSON能力很强,但确实存在边界。比如需要根据JSON内容做复杂业务判断、跨层级做关联分析、或者路径本身需要动态生成,这些场景写UDF反而更清爽。
我的选型思路是三个问题走一遍:
第一个问题:只用内置函数能不能表达清楚逻辑?如果能,比如就是个拆字段+炸数组,那就用内置函数。维护成本低,还能走SparkSQL的优化器,没必要为了“炫技”写UDF。
第二个问题:逻辑是否高频复用?如果大量SQL都用到同一段JSON解析代码,而且业务逻辑本身具有“嵌入SQL很难读”的特点,那写一个UDF或一个公共函数是合理的。
第三个问题:性能要求有多高?用Java或Scala写的UDF性能通常比Python UDF好很多,但都逃不开序列化和反序列化的开销。能不用UDF就不用UDF,能让SQL解决的问题绝不让代码来凑。
有一次我接手一个项目,线上代码里有一个Python UDF,专门用来解析订单JSON并提取金额。一看实现,内部逻辑就是get_json_object能做的。我改成纯SQL之后,那个Stage的执行时间从18分钟降到3分钟。这种例子多了之后,我更倾向于“SQL优先”的思路。
说到底,SparkSQL处理JSON不是一个“会不会函数”的问题,而是“能不能把数据结构看透”的问题。数组套对象就炸开,对象套数组就拆完再炸,动态key就Map化,字符串化就二次解析。掌握了这几板斧,绝大多数所谓“复杂JSON处理”就是纸老虎。
最后分享一个从实际项目里沉淀的小技巧:在处理JSON之前,先把一条样本数据格式化后完整打印出来,对着样本写schema比对着文档写快得多。毕竟文档的更新速度永远追不上业务的变化——这一点做数据的人应该都深有体会。