最近排查问题的时候,顺便给 fastjson2 提了个 PR #7641,maintainer review 后提了点问题。

1. review 说了什么

code review 主要提了两个点。
write() 文本路径漏了。 第一版修了 writeJSONB(),但 JSON.toJSONString(set, ReferenceDetection) 也会写出不可解析的 $ref。实测输出类似:
外层根节点是 Set 序列化出来的数组,$.codes 读回来找不到,字段就是 null
notion image
单元素循环引用可能炸。 如果继续用 suppressElementRefDetect 压掉子 writer 的引用检测,单元素 HashSet 里 bean 指回自己、或者指回外层 Set,递归可能停不下来。轻一点 level too large,重一点 StackOverflowError

2. 先补测试,再动代码

接收到CR建议后,不要急于直接去修改代码,而是要先根据提到的问题点,设计出合理的用例场景。
  • 文本路径:JSON.toJSONString() 往返
  • 单元素循环:自引用、多跳循环、指回外层 Set
  • TreeSetSortedSetrefDetect 判断里走另一条路径,只测 HashSet 不够
  • 回归和边界:ArrayList、空集合、单元素集合、不开 ReferenceDetection
按照问题点将涉及的要点列出来,把对于的测试用例补充出来。

3. 第一次修复:看着对,测不过

我第一反应很直接:把 JSONB 里已有的 suppressElementRefDetect 挪到 write()
思路是写非 List 集合元素时,临时压掉子 writer 的引用检测。第二个、第三个元素里的 codes 就不会写成 $ref,而是内联写完整对象。
听上去合理对吧?
跑测试,15/18 通过。共享引用那批好了,3 个单元素循环测试挂了。
notion image

4. 读源码:两层机制在打架

重新看 ObjectWriterImplCollection,问题不在「要不要开引用检测」,而是两层机制冲突了。
机制
干什么
单元素 Set
集合层 refDetect
写元素前 setPath(i, item) 注册路径
仍然开着
suppressElementRefDetect
清 context 级 ReferenceDetection
非 List 一律 suppress
外层觉得「我已经在做循环保护了」,内层却被关掉了真正终止递归的能力。无限递归就这么来的。
根因也清楚了:非 List 集合里 sibling 共享同一张 refs 表。前一个元素注册出来的 $.codes,泄漏给了后一个元素。路径本身在 Set 根下就不可解析。
所以不该关功能,该给 refs 划边界。
notion image

5. 换方案:saveReferences / restoreReferences

最终修法是给 JSONWriter 加两个很小的 API:
进入非 List 集合时,先保存一份 ancestor refs 快照。每写一个 element 前,恢复到这份快照。
  • 元素内部:ReferenceDetection 仍开着,循环引用还能靠 $ref 停住
  • sibling 之间:前一个元素注册的 codes -> $.codes 不会带到下一个
  • writeJSONB()write() 两条路径同一套逻辑
suppressElementRefDetect 绕一点,但语义准。前者像拉电闸,后者像把线路接对。
notion image

6. 性能:问题的修复是有代价的

每个 element 写入前恢复一次 refs 快照,理论上会有开销。元素多、refs 表大的时候,不能拍脑袋说没有。
不过触发条件本来就不太常见:非 List 集合 + 开启 ReferenceDetection + 元素内部 sibling 共享引用。正常小数量对象下,预期影响不会太明显。
真要知道对性能影响多大,得跑一轮 benchmark 才知道。
DeepSeek Harness 说"一切皆插件",我当真了JXCache:一个 JetCache 工具集,从可观测开始
Loading...