线上有个 RPC 调用的问题,把我看愣了。
consumer 明明发出去了 4 个完整对象,provider 收到后却只剩 1 个对象还是完整的,另外 3 个对象里的同一个字段全变成了
null。更麻烦的是,整条链路没有异常,没有超时,也没有重试失败。就是静悄悄地坏了。这类问题最难的地方,不是修,而是先判断锅到底在哪。业务代码有嫌疑,Dubbo 有嫌疑,日志打印方式也有嫌疑。只要中间哪一层没排干净,最后的结论就很容易下错。
说明:本文配图均为自制示意图,用来解释调用链路和定位过程,不对应真实线上界面截图。
1. 问题先摆出来
接口很普通:
一次业务调用里,会传 4 个
StatusQuery。consumer 发包前打印出来是这样的:provider 刚收到参数时,却变成了这样:
第 1 个对象正常。后面 3 个对象没有
statusCodeSet,等价于 null。最麻烦的还不是“丢了 3 个字段”,而是整条链路没报任何错。对调用方来说,请求成功了;对下游业务来说,数据已经坏了。这种静默数据损坏,比直接抛异常更难排。

图 1:consumer 发 4 个完整对象,provider 收到 1 个完整对象和 3 个空字段对象。
当时的环境版本是:
组件 | 版本 |
JDK | 1.8 |
Spring Boot | 2.7.18 |
Apache Dubbo | 3.2.0 |
fastjson2 | 2.0.40 |
注册中心 | Nacos |
2. 先止血,但这不算结案
出问题的业务代码,原来是这么构造参数的:
这里的
codeSet 是外面提前 new 出来的同一个 Set<String>。4 个 StatusQuery 指向的是同一个实例。紧急止血的改法只有一行:
每个对象各拿一份自己的
statusCodeSet,问题立刻消失。但这个改法只能救火,不能结案。它只说明“共享引用”和问题有关,还说明不了到底是谁把字段吞掉了。
如果这里就停,很容易留下 3 个坑:
- 以后别的接口也可能继续踩到同一类问题
- 团队会把锅扣到 DTO、Lombok 或者
HashSet身上
- 一旦有人把止血代码改回去,线上还会再炸
所以我没有停在“能跑就行”,而是继续往下收。
3. 先固定现场:Arthas 把证据链补齐
我当时的排查思路很简单:
- consumer 发出去之前,参数到底是不是完整的
- provider 刚进方法时,参数是不是已经坏了
- 如果刚进方法就坏了,那就继续往前看 wire 到底走了什么序列化
先用 Arthas 把 provider 入口和编解码路径盯住:
这几条证据给出的结论很明确:
- provider 刚进业务方法时,参数已经是“1 个完整 + 3 个字段为 null”了,说明不是业务代码后改坏的
CodecSupport.getSerialization(URL)返回的是FastJson2Serialization
- 对应的序列化 id 是
23,也就是 fastjson2
- decode 栈里能看到
FastJson2ObjectInput.readObject()
继续往前追,关键线索出现在 invoker URL 上:
这一步只能说明一件事:这条 RPC 的 wire,确实按 fastjson2 在编解码。它还不能直接证明“bug 就在 fastjson2”,但 Dubbo 这层的嫌疑已经很重了。
4. 复现不是一次成功的
这次定位最花时间的,不是最后那张单变量表,而是前面几次“看起来马上要成了,结果又不是它”的绕路。
4.1 第一版 demo 走偏了
我第一版最小复现,走的是同 JVM、裸
DubboBootstrap 的方式。结果问题一堆:- fastjson2 路径下会出现“连上了,但 5 秒后静默断开”的诡异现象
PermittedSerializationKeeper还会因为 service key 对不上,直接把调用拒掉
- hessian2 能跑,fastjson2 有时又“看起来也正常”,和线上现象完全对不上
这条路最大的问题,不是代码写不出来,而是干扰变量太多。你很难知道自己现在看到的异常,到底是这次线上 bug,还是 demo 搭法本身带来的副作用。
所以我很快放弃了这条路,改成双进程、Spring Boot starter 的方式。
4.2 双进程能复现,但换个干净工程又不复现了
换成 Spring Boot 双 JVM 之后,问题一度能稳定复现。
但当我把代码迁到一个更干净的 demo 工程里时,bug 又消失了。4 个对象全都完整,连共享引用都被完整还原了。
这种时候最容易犯的错,是开始同时改很多变量。版本、注解、端口、URL、
version="1.0.0",一起改,很快就会把自己绕进去。我当时就踩了这个坑。
一度我以为决定性因素是
version="1.0.0"。后来做单变量回退,才发现不是。真正起作用的,是 refer URL 里那一小段 query 参数。4.3 最后锁定的,不是版本号,而是 URL query
我把对照实验补成了最小集:
URL query | wire 最终落点 | 是否复现 |
无 | hessian2 | 否 |
serialization=fastjson2 | fastjson2 | 是 |
prefer.serialization=fastjson2 | fastjson2 | 是 |
prefer.serialization=fastjson2,hessian2 | fastjson2 | 是 |
prefer.serialization=hessian2,fastjson2 | hessian2 | 否 |
这张表一下子把问题收窄了:
只要 wire 最终落到 fastjson2,bug 就复现;落到 hessian2,就不复现。
4.4 线上线下真正的差异,在 Nacos metadata
线上走注册中心,本地 demo 一开始走的是直连 URL。两边真正的差异,不在 DTO,也不在版本号,而在 provider 注册出去的 metadata。
线上 provider 的 Nacos metadata 里,有这样一段:
这就解释了为什么本地第一版 demo 不复现:直连模式下,consumer 不会从注册中心 merge 这段 query,自然就不会走到和线上同一条路径。
把直连 URL 改成下面这样以后,复现就稳定了:

图 2:定位真正起作用的变量,不是版本号,而是 invoker URL 上的序列化 query。
到这里,我已经知道“线上为什么会命中这条路径”了。下一步要回答的是:Dubbo 到底在这条路径里做了什么。
5. 把 Dubbo 拿掉之前,先把 Dubbo 这层看清
要想证明锅不在 Dubbo,不能只靠“我感觉像”。得先把 Dubbo 到底做了什么说清楚。
5.1 Dubbo 是怎么选到 fastjson2 的
核心入口在
CodecSupport:继续往里走,是
UrlUtils:兜底默认值在
DefaultSerializationSelector 里,写死的是 hessian2。这几段源码放在一起,结论就很直接了:
- URL 上没有
serialization/prefer.serialization,默认走hessian2
- URL 上一旦把
fastjson2放到第一个,最终就会选到FastJson2Serialization
所以前面那张对照表,不是偶然现象,而是源码行为的直接结果。
5.2 为什么线上 invoker URL 会带上 prefer.serialization
关键在
ProtocolConfig.checkDefault():这段代码至少能说明一件事:如果 provider 没显式写
preferSerialization,Dubbo 会在运行时给它补一个值。provider 端常见的配置写法是:
但这段代码本身并不能单独证明线上最终一定会注册成哪个具体值。这个问题,还是要回到线上实际抓到的 Nacos metadata 去看。
而我们线上实际抓到的,就是:
也就是说,这里真正站得住的结论是两段证据拼起来的:
ProtocolConfig.checkDefault()说明 Dubbo 确实会参与补默认值
- 线上 Nacos metadata 证明最终注册出来的值就是
fastjson2,hessian2
所以线上为什么命中 fastjson2,不是猜出来的,是“源码行为 + 线上 metadata”一起对上的结果。

图 3:provider metadata 里的
prefer.serialization 最后被 merge 到 consumer 的 invoker URL。5.3 为什么 consumer 自己 yml 里写 fastjson2 也不生效
这里还有一个很容易把人带沟里的点。
我当时也试过在 consumer 自己的 yml 里写:
直觉上看,这么写以后,consumer 发包不就该走 fastjson2 了吗?
但 Dubbo 这里不是这么分层的。
dubbo.protocol.* 对应的是 ProtocolConfig,它只影响当前应用自己 export 出去的 URL。provider 的业务接口 export 用它,consumer 自己 export 的 MetadataService 也用它,但它不参与 consumer 的 refer URL 组装。consumer 发包时真正看的,还是
ReferenceConfig 最后拼出来的 invoker URL。也就是说,决定 wire 协议的不是“consumer 本地 yml 有没有写 fastjson2”,而是“最终 invoker URL 的 query 上有没有 serialization 或 prefer.serialization”。这也是为什么前面那组实验里,真正有决定性的始终是 URL query,不是本地 yml。
5.4 Dubbo 对 fastjson2 做了什么
Dubbo 在这条链路里,真正做的主要是两件事。
第一件事,是把参数交给
FastJson2ObjectOutput,并启用它那组 features:这里最关键的是
ReferenceDetection。它一开,共享引用就不会重复内联写值,而是会写成 $ref。第二件事,是 provider 侧 decode 时,按
Class[] 去读参数:对
Set<StatusQuery> 这种签名来说,这里拿到的是 Set.class。再往下会走进 FastJson2ObjectInput,继续交给 JSONB 去 parse。也就是说,Dubbo 在这条路径里,更像一个调度员:
- 它决定最后选哪个序列化实现
- 它决定调用 fastjson2 时用哪组 writer / reader features
- 它把参数交给 fastjson2 去真正做字节层的写和读
如果把 Dubbo 整层拿掉,只保留这组 features,问题还能复现,那锅就不能再算到 Dubbo 头上了。

图 4:Dubbo 负责选择路径和透传 features,真正的字节写读仍然在 fastjson2 里发生。
6. 把 Dubbo 拿掉,问题还在
我后面做的实验,就是把 Dubbo 完全剥掉,只保留它实际用到的那组 fastjson2 writer / reader features,直接跑:
JSONB.toBytes(...)
JSONB.parseObject(...)
而且这次不是传裸
Set.class,我直接传了完整泛型:如果这样还能复现,就说明问题已经和 Dubbo 业务路径无关了。
结果是:能复现。

图 5:去掉 Dubbo 后仍然是同样的结果,这一步把 Dubbo 从根因里排掉了。
我又做了 5 组单变量对照,把几个最容易误判的方向全洗了一遍:
单变量变更 | 结果 |
baseline: Set<StatusQuery> + 共享 statusCodeSet + Dubbo 真实 features | 1 完整 + 3 null |
去掉 writer 的 ReferenceDetection | 4 完整 |
去掉 reader 的 UseNativeObject | 1 完整 + 3 null |
把 DTO 从 @Data 改成只按 sn 做 equals/hashCode | 1 完整 + 3 null |
外层从 Set<StatusQuery> 改成 List<StatusQuery> | 4 完整 |
这张表的价值很大。因为它把几个“看起来像根因”的东西排掉了:
UseNativeObject不是必要条件
- Lombok
@Data不是必要条件
- 泛型擦除也不是必要条件
最后真正留下来的,只有 3 条必要条件:

图 6:真正的最小条件只有 3 条,其他更像放大器,不是根开关。
到这里就够了。
第一篇的任务,不是把 fastjson2 内部每一行源码都拆开,而是把最小问题路径钉死:
7. 定位问题中间件
到这里已经确认完了:问题不是 Dubbo 业务层的 bug,而是落在 fastjson2 这条序列化路径上。后面如果继续往下拆,我会单独写 fastjson2 的写端、读端和补丁思路。
完整 demo 我已经整理到 GitHub:
真正难的,从来不是“猜到像是哪个组件有问题”。
真正难的是,你得一层一层把中间件里没问题的部分排掉,跟着数据流走,把最小问题路径逼出来。等只剩最后那条路径的时候,锅是谁的,反而就不难看了。
8. 参考资料
- fastjson2源码:https://github.com/alibaba/fastjson2
- 作者:Yibin
- 链接:https://yibin.dev/article/36960b50-99a4-804a-9ea2-e0a8c6112c14
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章








