API 传输数据时 fastjson 循环引用检测($ref)会作怪
一、背景
在HistoryPriceHandler中,需要根据一批crmId构造出多个HistoryPriceQuery,再调用wso2Utils.getHistoryPrice(...)发送给 WSO2 历史价格接口。
构造逻辑大致是:
List<HistoryPriceQuery>priceQuery=HistoryPriceQuery.build(crmIdList,query);List<ApimHistoryPriceResponse>priceList=wso2Utils.getHistoryPrice(priceQuery);每个HistoryPriceQuery内部都持有一份mtm(List<CpmMtm>)。
二、优化引发的问题
出于「节约内存」的想法,我把build改成了共享同一份 mtm 列表:所有HistoryPriceQuery的mtm字段都指向同一个 List 对象(因为每个 query 的 mtm 内容本来就完全一样)。
// 共享版:mtm 只克隆一次,所有 query 共用同一个引用List<CpmMtm>sharedMtmList=buildMtmList(query.getMtm());for(...){metaQuery.setMtm(sharedMtmList);// 同一个对象!}结果出现了诡异现象:
- 原来的独立克隆版本:
getHistoryPrice调用成功。 - 共享 mtm 的新版本:
getHistoryPrice调用失败。
而更迷惑的是——两个版本用System.out.println(JSON.toJSON(...))打印出来的 JSON看起来一模一样。
三、根因:fastjson 的循环引用检测($ref)
3.1 什么是 $ref
fastjson 在序列化时会记录已经序列化过的对象。如果同一个对象引用在结构里出现第二次,它不会再展开,而是写成一个引用标记:
{"$ref":"$[0].mtm"}$ref路径语法:
$:根对象$[0]:根数组第 0 个元素$[0].mtm:第 0 个元素的 mtm 字段
它的本意是防止循环引用导致的无限递归(StackOverflowError),例如父子对象互相持有的场景。
3.2 判定依据是「引用相同」而非「内容相同」
关键点:fastjson 用的是**对象身份(==,同一个内存地址)**来判重,类似IdentityHashMap,不是用equals()比内容。
| 情况 | 是否同一个引用 | 会不会 $ref |
|---|---|---|
| 同一个 List 被多个字段引用(共享 mtm) | 是(==成立) | 会 |
| 两个不同对象,内容一模一样(独立克隆) | 否(==不成立) | 不会 |
推论:
- 同一个对象引用 → 内容一定相同(成立,因为就是同一个对象)。
- 内容相同 → 是同一个对象(不成立,可能是内容巧合相同的独立对象)。
所以「内容相同但不是同一个实例」不会触发$ref,不会出问题。
3.3 为什么这次会踩坑
共享 mtm 后,多个HistoryPriceQuery的mtm指向同一个 List 实例。fastjson 序列化时:
[{"key":"0","mtm":[{"material":"82H803GJJP","key":"0"}]},// 第一次,完整展开{"key":"1","mtm":{"$ref":"$[0].mtm"}},// 第二次,变成 $ref{"key":"2","mtm":{"$ref":"$[0].mtm"}}// 第三次,变成 $ref]WSO2 接口不认识$ref(它是 fastjson 私有语法,不是标准 JSON 语义),解析畸形报文失败 → 请求报错。
独立克隆版本里每个 mtm 都是不同对象,没有重复引用,不会出现$ref,报文干净 → 成功。
四、为什么「打印一样、发送却失败」
这是最迷惑的一点,原因在于两个 API 的差异:
JSON.toJSON(obj):先把对象转成一棵全新的 JSONObject/JSONArray 树,转换过程把共享引用摊平成各自独立的节点,再toString。此时已经没有「同一对象」了,所以打印结果正常、看不出$ref。JSON.toJSONString(obj):直接序列化原始对象,共享引用仍然存在 → 触发$ref。
getHistoryPrice内部发请求时用的是toJSONString(或等价的直接序列化),所以才会带$ref。
结论:判断有没有$ref,一定要看toJSONString的结果,不能用toJSON。
五、如何检测是否触发了 $ref
方法 1:直接打印 toJSONString
System.out.println(JSON.toJSONString(priceQuery));输出里出现{"$ref":"..."}即触发。
方法 2:字符串搜 $ref
Stringjson=JSON.toJSONString(priceQuery);booleanhasRef=json.contains("$ref");System.out.println("包含 $ref = "+hasRef);方法 3:对比开/关检测的结果
importcom.alibaba.fastjson.serializer.SerializerFeature;StringwithDetect=JSON.toJSONString(priceQuery);// 默认开检测,可能有 $refStringwithoutDetect=JSON.toJSONString(priceQuery,SerializerFeature.DisableCircularReferenceDetect);// 关检测,全展开System.out.println("两者是否不同 = "+!withDetect.equals(withoutDetect));两者不同即说明默认序列化里存在$ref。
六、解决方案
| 方案 | 说明 | 建议 |
|---|---|---|
| 独立克隆,不共享引用 | 每个对象各自持有独立实例,从源头避免重复引用 | ✅ 推荐 |
| 关闭引用检测 | JSON.toJSONString(obj, SerializerFeature.DisableCircularReferenceDetect),需改到每个序列化点,易漏 | ⚠️ 谨慎 |
| 全局关闭 | 影响面大 | ❌ 不建议 |
本次采用独立克隆:虽然多占一点内存,但对外传输数据正确、稳定,不用碰序列化配置,是最省心的选择。
七、经验总结
- 对外传输(尤其发给第三方接口)的对象,避免多个字段/元素共享同一个可序列化对象实例,否则 fastjson 会用
$ref表示重复引用,导致对端解析失败。 $ref只认==(同一实例),不认equals(内容相同)。内容相同但不同实例是安全的。- 排查时用
toJSONString看真实报文,别用toJSON,后者会把引用摊平,掩盖问题。 - 「省内存的共享」和「对外序列化的安全」有时是冲突的,涉及序列化传输的场景优先保证隔离性(独立实例)。