本文永久链接https://tonybai.com/2026/09/16/go-json-v2-history

大家好,我是Tony Bai。

【导读】

在 Go 语言的发展史上,encoding/json 始终是一个让人又爱又恨的标准库。自 2012 年随 Go 1.0 诞生以来,大小写不敏感匹配、静默容忍重复键与非法 UTF-8、time.Duration 粗暴序列化为纳秒等设计缺陷被诟病了整整十四年;受制于严苛的向后兼容承诺,这些缺陷连同性能瓶颈一度成了无法动手术的“活化石”。日本Go 开发者 Yoshi Yamaguchi 撰写的专题长文,全景复盘了从 2016 年最初的 Bug 质问、第三方高性能库的畸形妥协,到 Joe Tsai 历经六年潜心重塑,最终在 Go 1.27 中以“v1 构筑于 v2 之上”达成惊天逆转的壮阔史诗。

【文章要点】

  • 向后兼容的甜蜜与枷锁:因为写在文档里,大小写不敏感匹配等历史缺陷无法被视作 Bug 修复;任何试图更正默认行为的努力都会破坏存量系统,直接导致早期的官方补丁(如 CL 224079)无疾而终;
  • 第三方库的妥协与技术债:为了成为“只需改一行 import 即可生效”的无缝替代品,go-jsonsonic 等第三方库被迫 100% 忠实复刻 v1 的所有缺陷,甚至在极致优化与兼容性冲突下酿成了隐蔽的生产 Bug;
  • 双包解耦架构的破局:v2 从最初立项就将纯语法分词(jsontext)与 Go 类型映射(v2)彻底剥离,终结了内存多次分配与二次递归解析的性能沉疴;
  • 倒反天罡的工程奇迹:Go 1.27 并没有废弃老包,而是将整个经典 encoding/json(v1)完全重构并托举在 v2 的底座之上,通过 13 个隐式历史标志位(DefaultOptionsV1)实现了零破坏性的底层换芯;
  • 防范海勒姆定律的“蓄意随机化”:v2 源码故意在 cannotunable to 之间随机切换报错文案,从根源上粉碎开发者对报错字符串进行正则匹配的幻想;
  • 理想与现实的技术妥协:为了等待未来的“类型化结构体标签(Typed struct tags)”,format 标签在发布前夜被紧急撤回,导致 time.Duration 在 Go 1.27 v2 中陷入了原生无法直接序列化的奇妙死局。


在 Go 语言的发展史上,encoding/json 一直是一个兼具极高使用率与诸多设计遗憾的核心标准库。自 Go 1.0 发布的 14 年间,诸如“字段名大小写不敏感匹配”、缺少真正的流式处理、部分场景下二次解析的性能开销等问题屡被诟病。然而,受制于 Go 1 极为严苛的“向后兼容性保证(Go 1 Compatibility Promise)”,这些早已被确认的技术缺陷始终无法在原有包中得到彻底根治。

本文翻译自 Go 开发者 Yoshi Yamaguchi 的专题著作《The 14-Year Road to encoding/json/v2》。文章以极其详实的时间线与代码切片,全景式复盘了 encoding/json/v2 从最初的问题暴露、第三方高性能库的妥协与技术债、社区早期的折戟尝试,到 Joe Tsai 等人主导的体系化重塑,再到最终在 Go 1.27 中巧妙实现“以 v2 为底座承载 v1”的完整演进历程。这既是一篇深入理解 Go 标准库设计思想与演变细节的技术佳作,更是一部关于大型基础软件如何在“追求工程卓越”与“恪守向后兼容”之间达成精妙平衡的生动启示录。

现将全文完整翻译,以飨读者。


本文最初以日文发表于 https://zenn.dev/ymotongpoo/books/go-json-v2-history

从 Go 1.0 开始的长达 14 年里,encoding/json 伴随着诸如大小写不敏感字段匹配等已知问题发布,且从未得到修复。本书通过塑造它的众多 Issue、提案、Gerrit 变更以及标准库源码,追溯了在 Go 1.27 中正式确立的 encoding/json/v2 的演化之路。

引言

注意:本书中的所有执行结果均在 go1.27 darwin/arm64 环境下测得。源码引用亦来自该版本。

在 Go 中编写如下代码,Name 字段最终会获取到 gopher

type User struct {
    Name string `json:"name"`
}
var u User
json.Unmarshal([]byte(`{"NAME":"gopher"}`), &u)

JSON 的键名是全大写的 NAME,而结构体标签中指定的是全小写的 name,但它们仍然匹配成功了。换成 nAmEName 结果也完全一样。第一次遇到这种行为的人往往会感到惊讶,我最初也觉得这是个错误。但这并不是 Bug,而是规范中明确规定的行为:encoding/json 的文档清晰地写着,匹配过程是大小写不敏感的。

正因为它是规范明确规定的行为,这种表现维持了整整 14 年未曾改变。

2016 年,有人提交了一个 Issue,质疑这种行为究竟是否合理。有人指出了潜在的安全隐患,甚至有人提交了修复该问题的实际 补丁(Patch)。然而,它依旧没有被修复。直到 encoding/json/v2 在 Go 1.27 中正式落地,这一行为从未发生过哪怕一次变更。

为什么它没能得到修复?为什么它最终被以另一个全新的名称彻底重构?历经 14 年光阴,它最终呈现出了怎样的形态?

答案的一部分其实存在于标准库之外。诸如 goccy/go-jsonbytedance/sonic 这类高性能第三方 JSON 库之所以会诞生,正是因为 v1 无法被修复。而在追求极致性能的过程中,它们又背负起一项奇怪的包袱:必须丝毫不差地忠实复刻 v1 的所有缺陷。这种盲目的忠实复刻,甚至引发了现实中的 Bug。

准备知识

阅读本书只需了解以下三项背景知识:

  • encoding/json 是自 Go 1.0(2012 年)起就存在的标准库;Marshal 将 Go 的值转换为 JSON,Unmarshal 则将 JSON 转换为 Go 的值。
  • Go 拥有严格的向后兼容性保证。按照 Go 1 规范编写的程序,在后续的 Go 版本中将始终能够编译并保持相同的运行行为。
  • GOEXPERIMENT 是一个在构建期指定的环境变量,用于启用尚未正式转正的实验性特性,或将已变成新默认行为的特性回退为旧实现。它与在运行时生效的 GODEBUG 有着本质不同。

那些无法修复的问题

最初的 Issue

同一天,Russ Cox 作出了回复

至少从 Go 1.2 开始这就一直是既有行为了……而且文档中也非常清晰地指出了会发生这种情况……我理解如果在安全上下文中解析 JSON 会带来安全隐患,我也略感惊讶,但文档写得非常清楚。

最终的决定是不作任何修改,理由正是“它已经在文档中明确写明了”。一旦审视 Go 的向后兼容性保证具体写了什么,这种判定的决定性依据便一目了然。

向后兼容性保证到底说了什么

Go 的向后兼容性承诺(Go 1 compatibility promise) 可以归结为一句话:

其目标是让按照 Go 1 规范编写的程序,在该规范的整个生命周期内,无需修改即可持续正确地编译并运行。

这是整个 Go 项目的立足之基石。

不过该承诺也列出了例外情形:安全漏洞、未明确定义的行为、规范本身的错误以及显而易见的 Bug。文档明确声明,这些情况是允许发生变更的。

然而大小写不敏感匹配并不符合上述任何一条例外。既然文档已写明,它就不是“未明确定义的行为”;既然被白纸黑字陈述,它也就谈不上是“Bug”。尽管有人提出了安全隐患,但这种行为被定性为了“危险的默认行为”,而非漏洞本身。

换言之,这一行为成了逃过例外条款筛网的漏网之鱼。强行修复它,势必会破坏那些依赖此特性的既有程序。无论它多么令人讨厌,向后兼容性的承诺始终凌驾其上。

搁置至 Go 2

同样的裁定在其他 Issue 中反复上演。

golang/go#4712 是一个关于 time.Duration 的 JSON 序列化形式的 Issue。encoding/json 会将 time.Duration 输出为以纳秒为单位的整数。90 * time.Second 会被序列化为 90000000000。由于其中并未标注时间单位,接收方除了数零的个数之外别无选择。

2017 年 2 月 17 日,该 Issue 被关闭。以下是 Russ Cox 的评论

如果你需要自定义 duration 的序列化行为,请定义一个实现了 json.Marshaler/json.Unmarshaler 的类型。在当前阶段,我们不会去更改 json 包中这个如此底层的细节。

Brad Fitzpatrick 紧接着简短地补充道

一切都将留待未来的 Go 2 重新考量。

截至 2017 年,这类问题全部被归类为了“当下的 Go 无法处理的事情”,被推给了遥不可及的未来版本去解决。

一次修复尝试,及其无疾而终

故事并没有以“Go 团队无所作为”而告终。实际上,有人曾付诸实践。

2020 年 2 月 26 日,encoding/json 的维护者之一 Daniel Martí (mvdan) 在该 Issue 下留言

我逐渐意识到我之前在这个 Issue 里的几乎所有评论都是错的 :) ……我确实认为 json 包的很多部分可以设计得更好,我认为关于缺失、重复或大小写不敏感匹配的边缘情况就是其中一部分。

他甚至真正写出了修复代码。摘自他 3 月 19 日的评论

解码结构体的速度慢了约 1%,但我们得到了期望的收益。……1% 的性能损失令人遗憾,但我找不到绕过它的办法。

一个可用的补丁已经写好,性能开销也被精确量化到了具体的数字:1%。即便如此,破坏依赖现有行为的存量程序这一风险依然无解。它不仅会给所有用户带来 1% 的性能回退,还会导致一部分用户的代码发生崩溃,因此 Go 核心团队最终并未批准该变更。

这次尝试产生的补丁代码——CL 224079,于 2024 年以未合入状态被正式放弃。

这是一个转折点。“无法修复”不再是一句妥协的叹息,而是在亲手写完代码、运行并基准测试后得出的确定性结论。正是在那一年的下半年,v2 的最初雏形开始悄然孕育。

v1 与 v2 的行为差异

在 go1.27 中,encoding/jsonencoding/json/v2 同时可用。让我们将相同的输入喂给两者:

package main

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

type User struct {
	Name string `json:"name"`
}

func main() {
	in := []byte(`{"NAME":"gopher"}`)
	var a User
	err1 := jsonv1.Unmarshal(in, &a)
	fmt.Printf("v1: %+v  err=%v\n", a, err1)
	var b User
	err2 := jsonv2.Unmarshal(in, &b)
	fmt.Printf("v2: %+v  err=%v\n", b, err2)
}
v1: {Name:gopher}  err=<nil>
v2: {Name:}  err=<nil>

v2 没有匹配该字段。注意,此处 v2 并没有报错。NAME 仅仅被作为未知的成员属性直接忽略,Name 字段保持零值。如果你希望在这种情况下报错,可以显式指定 RejectUnknownMembers 选项。

重复键与非法 UTF-8

大小写敏感性还不是故事的全部。我们再来看重复键和非法 UTF-8 字符的处理。

dup := []byte(`{"name":"alice","role":"user","role":"admin"}`)
bad := []byte("{\"name\":\"go\xffpher\"}")

dup 是包含了两个重复键 role 的 JSON。bad 则是在字符串内部包含了在 UTF-8 规范中非法的字节 0xff

v1 dup: {Name:alice Role:admin}  err=<nil>
v2 dup: err=jsontext: duplicate object member name "role"
v1 utf8: err=<nil> -> "gopher"
v2 utf8: err=jsontext: invalid UTF-8 within "/name" after offset 11

v1 对二者均照单全收,不产生任何错误。重复键采用了“后者胜出(last-wins)”的覆盖规则,而无效的 UTF-8 字符则被悄悄替换为了 Unicode 替换字符(``)。调用方根本无法得知输入的 JSON 数据其实已损坏。

当有多方读取同一份 JSON 时,重复键就会引发安全灾难。试想一个认证代理(Proxy)在看到 "role":"user" 后放行了请求,而其后的后端应用却读到了 "role":"admin"。即便二者都遵循同样的规范,只要一方是“前者优先”,另一方是“后者优先”,它们的判定结果就会产生偏差。2023 年发起的 讨论 #63397 将此描述为“容易被攻击者利用,且在过去曾被攻击并导致严重后果的隐患”。

上述 v2 的错误信息均以 jsontext: 开头。它们来自一个独立的包——encoding/json/jsontext。在 v2 中,处理 JSON 语法结构的层级与负责在 JSON 和 Go 值之间建立映射的语义层级被彻底解耦。重复键和非法 UTF-8 均在纯语法校验阶段就被直接拦截,在此阶段根本不会触碰任何 Go 类型。那么,为什么要把读写 JSON 的层级单独拆分成一个独立的包呢?追求性能似乎是个合乎直觉的猜测,但其实这并非核心原因。

v1 问题的四大分类

讨论 #63397 一开篇就将 v1 存在的诸多问题划分为了四大类别。结合当时的 Issue 编号,它也成为了一份审视这 14 年历史的绝佳档案。

被归为**缺失的功能(Missing functionality)**的有:

  • 缺乏指定 time.Time 格式的途径(#21990
  • 缺乏在输出中忽略特定值的方式(#22480#50480 等)
  • 缺乏将 nil slice 和 nil map 序列化为 []{} 而非 null 的方法(#37711#27589
  • 缺少一个无需使用结构体内嵌即可展平结构体的 inline 标签(#6213

被归为 **API 缺陷(API deficiencies)**的有:

  • 即使输入数据末尾残留着垃圾字符,json.NewDecoder(r).Decode(v) 依然会静默返回成功(#36225
  • 无法向 MarshalUnmarshal 传递配置选项,因而无法深入影响嵌套类型的处理(#41144
  • CompactIndentHTMLEscape 只能写入 *bytes.Buffer;无法直接传入 []byteio.Writer

性能瓶颈则根植于其底层设计。

MarshalJSON 返回的是 []byte,这意味着其所有实现都不可避免地需要分配一次字节切片内存。随后,调用方必须重新解析返回的字节以进行校验和重新缩进。

UnmarshalJSON 的情况更为糟糕:由于它必须接收一个完整的 JSON 值,整个值在调用前需要先被完整解析一次,然后在该方法内部又被再次解析。当嵌套类型各自拥有独立的 UnmarshalJSON 时,这种双重重复解析的开销会随着嵌套层级成倍激增。该讨论援引了在加载 Kubernetes OpenAPI 规范时引发实际性能危机的案例。该分类下同样包含了 5 个痛点;其余 3 项则关乎流式 API 的缺失——即使 EncoderDecoder 接收了 io.Writerio.Reader,底层却依然会在内存中缓冲整个完整的 JSON 值(#33714 等)。

最后则是行为缺陷(Behavioral flaws)。这里列出了 5 项,本文章重点关注以下 3 项:

  • 容忍非法的 UTF-8 字符和容忍重复键(#43664
  • 大小写不敏感匹配(#14750
  • MarshalJSON 会因为值是否可寻址(addressable)而出现有时被调用、有时被跳过的诡异现象(#22967 等)

最后一项可以通过一段简短的程序复现:

package main

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

type Tag struct {
	Name string
}

// 采用指针接收者定义
func (t *Tag) MarshalJSON() ([]byte, error) {
	return []byte(`"` + strings.ToUpper(t.Name) + `"`), nil
}

func main() {
	slice := []Tag{{Name: "go"}}         // 元素是可寻址的
	m := map[string]Tag{"lang": {Name: "go"}} // map 的值是不可寻址的
	b1, _ := jsonv1.Marshal(slice)
	b2, _ := jsonv1.Marshal(m)
	fmt.Printf("v1 切片: %s\n", b1)
	fmt.Printf("v1 Map : %s\n", b2)
	b3, _ := jsonv2.Marshal(slice)
	b4, _ := jsonv2.Marshal(m)
	fmt.Printf("v2 切片: %s\n", b3)
	fmt.Printf("v2 Map : %s\n", b4)
}
v1 切片: ["GO"]
v1 Map : {"lang":{"Name":"go"}}
v2 切片: ["GO"]
v2 Map : {"lang":"GO"}

在 v1 中,如果目标值是切片元素,MarshalJSON 会被正常调用,输出 "GO";但如果是 map 的值,该方法就根本不会被调用,而是直接回退为结构体的默认输出方式。同一类型的同一个值,仅仅因为所处的数据结构位置不同,就输出了完全不同的结果。而在 v2 中,两种情况下均会一致地调用自定义方法。

关于最后一项,讨论中有一段值得细细品味的说明:

这完全可以被视为一个 Bug 并在当前的 ‘json’ 包中直接予以修复。然而,之前的修复尝试最终都被迫回滚了,因为有太多底层系统隐式地依赖了这种前后不一致的方法调用行为。

即便是一个完全符合例外条款的“Bug”,一旦在其之上建立起了庞大的依赖网络,它也变得无法被修复了。

‘json’ 的这些行为缺陷若不引入破坏性变更(Breaking Change)就根本无法修正。固然可以通过增加配置选项来切换行为,但这并不理想,因为我们所期望的正确行为理应成为默认行为,而非可选行为。改变默认行为,正昭示着一个全新的 v2 ‘json’ 包的必要性。

第三方的权宜之计

为速度而生的第三方库

在标准库 encoding/json 无法被修复的 14 年间,处理 JSON 的 Go 开发者们并没有坐以待毙。特别是在性能层面,社区涌现了大量外部库,各展所长:

  • mailru/easyjson 选择了代码生成路线。只需运行 easyjson -all foo.go,它就会为每个类型生成专有的序列化与反序列化逻辑。它彻底摒弃了反射,因此速度飞快,但代价是构建流程中多了一步代码生成环节,且无法直接充当 encoding/json 的无缝替代品。
  • json-iterator/go 保留了反射,但通过按类型缓存解析流程来大幅提速,并被精心打包成只需替换 import 路径即可直接生效的库。
  • goccy/go-json 在该方向上走得更远。它通过分析类型结构,将其编译为一系列操作码(Opcodes)并在循环中执行。整个执行流程基于跳转指令而非深层递归,且编译结果直接通过类型指针索引从切片中快速读取。针对字段数在 16 个及以下的结构体,它利用位图(Bitmap)来定位字段,完全免去了 map 查找。
  • bytedance/sonic 则走到了极致,它在运行时通过 JIT 动态生成对应类型的机器码,并利用 SIMD 指令集进行字节扫描。

这些库的诉求在它们的 README 中展现得淋漓尽致。segmentio/encoding 写道:在他们运转的业务规模下,构建程序所选用的工具会对整套系统的整体效率产生决定性影响。buger/jsonparser 则源于另一层痛苦:在处理结构未知的 JSON 时,encoding/json 强迫用户提前准备好结构体,而反序列化到 map[string]interface{} 中又极其缓慢。

作为无缝替代品的沉重代价

以下是摘自 goccy/go-json README 的一句话:

追求性能的话,采用自动代码生成或使用专用接口会更容易实现,但 go-json 偏偏执着于与 encoding/json 的兼容性,并维持了最简洁的接口。尽管如此,我们依然以成为最快的库为目标在持续开发。

这句“偏偏执着于(dares to stick to)”,正是这些外部库所背负的沉重枷锁。只要它们标榜的核心价值是“只需修改一行 import 路径就能生效”,它们就必须输出与 v1 完全一模一样的结果。而这个“与 v1 一模一样”,自然包含了上一章所提及的全部历史缺陷。

大小写不敏感匹配,成了它们必须照搬的规范;静默以“后者胜出”的方式处理重复键、将 nil slice 输出为 null,亦是如此。那些渴求极致速度的工程师们,却不得不把大量时间耗费在忠实复刻那些他们恨不得立刻修复的荒唐行为上。

极限优化与 v1 兼容性的剧烈冲突

这种严苛约束甚至最终演变成了一个真实的 Bug。

goccy/go-json#568 于 2026 年 2 月 12 日被提交,截至撰写本文时仍未解决。问题报告指出:当目标结构体拥有 17 个或更多可见 JSON 字段时,goccy/go-json 就不再进行大小写不敏感匹配了;而如果只有 16 个或更少的字段,匹配又能正常工作。

16 这个分水岭,正是前文提到的位图优化的上限。字段在 16 个以内时,通过位图就能确认字段,不需要经过 map 查找。从第 17 个字段开始,执行流程会退回到另一条分支路径。而那条备用路径,恰恰漏掉了 v1 的大小写不敏感匹配逻辑。

极致优化与对 v1 的忠实兼容在同一套代码中发生了不可调和的碰撞。更恶劣的是这种故障模式极其隐蔽:只要你在结构体里多加了一个字段,整个解析行为就会瞬间发生翻转。在该项目的同一个仓库中,指出嵌套结构体无法正常进行大小写不敏感匹配的 Issue #470 至今也依然处于打开状态。

对运行时内部细节的危险依赖

追求极速的代价还蔓延到了另一个领域。

2024 年,Go 团队开始在 golang/go#67401 中限制 linkname 的滥用。linkname 是一种允许外部代码绕过可见性限制,强行绑定到其原本无法访问的底层内部包符号的黑科技机制。该 Issue 中有这样一段描述:

例如,https://go.dev/cl/583756 导致 github.com/goccy/go-json 崩溃了,因为调查发现该库直接复制了 runtime 的大部分内部类型 API。现在我们几乎不敢改动那个列表里的任何东西,尽管那本是一个名正言顺的内部包,否则就会直接搞垮 goccy/go-json。而 goccy 被包括 Kubernetes 在内的众多核心项目广泛使用……这种局面是不可持续的。

这里提到的变更,仅仅是一个对 runtime 内部细节进行常规小清理的 CL

局势被彻底逆转了。在标准库内部,向后兼容性保证死死限制着 encoding/json 无法被随意修复;而在标准库之外,第三方库对底层内部机制的肆意依赖,反过来绑架了 Go runtime 自由重构的权力。goccy/go-json 所背负的,不仅是为了兼容性而复刻 v1 缺陷的重担,还有为了追求极速而建立在“runtime 内部永远不变”这一完全不可靠假设上的巨大风险。

为何标准库从未将它们吸纳采纳

既然外部社区已经有了如此迅捷的高性能实现,我曾困惑为什么 Go 官方不干脆直接合并吸收其中一个?讨论 #63397 正面回应了这一疑问:

社区中有许多针对 v1 ‘json’ 的分支或二次实现。尽管它们展现出了惊人的性能提升,但基于它们对 ‘unsafe’ 包的大规模滥用,它们绝不可能被吸纳进标准库中。2021 年 Go 开发者调查明确显示,相较于 CPU 或内存性能,用户对可靠性和安全性的保障给予了更高的优先级。

这一决策依据建立在 2021 年 Go 开发者调查报告 之上,在我看来极具 Go 的典型风格——在性能与安全性之间的抉择,并不是凭借设计者的个人偏好拍板,而是将其作为向全体用户征询意向后的民主结果。

v2 的作者 Joe Tsai 在其发布的基准测试仓库 go-json-experiment/jsonbench 中明确声明:goccy/go-json 存在会导致可复现的数据竞态(Data Race)和内存损坏的严重 Bug,无法安全地用于生产环境。虽然 Joe 本人就是 v2 的缔造者,这个评价很难算作绝对中立的第三方结论;但客观事实上,goccy/go-json 的 Issue 列表中确实充斥着多起关于数据竞态与程序崩溃的报告,其近期的提交记录也一直在频繁修复编解码编译代码中的竞态问题。

引用同一仓库中的基准测试数据时同样需要注意上下文背景:针对具体类型,v2 的 Unmarshal 速度被报道比 v1 快 2.7 到 10.2 倍;Marshal 则在快 1.4 倍到慢 1.2 倍之间浮动。但请谨记,这批测试数据取自 2025 年 1 月的 Go 1.23.5,测量的是它正式并入标准库之前的早期原型版本。

v2 的起源

2020 年底的设计草案

mvdan 在 2020 年 3 月提交并放弃了那个修复补丁。同年下半年,他便着手草拟 v2 的设计蓝图。

Go 官方博客 写道,无法在现有包中修复遗留问题正是这一行动的导火索。这份草案公开在名为 encoding/json v2 draft 的文档中,开篇便立下免责声明:

请注意,作为 encoding/json 的维护者之一,这很大程度上仅代表我个人的见解。这绝非一份正式的提案,目前也尚未获得 Go 项目官方的背书。

虽然标榜为个人见解,但文后的致谢名单中却赫然列着 Philip Pearl、Matt Layher、Dave Cheney、Chris Hines、Roger Peppe 和 Joe Tsai 等名字。

这是一个堪称豪华的技术阵容:有人曾写出深度剖析 encoding/json 缓慢原因的系列长文,有人写出了极速 JSON 分词器,有人自 Go 诞生之日起便是核心贡献者,更有长期在标准库之外不断精进序列化性能的技术大牛。其中,Go 核心团队成员 Joe Tsai 在仅仅数周之后,就为 v2 原型提交了第一行代码。

该设计草案的正文将亟待解决的核心矛盾概括为以下四大部分:

  • 内存缓冲不可避免
  • MarshalerUnmarshaler 无法传递配置选项
  • MarshalJSON 永远在发生内存分配
  • Decoder.Decode 容易诱发滥用

无法修复的顽疾被连同它们的 Issue 编号逐一罗列,但尤其值得关注的是写在最前面的设计原则:

我们希望坚守标准库的设计哲学:默认遵循正确性高于性能、绝不引入 unsafe、不需要引入诸如代码生成之类的额外构建步骤。这就直接排除了绝大部分第三方 JSON 库的设计思路。

然而,第三方库中依然有值得借鉴的智慧。“前人工作(Previous work)”章节中,引述了 json-iterator/go 旨在减少内存分配的 API 设计、Phil Pearl 对 Marshaler 性能瓶颈的透彻分析,以及 Dave Cheney 的极速分词器实现。草案的立场十分明晰:不会全盘照搬它们的设计理念,但会充分汲取它们拆解问题的切入视角。

文档中还保留着这样一条评论留言

另外一点:列出一份我们想要从新 API 中彻底埋葬/隐藏的 v1 既有语义清单,并且让这些过时语义仅通过旧的 API 入口保持工作。

后文中将介绍的 AllArshalV1Flags,正是将这短短的一行留言具象化为了代码。

草案序言还意味深长地写了这么一句:

本文并不打算催生又一个 encoding/json 的外部竞争对手。然而,为了能充分试验这些改动,未来很有可能会开启一个独立的分支演练项目。

始于语法层的全新实现

草案发布仅数周后,实际编码工作便正式启动。

github.com/go-json-experiment/json最初提交 发生于 2020 年 10 月 23 日,提交者正是 Joe Tsai。彼时他还在 Google 负责 Protocol Buffers 的 Go 语言实现。但这套仓库完全建在他的个人账号下,提交也全部使用了个人邮箱。这在一开始根本不是一份由公司委派的职务工作。随后的提交日志完整勾勒出了这项设计的递进次序:

2020-10-23  Initial commit of base files
2020-10-29  Add README.md (#1)
2020-11-21  Add initial API for syntactic JSON serialization (#2)
2020-11-23  Add error types and functionality (#7)
2020-11-23  Add "Design overview" section to the readme (#10)
2020-12-03  Add state machine for validating token sequences (#8)
2020-12-13  Add basic serialization functionality (#11)
2021-01-26  Implement Token (#22)
2021-02-05  Implement Encoder (#32)
2021-02-20  Implement Decoder (#33)

最早添加的 API 是“JSON 语法序列化初始 API(syntactic JSON serialization)”,即纯粹的语法层。而负责 Go 值与 JSON 之间映射的语义层,直到三周之后才首次登场。

将语法与语义清晰拆解并不是后来为了优化性能而做的事后剥离,而是在最初的 API 提交中就已被深度锚定。之所以采用这种架构,与 Joe Tsai 投身这项工作的个人背景密不可分。

protojson 的苛刻约束

Joe Tsai 曾深度参与 Protocol Buffers 的 Go 语言实现。其中包含一个名为 protojson 的核心子包,专门负责在 Protobuf 消息与 JSON 之间进行双向转换。

摘自 Go 官方博文《针对 JSON 的全新实验性 Go API》(“A new experimental Go API for JSON”):

在此前负责 Protocol Buffers 的 Go API 工作期间,Joe Tsai 感到十分受挫:protojson 包不得不自行维护一套高度定制的内部 JSON 实现,因为既有的 encoding/json 既无法满足 Protobuf 规范所要求的那种极为严苛的 JSON 标准,也无法以真正流式的方式高效序列化 JSON。

“规范严苛性”与“流式处理”,本质上都是 JSON 语法解析与输出层面的问题,与 Go 类型如何映射到 JSON 毫不相干。protojson 当初被迫自研的核心,恰恰正是那个纯粹的语法层。这也就完全解释了为何该项目的首个 API 提交会从语法层破局。这种层级划分的初衷并非单纯为了榨取速度,而是因为一个既能严格遵循 JSON 标准、又无需在内存中预先拼装整个完整数值的底层实现,早已在标准库之外被迫自研成型。jsontext 的前身与灵感源泉,正是深植于 Protobuf 内部的那套 JSON 底层驱动。

README 中的六大目标

go-json-experiment/json第一版 README 列出了 6 项宏大目标。其中两项尤为深远地奠定了后续的发展轨道。

其一是针对向后兼容性的界定:

在行为表现上,我们应当争取达到 95% 至 99% 的向后兼容。我们不追求 100% 的绝对兼容,因为我们必须保留推翻并纠正那些如今已被公认为设计失误的特性的自由。

在这里,v1 那些顽疾历史上第一次被直截了当地定性为了“失误(mistake)”。而另一项关键目标,则预示了其最终归宿:

既然 v1 的实现必须被永久保留,那么如果能在底层直接基于 v2 来重新实现 v1,将会带来极大的裨益。

早在 2020 年 10 月,整整 6 年后才最终被标准库采纳的技术架构,就已被清清楚楚地写落纸面。当时的 README 还包含一个“预期(Expectations)”章节,列出了 5 种可能的结局,排在首位的第一种可能竟然就是彻底放弃该项目。这绝非一份盲目乐观、默认自己必将功成名就的文档。

在 Tailscale 验证真金

仅仅拥有精良的设计和代码实现,还不足以成为并入 Go 标准库的通行证。这个全新的原型方案必须经历真实战火的洗礼。

2021 年 7 月,Joe Tsai 加盟 Tailscale;2022 年 10 月,他亲手将该实验性模块作为生产依赖引入了 Tailscale 内部体系。是由作者亲自将它带进了新东家的生产线;在此之前,甚至没有任何第三方团队在严肃业务中率先吃下这只螃蟹。

讨论 #63397 的稳定性(Stability)章节中有着这样的陈述:

我们对该模块的正确性与性能拥有充分的信心,因为它已经在 Tailscale 的各类生产服务中得到内测验证。然而,该模块目前本质上依然是一个实验性项目,基于本讨论的反馈我们很可能会引入破坏性变更;它绝不应该被用于公开发布的代码库中,否则将会导致大规模程序发生构建冲突。

这里提到的“各类生产服务”,指代的是 Tailscale 那些未开源的核心后端服务。彼时在其开源仓库中,唯一用到该模块的仅仅是一个用于日志格式化的小工具命令。

后半部分的警告,旨在防止公共开源库因依赖实验性模块而引发“钻石依赖地狱”。若程序 P 同时依赖模块 A 和 B,而 A 和 B 锁定了不同版本的 go-json-experiment,构建系统将直接宣告瓦解。

官方博客还给出了另一个现实生产维度的反思用例:Kubernetes OpenAPI 规范解析性能灾难——因嵌套的自定义 UnmarshalJSON 实现被层层递归重复调用,整个解析过程的耗时呈二次方指数级飙升。架构设计本身的深层弊端,在超大规模的现实生产场景中最终引爆成了不可承受之重。

从讨论到正式提案

math/rand/v2 开创的先例

2023年10月5日,Joe Tsai 创建了GitHub Discussion #63397(“encoding/json/v2”),这距离原型的第一次提交已经过去近三年了。

为什么选在那个时间点?因为就在两天前的 10 月 3 日,math/rand/v2 提案 golang/go#61716 刚刚获得正式批准。

发起 math/rand/v2 提案的 Russ Cox 曾在该年 6 月创建了讨论 #60751,标题直白地写着:“math/rand/v2: a new API for math/rand and a first v2 for std”(math/rand/v2:math/rand 的新 API,以及标准库历史上的首个 v2)。他亲自将其定性为整个 Go 标准库历史上的首个 v2 试验田。

在那之前,“标准库中到底允不允许引入 v2 版本的子包”本身还是一个毫无定论的未知数。就在该路径的可行性得到正式确认的仅仅两天后,encoding/json/v2 的公开讨论便正式拉开帷幕。

核心争议焦点

讨论 #63397 引发了巨大的关注,斩获了海量的回帖与点赞。文档正文明确注明:内容深度吸纳了 mvdan、johanbrandhorst、rogpeppe、chrishines 以及 rsc 等多位资深专家的核心意见。

当时集中交锋的核心矛盾主要聚焦于以下四点:

争议点 v1 v2 讨论过程
Map 的输出顺序 稳定有序(键经排序) 不保证确定性顺序(随机) 出于单元测试及文本比对(diff)的便利性考虑,大量开发者表达了强烈反对。最终决定默认不强加排序开销;确有强需求的用户可通过显式传入 Deterministic 选项来指定排序
nil slice 与 nil map 输出为 null 输出为 []{} 反对者认为这破坏了序列化往返的一致性(Round-tripping),且抹杀了 Go 原生 nil 所携带的语义信息
omitempty 的判定准则 依据是否为 Go 类型系统的零值(false0、nil 指针、空字符串等) 依据序列化后是否为空的 JSON 值 判定基准从 Go 的自身类型系统彻底迁移为了 JSON 的类型系统
null 解析至不可为 null 的 Go 类型 不产生错误 不产生错误 部分人主张应当直接拦截报错,但该提议最终被否决,理由是绝不应该为了迁就 Go 静态类型系统的便利,而去拒绝符合规范的合法 JSON 文本

对 v1 的永久支持承诺

在讨论中被反复抛出的一个核心担忧是:v1 会不会被宣告废弃(Deprecated)?

官方给出的答复毫不含糊。如今 encoding/json 的官方文档中赫然写着这样一句话:

Go 语言中所有全新的“json”使用场景均建议使用 v2 包,但 v1 包将永远获得官方的技术支持。

同样是在 2023 年 10 月,Joe Tsai 在 GopherCon 2023 上发表了题为《The Future of JSON in Go》 的演讲,时间恰好与发起该讨论处于同一周。

正式提案与功能冻结

从社区讨论走向正式提案耗费了一年零三个月。2025 年 1 月 31 日,golang/go#71497 提案正式确立,这是一份同时涵盖了 encoding/json/v2encoding/json/jsontext 两个全新包的联合提案。在提案正文 中,Joe Tsai 称其为“迄今为止针对 Go 标准库规模最为庞大的一次重构修订”。

从讨论阶段迈向正式提案的过程中,多处命名发生了重要调整。早期原型中的 MarshalWriter / MarshalNext 演变为了 MarshalWrite / MarshalEncode;而讨论阶段的 MarshalerV2 / UnmarshalerV2 则最终定型为更符合 Go 惯用法命名的 MarshalerTo / UnmarshalerFrom

随着代码审查的纵深推进,一种坚决遏制新功能膨胀的克制态度被明确树立。摘自 Damien Neil 在 2026 年 4 月 10 日的审查评论

我们当前的核心目标是让这份规模已经足够庞大的提案顺利过线,必须坚决避免在这个节骨眼上陷入无休止的特性蔓延(Feature Creep)。比起继续塞入新特性,我们当下更有可能为了能在首发版本中顺利落地,而暂时撤下部分功能,以便日后对其进行孤立考量。

这种表态绝非说说而已,这一点在临近最终发布的关键节点得到了令人震撼的印证。

借助 GOEXPERIMENT 开启公开尝鲜

在提案提交的同一年——2025 年 8 月,Go 1.25 将 v2 隐藏在 GOEXPERIMENT=jsonv2 构建标志之后随编译器一同发布。只有在显式设置该环境变量进行构建时,encoding/json/v2encoding/json/jsontext 两个包才会在标准库中对代码可见。未开启该标志时,标准库中完全不包含它们。

2025 年 9 月 9 日,Go 官方博客刊发了题为《针对 JSON 的全新实验性 Go API》(“A new experimental Go API for JSON”)的技术博文:

该项目主要是由非 Google 雇员的开发者主导推动并实现的,这生动展示了 Go 项目作为一个全社区协作生态的强大生命力。

2025 年 11 月 20 日,json/v2 专门工作组(Working Group)正式宣告成立,并开始举行每周定期会议并向全网公开发布会议纪要。

彻底解决 time.Duration 争议

在该阶段尘埃落定的核心议题之一,便是关于 time.Duration 的终极决断。整个 Go 项目在权衡取舍上的深层思维模型,几乎被凝炼地浓缩在了这一决断之中。

回想一下:v1 会将 time.Duration 粗暴地输出为纳秒整数。golang/go#71631 是一个专门用于拍板 v2 到底该何去何从的子提案。

2025 年 12 月 11 日,经过工作组的深入研讨,Damien Neil 给出了最终裁定

JSONv1 将 duration 序列化为以纳秒为单位的整数。我们一致认为这在当初是一个极其明显的失误:序列化结果中没有任何单位标识,极易引发时间单位转换的严重 Bug。并且,纳秒整数只需累加到第 104 天就会彻底击穿 JavaScript 的 float64 精度安全上限。

如果 JSON 是由前端 JavaScript 消费,任何超过 104 天的持续时间都会彻底丧失精度。那么,究竟应该改成什么呢?

全球行业标准显然正在向 ISO 8601 全面靠拢……如果我们是从一张白纸重新起步,这无疑是最正确的选择。然而,静默更改表现形式是极其凶险的。如果我们暗中改变了 time.Duration 的默认表示法,那么那些将代码从 encoding/json.Marshal 迁移到 encoding/json/v2.Marshal 的开发者,极有可能被这种隐式突变打得措手不及。

最终的结论宣告道:

我们的结论是:我们既绝不打算保留旧的荒唐默认值(纳秒),但我们也绝不想静默暗改 duration 的表示形态。因此,encoding/json/v2 应当强制要求调用方必须显式指定格式。

工作组最终敲定了这第三条路径。在 v2 中,直接对一个原生 time.Duration 调用 Marshal 会直接抛出错误。唯有开发者显式配置了期望的时间格式时,序列化才会宣告成功。既不“无脑继承糟糕的旧默认行为”,也不“擅作主张暗度陈仓换成新默认格式”,而是“索性剥夺默认行为,强迫代码编写者显式抉择”。

提案被正式批准

2026 年 4 月 16 日,#71497 提案的状态被正式推入“活跃(Active)”流程。

4 月 29 日,Austin Clements 划定了最后通牒:若想在功能冻结期之前完成整体审查,必须在一周之内提交一份完整反映了自 1.26 GOEXPERIMENT 发布以来的所有最新变动的终版提案。

次日,即 4 月 30 日,Joe Tsai 刷新了提案内容,指出这将作为初始稳定版本并初步拟定以 Go 1.27 为交付里程碑。

而在同一天,Joe Tsai 亲手撤回了一项重大核心功能。

5 月 6 日,提案全员终审会议正式召开。幸存至今的会议纪要 精准提炼了这份最终获批的架构设计精髓:

@dsnet 出席会议并带领全员进行了全套 API 的完整通盘走查。jsontext —— 倾向于追求极致性能而非绝对的安全冗余。其 API 倾向于直接引用(aliasing)内存而非无节制分配。该包的定位不是让大众随处滥用,而是专门服务于极致深度定制与极高吞吐的核心场景。Options —— 这一机制横向打通了编码与解码,纵向贯穿了语法层与语义层。工作组耗费了大量心血探索替代方案,最终论证后重新回归了此套方案。json/v2 —— UnmarshalRead 始终严格读取直到遇到 EOF。这与 v1 形成了鲜明对照,在 v1 中,调用 Decode(io.Reader) 却忘记在末尾校验 Reader 是否真正读完 EOF 是极为常见的低级失误。结构体标签 —— omitempty 如今完全基于 JSON 类型系统来界定,彻底摆脱了对 Go 类型系统定义的依赖。👍 会议室内全员一致赞成。

该提案在当天被直接标记为“大概率批准(likely accept)”,并于 2026 年 5 月 13 日 获得正式批准合入。Austin Clements 的批复短小精悍

评审共识未发生动摇,正式批准合入。🎉

5 月 22 日,交付里程碑被锁定为 Go 1.27;6 月 9 日,该 Issue 彻底标记为已实现(implemented)并关闭。

距离 2016 年 3 月那声“这难道不是有 Bug 吗?”的质问,时光已经悄然流转了整整 10 年零 2 个月。

v1 如何构筑于 v2 之上

将 v1 建立在 v2 之上

在 Go 1.27 中,encoding/json/v2encoding/json/jsontext 迎来了名正言顺的官方转正。与此同时,原有的 encoding/json 在底层被完全重写,直接嫁接在了 v2 的底座之上。

摘自官方 Go 1.27 发行说明(Release Notes)

encoding/json 包如今已全面改由 v2 核心实现提供底层支撑。原有的序列化与反序列化行为得到完整保留,但具体的错误提示文本可能存在细微差异。

2020 年 10 月那份初代 README 中写下的“如果能在底层基于 v2 重新实现 v1 将会极为有益”,在今朝字字兑现,全然成真。

初看之下,这俨然是一场普天同庆的技术飞跃。它读起来就像是给全世界所有依然在写 json.Marshal 的开发者送上的免费午餐:你什么都不用改,无需任何繁琐迁移,就能自动享受 v2 那极致飞快的全新实现红利。

历史遗留行为标志位的全貌

那么,原先 v1 的怪异行为究竟是如何做到完美保真的呢?encoding/json 的官方文档给出了直白的答案:

如前所述,v1 的全部能力均是在底层直接基于 v2 组装构建的,它通过在底层隐式注入一系列兼容选项来主动向旧有行为妥协。举例而言,Marshal 在底层是直接透传调用并向 jsonv2.Marshal 传入了 DefaultOptionsV1。

而这份神秘的 DefaultOptionsV1,其底层常量就深藏于标准库的一个内部私有包中。点开 internal/jsonflags/flags.go,你将赫然看到这样一份壮观的清单:

// Marshal and Unmarshal flags (for v1).
const (
	_Bools = (maxArshalV2Flag >> 1) << iota
	CallMethodsWithLegacySemantics // marshal or unmarshal
	FormatByteArrayAsArray         // marshal or unmarshal
	FormatBytesWithLegacySemantics // marshal or unmarshal
	FormatDurationAsNano           // marshal or unmarshal
	MatchCaseSensitiveDelimiter    // marshal or unmarshal
	MergeWithLegacySemantics       // unmarshal
	OmitEmptyWithLegacySemantics   // marshal
	ParseBytesWithLooseRFC4648     // unmarshal
	ParseTimeWithLooseRFC3339      // unmarshal
	ReportErrorsWithLegacySemantics// marshal or unmarshal
	StringifyWithLegacySemantics   // marshal or unmarshal
	UnmarshalAnyWithRawNumber      // unmarshal; for internal use by jsonv1.Decoder.UseNumber
	UnmarshalArrayFromAnyLength    // unmarshal
	maxArshalV1Flag
)

整整 14 年间“那些想改却永远不敢改的沉旧顽疾”,如今在这里整整齐齐地列队排开,每一项都被打上了耻辱却又不可或缺的名字。

FormatDurationAsNano,正是当年 Russ Cox 在 2017 年挥泪关闭 Issue、宣称“这个核心细节改不得”的终极真身;某些老读者也一定能瞬间看懂 ParseTimeWithLooseRFC3339ParseBytesWithLooseRFC4648:历史上的标准库对 RFC 规范的遵循极其松散,甚至本该严正报错拦截的畸形输入都被悄悄予以放行。UnmarshalArrayFromAnyLength 允许把一个只有 3 个元素的 JSON 数组强行塞给一个长度定义为 5 的 Go 静态数组。OmitEmptyWithLegacySemantics 则见证了 omitempty 在语义上的根本质变:判定准则彻底从 Go 的类型系统脱钩,回归到了 JSON 自身的类型系统。

在全部 13 个标志位中,有多达 6 个带上了 WithLegacySemantics 后缀,直译过来就是“沿用那些过时的历史语义”。单纯为了向 v1 行为对齐本可以随意起名为 V1Semantics,但设计者偏偏极具批判色彩地选用了 Legacy(历史包袱) 一词。2020 年 README 中被定性为“如今已被公认为失误的设计”,终究在标准库的底层源码中化为了一个又一个无地自容的专属标识符。

这些陈旧行为没有被暴戾地直接删去,也没有被掩耳盗铃地隐藏。它们以 v1 兼容行为的姿态被永恒封印,并以一种在必要时可以被按需逐一手动启用的精细形态完整留存了下来。

利用 DefaultOptionsV1 精雕细琢运行行为

只要亲手运行一段代码,就能透彻领悟这一长串标志位到底蕴含着多么强大的威力:

package main

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

type Doc struct {
	Tags  []string          `json:"tags"`
	Attrs map[string]string `json:"attrs"`
	Body  string            `json:"body"`
}

func main() {
	d := Doc{Body: "<b>hi</b>"} // Tags 和 Attrs 此时均为 nil
	b1, _ := jsonv1.Marshal(d)
	fmt.Printf("v1                       : %s\n", b1)
	b2, _ := jsonv2.Marshal(d)
	fmt.Printf("v2                       : %s\n", b2)
	b3, _ := jsonv2.Marshal(d, jsonv1.DefaultOptionsV1())
	fmt.Printf("v2 + DefaultOptionsV1    : %s\n", b3)
	fmt.Printf("与 v1 是否完全一致       : %v\n", string(b1) == string(b3))
	// 以 v1 为基准,仅将 HTML 转义行为单独切换至 v2 风格
	b4, _ := jsonv2.Marshal(d, jsonv1.DefaultOptionsV1(), jsontext.EscapeForHTML(false))
	fmt.Printf("v1 - HTML 转义           : %s\n", b4)
	// 以 v2 为基准,仅将 nil 切片的序列化表现单独倒退回 v1 风格
	b5, _ := jsonv2.Marshal(d, jsonv2.FormatNilSliceAsNull(true))
	fmt.Printf("v2 + nil 切片转为 null   : %s\n", b5)
}
v1                       : {"tags":null,"attrs":null,"body":"\u003cb\u003ehi\u003c/b\u003e"}
v2                       : {"tags":[],"attrs":{},"body":"<b>hi</b>"}
v2 + DefaultOptionsV1    : {"tags":null,"attrs":null,"body":"\u003cb\u003ehi\u003c/b\u003e"}
与 v1 是否完全一致       : true
v1 - HTML 转义           : {"tags":null,"attrs":null,"body":"<b>hi</b>"}
v2 + nil 切片转为 null   : {"tags":null,"attrs":{},"body":"<b>hi</b>"}

输出的第三行与原生的 v1 产生了逐字节的绝对一致。你调用的是现代的 v2 API,拿到的却是跟 v1 分毫不差的古典输出。

再紧接着观察第 4 行与第 5 行:你可以立足于 v1 的旧基底,将某一个特定行为单点推进至 v2 的新标准;抑或是以 v2 的新规范为起点,把某项遗留习惯单点回拨到 v1 的旧世界。后传入的选项拥有更高的覆盖优先级,因此“先放一个 DefaultOptionsV1() 兜底、随后在后面跟上单项微调覆盖”的编程模式运作得极为优雅。

在长达 14 年的时间里,全宇宙的 Go 开发者面对的永远只有一道残忍的二选一非黑即白:要么生吞活剥咽下 v1 的所有缺陷,要么承受业务代码被彻底搞炸的风险强推翻新。而如今,你可以在 v1 和 v2 之间随心所欲地任意游弋驻足,兼容性再也不再是一个被绑架的单选死局。

encoding/json官方技术文档 极其坦诚地将这种精细调谐用法列为了渐进式迁移的官方推荐范式:

jsonv1.Marshal(v)
// 默认的 v1 原生历史行为

jsonv2.Marshal(v, jsonv1.DefaultOptionsV1())
// 语义上等价于调用 jsonv1.Marshal

jsonv2.Marshal(v, jsonv1.DefaultOptionsV1(), jsontext.AllowDuplicateNames(false))
// 大体遵循 v1 行为,但单点启用了 v2 严正拒绝重复键的高可靠安全特性

jsonv2.Marshal(v, jsonv1.CallMethodsWithLegacySemantics(true))
// 大体遵循 v2 规范,但单点保留了 v1 那种针对不可寻址值跳过方法调用的陈旧行为

jsonv2.Marshal(v)
// 完全纯正的 v2 默认现代化标准行为

实操验证

源码中并存的两套实现

探入 Go 1.27 的源码树,你还会挖掘出一处奇妙的风景:

$ ls $(go env GOROOT)/src/encoding/json/
decode.go       encode.go       ...
v2_decode.go    v2_encode.go    v2_inject.go   v2_options.go   ...

encode.gov2_encode.go 就这样静悄悄地并排并躺在一起。翻开两者的文件头:

// encode.go
// Copyright 2010 The Go Authors. All rights reserved.
//go:build !goexperiment.jsonv2

// v2_encode.go
// Copyright 2010 The Go Authors. All rights reserved.
//go:build goexperiment.jsonv2

顶部的构建标签(Build Tags)使得这两个文件在编译时构成了互斥的存在。由于当前环境默认激活了 GOEXPERIMENT,实际上参与运行构建的正是崭新的 v2_encode.go

$ grep -n "JSONv2" $(go env GOROOT)/src/internal/buildcfg/exp.go
        JSONv2:                true,

在 Go 1.25 和 1.26 时代,你必须通过手工指定 GOEXPERIMENT=jsonv2 来显式加入尝鲜。而到了 Go 1.27,它已被调整为默认启用,并转化为了“主动退隐模式(Opt-out)”:你只能通过传递 GOEXPERIMENT=nojsonv2 来强行关闭它。

而在你强行关闭它之后参与编译运行的,正是那套写就于遥远的 2010 年的老古董实现。发行说明 已明确指出:这个救急的回退后门预计将在未来的某个版本中被彻底剔除。

单单通过阅读该目录下各个源文件的版权年份声明,你就能完整复盘出整套惊心动魄的演进图谱。这本身就足以令人叹为观止:

$ head -1 $(go env GOROOT)/src/encoding/json/encode.go
// Copyright 2010 The Go Authors. All rights reserved.

$ head -1 $(go env GOROOT)/src/encoding/json/v2/arshal.go
// Copyright 2020 The Go Authors. All rights reserved.

$ head -1 $(go env GOROOT)/src/encoding/json/jsontext/doc.go
// Copyright 2023 The Go Authors. All rights reserved.

2010 年属于初代 v1,2020 年属于 v2 的语义映射层,而 2023 年则属于底层的 jsontext。v2 原型的首个 Commit 降生于 2020 年 10 月;而 jsontext 这个名字则定型于 2023 年在讨论 #63397 中提出的“双包架构”设计方案。长达十六年的技术决策足迹,就这样无声地层层叠叠堆叠在同一个物理目录之下。

与 nojsonv2 的深度比对

这种退隐后门在逻辑上是否真正做到了绝对等价?

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name string `json:"name"`
}

func main() {
	cases := []string{
		`{"name":}`,
		`{"name":"a"`,
		`{"name":"a"} extra`,
		`[1,2,3]`,
	}
	for _, in := range cases {
		var u User
		fmt.Printf("%-22s -> %v\n", in, json.Unmarshal([]byte(in), &u))
	}
	var ch chan int
	_, err := json.Marshal(ch)
	fmt.Printf("%-22s -> %v\n", "chan int", err)
}
--- default (v2 backend) ---
{"name":}              -> invalid character '}' looking for beginning of value
{"name":"a"            -> unexpected end of JSON input
{"name":"a"} extra     -> invalid character 'e' after top-level value
[1,2,3]                -> json: cannot unmarshal array into Go value of type main.User
chan int               -> json: unsupported type: chan int

--- GOEXPERIMENT=nojsonv2 ---
{"name":}              -> invalid character '}' looking for beginning of value
{"name":"a"            -> unexpected end of JSON input
{"name":"a"} extra     -> invalid character 'e' after top-level value
[1,2,3]                -> json: cannot unmarshal array into Go value of type main.User
chan int               -> json: unsupported type: chan int

甚至连抛出错误的标点与字眼都实现了惊人的一字不差。

尽管官方发布说明曾谨慎地打过预防针称“具体报错文本可能存在细微差异”,但在我测试的上述边界用例中,却未曾捕捉到哪怕一丁点的字符出入。这绝非意味着每个角落都 100% 毫无二致;但对于一个把内部整个底层引擎完全连根拔起彻底替换的超大手术而言,能将表层行为还原到如此近乎偏执的程度,已经足以载入史册。

类似 v2_inject.go 这样的桥接胶水代码立下了汗马功劳:它在 v2 的底座上,全手工精细模拟并拼凑出了 v1 原本习惯返回的诸如 *MarshalerError 等一系列特有的老旧错误对象。

蓄意为之的随机化报错话术

在细致比对各种报错字符串时,我偶然捕捉到了一丝非比寻常的异样。

当我反复多次打印同一个针对 time.Duration 失败的错误信息时,即便代码完全静止未改,报出的文案竟然在暗中变幻。以下是在完全相同的二进制文件上连续执行 12 次的去重统计:

$ for i in $(seq 1 12); do ./exp6bin; done | sort | uniq -c
  11 json: cannot marshal from Go time.Duration within "/d": no default representation
   1 json: unable to marshal from Go time.Duration within "/d": no default representation

cannotunable to 竟然在交替登场!这绝非系统 Bug。翻看 v2/errors.go 的源码,真相大白:

// errorModalVerb is a modal verb like "cannot" or "unable to".
//
// Once per process, Hyrum-proof the error message by deliberately
// switching between equivalent renderings of the same error message.
// The randomization is tied to the Hyrum-proofing already applied
// on map iteration in Go.
var errorModalVerb = sync.OnceValue(func() string {
	for phrase := range map[string]struct{}{"cannot": {}, "unable to": {}} {
		return phrase // 随机选用首次迭代命中的词组
	}
	return ""
})

在语义完全等价的表述词汇之间实施动态切换,其核心目的就是为了彻底杜绝外部开发者在自己的逻辑里写死对错误文本的精确正则或字符串匹配——也就是所谓的防范海勒姆定律(Hyrum-proofing)

该实现的机巧之处在于巧妙寄生于 Go 语言自身对 map 的遍历顺序生来就带有随机打乱特性的机制,并在每个进程生命周期内通过单例固定一次。

海勒姆定律(Hyrum’s Law)指出:当系统拥有足够多的使用者时,无论你的接口文档是如何约定的,系统展现出的任何一丝一毫可被观测到的蛛丝马迹(哪怕是报错文案里的一个空格),最终都必然会被某一个下游代码深深依赖并视为规范的一部分。

在阅读上文中,你其实早已多次目睹海勒姆定律造成的巨大破坏力:那些大家梦寐以求的修复,每一次都因为害怕破坏他人既有的荒唐依赖而动弹不得。大小写不敏感匹配因为写进了文档而无法被修复;方法在不可寻址场景下被跳过调用的严重 Bug,仅仅因为外部产生了太多隐式习惯依赖而被迫推翻回滚;甚至对重复键的纵容,至今还在剥夺着快速解码进 any 的极速通道。

因此,v2 在破土而出的第一天,就毫不留情地故意粉碎了“报错文本是完全一成不变的”这一曾经的可观测事实——其手段正如早年 Go 团队通过对 map 的遍历引入随机打乱来彻底粉碎开发者依赖遍历顺序的幻想如出一辙。整整 14 年间咽下的所有血泪教训,在全新的标准库代码降生的那一秒起,就被铸造为了牢不可破的底层铁律。

顺理成章地:如果你以前在单元测试里习惯用 strings.Contains(err.Error(), "cannot marshal") 来断言异常,在迁移到 v2 时你的测试就会必然遭遇随机失败。这并非 Bug,这正是官方故意针对你设计的。

实测性能到底提升了几许

那么,切换到 v2 到底能带来多大幅度的性能狂飙呢?摘自官方发行说明:

序列化(Marshal)性能总体上与前代实现持平,而反序列化(Unmarshal)性能则迎来了大幅跃升。

官方博客 在宣讲时更加激进,甚至针对 Unmarshal 喊出了“最高达 10 倍性能提升”的豪言壮语。我亲自进行了一轮深度实测:用它反序列化一个包含 1000 个元素的 JSON 数组并塞入强类型的结构体切片中。

goos: darwin
goarch: arm64
cpu: Apple M5 Pro
BenchmarkUnmarshalV1-18    2295   523371 ns/op   121.66 MB/s   242924 B/op   4012 allocs/op
BenchmarkUnmarshalV2-18    2716   446989 ns/op   142.44 MB/s   242924 B/op   4012 allocs/op
BenchmarkMarshalV1-18      5594   213990 ns/op                  66148 B/op      3 allocs/op
BenchmarkMarshalV2-18      5516   217250 ns/op                  66467 B/op      3 allocs/op

Unmarshal 仅获得了区区 1.18 倍的提升;Marshal 更是基本战平。这跟吹上天的“最高达 10 倍”简直相去甚远。

那个夸张的“10 倍之跃”其实源自另一种完全不同的工况场景。把完全相同的测试数据,直接粗暴反序列化进一个万能的 any 变量中,局势瞬间大变:

BenchmarkAnyV1-18    921   1306860 ns/op    48.72 MB/s   739932 B/op   23014 allocs/op
BenchmarkAnyV2-18   2091    581713 ns/op   109.45 MB/s   626968 B/op   17012 allocs/op

性能直接拉开了高达 2.25 倍的差距,单次操作的内存分配次数更是直接从 23014 次暴跌到了 17012 次。

在此务必格外留心 BenchmarkAnyV1 测的究竟是什么:它测的可是原装的 encoding/jsonUnmarshal。在当前的 Go 1.27 中,这个方法在底层明明也是由 v2 的引擎在驱动执行的!然而,它的速度却比你直接调用 v2 的原生 API 慢了足足两倍以上。

之所以会出现这种不可思议的性能鸿沟,祸根恰恰深埋在为了保真兼容 v1 历史行为而暗中启用的那堆选项之中。在反序列化至 any 的处理逻辑中,底层其实专门铺设了一条高度优化的专用快车道;然而进入这条快车道的关口,却设有极其苛刻的防线门槛:

if optimizeCommon &&
	t == anyType && !uo.Flags.Get(jsonflags.AllowDuplicateNames|jsonflags.FormatTag) &&
	(uo.Unmarshalers == nil || !uo.Unmarshalers.(*Unmarshalers).fromAny) {
	v, err := unmarshalValueAny(dec, uo)

只要设置了 AllowDuplicateNames 标志位,这条优化快车道就彻底变成了死路一条!因为这条追求极速的优化快车道完全没有设计重复键检查的开销,所以在允许重复键并存的宽松设置下,绝对不允许启用该路径。

而正如上文所述,v1 规范的既有原则,恰恰就是默认纵容重复键并存!换言之,只要你依然在调用旧的 v1 API,这个标志位在底层就永远被强制钉死为启用状态。

决定性能生死的到底是不是这一处开关?我们完全可以通过单因子隔离实验一验便知:我们直接去调用原生的 v2 API,但在参数里仅仅把“容许重复键”这一项孤立地倒退回 v1 的行为模式:

jsonv2.Unmarshal(data, &v, jsontext.AllowDuplicateNames(true))
BenchmarkAnyV1-18            890   1306831 ns/op   48.72 MB/s   739952 B/op   23014 allocs/op
BenchmarkAnyV2-18           2060    584852 ns/op  108.87 MB/s   626969 B/op   17012 allocs/op
BenchmarkAnyV2AllowDup-18    994   1219219 ns/op   52.22 MB/s   739925 B/op   23014 allocs/op

仅仅拨动了这一个选项,性能瞬间被无情打回了原形,全面跌落至与 v1 相同的糟糕水准。甚至连内存分配次数——23014 次,都与 v1 毫无二致。

这就是在为整整 14 年前草率做下的错误技术决策支付的沉痛代价:静默容忍重复键的存在。为了恪守这种扭曲的向后兼容性,现代的高性能快车道只能被迫忍痛放弃。

虽然宣传口径上确实存在着“v1 如今已由 v2 托底,因此无需任何改动就能自动白嫖提速”的红利空间;然而在性能差异最为悬殊的核心战场,你却根本享受不到这等甘霖。只要你的代码中依然残留着调用 v1 API 的旧习惯,复刻旧缺陷的隐性成本就必须由你的 CPU 和内存一分不少地照单全收。如果你真心想榨干全部性能,你必须毫无保留地显式拥抱并直接调用 v2 API。

需要严肃声明的是:该基准测试仅代表在特定数据形态下的单一断面表现。实际性能会随着具体的 JSON 拓扑结构、字段数量以及所组合的类型系统产生剧烈变化。如果在你的生产集群中性能占据举足轻重的地位,在做最终架构裁决前,请务必使用你们自己的真实业务载荷建立精准的基准压测。

v1 与 v2 之间不可调和的 16 大行为鸿沟

encoding/json 的官方文档开辟了一个名为“迁移至 v2(Migrating to v2)” 的宏大章节,详尽归纳了 v1 与 v2 之间多达 16 项重大的行为分歧。它虽被包装成一份详尽的迁移指引手册,但如果你将左列从头到尾一字不漏地细细读来,它赫然变成了一份截然不同的卷宗:一份历经十四载漫长岁月洗礼、最终被全行业一致判决为“设计失误”的罪己诏。

v1 行为表现 v2 行为表现
结构体字段名匹配大小写不敏感 严格实行大小写敏感的绝对匹配
omitempty 依据该值在 Go 类型系统中是否为零值来判定是否忽略 omitempty 依据该值在 JSON 类型系统中是否为空值来判定是否忽略
string 标签可作用于 string、bool 以及 number,且不支持递归穿透 作用于 number,且支持向复合类型内部递归穿透
nil slice 与 nil map 会被格式化为 null 会分别被格式化为空 JSON 数组 [] 与空 JSON 对象 {}
Go 静态数组可以被任意长度的 JSON 数组反序列化填充 长度必须严格完全一致,否则直接报错
[N]byte 会被序列化为由数字构成的 JSON 数值数组 会被序列化为经过 Base64 编码的 JSON 字符串
拥有指针接收者的方法,唯有在目标值可寻址(addressable)时才会被调用 无论何时何地,均始终坚决调用
针对 map 的键(Keys),绝不会触发其自定义方法 针对 map 的键,亦照常触发调用自定义方法
Map 在序列化输出时,会强行排序以保持确定性顺序 不再保证输出顺序的一致性(非确定性顺序)
默认会对 HTML 与 JavaScript 敏感字符进行强制转义 仅在语法有强制规范要求时,才进行最少限度的必要转义
将畸形非法的 UTF-8 字节静默替换为替换字符(``) 严正抛出错误并中断处理
对含有重复键的 JSON 采取静默放行并后者覆盖 严正抛出错误并中断处理
null 反序列化给非零初始值时,有时将其归零,有时却弃之不理 一视同仁,永远将其坚决归零重置
针对非零初始值的合并逻辑极其支离破碎、前后矛盾 仅深度合并 JSON 对象,其余一切类型一律坚决彻底覆盖
time.Duration 被序列化为一个光秃秃的纳秒纯整数 彻底抹除默认表示形式,若不显式指定格式则直接严正报错
结构上非法的 Go 类型在编译期可能被放过,且不会诱发运行时报错 一旦检测到结构非法的类型,直接触发运行时错误

细读表格中关于 v1 的诸多描述,诸如“前后矛盾(inconsistent)”或“有时这样,有时那样(sometimes, sometimes not)”等字眼反复出现触目惊心。

指针接收者方法是否被执行完全取决于内存是否可寻址,便是一个极具代表性的历史怪胎。即便很多特性完全可以直接被定性为严重的 Bug,但由于无数庞杂的软件工程早已在不知不觉中与其形成了共生依赖,它们便由此彻底获得了免死金牌,变得再也无法被直接更正。

v2 之后的生态版图

被无情撤回的 format 标签

format 标签原本是一项极其强大的机制,允许开发者精确控制每个字段的底层表现形态:在 time.Time 上标注 format:RFC1123 即可定义排版版式,在 []byte 上挂上 format:base64url 即可自选编码,而在 time.Duration 上打上 format:iso8601format:units 即可指定时间表现。前文费尽周折才落定的针对 time.Duration 的最终仲裁(剥夺默认值,强迫调用方显式指定时间格式),其全部技术可行性,完全是牢牢构筑在这一套机制之上的。

然而,撤回 format 标签的官方理由却极其耐人寻味:

鉴于 Go 1.28 有极高期望以某种形式正式引入“类型化结构体标签(Typed struct tags)”,json/v2 工作组经过深思熟虑,决定彻底砍掉对 format 标签选项的支持,因为该能力在未来通过类型化结构体标签来表达会更加自然优雅。

这项撤回决定的核心逻辑是:静候更具革命性的底层语言级特性——类型化结构体标签。

所谓类型化结构体标签,是 Axel Wagner 于 2025 年 7 月在 golang/go#74472 中提出的一项重大的 Go 核心语法变更提案,它试图在当今这种粗糙的纯字符串标签之外,为结构体引入带类型的常量表达式级标签。一旦该语法成真,原本写成 time.Time `json:",format:RFC3339"` 的代码,将能够被直接编写为类似于 time.Time {json.Format(time.RFC3339)} 的高级语法。编译器终于能够在编译期对其进行严密而强类型的静态类型检查,而无需再在毫无保证的纯字符串里闭门造车地手工发明微型的临时语法解释器。

该语法提案曾因团队人手不足以并行推进而在 2025 年 11 月被短暂打入冷宫挂起。然而到了 2026 年 4 月 20 日,Austin Clements 却突然撰文宣布

我已经开始与 @rsc、@griesemer、@adonovan 以及 @neild 组会,全力‘点火启动’并将该提案提升至最高优先级推进。这在相当程度上是因为该提案的可行性以及推进时间线,将直接生死攸关地牵动对 json/v2 诸多设计决断的最终走向。

仅仅十天之后,#79071 号 Issue 便轰然破土,format 标签在最后一刻被生生砍掉。一项尚未完全盖棺定论的 Go 核心语言层面的重大语法变动,就这样在最后关头直接强行抽空了标准库提案中已经十拿九稳的一项核心杀手级特性。

第四章所引用的Damien Neil 那段冷酷的发言——“与其任由特性蔓延,我们当下更有可能为了顺利发布而主动在初始版本中临时砍掉某些特性”,在这一刻以最残酷的方式应验了,那绝非一句虚张声势的戏言。

抛给 time.Duration 的未解死局

一种极度荒诞而尴尬的死局就这样毫无预兆地降临了。

大家费尽唇舌为 time.Duration 达成的最终共识是:要求开发者必须显式指定格式。而开发者用以显式指定格式的核心物理载体,恰恰就是这个被寄予厚望的 format 标签。

然而,format 标签如今被连根拔起了。

亲手写两行动手一试,你便能真切体会到那种令人窒息的绝望:

type Config struct {
	Timeout time.Duration `json:"timeout"`
}
type ConfigTagged struct {
	Timeout time.Duration `json:"timeout,format:units"`
}
v1             : {"timeout":90000000000}  err=<nil>
v2             : {"timeout"  err=json: cannot marshal from Go time.Duration within "/timeout": no default representation
v2 format:units:   err=json: cannot marshal from Go main.ConfigTagged: Go struct field Timeout has unsupported `format` tag option

不加任何标签直接序列化:当场报错!自作聪明按照原先的教程加上 format:units 标签:再次当场报错!

在当今崭新的 Go 1.27 标准库内部,你根本没有任何合法的原生手段能够使用 v2 直接序列化一个 time.Duration 要么你自己含泪亲手给它手写一个 Marshaler 实现,要么你就只能灰溜溜地退回去继续使用 v1。

还记得 2017 年 Russ Cox 关掉 #4712 时的金句吗?——“请自己去实现 Marshaler”。历经整整 9 年的反复挣扎与推演,time.Duration 最终竟然完完全全被原封不动地踢回了梦开始的地方。

然而,底层的实现代码其实并没有被抹杀。arshal_time.go 的深处至今依然完好无损地封存着解析 unitssecmillimicronano 以及 iso8601 的全部业务逻辑;官方仅仅是通过一个小小的内部开关补丁,将其彻底死锁在一个绝不对外导出的私有内部包中。

而这把唯一能解锁它的金钥匙,却荒谬地留在了标准库之外的实验模块——github.com/go-json-experiment/json 之中。标准库内部的注释清清楚楚地写道:

注意:尽管 ExperimentalSupportFormatTag 已经完成了导出,但它深埋于内部私有包中,因此公共外部代码根本无法访问。github.com/go-json-experiment/json 外部模块将始终保持与 Go 标准库完全同步,并在外部直接暴露此配置开关,以便公共业务代码如今能够直接引用开启。

整套历史宿命完成了一场令人啼笑皆非的宏大莫比乌斯环闭环:正是因为标准库被向后兼容性绑架寸步难行,社区才被迫在标准库外部自立门户搞实验原型;而如今原型历经千难万险终于被官方正式吸收转正;但为了能用上这个因故被标准库转正时惨遭阉割的核心特性,全世界的开发者最终却不得不重新把那个当初孵化它的外部原型模块再次请回自己的 go.mod 依赖里!

值得玩味的是,无论是关于 format 标签的惨遭剔除,还是 time.Duration 如今必然报错的技术尴尬,在长篇累牍的 Go 1.27 发行说明 中均被轻描淡写地一笔带过,甚至未见丝毫专门提及。整篇发行说明中唯一涉及 encoding/json 自身的地方,就只有第五章引述过的那短短一小段声明——“如今已由 v2 实现全面接管”。

各大第三方开源库的连锁反应

encoding/json 在底层全面移步换轨至 v2,对于那些多年来靠“标榜自己是 100% 毫无心智负担的开箱即用无缝替代品”为核心卖点的第三方库而言,无疑是一场天崩地裂的底层地震。社区的反应瞬间呈现出截然不同的两极分化。

其中严阵以待、应对最为专业果决的当属字节跳动的 bytedance/sonic。他们提交的题为“feat: support Go 1.27” 的巨型 Pull Request 狂揽了多达 2500 余行核心源码重构。并由此催生出了一份长篇技术兼容矩阵白皮书——作为 docs/sonic-go127-compatibility.md 永远珍藏在了其仓库之中。

该矩阵极其严谨地将四大维度置于同一天平上残酷比对:sonic 自带的标准兼容模式、Go 1.27 的原生 v1(即底层由 v2 支撑的全新 v1)、通过 GOEXPERIMENT=nojsonv2 强行召唤回来的远古老 v1,以及直接调用 v2 原生接口。

为了严格兑现其“结果必须与官方 encoding/json 分毫不差”的沉重契约,sonic 如今的持续集成(CI)流水线必须在默认配置与 nojsonv2 配置下同时并发跑通两套测试矩阵。为了追赶标准库这头狂奔的靶子,第三方库如今需要疲于奔命追踪的兼容目标整整翻了一倍。

该白皮书记录的部分残酷技术裂痕包括:

  • 当读取到一个数值超越了 float64 能表达的安全极限时,Go 1.27 会在抛出错误的同时将目标内存强行修改置为 +Inf,而 sonic 则会在报错的同时将目标内存静默保留重置为 0
  • sonic 坚决彻底拒绝解析 map[float64]string,但 Go 1.27 却能够将其合法编码。
  • 针对同时实现了 AppendTextMarshalText 的类型,Go 1.27 会绝对优先选用更高性能的 AppendText

而反观 goccy/go-json,我在 GitHub 上几乎搜寻不到它对 v2 到来所作出的任何积极响应。Issue 和 README 中对这场翻天覆地的剧变只字未提,甚至连 RoadMap 文本都依然静止在旧时代毫无寸进。

曾经显赫一时的明星库 json-iterator/go 则彻底走向了归宿——它被正式打上了只读归档(Archived)的印章。最后的代码更新永远定格在了 2024 年 5 月。曾经在 GitHub 上坐拥最多 Star 的一代无缝替代王者就此悄然落幕,它的历史使命已然功成身退,被全盘收拢回了标准库的麾下。在长达八年的漫长岁月里,该库在 README 里自信洋溢地高呼自己是“100% 绝对兼容的原地替代品”,而其 Issue 列表中却永远刺眼地悬挂着一个标题大写着“并非 100% 绝对兼容的替代品” 的未解讨论,二者就这样在主页上荒诞地并存了近十年。

protojson 与 jsontext 如今行至何方

正如前文所述,催生出 jsontext 核心灵感的不是别人,正是当初因为无法忍受 encoding/json 的粗制滥造而被迫另起炉灶的 protojson。那么功成名就的今天,protojson 到底有没有在第一时间用上久违的 jsontext 呢?

我深入扒阅了其代码库:很遗憾,截至目前依然没有。

要求允许 protojson 接收 io.Writer 的 Issue golang/protobuf#1673 至今依然倔强地敞开着。早在 2025 年 1 月,Joe Tsai 就曾在该 Issue 下描绘了一幅美妙的设计蓝图:

func MarshalWrite(io.Writer, proto.Message) error
func MarshalEncode(*jsontext.Encoder, proto.Message) error
func UnmarshalRead(io.Reader, proto.Message) error
func UnmarshalDecode(*jsontext.Decoder, proto.Message) error

他豪情万丈地写道:

届时整个私有的 internal/encoding/json 包都可以被彻底连根删掉,并全盘替换为呼之欲出的官方 encoding/json/jsontext 标准库。事实上,我们当年的 internal/encoding/json 完全可以被视为最终演变为 jsontext 的早产原型。

一旦这一划时代的重构并轨完成,全网的 Go 开发者将能够写出梦幻般的联动代码:

json.Marshal(v,
	json.WithMarshalers(json.MarshalToFunc(protojson.MarshalEncode)),
)

这意味着:一个巨大且错综复杂的 Go 业务结构体,哪怕其深处嵌套着若干个 proto.Message 字段,我们也能在一趟无缝的扫描中一次性将其全盘序列化完成,并且仅仅把属于 Protobuf 的子树精确委派给 protojson 的底层逻辑处理。

Go 官方 Protobuf 维护者 Michael Stapelberg 当初之所以选择按兵不动,完全是由于他极其厌恶在基础库中引入外部依赖,且标准库外的 Vendor 机制维护起来极其繁琐痛苦。他在 2026 年 7 月 20 日的跟帖评论 中感慨道:“json/v2 终于在这个月正式随 Go 发版了。我认为我们当初选择咬牙苦等的决策终于赢得了丰厚的回报。”

然而残酷的现实是,在当下的 protobuf-go 生产源码中,你依然翻不到任何关于 jsontext 的物理引用。那个当初全手工打造的 internal/encoding/json 也依然牢牢钉在原地。因为 Go Protobuf 的官方维护策略严格要求自身必须无条件兼容支持数个历史版本的旧 Go 编译器,因此整个技术生态想要真正完成向新内核的全面换道超车,客观上依然横亘着一段不可忽视的时空距离。

亲手孕育了 jsontext 的 Joe Tsai,如今也不得不抱臂伫立,静默等待着他终于能亲手将它接入自己最初战壕的那神圣一刻。

为何采取 v2 路线

打造 v2 的三大设计原则

在标准库中敢于开辟 v2 这一前所未有的激进路径,其背后的深层思考被 Russ Cox 体系化地凝炼在了他于 2024 年 5 月发表的重磅长文《以 math/rand/v2 演进 Go 标准库》(“Evolving the Go Standard Library with math/rand/v2”)之中。文章高屋建瓴地确立了三大核心军规:

第一条军规,是语义化导入版本规范(Semantic Import Versioning)。 根据该规则,math/randmath/rand/v2 在编译器的视角里被一视同仁地视为两个互不相干的独立包,并能够毫无阻碍地同时并存于同一个可执行程序之中。这是整场变革的技术立足基石:在标准库中直接添加全新的包,绝不会霸道地强迫任何既有老代码立刻改换门庭。

第二条军规,是对用户的绝对敬畏与尊重。 任何大动干戈的变更都必须拥有无可辩驳的充足理由,必须真正值得用户付出痛彻心扉的迁移成本。原有的老包永远是重构不可动摇的思考原点;进化的职责是去切除真正致命的病灶,而不是凭主创团队的设计审美去任性地推倒重来。

第三条军规,是必须向深陷 v1 的泥潭老用户伸出援手。 按照最完美的技术图景,v2 理应演化为包裹在 v1 外围的一层极薄胶水层(抑或是反其道而行之),使得那些由于种种历史包袱而根本动弹不得的老用户,哪怕在代码里继续无休止地使用 v1,也依然能够在底层享受到上游最新的漏洞修复与性能红利。Russ Cox 当时也坦诚地直言:这一条近乎苛刻的黄金准则,在现实中绝非每一次都能被百分之百实现。

encoding/json/v2,不仅彻底征服了这看似不可能逾越的第三条铁律,甚至是以一种更为决绝的倒反天罡的颠覆性姿态达成了极致:让古老的 v1,全盘彻底沦为了构筑在 v2 宏大底座之上的一层极薄胶水层!

这带来的工程奇迹是震撼性的:全天下所有哪怕在代码里继续执迷不悟调用老旧 v1 接口的人,在底层悄无声息地全面跑上了现代化的极速反序列化引擎;未来针对 encoding/json 发生的所有前沿 Bug 修复,都将名正言顺地直接作为主干收益沉淀在 v2 的代码库中。

整个 Go 核心团队再也无需精神分裂般地同时维护两套完全平行分裂的底层解析实现。2020 年 10 月第一版 README 中写下的“既然 v1 的实现必须被永久保留,那么如果能在底层基于 v2 重新实现 v1 将会极为有益”,原本仅仅是作者为了贪图日后标准库维护省事的一句感叹,但在最终的工程实践中,它却神乎其技地一箭双雕,顺手彻底化解了困扰 Go 语言长达十余载的世纪兼容死结。

在同一篇博文中,Russ Cox 还写下了这样充满警示的一段话:

在接下来的若干个 Go 版本中,我们绝不会纵容标准库中爆发式涌现出铺天盖地的 v2 泛滥浪潮。恰恰相反,我们将会以如履薄冰的克制姿态,每一次只审慎地处理一个核心包……很多年迈的标准库,在它的整个生命周期中,根本就不应该也不会需要迎来它的 v2。

事实胜于雄辩:标准库历史上的首个 v2 包 math/rand/v2 降生于 Go 1.22,而作为第二位登堂入室者的 encoding/json/v2 直到 Go 1.27 才堪堪落地——在这两座看似微小的版本号之间,足足横亘了整整五个漫长的大版本周期。

结语

2016 年 3 月 10 日,有人在 GitHub 上提交了一个弱小的 Issue,指出“这个 JSON 解析器竟然无视了结构体字段名的大小写”;2026 年 5 月 13 日,encoding/json/v2 正式提案获得全员批准。整整 10 年零 2 个月的光阴就此呼啸而过。若从 encoding/json 作为 Go 1.0 的核心基石正式问世的那天算起,这场无声的马拉松已经足足奔跑了整整十四年。

你可以轻描淡写地将这段漫长的岁月贬低为 Go 团队的僵化与停滞。致命的默认安全隐患早已人尽皆知,连能够彻底根除它的修复补丁代码 都早已被工程师工工整整地写就并提交在 Gerrit 上,然而在长达数年的时光里,整个官方标准库却始终宛若磐石般纹丝未动。

然而,只要你顺着本书的脉络,将这十四年间埋藏在暗流之下的所有足迹一字排开,你眼前的全景就会瞬间焕然一新:亲手写出补丁,精准压测开销,忍痛放弃幻想。起草一份宏大的设计文档,立誓换个名字彻底推倒重构。亲手打造出初代原型,并直接拉进自己东家的核心生产集群中无死角经受长达三年的真实战火淬炼。在全社区公开发起大讨论,在 96 篇深度长文交锋中反复推演重塑架构。将其提炼为正式提案,并以极其高超的政治与工程智慧,将聚集了 229 位顶尖极客的超大规模技术论战精准解构拆分为数个专项子任务逐个击破。让它以试验性环境变量的隐秘形态进入真实的工业界生产线,接受全球海量真实开发者整整一年的千锤百炼。

直至最后,默认将开关联头反转,甚至在引擎更替的最后一秒,依然竭尽全力保留了随时可以退回 2010 年老代码的逃生后门。在这漫长的十四年里,没有任何一个繁琐痛苦的工程阶段被心存侥幸地偷工减料或越级跳过。而它最终呈现在全人类眼前的形态是如此惊艳:它完美实现了自我救赎,同时没有强迫全球任何一行现存的既有业务代码发生哪怕半个字符的破坏!

这项工程最终落地的精妙绝伦之处,在于它把曾经冰冷的向后兼容性,从一场非此即彼的残酷零和博弈中彻底解放了出来。你只需在最前面轻描淡写地摆上一个 DefaultOptionsV1(),并在其后随心所欲地追加你心仪的局部微调参数,你便能够在 v1 的远古时代与 v2 的现代化乐土之间,随心所欲地停靠在任何一段你感到最惬意的水位坐标上。

曾经横亘在全人类 Go 工程师面前那道“要么彻底砸烂旧世界,要么抱残守缺生吞毒药”的残酷绝境,如今已被彻底化解为了一枚可以在每一次函数调用时由编写者自由拨动的无级变速旋钮。

当然,世界永远不是一个被清理得毫无瑕疵的童话乌托邦。为了等待未来语言级的类型化结构体标签成真,format 标签在最后一刻被生生砍掉,导致 time.Duration 在 Go 1.27 首发时竟然陷入了在标准库原生体系内根本无法被直接序列化的巨大尴尬。

为了挽救这一尴尬,你甚至不得不重新把那个刚刚被标准库吸纳的原型外部库再次引回自己的生产依赖中。一项尚处于语言设计前沿的未定提案,就这样残酷地导致了标准库重大发布的一角坍塌。而这一切戏剧性的技术妥协,在光鲜亮丽的官方发行说明 中,甚至连一个标点符号的版面都没能得到。

如果你自己也在日常的业务中负责维护一套供成百上千人调用的公共库,你一定也曾遭遇过完全一模一样的痛苦折磨:你明知现有的某项行为设计从根上就错得离谱,但只要你去动它,就必然会搞垮下游某个关键业务的正常运转。

此时摆在你面前的选择,从来就不仅有“含泪装聋作哑忍耐下去”或者“做一个暴君把一切打得稀烂”这两杯毒酒。还有第三条更为宏伟坚韧的道路:换一个全新的空间将其彻底重塑,随后,再把那个伤痕累累的旧世界,稳稳地托举在崭新的新世界基座之上。这条道路的代价高昂得令人望而生畏;在 encoding/json 的案例中,从最初的灵感闪现到最终的正式转正,整整跨越了六年的漫长严冬。

让我们重新将目光投向本书开篇的那行经典代码:

json.Unmarshal([]byte(`{"NAME":"gopher"}`), &u)

即便是在崭新的 Go 1.27 中,只要你使用的是传统的 encoding/json,这段代码依然会顽固地把 gopher 稳稳当当地塞进那个全小写的 Name 字段中。就如同十四年前在 Go 1.0 中初次运行时一模一样。

然而,只要你轻轻将其重构敲下为 encoding/json/v2,这种魔法就会瞬间彻底消失遁形。如今的你,终于拥有了在每一次函数调用时,从容抉择自己到底需要何种契约的最高自由。回首这漫长十四年光阴所换回的最宝贵的馈赠,在我看来,与其说是得到了一个更为严谨苛刻的高性能 JSON 库本身,不如说是每一位 Go 开发者终于在面对技术历史债务时,真正被赋予了能够自由做出从容抉择的至高权利

前方的征程依然山高水长。类型化结构体标签提案依旧在风雨中摇曳等待裁决,format 标签未来究竟能否名正言顺地杀回标准库如今依然悬而未决。

那个饱含技术温情的 GOEXPERIMENT=nojsonv2 逃生后门,也注定将在未来的某一天被无情地抹去。当那一天真正到来之时,那份凝固在 2010 年的 encode.go 源码终将被彻底从 Git 树中彻底 rm 删除。而届时依然会巍然矗立在标准库最深处的,正是那一长串将十四年间所有光荣、妥协与决断化为永恒名字的历史遗留标志位。

encoding/json/v2 已经正式扬帆启航,而我无比期待着与诸君一同见证,围绕着它的全新技术生态在未来又将谱写出怎样波澜壮阔的下一章。

附录:参考文献

Go 官方权威资料

Issue 与核心讨论

编号 详细描述
#14750 结构体匹配大小写不敏感问题 (2016-03-10 至 2025-06-27)
#4712 time.Duration 的序列化表现问题 (关闭于 2017-02-17)
#63397 encoding/json/v2 社区大讨论 (2023-10-05)
#71497 官方正式提案 (2025-01-31 提出; 2026-05-13 正式批准)
#71631 time.Duration 默认表现形式决断子提案 (关闭于 2025-12-18)
#79071 撤回 format 结构体标签支持 (2026-04-30)
#74472 类型化结构体标签(Typed struct tags)提案 (目前处于挂起状态)
#76406 json/v2 工作组例行全网公开会议纪要
#61716 math/rand/v2 正式提案 (批准于 2023-10-03)
#60751 确立标准库历史首个 v2 子包地位的关键讨论
#67401 限制与封锁 linkname 滥用的系统级治理

原型实现与基准测试

第三方开源生态

本书引述的标准库核心源码

详细说明 源码物理路径
v1 历史遗留行为标志位清单 src/encoding/json/internal/jsonflags/flags.go
DefaultOptionsV1 实现及官方迁移文档 src/encoding/json/v2_options.go
桥接 v1 与 v2 历史错误类型的仿真胶水层 src/encoding/json/v2_inject.go
format 标签的底层实现及其内部解封开关 src/encoding/json/v2/arshal_time.go, src/encoding/json/internal/jsonopts/options_format.go
蓄意防范海勒姆定律的随机化报错生成器 src/encoding/json/v2/errors.go
反序列化至 any 的独享高性能优化快车道 src/encoding/json/v2/arshal_default.go
GOEXPERIMENT 编译器内置默认配置项 src/internal/buildcfg/exp.go

本书中的所有基准测试数据均实测于 go1.27 darwin/arm64(搭载 Apple M5 Pro 芯片)物理机环境。基准压测数值高度依赖运行环境及测试数据拓扑结构,在将其作为技术决策依据之前,请务必使用您团队自身的真实业务载荷重新度量。引用的标准库源码均摘自 Go 1.27.0 正式发售版本。相关内部选项命名及标志位底层内存布局在未来的后续版本中可能仍会发生调整。

© 2026 Yoshi Yamaguchi


还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 从0 开始构建 Agent Harness 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。