1. 为什么要拆JSON数组字符串:这类需求从哪里来
先讲一个我实际遇到的场景。对接过一个订单同步项目,上游系统每天推送一个很大的JSON数组,里面是当天所有订单的商品明细。落地时图省事,整段JSON数组直接塞进了一个 varchar 字段,叫 products_json。刚开始没什么问题,反正就是存着。等到业务方提需求——“把昨天所有订单里卖得最好的商品Top 10统计出来”“查一下某个商品出现在哪些订单里”——问题就来了:你没办法在SQL里直接对一段JSON数组字符串做分组、过滤、关联。
这种字段在真实环境里非常常见,来源基本是三类:
- 接口回调数据落库。第三方系统返回的报文是一个JSON数组,接收程序没有解析,直接把body原样存了。
- 老表设计遗留。早期为了“灵活”,把一对多关系塞进一个字段,没有做规范的明细表。比如一张订单表里存了
product_list,里面是商品ID数组。 - 跨库迁移残留。从MySQL、SQL Server、Oracle等系统迁到openGauss时,原字段本身就是JSON/TEXT,迁完仍然是字符串,没有顺手拆开。
这类数据有个共同点:表面上是一行字符串,实际上里面装着一个“表”。SQL里的等于、IN、LIKE都没有办法直接作用于数组内部的对象和属性。要做统计、做关联,先得把它从“一行”拆成“多行多列”,让它重新变回一张能参与JOIN、能走索引的普通表结构。
可以这么理解:products_json 字段好比一辆快递车的车牌号,你要找的是车里的某一个包裹,只看车牌是找不到的,必须先把车卸了,把所有快递铺在桌上,才能按单号去匹配。拆JSON数组字符串,干的就是“卸货”这件事。
为什么要单独聊openGauss呢?因为openGauss虽然是PostgreSQL生态,但它对JSON的处理能力和原生PostgreSQL并不是完全一致的。网上搜到的PG写法,直接搬到openGauss上报函数不存在的情况,我遇到过不少次。所以这篇就针对openGauss,从函数能力、拆解SQL、关联写法到踩坑点,一条线讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. openGauss的JSON函数家底:能用什么,不能用什么
openGauss的JSON支持,总体来说是一个“够用但不全”的状态。它继承了PostgreSQL的一部分JSON函数和操作符,但并不是把PG所有JSON功能都搬了过来,尤其是 jsonb 相关的函数,在不少版本里是残缺的。在写SQL之前,你最好先摸一下自己环境里的函数支持情况,别等到上线了才发现函数根本不存在。
下面这几个是我在openGauss 3.x、5.x上实测过可用的常用功能:
| 函数/操作符 | 作用 | 返回类型 | 备注 |
|---|---|---|---|
json_array_elements(json) |
展开JSON数组为多行 | setof json | 数组每个元素一行,元素保留json格式 |
json_array_elements_text(json) |
展开JSON数组为多行 | setof text | 元素直接转成text,适合纯字符串数组 |
json_each(json) |
把JSON对象按key/value拆成多行 | setof record(key text, value json) | 适合拆对象 |
json_object_keys(json) |
返回对象所有key | setof text | 常用在动态取字段名 |
json_to_record(json) |
把单个JSON对象映射到一行多列 | record | 需在AS里定义列名和类型 |
json_to_recordset(json) |
把JSON数组映射到多行多列 | setof record | 数组里每个对象一行 |
-> |
按key/下标取子JSON | json | 例如 data->'name' |
->> |
按key/下标取子JSON的文本值 | text | 例如 data->>'name' |
#> |
按路径取子JSON | json | 例如 data #> '{a,b}' |
#>> |
按路径取子JSON的文本值 | text | 同上,返回text |
这些已经覆盖了日常拆解JSON数组字符串的大部分需求,尤其是 json_array_elements 和 json_to_recordset,是今天的主角。
相对地,有几个在PG新版本里很好用但openGauss不一定有的:
jsonb_path_query、jsonb_path_exists这类SQL/JSON路径函数,在大部分openGauss版本上不可用,别在代码里写。jsonb类型虽然openGauss 3.0以后有,但jsonb上做GIN索引的能力和PG比差得多。如果你要在JSON字段上做高性能检索,别指望像PG那样建个GIN索引就万事大吉。json_array_elements在部分版本里只接受json类型,不接受jsonb。所以如果你字段是varchar,记得先::json转一下。
有一回我同事直接照搬PG博客里的写法,写了 jsonb_array_elements('...'::jsonb),在openGauss上报“function jsonb_array_elements(jsonb) does not exist”,查了半天才发现是函数差异。所以稳妥起见,在openGauss上处理JSON数组,优先用 json 类型和对应函数,不要默认jsonb可用。
另外,一个很实际的小技巧:不确定函数在当前环境是否存在,直接执行 select * from pg_proc where proname = 'json_array_elements';,能看到结果就说明可用。这比翻文档快得多。
3. 从字符串到多行多列:三种可落地的SQL方案
假设你有一张表 biz_orders,字段 products_json 存的是JSON数组字符串,内容大概是:
json复制[
{"product_id": "P001", "product_name": "无线鼠标", "price": 129.5, "qty": 2},
{"product_id": "P002", "product_name": "机械键盘", "price": 399.0, "qty": 1}
]
你的目标是把这两条数组元素拆出来,变成两行,每行有 product_id、product_name、price、qty 四列。下面三种方法按推荐程度排列,你可以根据实际情况选。
3.1 基础拆行:json_array_elements + 字段提取
这是最通用、最直观的写法:先 json_array_elements 把数组展开成多行,每一行是一个JSON对象,然后对这个对象用 ->> 提取字段值。
sql复制SELECT
item->>'product_id' AS product_id,
item->>'product_name' AS product_name,
(item->>'price')::numeric AS price,
(item->>'qty')::int AS qty
FROM (
SELECT json_array_elements(
'[{"product_id":"P001","product_name":"无线鼠标","price":129.5,"qty":2},
{"product_id":"P002","product_name":"机械键盘","price":399.0,"qty":1}]'::json
) AS item
) t;
执行结果:
| product_id | product_name | price | qty |
|---|---|---|---|
| P001 | 无线鼠标 | 129.5 | 2 |
| P002 | 机械键盘 | 399.0 | 1 |
这里有个细节:->> 返回的一定是 text,所以如果字段在JSON里是数字,你要参与运算或者关联的时候,记得显式转类型。price 转了 numeric,qty 转了 int。不转的话,它是文本,后续排序、求和、JOIN都可能出问题。
如果字段本身是表里的varchar,不是字面量,写法其实一样,把字面量换成列名即可:
sql复制SELECT
item->>'product_id' AS product_id
FROM biz_orders,
json_array_elements(products_json::json) AS item;
注意 products_json::json 这个转换是必须的。varchar字符串本身没有JSON操作符,转成 json 之后才能用 ->> 和 json_array_elements。
3.2 一步到位:json_to_recordset 定义列结构
如果数组里每个对象的字段结构一致,json_to_recordset 是效率最高也最省事的方案。它直接把JSON数组映射成一张多行多列的表,字段类型在 AS 里定义:
sql复制SELECT *
FROM json_to_recordset(
'[
{"product_id":"P001","product_name":"无线鼠标","price":129.5,"qty":2},
{"product_id":"P002","product_name":"机械键盘","price":399.0,"qty":1}
]'::json
) AS x(product_id text, product_name text, price numeric, qty int);
结果和方法一完全一样,但代码短了很多,而且列类型明确。遇到字段不存在的情况,结果是NULL而不是报错,这对处理“有的记录有某个字段、有的没有”这种数据非常友好。
用这个函数要记住两个规矩:
AS后面的别名不能省,x就是结果表的别名。- 括号里列定义必须写全,列名和类型都不能少。因为
json_to_recordset返回的是record类型,SQL引擎需要靠AS子句里的定义才知道怎么解释结果。如果列定义写少了,查询直接报“structure is not compatible with the expected”之类的错。
表里真实字段的写法:
sql复制SELECT x.*
FROM biz_orders o
CROSS JOIN LATERAL json_to_recordset(
o.products_json::json
) AS x(product_id text, product_name text, price numeric, qty int);
CROSS JOIN LATERAL 的意思是:对 biz_orders 的每一行,都执行一次 json_to_recordset 展开。这是标准写法,后面关联场景会大量用到。
3.3 纯字符串数组:json_array_elements_text
有时候JSON数组里的元素不是对象,就是纯字符串,比如:
json复制["P001", "P002", "P003"]
这种场景不需要拆多列,只要拆多行。直接用 json_array_elements_text:
sql复制SELECT json_array_elements_text('["P001","P002","P003"]'::json);
返回一行一个字符串:
code复制json_array_elements_text
------------------------
P001
P002
P003
这个方法很适合做“某个字段的值在数组里”的过滤和关联。比如查哪些订单包含P001商品,直接配合EXISTS写:
sql复制SELECT order_no
FROM biz_orders
WHERE 'P001' IN (
SELECT json_array_elements_text(products_json::json)
);
这种写法一眼能看懂,但要注意性能:每行的JSON数组都会被重新解析一遍,表大时要谨慎。
3.4 兼容性兜底:字符串函数手工拆分
这个方法我不太推荐,但它能在JSON函数彻底不可用的极端环境里救命。思路是用 regexp_split_to_table 按逗号拆字符串,再用 replace 清理大括号和引号:
sql复制SELECT
btrim(
btrim(elem, '"'),
'{'
) AS item
FROM regexp_split_to_table(
'[{"product_id":"P001"},{"product_id":"P002"}]',
'},{'
) AS elem;
实际效果并不好,JSON内部如果有嵌套对象、数组、comma嵌套在字符串里,都会拆错。所以这方法只适合两条路:一是JSON格式非常规整、结构简单,二是当前环境连 json_array_elements 都没有。正常情况下,还是优先把JSON函数用起来。如果担心字段里有非法JSON导致解析报错,正确的做法是提前过滤清洗,而不是绕开函数。
三种方法放一起比较:
| 方法 | 适用场景 | 格式要求 | 性能 | 推荐度 |
|---|---|---|---|---|
json_array_elements + ->> |
数组元素是对象,需要提取多个字段 | JSON格式合法 | 中 | 高 |
json_to_recordset |
数组元素是对象,字段结构一致 | JSON格式合法 | 中 | 最高 |
json_array_elements_text |
数组元素是纯字符串 | JSON格式合法 | 中 | 高 |
| 字符串函数拆分 | JSON函数不可用、格式极简单 | 格式严格规整 | 低,易错 | 极低 |
4. 拆完如何参与关联:JOIN写法、类型陷阱和执行计划验证
拆行拆列只是第一步,真正的核心需求是拆出来的多列要参与关联条件。这一节用三个常见场景说明,每个都附带完整SQL。
4.1 场景1:数组里的对象要和主表JOIN
这是最常见的情况:订单表 biz_orders 里有 products_json,里面每个对象有 product_id;商品主表 products 有 product_id、product_name、category。现在要查出每笔订单的每个商品名称和分类。
最清晰的写法是先用CTE把JSON拆成明细,再JOIN商品表:
sql复制WITH order_items AS (
SELECT
o.id AS order_id,
o.order_no AS order_no,
item->>'product_id' AS product_id,
item->>'product_name' AS product_name
FROM biz_orders o
CROSS JOIN LATERAL json_array_elements(o.products_json::json) AS item
)
SELECT
oi.order_no,
p.product_name,
p.category
FROM order_items oi
LEFT JOIN products p
ON p.product_id = oi.product_id
ORDER BY oi.order_no;
这里有几个容易踩的点:
- JOIN条件的类型要一致。
product_id在主表里如果定义成varchar,那item->>'product_id'返回的text和varchar能直接匹配,没问题。但如果你在主表里用的是int,就必须写(item->>'product_id')::int,否则要么报错,要么因为隐式转换导致索引失效。规则很简单:解析出来的text,要和JOIN字段保持同一类型,不一致就显式转。 - 别在ON条件里再解析JSON。下面这种写法虽然能跑,但会让执行计划变得很不可控:
sql复制-- 不推荐的写法
SELECT ...
FROM biz_orders o
LEFT JOIN products p
ON p.product_id = (json_array_elements(o.products_json::json))->>'product_id'
json_array_elements 是集合返回函数,放在ON里语义不清晰,优化器很难做索引扫描。先拆成CTE或子查询再JOIN,执行计划和可读性都好得多。
4.2 场景2:数组里的ID集合做IN/EXISTS判断
业务方经常问:“哪些订单里包含商品P001?”如果不想把所有明细都拆出来聚合,用EXISTS是最经济的写法:
sql复制SELECT order_no
FROM biz_orders o
WHERE EXISTS (
SELECT 1
FROM json_array_elements(o.products_json::json) AS item
WHERE item->>'product_id' = 'P001'
);
这个写法好在不需要把全部订单的数组都展开成结果集,只需要逐行判断。符合条件就返回TRUE,提前结束。如果业务里“判断某个值是否在JSON数组内”是个高频需求,可以把数组里的ID提前拆到一个中间表,然后走普通索引。
用IN的写法也可以,效果类似:
sql复制SELECT order_no
FROM biz_orders
WHERE 'P001' IN (
SELECT item->>'product_id'
FROM json_array_elements(products_json::json) AS item
);
实测下来EXISTS在JSON数组这种场景下通常更快,因为IN会把子查询结果物化一遍,而EXISTS是流式的。差别在数据量大时能明显感觉到。
4.3 场景3:多列关联条件要同时匹配
有的数组对象里不止一个关联键,比如:
json复制[
{"supplier_id": 101, "warehouse_id": 3, "qty": 5}
]
要JOIN仓库商品表,匹配条件是 supplier_id 和 warehouse_id 都要对上。这种情况在CTE里把两个字段都拆出来,JOIN时写多个条件即可:
sql复制WITH parsed AS (
SELECT
o.id AS order_id,
(item->>'supplier_id')::int AS supplier_id,
(item->>'warehouse_id')::int AS warehouse_id
FROM biz_orders o
CROSS JOIN LATERAL json_array_elements(o.products_json::json) AS item
)
SELECT
p.warehouse_name,
p.supplier_name
FROM parsed pr
JOIN warehouse_supplier p
ON p.supplier_id = pr.supplier_id
AND p.warehouse_id = pr.warehouse_id;
多列JOIN本身没有额外魔法,重点是CTE里就把类型转对。比如JSON里 supplier_id 是数字,在 json_to_recordset 方案里可以定义成int,在 ->> 方案里必须手动 ::int。类型不对,JOIN条件就会出问题或者全表扫。
4.4 执行计划验证:索引有没有失效
无论哪种写法,上线前我都建议跑一遍 EXPLAIN ANALYZE,重点看JOIN部分是不是走了索引:
sql复制EXPLAIN ANALYZE
WITH order_items AS (
SELECT o.id AS order_id, item->>'product_id' AS product_id
FROM biz_orders o
CROSS JOIN LATERAL json_array_elements(o.products_json::json) AS item
)
SELECT oi.order_id, p.product_name
FROM order_items oi
LEFT JOIN products p ON p.product_id = oi.product_id;
如果 products 表的 product_id 上有索引,解析后的 product_id 类型又是 text,而主表字段是 varchar,openGauss一般能匹配索引;但如果主表字段是 int 而你忘了转,执行计划里会出现很显眼的“Seq Scan on products”,索引白建了。
JSON数组拆出来之后,数据量可能被放大好几倍。比如10万笔订单,每笔平均3个商品,拆完就是30万行。这30万行本身不是一个物理表,在执行计划里是Subquery Scan,JOIN时优化器可能会选择hash join,也可能选nestloop。数据量到了一定规模,我倾向于把拆好的结果先落成一个临时表或者物化视图,再在上面做关联,这样索引、统计信息都齐全,查询更可控。
5. 避坑清单:连接报错、number超范围、非法JSON和嵌套数组
最后这部分,我把实际项目中碰到过的问题集中列一下,每一个都是真实踩过的。
5.1 开发工具连接报错,别赖SQL
写JSON解析SQL时,有同事用Navicat连接openGauss报“server closed the connection unexpectedly”,第一反应以为是SQL写坏了。其实这个报错绝大多数和SQL无关,是连接层的问题。openGauss的默认认证、密码加密方式和原生PG有差异,而部分图形工具对openGauss的兼容做得并不到位。我从实践里总结的处理顺序是:
- 先用官方命令行工具
gsql执行同一条SQL,确认SQL本身没问题。 - 排查连接参数:数据库版本、驱动版本、加密方式是否匹配。
- 不要在同一个连接上长时间反复执行大JSON拆解,连接池超时也容易触发这个报错。
工具连不上,和SQL没有关系。先把SQL放到 gsql 里验证,能少走很多弯路。
5.2 JSON里的number超范围,精度会丢
OpenGauss的JSON解析层对数字的处理,有一个很容易被忽略的问题:如果JSON里的数字超过 int8 范围,比如常见的18位、19位分布式ID,用某些客户端工具直接看可能显示成科学计数法,或者经过驱动转类型时被截断。更隐蔽的是,如果用 -> 提取它,返回的是json类型,未来再做类型转换时可能出问题。
解决思路是用 ->> 提取文本,从根上保留原始字符串:
sql复制SELECT
item->>'order_id' AS order_id
FROM json_array_elements(
'[{"order_id":73294823748923478923}]'::json
) AS item;
->> 返回text,不会丢精度。如果这个字段要做关联,关联键在表里也保持varchar,两边都是text/varchar,就不会触发类型转换问题。记住一条:JSON里的大整数,能不转int尽量不转int,用text把原值留住。
5.3 空值、空字符串、非法JSON的防御
这是JSON拆解里最烦人的问题。字段为NULL、字段是空字符串、字段里是“半个”JSON——任何一种情况,直接 products_json::json 都会让查询报错,并中断整条SQL。
防御的常见做法是解析前先过滤掉明显非法的数据:
sql复制WITH filtered AS (
SELECT id, products_json
FROM biz_orders
WHERE products_json IS NOT NULL
AND btrim(products_json) <> ''
AND left(btrim(products_json), 1) = '['
)
SELECT ...
FROM filtered f
CROSS JOIN LATERAL json_array_elements(f.products_json::json) AS item;
但这里有个残酷的现实:left(...) = '[' 只能挡掉一部分脏数据,{"a": 1 这种结构残缺的JSON仍然会触发解析报错。最可靠的方式是在ETL或应用层把数据清洗好,而不是在查询SQL里反复做防御。如果脏数据无法避免,又想保住SQL不中断,那就得写存储过程,用 EXCEPTION WHEN OTHERS 捕获解析失败的行。这个方案虽然有效,但我一般不建议在核心查询路径上引入,性能和复杂度都不划算。
5.4 JSON数组嵌套一层:父数组套子数组
业务数据结构经常是“订单里有商品,商品里又有批次”这种两层嵌套。处理方法是连续用 LATERAL 展开:
sql复制SELECT
parent->>'sku' AS sku,
child->>'batch_no' AS batch_no
FROM (
SELECT json_array_elements(
'[
{"sku":"A001","batches":[{"batch_no":"B01"},{"batch_no":"B02"}]},
{"sku":"A002","batches":[{"batch_no":"C01"}]}
]'::json
) AS parent
) t
CROSS JOIN LATERAL json_array_elements(parent->'batches') AS child;
结果:
| sku | batch_no |
|---|---|
| A001 | B01 |
| A001 | B02 |
| A002 | C01 |
注意 parent->'batches' 返回的是json,可以直接喂给 json_array_elements。下一层的字段继续用 ->> 取。嵌套层级再多,逻辑也还是这个套路,一层一层横向展开。
5.5 国产化环境下的运行观察
顺带提一句,如果你部署在麒麟V10这类国产化操作系统上,openGauss的JSON函数在x86和ARM架构下的行为是一致的,但相同数据量下ARM上解析大JSON数组会慢一些。遇到性能瓶颈,不要光想着调整SQL,先确认CPU架构、内存分配和数据库参数是否匹配。JSON拆解本身是计算密集型操作,给足内存会明显改善。
在实际项目中,我的习惯是:如果JSON数组需要频繁参与关联和统计,就不要每次都现拆,而是用定时任务或物化视图,把拆好的明细落成一张物理表,再在这张表上建索引、做JOIN。等哪天数据链路稳定了,最理想的状态是源头直接改成明细表加JSON字段并存,JSON只做存档展示,明细表负责分析和关联。这套思路在openGauss上实践了好几个项目,效果都挺稳的,算是处理这类问题最省心的方式。
