资讯动态

SparkSQL复杂JSON解析实战:从函数原理到性能调优

发布时间:2026/10/22 4:29:41 来源:尧图企业网站定制
搞过几年SparkSQL的人基本都跟JSON打过交道。尤其是做数仓、做数据清洗的日常需求里一半以上都是“把这段JSON里的某个字段给我拆出来”。简单场景用get_json_object就搞定了但一遇到数组套对象、对象套数组、动态key、多层嵌套这种组合拳不少人的第一反应是“写UDF吧”。其实大部分场景根本轮不到UDFSparkSQL自带的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数组第一个元素的skuP001$.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}, structname: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, structname: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, arraystructsku:string, price:double, tags:arraystring)) AS item ) SELECT orderId, item.sku, item.price, item.tags, province, city FROM order_data;这里的关键是explode时传入了from_json的结果。items最开始是JSON字符串explode只能处理数组类型所以需要先用from_json把它转成arraystruct...才能逐行炸开。有个细节容易忽略from_json的schema定义里tags字段是arraystring炸开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的mapstring, struct...类型派上用场SELECT sku, stock_info.warehouse, stock_info.count FROM ( SELECT from_json( get_json_object(json_col, $.stock), mapstring, structwarehouse: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三种复杂类型还可以互相嵌套。能定义出正确的schemaJSON解析就成功了一大半。3.4 案例四多层嵌套的“连环炸”最复杂的一种场景是数组套对象、对象里再套数组。比如一个店铺有多个订单每个订单里有多个商品{ shopId: S001, orders: [ { orderId: A001, items: [ {sku: P001, price: 10}, {sku: P002, price: 20} ] }, { orderId: A002, items: [ {sku: P003, price: 30} ] } ] }展开成明细行的SQL需要两次explodeSELECT 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), arraystructorderId:string, items:arraystructsku: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, arraystructid: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, arraystring)) AS (sku_pos, sku), from_json(qtys, arrayint) 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, arraystring) AS skus, from_json(qtys, arrayint) AS qtys FROM raw_data ) );arrays_zip把多个数组按索引合成一个结构体数组再整体炸开既避免了笛卡尔积又保持了位置上的一一对应。4. 性能调优让JSON处理不再拖慢整个任务4.1 JSON处理慢的三个典型原因JSON解析本质上是一个CPU密集型的字符串操作。大数据量场景下拖慢速度的情况基本只有三类第一类是反复解析同一段JSON。一次SQL里用5次、10次get_json_objectJSON字符串就被解析同样次数。数据量上千万行时这个浪费非常明显。解决办法是调整逻辑用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}]}]});执行结果为structorders:arraystructorderId:string,items:arraystructsku:string直接把这个结果粘贴到from_json的schema参数里省时还不会错。注意事项这个函数基于单条样本推导字段缺失或类型不一致会导致推断不准生产环境要抽样多条验证。第二个是尽量先过滤再解析。如果只需要处理特定类型的记录可以先对原始字符串做like匹配过滤掉大部分无关数据再做JSON解析。JSON解析比字符串匹配贵得多先过滤后解析的顺序能让任务快一倍都不止。第三个是重复使用的schema定义成变量。一个复杂的schema字符串如果散落在SQL各处后期改了JSON结构需要逐个地方改。可以把schema定义在公共配置里通过变量传递这样只改一处。实际上在代码里定义好schema常量拼SQL时引用这个常量能有效避免“改漏了”的情况。4.3 常见错误排查速查表现象可能原因解决方案get_json_object返回NULLJSONPath写错字段名大小写不匹配JSON字符串本身不合法用json格式化工具校验数据逐级测试路径from_json返回NULLschema类型定义和实际数据不符数据中存在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比对着文档写快得多。毕竟文档的更新速度永远追不上业务的变化——这一点做数据的人应该都深有体会。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑