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 文件同样会变。所以第一次跑全量测试时,预期红的是断言文本的地方,不是逻辑。
迁移顺序
- 升工具链,跑全量测试。这一步只关心两件事:错误文本断言、golden 文件。
- 对热点路径单独跑 benchmark,定位解析到
any的调用点。 - 从最独立的新代码开始,把 import 换成
encoding/json/v2。 - 第一步先传
jsonv1.DefaultOptionsV1(),行为保持不变,确认只是换了入口。 - 逐个关兼容开关,一个开关一个 commit,每个 commit 跑一遍测试。建议顺序:
AllowDuplicateNames、AllowInvalidUTF8,然后FormatNilSliceAsNull、MatchCaseInsensitiveNames、EscapeForHTML。 any解析点单独迁到 v2,这是性能收益最大的地方,也是唯一需要改语义假设的地方。
第 5 步的顺序逻辑是:先关解码侧的严格性开关,因为这类问题会在测试里立刻暴露;再关编码侧的格式开关,因为这类问题往往要等对端报错才发现。