编程 Go 1.27 的 encoding/json 换内核:升级后默认行为变了,any 解析反而更慢

2026-10-17 21:03:50

Go 1.27 的 encoding/json 换内核:升级后默认行为变了,any 解析反而更慢

Go 1.27 里 encoding/json 还是原来那个 import 路径,函数签名也没动,但内部已经换成 encoding/json/v2 之上的一层薄兼容层。旧代码不改也能编译、能跑测试,只是解码宽容度、输出字节和部分路径的性能都跟着变了——没有编译错误提示,只有测试红或者线上对端解析失败。

参考:Go 1.27 release notes、pkg.go.dev/encoding/json/v2。

解码:默认收紧,出错和“静默拿到零值”各占一半

v1 入口现在默认按 v2 语义走,宽松行为变成需要显式打开的选项。

非法 UTF-8:v1 把坏字节替换成 U+FFFD 继续解析,v2 直接报错。要恢复旧行为传 jsontext.AllowInvalidUTF8(true)。

重复字段名:v1 接受,后者覆盖或合并;v2 报错。恢复用 jsontext.AllowDuplicateNames(true)。

字段名匹配:v1 大小写不敏感,v2 默认大小写敏感,这是出于安全考虑(避免 {"admin":true} 和 {"Admin":true} 落到同一字段)。恢复用 json.MatchCaseInsensitiveNames(true),或者在字段 tag 上单独处理。

第三点最容易被忽略:它不会报错。结构体字段匹配不上时,解码器把零值留在那里,代码继续往下跑。

package main

import (
	"encoding/json"
	"encoding/json/jsontext"
	"fmt"
)

type Cfg struct {
	Name string
	Kind string
}

func main() {
	src := []byte("{\"name\":\"a\",\"NAME\":\"b\",\"kind\":\"\xff\"}")

	var v Cfg
	// 默认:重复键报错、坏 UTF-8 报错、NAME 不匹配 Name
	err := json.Unmarshal(src, &v)
	fmt.Println("default:", v, err)

	// 打开 v1 的宽松行为
	err = json.Unmarshal(src, &v,
		jsontext.AllowDuplicateNames(true),
		jsontext.AllowInvalidUTF8(true),
		json.MatchCaseInsensitiveNames(true),
	)
	fmt.Printf("legacy: %+v %v\n", v, err)
}

编码:null 变成空集合,[]byte 变成 base64

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Event struct {
	Tags []string
	Meta map[string]string
	Blob []byte
}

func main() {
	e := Event{Blob: []byte("hi")}
	b, _ := json.Marshal(e)
	fmt.Println(string(b))
}

输出从 v1 的 {"Tags":null,"Meta":null,"Blob":[104,105]} 变成 {"Tags":[],"Meta":{},"Blob":"aGk="}。

  • nil slice → [],恢复用 jsonv1.FormatNilSliceAsNull(true)
  • nil map → {}
  • []byte → base64 字符串,恢复用 jsonv1.FormatByteArrayAsArray(true)

对端如果是强类型语言或带 schema 校验的服务,null 变 [] 和 Blob 从数组变字符串都会直接解析失败。

map 键顺序:v1 排序输出,v2 不保证顺序。依赖输出字节做签名、做 diff、做 golden 比较的地方会随机红。需要稳定顺序传 Deterministic(true)。

time.Duration:v1 输出纳秒整数,v2 没有默认格式,直接报错。恢复用 jsonv1.FormatDurationAsNano(true),否则得给字段加自己的 MarshalJSON 或换成显式单位字段。

FormatNilSliceAsNull 和 FormatDurationAsNano 声明在旧的 encoding/json 包里(jsonv1),但 Options 类型是共享的,可以直接传给 v2 的 Marshal/Unmarshal。这就是渐进迁移能落地的原因:

import (
	jsonv1 "encoding/json"
	jsonv2 "encoding/json/v2"
)

b, err := jsonv2.Marshal(v, jsonv1.FormatNilSliceAsNull(true))

v2 里没有 json.Decoder 和 json.Encoder

v2 不提供 json.Decoder/json.Encoder。流式读写、token 级操作、值级操作下沉到 encoding/json/jsontext,v2 的 Marshal/Unmarshal 接受可变 options。原来围绕 Decoder.Token()、Decoder.More()、SetEscapeHTML 写的流式解析代码,迁移时工作量比改一个 import 大得多,要单独排期。

回退:一个环境变量保底

GOEXPERIMENT=nojsonv2 go build ./...

构建回到旧实现,用来快速确认“线上问题是不是这次换内核引起的”。这是排查手段,不是长期方案——兼容开关和 GOEXPERIMENT 都是过渡设施。

benchmark:v1 比 v2 慢,any 解析倒退回旧版本水平

同一台机器上解同一个 struct:

路径ns/op
v2 API~450
v1 API(走 v2 实现)~550
旧实现(nojsonv2 或更早版本)~920

v1 比 v2 多出来的约 100 ns/op 是兼容层开销,可以接受。

反直觉的是解析到 any(map[string]any)。Habr 作者的实测里,v1 API 解到 any 比旧实现慢约 1.5 倍,v2 API 同样的输入是 3.5 ms 对 4.2 ms。原因是 v2 为 any 准备了一条非反射的快速路径,但这条路径只在禁止重复字段名时成立。v1 的规范要求必须允许重复键,快速路径永远进不去,只能退回通用反射解码器。

而 AllowDuplicateNames(false) 在 v1 API 上设不了。摆在面前的选择只有两个:把这段解析改到 v2 上,或者继续用 v1 并接受变慢。前者是唯一能拿回性能的路。

先红的是错误文本,不是类型断言

*json.SyntaxError 和 *json.UnmarshalTypeError 仍在原位置返回,errors.As 那类代码是安全的。会坏的是比较 err.Error() 字符串的代码,请求参数校验的测试里尤其常见。

v2 的错误分成 json.SemanticError 和 jsontext.SyntacticError 两类,都带 RFC 6901 路径(形如 /items/0/name)。含非法 UTF-8 的 golden 文件同样会变。所以第一次跑全量测试时,预期红的是断言文本的地方,不是逻辑。

迁移顺序

  1. 升工具链,跑全量测试。这一步只关心两件事:错误文本断言、golden 文件。
  2. 对热点路径单独跑 benchmark,定位解析到 any 的调用点。
  3. 从最独立的新代码开始,把 import 换成 encoding/json/v2。
  4. 第一步先传 jsonv1.DefaultOptionsV1(),行为保持不变,确认只是换了入口。
  5. 逐个关兼容开关,一个开关一个 commit,每个 commit 跑一遍测试。建议顺序:AllowDuplicateNames、AllowInvalidUTF8,然后 FormatNilSliceAsNull、MatchCaseInsensitiveNames、EscapeForHTML。
  6. any 解析点单独迁到 v2,这是性能收益最大的地方,也是唯一需要改语义假设的地方。

第 5 步的顺序逻辑是:先关解码侧的严格性开关,因为这类问题会在测试里立刻暴露;再关编码侧的格式开关,因为这类问题往往要等对端报错才发现。

复制全文 生成海报 Go JSON 版本升级 性能

推荐文章

程序员茄子在线接单