Go encoding/json/v2:升级后拒绝“合法”JSON?这是故意的
最近把项目从 encoding/json 切到 v2,第一个“事故”是线上突然开始报错——日志里出现了 invalid UTF-8 和 duplicate name。第一反应是数据源脏了,查完才发现是 v2 默认行为变了:它对 JSON 的严格程度超出 v1。这不是 bug,而是 v2 的核心设计取向。
v2 的 API 结构
v2 不再是 v1 的简单补丁,而是一次重新组织。暴露的入口更细:
Marshal、MarshalWrite、MarshalEncodeUnmarshal、UnmarshalRead、UnmarshalDecode
区别在于目标:MarshalWrite 写入某个 io.Writer,MarshalEncode 配合 jsontext.Encoder 做流式编码;读取侧同理。所有函数都接受可变 Options,用于配置序列化/反序列化行为。这意味着“严格模式”不是编译期写死的,而是可以通过选项动态调整。
底层还有一个新包:encoding/json/jsontext。它提供低层 JSON 语法处理,Encoder/Decoder 把 JSON 当作 Token 序列和 Value 序列来操作。内部维护状态机,保证每一步产出的都是合法 JSON。适合需要自定义语法或细粒度控制吞吐的场景,普通业务很少直接用,但它让上层 Marshal/Unmarshal 的行为有了坚实的基础。
反直觉的默认严格
v2 默认比 v1 更严格、更可互操作,主要体现在两点:
- 拒绝 JSON 字符串中的无效 UTF-8。v1 会容忍某些非法字节,v2 直接报错。
- 拒绝 JSON 对象中的重复名称。v1 通常“后写覆盖”,v2 判定为错误。
如果你的数据源是历史遗留接口,或者来自不受控的上游,这些变化很容易在升级后以“不明原因报错”的形式冒出来。反直觉点就在这里:之前一直正常解析的数据,换到 v2 突然变成“非法 JSON”了。
迁移步骤与回退开关
好消息是,原先的 encoding/json API 仍然被 v2 实现支撑。也就是说,你的 v1 代码不用改,序列化/反序列化行为基本保留,只是错误信息的确切文本可能不同。要按 v1 语义运行 v2,可以通过 Options 配置,不需要重写业务代码。
我的迁移建议是:
- 先跑一遍全量测试,观察哪些输入触发了严格模式报错。
- 对确实需要 v1 宽松语义的调用点,通过
Options显式配置,而不是全局放开。 - 如果被阻塞,构建时加
GOEXPERIMENT=nojsonv2可以禁用 v2,恢复原 v1 实现。这是一个临时的“逃逸舱”,但预计未来版本会移除,不能作为长期依赖。
注意:GOEXPERIMENT=nojsonv2 是构建时环境变量,不是运行时配置。它适合紧急止血,但团队必须计划后续适配,否则下一次升级会重复踩坑。
性能与取舍
性能方面,v2 的反序列化有显著提升,序列化大致与 v1 持平。如果你的服务主要是读 JSON(API 请求解析、配置加载),收益会很明显;写入侧没有退化,也不需要为性能做额外迁移。
取舍很清楚:v2 的严格模式短期内可能暴露历史数据问题,但长期看,拒绝无效 UTF-8 和重复名称能避免不少隐蔽 bug。比起“能解析但结果不可预期”,我更愿意接受升级时的一次性阵痛。
最后提醒一句:如果你在升级后遇到“行为变化”,先别急着 GOEXPERIMENT=nojsonv2。确认是不是默认严格模式触发的,然后用 Options 精确控制。v2 是 Go 官方对 JSON 处理的一次“纠偏”,方向是对的,只是我们这些调用方需要先跟上。