- Published on
Flink TableFunction 解析嵌套 JSON 数组:从“UNNEST 报错”到“STRING 内层解析”
- Authors

- Name
- Charles Chen
实时链路里经常遇到这种消息:一个字段里塞着一整串 JSON 数组,要拆成明细行。但难点往往不在“会不会写 UNNEST”,而在把这一层的数据类型认错了。
本文记一次真实翻车:外层 data 是货真价实的 ARRAY<ROW<…>>,可每个 row 里那个装子记录的字段,其实是一个 STRING,里面塞着带转义的 JSON 数组字符串——不是原生数组。把它当数组去 CROSS JOIN UNNEST,source 端直接反序列化报错、作业起不来。最后靠“一层 UNNEST + 一层 TableFunction”才理顺,并且让一个 UDF 类同时吃下三个 Schema 略有不同的 Topic。
1. 先看清结构:外层是数组,内层是“字符串包数组”
先别急着写 SQL,把消息体的层级画出来:
消息体 (JSON object)
├─ data : ARRAY<ROW< … > > ← 外层是真数组,需要 UNNEST 一次
│ └─ 每个 ROW 有二十余列,其中一列是:
│ account_detail : STRING ← 关键:是“字符串”,里面塞着转义的 JSON 数组
├─ old : ARRAY<ROW< … > > (旧值,本场景可忽略)
├─ type : STRING ("UPDATE")
├─ database : STRING
└─ ts : BIGINT
而 account_detail 这个 STRING 里存的东西,展开长这样(注意那些转义引号 \"):
[ { "field1": "...", "field2": "..." },
{ "field1": "..." },
... ] ← 这是“被当作字符串存的 JSON 数组”
判断它到底是“数组”还是“字符串包的数组”,就看 JSON 里那个字段的值:
- 是原生数组 → 值形如
[{...}](无外层引号、无转义); - 是字符串包的数组 → 值形如
"[{\\"...\\"]"(整体带引号、内部\"转义)。
这是整件事的根:内层是 STRING,不是 ARRAY。类型系统的 UNNEST 根本看不到“字符串里藏着的数组”,它只认到 STRING 为止。
顺带说下外层还有几个容易踩的点:old、pkNames 这类字段源里有、但下游用不到,默认 JSON 反序列化会忽略未知字段,不影响;但 database、table 这种词在 Flink 里是保留字,DDL 里要加反引号或干脆不声明。
2. 一个坑:为什么不能把内层当 ARRAY<ROW> 去 UNNEST
最初的写法是这样,想“两层都 UNNEST”:
-- 把 account_detail 声明成 ARRAY<ROW<...>>,然后两层 UNNEST
SELECT u.uid, b.field1, b.field2
FROM user_source AS S
CROSS JOIN UNNEST(S.data) AS u -- 摊开外层
CROSS JOIN UNNEST(u.account_detail) AS b -- 想摊开内层
两条路都走不通,分别对应两种误解。
2.1 误以为内层是数组:把它声明成 ARRAY<ROW<…>>
如果信“这看起来就是个数组”,在 DDL 里把 account_detail 写成 ARRAY<ROW<…>>,再接 CROSS JOIN UNNEST。结果 source 端 JSON 反序列化器一碰就挂,整条消息解析失败,报的是:
org.apache.flink.formats.json.JsonParseException: Fail to deserialize at field: data.
at org.apache.flink.formats.json.JsonParserToRowDataConverters
.lambda$createRowConverter$43b82837$1(...:419)
at org.apache.flink.formats.json.JsonParserToRowDataConverters
.lambda$wrapIntoNullableConverter$ca96cb8f$1(...:463)
at org.apache.flink.formats.json.JsonParserRowDataDeserializationSchema
.deserialize(...:91)
...
Caused by: java.io.IOException: Failed to deserialize JSON
'{
"data": [ { "uid": <int>, ... , "account_detail": "[]", ... } ],
"old" : [ { ... } ],
"type" : "UPDATE",
"database": "<db>",
"table" : "<table>",
"ts" : <long>,
"pkNames": [ "<pk>" ]
}'
at org.apache.flink.formats.json.JsonParserRowDataDeserializationSchema
.deserialize(...:97)
at org.apache.flink.streaming.connectors.kafka.table
.DynamicKafkaDeserializationSchema.deserialize(...:113)
...
Caused by: org.apache.flink.formats.json.JsonParseException:
Fail to deserialize at field: data.
两个要点:
- 报错指向
data,不是account_detail。因为account_detail嵌在data的 row 结构里,内层解析失败会冒泡到最外层的ARRAY<ROW>反序列化节点,于是堆栈报Fail to deserialize at field: data。看堆栈别只盯着最外层字段名,要顺着“是哪一层类型不匹配”往下查。 - 本质是类型不匹配:DDL 说这个字段是
ARRAY<ROW<…>>,JSON 里它的值却是一个字符串("[]"或一长串带\"的转义文本)。反序列化器拿着“期望 array、实际 string”去转,直接失败。这不是数据错、是类型声明错了。
这类错误最坑在:它是 source 端就挂,不是运行期脏数据触发,而是“每条消息都反序列化不过”,作业直接起不来或消费就崩。
2.2 改对了类型,却又去 UNNEST 一个 STRING
顺着报错把 account_detail 改成 STRING 是对的。但若还想 CROSS JOIN UNNEST(u.account_detail),又撞墙:
UNNEST requires a MULTISET / ARRAY type, but got STRING
UNNEST的操作数必须是 ARRAY 或 MULTISET,对 STRING 调用是类型错误,作业起不来。- 原因还是上面那句:字符串里“藏”的数组对类型系统不可见,UNNEST 无从展开。
2.3 正解:外层 UNNEST 一次 + 内层交给 TableFunction
- 外层
data是真正的数组 →CROSS JOIN UNNEST(S.data)摊开一次; - 内层
account_detail是 STRING → 交给一个TableFunction解析,再用LEFT JOIN LATERAL TABLE展开成多行。
一句话:外层用 UNNEST,内层(字符串包数组)用表函数。
3. 解决思路 — 为什么内层选 TableFunction
内层这串“字符串包数组”要展开,手里有几种工具,对号入座:
UNNEST:只拆原生ARRAY/MULTISET。内层是 STRING,不认。- 标量 UDF(
ScalarFunction):一进一出,没法“一条输入吐多条输出”。 TableFunction<Row>+LATERAL TABLE(...):天生一对多,配合LATERAL TABLE还能把左侧列和右侧展开列拼成一行,正好匹配 JSON 数组语义。
选 TableFunction 的关键收益:解析逻辑写在 Java 里一次到位,比在 SQL 里堆嵌套 JSON_VALUE/CAST/UNNEST 可读、可测、可复用;而且天然支持一对多。
4. 这个 UDF 到底解决了什么
4.1 一对多展开 + 保留“无任何绑卡”的用户
LEFT JOIN LATERAL TABLE(fn(account_detail)) b ON TRUE 的语义:即便某个用户一条子记录都没有(内层是 "[]" 或空串),左侧那行也要保留,右侧各列填 null。这是“保留无子记录主行”的刚需——用内连接会把这部分用户直接丢掉。
4.2 一个类,三个 Schema 通吃
接入的不是一个 Topic,而是三个,它们的内层数组 Schema 略有差异(字段名、有没有某几列、外层有无引号各不相同)。不写三个类,写一个,诀窍是永远按键取值、缺失即 null:
- 字段缺失 → 取值为
null,不报错; - 某字段在 A 里叫
x、在 B/C 里叫x2:取x取不到就回退到x2,统一落到同一列; - B/C 没有的字段,取值自然得
null。
这样三套 Schema 在同一个 eval 里被抹平成同一套输出列,三个 Topic 之后直接 UNION ALL,下游零改动。
4.3 脏数据兼容
内层这串经常不是“干净 JSON”:外面裹单/双引号(' [...] ' / "\"[...\"\\"")——先剥壳;空串、"[]"、字面量 "null"——一行都不产出;外层被二次转义成字符串("[...]")——解析到 isTextual() 再 readTree 一层。
5. 代码实现
5.1 源表与展开
先把 data 声明成 ARRAY<ROW<…>>,其中装子记录的字段必须是 STRING(其余二十余通用属性此处省略):
CREATE TABLE user_sensitive_source (
data ARRAY<ROW<
uid BIGINT,
/* ... 其余通用属性省略 ... */
account_detail STRING -- 关键:内层 JSON 数组以“字符串”形式存放
/* ... 其余属性省略 ... */
>>,
type STRING,
ts BIGINT,
proctime AS PROCTIME()
) WITH (
'connector' = 'kafka',
'topic' = 'user_sensitive_topic',
-- ${kafka_bootstrap} / 凭据等环境相关配置用变量注入,不写死
'scan.startup.mode' = 'latest-offset',
'properties.auto.offset.reset' = 'earliest',
'format' = 'json'
);
-- 第一层:UNNEST 把外层 ARRAY<ROW> 摊成“一行一个用户”
CREATE TEMPORARY VIEW v_user AS
SELECT u.uid, u.account_detail
FROM user_sensitive_source AS S
CROSS JOIN UNNEST(S.data) AS u
WHERE u.uid IS NOT NULL;
-- 第二层:内层是 STRING,交给表函数展开;LEFT JOIN 保留“无子记录”用户
CREATE TEMPORARY VIEW v_user_unnest AS
SELECT
u.uid,
b.field1, b.field2, b.field3
FROM v_user AS u
LEFT JOIN LATERAL TABLE(parse_detail(u.account_detail)) b ON TRUE
WHERE u.uid IS NOT NULL;
对比 2.1 里“两层都 UNNEST”的错法,差别就在第二层:内层从 CROSS JOIN UNNEST(u.account_detail) 换成 LEFT JOIN LATERAL TABLE(parse_detail(u.account_detail))。
5.2 表函数本体
把内层解析写成 UDF(字段名已替换为 field1/field2/field3,仅保留机制)。核心就是:外层 UNNEST 负责摊开“真数组”,表函数负责展开“字符串包数组”。
package com.fb.udf;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.FunctionHint;
import org.apache.flink.table.functions.TableFunction;
import org.apache.flink.types.Row;
// 三个 Topic / 三套 Schema 共用这一个类:
// 永远“按键取值、缺失即 null”;别名做 fallback 统一到同一列;
// 输出固定 N 列,方便多 Topic 之后直接 UNION ALL。
@FunctionHint(
input = @DataTypeHint("STRING"),
output = @DataTypeHint(
"ROW<field1 STRING, field2 STRING, field3 STRING>"
)
)
public class AccountDetailParserYH extends TableFunction<Row> {
private static final long serialVersionUID = 1L;
private static final ObjectMapper MAPPER = new ObjectMapper();
public void eval(String detail) {
if (detail == null) {
return;
}
String value = unquote(detail.trim());
// 空串 / 空数组 / 字面量 null:一行都不产出
if (value.isEmpty()
|| "[]".equals(value)
|| "null".equalsIgnoreCase(value)) {
return;
}
try {
JsonNode root = MAPPER.readTree(value);
// 外层可能被二次转义成字符串,再解析一层
if (root.isTextual()) {
String inner = root.asText();
if (inner == null || inner.trim().isEmpty()) {
return;
}
root = MAPPER.readTree(inner);
}
if (!root.isArray()) {
throw new IllegalArgumentException(
"detail must be a JSON array, actual: " + value
);
}
for (JsonNode item : root) {
if (item == null || item.isNull()) {
continue;
}
if (!item.isObject()) {
throw new IllegalArgumentException(
"array element must be JSON object, actual: " + item
);
}
// 字段 A 缺失时回退到 B,落到同一列
String fallback = firstNonNull(
getAsString(item, "x"),
getAsString(item, "x2"));
Row row = new Row(3);
row.setField(0, getAsString(item, "field1"));
row.setField(1, getAsString(item, "field2"));
row.setField(2, getAsString(item, "field3"));
collect(row);
}
} catch (Exception e) {
throw new RuntimeException("Failed to parse detail JSON: " + value, e);
}
}
private static String firstNonNull(String a, String b) {
return a != null ? a : b;
}
// 剥掉首尾的单/双引号
private static String unquote(String s) {
if (s.length() >= 2
&& ((s.charAt(0) == '\'' && s.charAt(s.length() - 1) == '\'')
|| (s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"'))) {
s = s.substring(1, s.length() - 1);
}
return s;
}
private String getAsString(JsonNode node, String fieldName) {
JsonNode field = node.get(fieldName);
if (field == null || field.isNull()) {
return null;
}
// 容器(对象/数组)取 JSON 串,标量取文本
if (field.isContainerNode()) {
return field.toString();
}
return field.asText();
}
}
几个设计决策:
ObjectMapper用static final:它本身线程安全、构建昂贵,放成静态常量避免每条记录都 new 一个,GC 压力小得多。注意这里“读”是安全的;若哪天改成可配置的 schema 缓存,再评估ThreadLocal。- 缺失容错靠
getAsString:字段不存在或 JSON null 一律回null,配合下游COALESCE兜底,脏数据不会让作业挂。 - 异常带上下文:
catch里把原始value拼进异常信息,排障时一眼能定位是哪条消息。
5.3 部署
把类打成 jar(含 Flink 依赖,建议 shade 出 Jackson),注册:
ADD CUSTOMJAR 'rs:udf/account-detail-parser-1.0.jar';
CREATE TEMPORARY SYSTEM FUNCTION parse_detail
AS 'com.fb.udf.AccountDetailParserYH';
5.4 落库
下游 sink 用 COALESCE 把 null 兜成空串,主键约束 NOT ENFORCED(连接串、表名、账号用 ${...} 注入,不写死;调试期先 print 再切真实 sink):
CREATE TABLE dw_user_detail_sink (
uid VARCHAR(32),
field1 VARCHAR(128),
field2 VARCHAR(128),
field3 VARCHAR(128),
PRIMARY KEY (uid) NOT ENFORCED
) WITH (
-- 'connector' = 'print' -- 调试期先打 stdout
-- 生产切 jdbc:'connector'/'url'/'table-name'/'driver' 等用 ${...} 注入
'connector' = 'print'
);
INSERT INTO dw_user_detail_sink
SELECT
CAST(b.uid AS VARCHAR) AS uid,
COALESCE(b.field1, '') AS field1,
COALESCE(b.field2, '') AS field2,
COALESCE(b.field3, '') AS field3
FROM v_user_unnest b;
一个小细节:进主键的列别一律
COALESCE(...,'')兜成空串,否则“无数据行”会因主键相同互相覆盖;真进主键的列建议保留 null 或加哨兵值。
6. 这套写法的优点
- 数据类型先认对:外层
ARRAY<ROW>才用 UNNEST,内层“字符串包数组”才用表函数——这一步认错,后面全是坑。 - 一物多用的复用性:一个类吃三套 Schema,靠“按键取值 + 缺失回退”。新来一个 Topic,只要字段能映射到同一批列,不动 UDF,只加一条
SELECT ... UNION ALL。 - 解析逻辑下沉到 Java:比在 SQL 里堆
JSON_VALUE/CAST/UNNEST可读、可单测、可复用;脏数据容错全在eval里收口,SQL 侧保持干净。 - 一对多语义天然对齐:
TableFunction + LATERAL TABLE就是 JSON 数组展开的“母语”,LEFT JOIN还顺带保留空主键行。 - 环境无关:内网地址、凭据、topic 名全部
${...}注入,SQL 本身可进 Git 版本管理。
7. 未来架构优化
这套能跑,但还不是终点:
- 字段映射外置化:把“哪个源字段映射到哪个输出列、回退链是什么”做成外部配置(YAML/小表),UDF 启动时加载;“加一个 Topic / 加一个别名”就是改一行配置,连 UDF 都不用重打包。
- 强类型输出:当前输出全
STRING,下游还得再CAST;可按目标表类型生成精确的ROW<a INT, b TIMESTAMP, …>,让 Flink 在展开时就做类型转换。 - 坏数据隔离而非中断:现在异常向上抛——一条毒消息会让算子报错、触发重启。可选:坏行降级为“丢弃 + 打 metrics / 落死信 topic”,让主链路不被单条脏数据拖垮。
- 反序列化预热与对象池:
ObjectMapper静态化已做到;吞吐更高时可考虑JsonParserFactory复用、Row对象复用,进一步压 GC。 - 可观测性:给“解析成功 / 兜 null / 丢弃坏行 / 回退命中”分别打 counter + gauge,下游直接看到“有多少行是回退出来的”,比事后翻日志靠谱。
- UDF 版本管理:jar 带版本号进资源库,发布走评审;字段映射变更留 changelog。
8. 小结
外层是真数组、内层却是“字符串包 JSON 数组”,这种消息最容易栽在类型认错上:把内层当 ARRAY<ROW> 去 CROSS JOIN UNNEST,source 端 JSON 反序列化直接 Fail to deserialize at field: data,作业起不来;改成 STRING 又去 UNNEST,则撞“UNNEST 只认 MULTISET/ARRAY”的墙。
正解是分层处理:外层 UNNEST 摊开真数组,内层 LEFT JOIN LATERAL TABLE(表函数) 展开字符串包数组。而表函数的精髓又不在“会写”,在怎么用一个类吃下多套 Schema——永远按键取值、缺失即 null、别名回退、脏数据先剥壳。做到这一点,UDF 就从“一次性胶水”变成“可复用的解析组件”。
再往后:把字段映射外置、输出强类型化、坏数据降级而非中断,这条链路就从“能跑”走向“好维护、可观测、扛得住脏数据”。