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() 比内容。
| 同一个 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
String json = JSON.toJSONString(priceQuery);
boolean hasRef = json.contains("$ref");
System.out.println("包含 $ref = " + hasRef);
方法 3:对比开/关检测的结果
import com.alibaba.fastjson.serializer.SerializerFeature;
String withDetect = JSON.toJSONString(priceQuery); // 默认开检测,可能有 $ref
String withoutDetect = JSON.toJSONString(priceQuery,
SerializerFeature.DisableCircularReferenceDetect); // 关检测,全展开
System.out.println("两者是否不同 = " + !withDetect.equals(withoutDetect));
两者不同即说明默认序列化里存在 $ref。
六、解决方案
| 独立克隆,不共享引用 | 每个对象各自持有独立实例,从源头避免重复引用 | ✅ 推荐 |
| 关闭引用检测 | JSON.toJSONString(obj, SerializerFeature.DisableCircularReferenceDetect),需改到每个序列化点,易漏 | ⚠️ 谨慎 |
| 全局关闭 | 影响面大 | ❌ 不建议 |
本次采用独立克隆:虽然多占一点内存,但对外传输数据正确、稳定,不用碰序列化配置,是最省心的选择。



