前面的排查文章【线上 4 个 RPC 参数丢了 3 个字段,到底谁干的】已经把问题收敛到了 fastjson2。
当时我用一个最小的 Dubbo provider + consumer 工程,把线上路径复现出来。结论也很明确:锅不在业务代码,也不在 Dubbo 业务层本身,而是在 Dubbo 最终选中的 fastjson2 序列化路径。
但只说“问题落在 fastjson2”还不够。
下一步不能继续盯着 Dubbo 看了。Dubbo 在这条链路里更像一个把请求送到 fastjson2 的壳。真正要确认的是:把 Dubbo 完全拿掉,只保留它调用 fastjson2 时那组 writer / reader features,纯 JSONB 还能不能独立复现。
这篇我想把 4 件事讲清楚:
  1. 纯 JSONB 最小实验怎么搭。
  1. fastjson2 的写端和读端,分别在哪一步把问题坐实。
  1. 为什么最后应该修写端,不修读端。
  1. 我提给 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 线上路径放在一起比较。
notion image
图 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 上求值的路径。
notion image
图 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 处理的是一条它自己也无法成立的路径。
notion image
图 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
notion image
图 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,那就会出现一个尴尬局面:
  1. 先按 statusCodeSet = null 的状态算 hash,把对象放进 HashSet
  1. 后面 resolve 成功,又把 statusCodeSet 回填成一个非空集合。
  1. 对象的 hash 变了,但它在 HashSet 里的桶位没跟着重排。
这已经不是“补一条 resolve 规则”那么简单了。它会把你拖进更大的读端改造里:先缓冲元素、等引用全部回填完再 addAll、必要时重建容器、还要考虑对象稳定性。
所以我的判断很明确:这次应该先用写端的小修,堵住一个写端先制造坏路径的问题,而不是为了补读端,把动刀范围扩大一圈。

6. PR 到底改了什么

最后补丁落在:
核心思路很简单:
  1. 进入集合写出逻辑时,先判断外层是不是 List
  1. 如果不是 List,并且当前 context 开着 ReferenceDetection
  1. 那就在写元素期间临时把这个 feature 关掉
  1. 写完再用 try/finally 恢复原来的 context
关键代码大致是这样:
然后把原来的元素遍历包进 try/finally 里,结束后再恢复。
这个补丁的意思其实非常朴素:
对外层是非 List 的集合,不要再为元素内部共享引用生成 $ref 路径;直接内联写值。
我没有只对 HashSet 写特判,而是把边界放在“非 List 集合”。因为这次问题的本质,不是某个类名,而是“这类容器没有稳定的数组索引语义”。
这里还有一个实现细节。
我关的不是调用方传进来的 features 数组,也不是永久改掉全局配置,而是临时改当前 JSONWriter.Context 里的 feature bit。写元素前关掉,元素写完后在 finally 里恢复。
这样做有两个好处:
  1. 影响范围只包住这一次非 List 集合的元素写出。
  1. 如果中间抛异常,finally 也会把原来的 context 还回去。
我本地验证时用的是“源文件覆盖”的方式:从 fastjson2 sources jar 里取出 ObjectWriterImplCollection.java,放到测试工程的 src/main/java 同路径下。Maven 编译后,这个 class 会比依赖 jar 里的原版更早出现在 classpath 里,所以不用重打 fastjson2 jar,也能快速验证补丁。
notion image
图 5:外层是非 List 集合时,写元素期间临时关闭 ReferenceDetection,避免生成不可 resolve 的 $ref 路径。

6.1 为什么这个改法能自圆其说

补丁不是只要让一个 case 通过就行。它至少要同时满足两件事:
  1. 原来会炸的 case 现在好起来。
  1. 原来没问题的 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。这不是零成本,但我觉得这个取舍能接受:字节流变大一些,换反序列化结果正确。
notion image
图 6:共享 HashSet 和 LinkedHashSet 从 null count = 3/4 变成 0/4,外层 List 基线保持健康。

6.2 PR 里我放了什么

我最后提给 fastjson2 的 PR 是:
PR 里我刻意把内容收成了 3 件事:
  1. 改动点集中在 ObjectWriterImplCollection.java
  1. 增加回归测试 SharedReferenceInSetTest.java,覆盖非 List 集合元素里的共享引用场景。
  1. PR 说明里把问题边界写清楚:这是 non-List collection elements + shared reference 的问题,不是泛泛的“JSONB 共享引用都不对”。
我不想把它提成一个“到处都改一点”的大补丁。因为这类问题一旦进开源项目 review,改动范围越大,越难说服 reviewer。
截至现在,这个 PR 还是 open 状态。所以我不会写“某个版本已经修了”。这类结论,得等 merge 和 release 之后再说。

7. 收尾

排查走到这里,我想讲清楚的东西已经够了。
如果只记一句话,我希望是这一句:
这次不是 reader 平白无故把字段吃掉了,而是 writer 先写出了一条 reader 自己也无法成立的路径。
所以最后才会落到“修写端,不修读端”。
完整 demo 我已经整理到 GitHub:
对应的 fastjson2 PR 在这里:
前半段排查解决的是“锅到底在哪”。这次继续往下走,解决的是“知道锅在哪以后,怎么把补丁收得足够小”。
对我来说,这两部分缺一不可。只会把问题指给别人看,不够。只会闷头改代码,也不够。
真正有价值的,是你既能把问题一路收窄到最小路径,也能把补丁收得足够小、足够准,让上游能接得住。

8. 参考资料

 
 
四个装机脚本,新机到手十分钟搞定线上 4 个 RPC 参数丢了 3 个字段,到底谁干的
Loading...