前面的排查文章【线上 4 个 RPC 参数丢了 3 个字段,到底谁干的】已经把问题收敛到了 fastjson2。
当时我用一个最小的 Dubbo
provider + consumer 工程,把线上路径复现出来。结论也很明确:锅不在业务代码,也不在 Dubbo 业务层本身,而是在 Dubbo 最终选中的 fastjson2 序列化路径。但只说“问题落在 fastjson2”还不够。
下一步不能继续盯着 Dubbo 看了。Dubbo 在这条链路里更像一个把请求送到 fastjson2 的壳。真正要确认的是:把 Dubbo 完全拿掉,只保留它调用 fastjson2 时那组 writer / reader features,纯 JSONB 还能不能独立复现。
这篇我想把 4 件事讲清楚:
- 纯 JSONB 最小实验怎么搭。
- fastjson2 的写端和读端,分别在哪一步把问题坐实。
- 为什么最后应该修写端,不修读端。
- 我提给 fastjson2 的 PR,到底改了什么。
1. 先把 Dubbo 拿掉,只保留它那组 features
前一轮排查已经说明:Dubbo 在这条路径里更像调度员。它决定选哪个序列化实现,也决定调用 fastjson2 时带哪组 features。
那现在最干净的验证方式,就是把 Dubbo 拿掉,只保留这组 features。
最小实验核心代码是这样的:
再准备两组数据:
- 一组让 4 个
StatusQuery共享同一个statusCodeSet
- 一组让每个
StatusQuery各自new HashSet<>(codeSet)
最后直接跑:
这里有两个点值得先说。
第一,我故意传了完整泛型
TypeReference<Set<StatusQuery>>(),不是裸 Set.class。因为我想先把“泛型擦除”这个嫌疑人摘掉。既然前面已经怀疑它不是根因,这里干脆先把条件给足。第二,这里直接复用了 Dubbo 3.2.0 在
FastJson2ObjectOutput / FastJson2ObjectInput 里实际打开的 features。这样跑出来的结果,才有资格和 Dubbo 线上路径放在一起比较。
图 1:Dubbo 被拿掉后,只保留 fastjson2 features,问题仍然复现。
2. 先看运行结果,再谈源码
最关键的 baseline 输出是这样:
Dubbo 已经不在场了,但结果和线上一模一样:还是“1 个完整,3 个 null”。
再把几组对照一起放出来,问题就更清楚:
场景 | 外层容器 | statusCodeSet | 结果 |
A | HashSet | 共享 | 1 完整 + 3 null |
B | HashSet | 独立 | 4 完整 |
C | LinkedHashSet | 共享 | 1 完整 + 3 null |
D | LinkedHashSet | 独立 | 4 完整 |
这一步已经足够把第一层结论坐实:
只要保留 Dubbo 那组 fastjson2 features,这个问题不需要 Dubbo 也能独立复现。
2.1 先排除一个很容易误判的点
如果你直接看
JSONB.toJSONString(bytes),会看到一种很别扭的输出:这个输出很容易把人带偏,以为“写端把同一个字段写了两次”。
但这其实不是根因,而是
JSONBDump 的打印问题。JSONB.toJSONString(bytes) 底层会把 JSONB 字节流 dump 成一段方便人看的 JSON。它在 dumpObject 里遇到 BC_REFERENCE 时,先把引用打印成了 {"$ref":"..."};但这个分支结束后,又继续落到外层通用逻辑里,补了一次“写冒号 + 再读下一个值”。所以你看到的像是:
真实字节流不是这样。
真实的 JSONB 字节流里,field name 没有被写两遍。这个误判先摘掉,后面的源码才不会看歪。
3. 先看写端:$ref 是怎么写出来的
这次问题的第一刀,要先落在 writer。
因为只要你看到“共享引用 +
ReferenceDetection”,就该问一句:writer 到底把第二次、第三次出现的对象写成了什么。3.1 第一次出现时,writer 会先登记路径
fastjson2 开了
ReferenceDetection 以后,第一次遇到对象,不是简单写完就算了。它还会把这个对象和当前路径登记起来。在这个场景里,第 1 个
StatusQuery.statusCodeSet 首次出现时,大概会走到类似这样的逻辑:这一步的意思是:把“这个对象现在在什么路径下”记住。后面再碰到同一个对象实例,就可以不重复写值,而是直接写引用路径。
问题也从这里开始露头。
setPath(fieldWriter, value) 记录的是字段路径。放在普通 Bean 里,它很好理解;放在 List 的第 0 个元素里,也还能拼出 $[0].statusCodeSet 这种可求值路径。但外层如果是
HashSet,writer 此时没有一个稳定的“第几个元素”可以用。于是第一次登记出来的路径就变成了 $.statusCodeSet。这条路径看起来短,实际上危险。
3.2 第二次开始,不再内联写值,而是写 $ref
后面第 2、3、4 个
StatusQuery 再遇到同一个 HashSet<String> 时,FieldWriterObject.writeInternal(...) 就不会再完整写内容了,而是走 $ref 分支。源码里的判断大致可以理解成这样:
也就是说,只要 writer 判断“这个对象之前见过”,就直接把之前登记的路径写出去。
在这个场景下,最终写出来的几种路径是:
- 外层是
HashSet时:$.statusCodeSet
- 外层是
LinkedHashSet时:$[0].statusCodeSet
- 后续相对引用:
#-1
这几个路径里,最危险的是第一个。
$.statusCodeSet 看上去像在说:“从根节点拿到 statusCodeSet 这个字段。”但根节点是谁?不是
StatusQuery,而是外层那个 HashSet<StatusQuery>。HashSet 根本没有 statusCodeSet 这个属性。换句话说,writer 这时候已经埋下雷了:它写出了一个以
Set 为根、但根本不适合在 Set 上求值的路径。
图 2:第一次出现时 writer 登记路径,后续共享引用直接写
$ref。问题在于这个路径对 HashSet root 不成立。3.3 为什么 LinkedHashSet 也没活下来
有人看到这里会说:那
LinkedHashSet 写成 $[0].statusCodeSet,不是合理多了吗?表面上是。
但这只是“看起来更合理”,不等于 reader 一定能按原意解回来。因为这件事还要看读端把 root 建成了什么。这个坑要放到下一节一起看。
4. 再看读端:为什么这些路径解不回来
如果 writer 的问题是“写出了坏路径”,那 reader 的问题就是“它到底是在哪一步解不回来”。
4.1 读端最后会把 $ref 任务攒起来,再统一 resolve
fastjson2 反序列化时,不会在第一次读到
$ref 的那一刻立刻把所有字段补齐。它会先记一笔待回填任务,等对象树大致建好以后,再在 JSONReader.handleResolveTasks() 里统一处理。核心逻辑可以粗暴理解成:
问题就出在这里的
root。4.2 $.statusCodeSet 在 HashSet root 上根本求不出值
当 root 是外层的
HashSet<StatusQuery> 时:$.statusCodeSet的含义是“去根节点上找statusCodeSet字段”
- 但
HashSet根本没有这个字段
于是这条路径天然求值失败。reader 不是把一个正确路径解错了,而是根本拿到了一条不可能在当前 root 上成立的路径。
这也是为什么我说这次问题的本质更偏向 writer:reader 处理的是一条它自己也无法成立的路径。

图 3:reader 最后会对 root 执行
path.eval(root)。当 root 是 HashSet 时,$.statusCodeSet 没有求值对象。4.3 LinkedHashSet 之所以也失败,是因为读回来时 root 还是 HashSet
外层换成
LinkedHashSet 后,writer 写的是 $[0].statusCodeSet。这个路径只有在 root 是有稳定索引语义的容器时,才真正有意义。但实际实验里,reader 侧拿到的外层容器是
HashSet,不是 LinkedHashSet。也就是说:
- writer 以为自己在给一个“有顺序”的容器写路径
- reader 最后却在一个没有稳定索引语义的
HashSet上做path.eval(root)
那
$[0] 还能不能稳定落到 writer 以为的第一个元素上?答案是不行。这也是为什么
LinkedHashSet 这组实验看起来更像是“差一点就好了”,但最后结果还是“1 完整 + 3 null”。4.4 为什么 List 没事,Set 不行
这也是我最后没有全局禁用
ReferenceDetection 的原因。如果外层是
List<StatusQuery>,writer 写出来的路径通常是 $[0].statusCodeSet。读端拿到的 root 也是 List,$[0] 能先定位到第 1 个元素,再继续取 .statusCodeSet。这条路径能成立,所以
List 场景本来就是健康的。Set 不一样。HashSet 没有稳定下标。$.statusCodeSet 在 HashSet root 上也没有意义。LinkedHashSet 虽然有迭代顺序,但反序列化后 root 未必还能按 writer 想象的方式提供 $[0] 语义。所以这次问题的边界不是“JSONB 共享引用都坏了”,而是更窄:
外层是非List集合,元素内部又共享了同一个对象,writer 还打开了ReferenceDetection。

图 4:
List 有稳定下标,$[0].statusCodeSet 能成立;Set 没有稳定下标,$.statusCodeSet 在 root 上也不成立。4.5 到这里,又能顺手排掉两个误判
第一,完整泛型 Type 也救不了。
这次纯 JSONB 实验里,我已经传了完整的
TypeReference<Set<StatusQuery>>()。问题照样复现,所以“泛型擦除”不是必要条件。第二,
UseNativeObject 也不是必要条件。我单独做过一组实验,把 reader 的
UseNativeObject 去掉,结果还是“1 完整 + 3 null”。这说明它确实会放大 LinkedHashSet 那条路径的问题,但不是这次 bug 的根开关。到这里,触发这个问题的最小条件其实已经很清楚了:
5. 为什么最后修写端,不修读端
把根因钉到这里以后,摆在我面前其实有 3 条路。
方案 | 思路 | 主要问题 |
改读端 | 让 reader 对 Set 这类容器做更复杂的 resolve | 改动面大,要碰 resolve 生命周期和 hash 容器行为 |
改写端 | 非 List 集合场景下,不再为元素内部共享引用生成 $ref 路径 | 共享对象会内联,字节流变大 |
全局禁用 | 一刀切关掉 ref detection | 太粗暴,会伤到本来没问题的 List 路径 |
我最后选的是第二条:修写端,不修读端。
原因有两个。
5.1 直接问题在写端
这次最直接的问题,不是 reader 无缘无故把一个好好的字段吞掉了。
而是 writer 先写出了一条在当前 root 上根本不成立的路径。
既然坏路径从这里开始,那最小修法就应该优先瞄准这里。先别让 writer 再写出这种路径。
5.2 读端真要修,会牵出更大的坑
读端不是不能修,而是会修得很重。
因为一旦你想在 reader 侧“帮忙把
Set 上的引用回填好”,马上就会碰到另一个问题:HashSet 这类容器依赖元素的 hashCode()。如果对象在“字段还没回填完成”的状态下就已经被
add 进 HashSet,后面再去补字段,理论上就可能破坏 hash 容器的不变量。这不是假想出来的复杂度。
fastjson2 读
$ref 时,会先把回填任务记下来。等元素已经进了集合,再统一跑
handleResolveTasks()。如果
StatusQuery.hashCode() 又依赖 statusCodeSet,那就会出现一个尴尬局面:- 先按
statusCodeSet = null的状态算 hash,把对象放进HashSet。
- 后面 resolve 成功,又把
statusCodeSet回填成一个非空集合。
- 对象的 hash 变了,但它在
HashSet里的桶位没跟着重排。
这已经不是“补一条 resolve 规则”那么简单了。它会把你拖进更大的读端改造里:先缓冲元素、等引用全部回填完再
addAll、必要时重建容器、还要考虑对象稳定性。所以我的判断很明确:这次应该先用写端的小修,堵住一个写端先制造坏路径的问题,而不是为了补读端,把动刀范围扩大一圈。
6. PR 到底改了什么
最后补丁落在:
核心思路很简单:
- 进入集合写出逻辑时,先判断外层是不是
List
- 如果不是
List,并且当前 context 开着ReferenceDetection
- 那就在写元素期间临时把这个 feature 关掉
- 写完再用
try/finally恢复原来的 context
关键代码大致是这样:
然后把原来的元素遍历包进
try/finally 里,结束后再恢复。这个补丁的意思其实非常朴素:
对外层是非List的集合,不要再为元素内部共享引用生成$ref路径;直接内联写值。
我没有只对
HashSet 写特判,而是把边界放在“非 List 集合”。因为这次问题的本质,不是某个类名,而是“这类容器没有稳定的数组索引语义”。这里还有一个实现细节。
我关的不是调用方传进来的 features 数组,也不是永久改掉全局配置,而是临时改当前
JSONWriter.Context 里的 feature bit。写元素前关掉,元素写完后在 finally 里恢复。这样做有两个好处:
- 影响范围只包住这一次非
List集合的元素写出。
- 如果中间抛异常,
finally也会把原来的 context 还回去。
我本地验证时用的是“源文件覆盖”的方式:从 fastjson2 sources jar 里取出
ObjectWriterImplCollection.java,放到测试工程的 src/main/java 同路径下。Maven 编译后,这个 class 会比依赖 jar 里的原版更早出现在 classpath 里,所以不用重打 fastjson2 jar,也能快速验证补丁。
图 5:外层是非
List 集合时,写元素期间临时关闭 ReferenceDetection,避免生成不可 resolve 的 $ref 路径。6.1 为什么这个改法能自圆其说
补丁不是只要让一个 case 通过就行。它至少要同时满足两件事:
- 原来会炸的 case 现在好起来。
- 原来没问题的 case 别被顺手改坏。
所以我本地验证时盯的重点是这 4 组:
用例 | 修复前 | 修复后 |
共享 HashSet | null count = 3/4 | null count = 0/4 |
共享 LinkedHashSet | null count = 3/4 | null count = 0/4 |
关闭 ReferenceDetection 的健康基线 | null count = 0/4 | null count = 0/4 |
外层 List 的健康基线 | null count = 0/4 | null count = 0/4 |
最后一行尤其关键。
因为它说明这次改动没有把
List 这种本来能正确处理 $ref 的路径一起打坏。补丁的影响面,确实被控制在“非 List 集合元素内部的共享引用”上。代价也有。
非
List 集合里被共享的对象,会从 $ref 变成内联写出。我的最小用例里,wire bytes 从 217 变成了 373。这不是零成本,但我觉得这个取舍能接受:字节流变大一些,换反序列化结果正确。
图 6:共享
HashSet 和 LinkedHashSet 从 null count = 3/4 变成 0/4,外层 List 基线保持健康。6.2 PR 里我放了什么
我最后提给 fastjson2 的 PR 是:
PR 里我刻意把内容收成了 3 件事:
- 改动点集中在
ObjectWriterImplCollection.java。
- 增加回归测试
SharedReferenceInSetTest.java,覆盖非List集合元素里的共享引用场景。
- PR 说明里把问题边界写清楚:这是
non-List collection elements + shared reference的问题,不是泛泛的“JSONB 共享引用都不对”。
我不想把它提成一个“到处都改一点”的大补丁。因为这类问题一旦进开源项目 review,改动范围越大,越难说服 reviewer。
截至现在,这个 PR 还是 open 状态。所以我不会写“某个版本已经修了”。这类结论,得等 merge 和 release 之后再说。
7. 收尾
排查走到这里,我想讲清楚的东西已经够了。
如果只记一句话,我希望是这一句:
这次不是 reader 平白无故把字段吃掉了,而是 writer 先写出了一条 reader 自己也无法成立的路径。
所以最后才会落到“修写端,不修读端”。
完整 demo 我已经整理到 GitHub:
对应的 fastjson2 PR 在这里:
前半段排查解决的是“锅到底在哪”。这次继续往下走,解决的是“知道锅在哪以后,怎么把补丁收得足够小”。
对我来说,这两部分缺一不可。只会把问题指给别人看,不够。只会闷头改代码,也不够。
真正有价值的,是你既能把问题一路收窄到最小路径,也能把补丁收得足够小、足够准,让上游能接得住。
8. 参考资料
- fastjson2源码:https://github.com/alibaba/fastjson2
- 作者:Yibin
- 链接:https://yibin.dev/article/36960b50-99a4-808b-a32b-dbe53793c66a
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章





