API 传输数据 小心 fastjson 循环引用检测($ref)会作怪

发布时间:2026/7/18 18:17:27
API 传输数据 小心 fastjson 循环引用检测($ref)会作怪 API 传输数据时 fastjson 循环引用检测$ref会作怪一、背景在HistoryPriceHandler中需要根据一批crmId构造出多个HistoryPriceQuery再调用wso2Utils.getHistoryPrice(...)发送给 WSO2 历史价格接口。构造逻辑大致是ListHistoryPriceQuerypriceQueryHistoryPriceQuery.build(crmIdList,query);ListApimHistoryPriceResponsepriceListwso2Utils.getHistoryPrice(priceQuery);每个HistoryPriceQuery内部都持有一份mtmListCpmMtm。二、优化引发的问题出于「节约内存」的想法我把build改成了共享同一份 mtm 列表所有HistoryPriceQuery的mtm字段都指向同一个 List 对象因为每个 query 的 mtm 内容本来就完全一样。// 共享版mtm 只克隆一次所有 query 共用同一个引用ListCpmMtmsharedMtmListbuildMtmList(query.getMtm());for(...){metaQuery.setMtm(sharedMtmList);// 同一个对象}结果出现了诡异现象原来的独立克隆版本getHistoryPrice调用成功。共享 mtm 的新版本getHistoryPrice调用失败。而更迷惑的是——两个版本用System.out.println(JSON.toJSON(...))打印出来的 JSON看起来一模一样。三、根因fastjson 的循环引用检测$ref3.1 什么是 $reffastjson 在序列化时会记录已经序列化过的对象。如果同一个对象引用在结构里出现第二次它不会再展开而是写成一个引用标记{$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直接打印 toJSONStringSystem.out.println(JSON.toJSONString(priceQuery));输出里出现{$ref:...}即触发。方法 2字符串搜 $refStringjsonJSON.toJSONString(priceQuery);booleanhasRefjson.contains($ref);System.out.println(包含 $ref hasRef);方法 3对比开/关检测的结果importcom.alibaba.fastjson.serializer.SerializerFeature;StringwithDetectJSON.toJSONString(priceQuery);// 默认开检测可能有 $refStringwithoutDetectJSON.toJSONString(priceQuery,SerializerFeature.DisableCircularReferenceDetect);// 关检测全展开System.out.println(两者是否不同 !withDetect.equals(withoutDetect));两者不同即说明默认序列化里存在$ref。六、解决方案方案说明建议独立克隆不共享引用每个对象各自持有独立实例从源头避免重复引用✅ 推荐关闭引用检测JSON.toJSONString(obj, SerializerFeature.DisableCircularReferenceDetect)需改到每个序列化点易漏⚠️ 谨慎全局关闭影响面大❌ 不建议本次采用独立克隆虽然多占一点内存但对外传输数据正确、稳定不用碰序列化配置是最省心的选择。七、经验总结对外传输尤其发给第三方接口的对象避免多个字段/元素共享同一个可序列化对象实例否则 fastjson 会用$ref表示重复引用导致对端解析失败。$ref只认同一实例不认equals内容相同。内容相同但不同实例是安全的。排查时用toJSONString看真实报文别用toJSON后者会把引用摊平掩盖问题。「省内存的共享」和「对外序列化的安全」有时是冲突的涉及序列化传输的场景优先保证隔离性独立实例。