Go 标准库与生态 · 第三篇

encoding/json:struct tag 是怎么被解析、判优先级、缓存下来的

两个嵌入结构体都有一个叫 Name 的字段,json.Marshal 应该输出哪一个?一个字段有 json tag、另一个同名字段没有,谁赢?这些问题的答案不是文档里能查到的,而是写死在 encode.gotypeFields() 里的一套确定性排序规则。这一篇把这套规则读出来,用真实的嵌入结构体验证了三种冲突场景;另外还搞清楚了两件容易被忽略的事:反射得到的字段元数据是怎么被缓存的,以及"递归类型"(比如一棵树)在第一次被 Marshal 时,内部的 encoderCache 是怎么用一个和上一篇 sync 系列相关的技巧,做到并发安全又不会死循环的。

延续前两篇的方法论:本机装的就是真实 Go 1.26.6,标准库源码就是本机 go run 时真正在跑的那份代码。全部实验都是本机真实执行的 Go 程序。

3 个真实 Go 程序
本机真实 go run(其中一个用 -race 跑),验证字段优先级、并发构建安全性、缓存加速比
4 处真实源码
跟本机 go1.26.6 完全同版本的 encoding/json 标准库源码

真实源码:字段发现是按"层级"一层一层做广度优先遍历的

$GOROOT/src/encoding/json/encode.go(go1.26.6 本机源码) · L1093 func typeFields(t reflect.Type) structFields { // 当前层要探索的匿名字段,以及下一层要探索的匿名字段 current := []field{} next := []field{{typ: t}} ... for len(next) > 0 { current, next = next, current[:0] for _, f := range current { for i := 0; i < f.typ.NumField(); i++ { sf := f.typ.Field(i) ... tag := sf.Tag.Get("json") name, opts := parseTag(tag) ... // 记录这个字段,或者(如果是匿名结构体)留到下一层再展开 } } }

这是一次标准的 BFS(广度优先搜索):第 0 层是结构体自己直接声明的字段,第 1 层是嵌入结构体里的字段,第 2 层是嵌入结构体里再嵌入的结构体里的字段……每个字段被发现时,都会记下它是在第几层被发现的(len(f.index))。这个"层级"信息就是后面判断"谁赢"的关键依据之一。

真实源码:三条冲突消解规则,写在排序函数和 dominantField 里

$GOROOT/src/encoding/json/encode.go(go1.26.6 本机源码) · L1237 slices.SortFunc(fields, func(a, b field) int { // 先按字段名排序,然后按深度打破平局, // 然后按"名字是不是来自 json tag"打破平局 if c := strings.Compare(a.name, b.name); c != 0 { return c } if c := cmp.Compare(len(a.index), len(b.index)); c != 0 { return c } if a.tag != b.tag { if a.tag { return -1 } return +1 } ... })
$GOROOT/src/encoding/json/encode.go(go1.26.6 本机源码) · L1315 func dominantField(fields []field) (field, bool) { // 字段已经按"索引深度递增"排好序,深度相同时 tag 优先。 // 也就是说第一个字段就是"最终获胜的那个"。这里只需要 // 检查错误情况:同一层出现了两个字段,并且它们的 tag // 状态还相同(要么都有 tag,要么都没有)——无法判断, // 这种情况全部丢弃。 if len(fields) > 1 && len(fields[0].index) == len(fields[1].index) && fields[0].tag == fields[1].tag { return field{}, false } return fields[0], true }

同名字段先按名字分组,组内排序规则是:深度越浅越优先;深度相同时,带 json tag 的优先;如果深度相同、tag 状态也相同(两个都有 tag,或者两个都没有),规则给不出答案——dominantField 直接返回 false,这一整组同名字段全部被丢弃,最终 JSON 里完全不出现这个字段。

真实实验:三种冲突场景,一次性验证全部规则

真实实测
type A struct{ Name string }
type B struct{ Name string }

// 场景 1: 深度相同(都是 1),都没有 tag —— 无法判断,整组丢弃
type Ambiguous struct { A; B }

// 场景 2: 外层字段深度是 0,天然获胜
type Shallow struct { A; B; Name string }

// 场景 3: 深度相同(都是 1),一个有 tag 一个没有 —— tag 优先
type A3 struct { Name string `json:"Name"` }
type B3 struct { Name string }
type TagWins struct { A3; B3 }
Ambiguous{A:"from-A", B:"from-B"} -> {} Shallow{A:"from-A", B:"from-B", Name:"from-outer"} -> {"Name":"from-outer"} TagWins{A3.Name(tagged):"from-A3-tagged", B3.Name(untagged):"from-B3-untagged"} -> {"Name":"from-A3-tagged"}

三种场景跟源码逐字对上:场景 1 里 Name 字段完全消失,不是空字符串,是 整个 key 都不出现;场景 2 里外层的 Name(深度 0)完全无视两个嵌入结构体里的同名字段,直接获胜;场景 3 里两个字段深度相同,带 tag 的 A3.Name 赢过没有 tag 的 B3.Name。这也是为什么实际项目里"两个库都嵌入了一个同名字段"经常会诡异地少一个字段——不是 bug,是这条冲突消解规则在起作用。

真实源码 + 真实实验:反射结果被缓存了,而且缓存本身沿用了上一篇 sync 系列的技巧

$GOROOT/src/encoding/json/encode.go(go1.26.6 本机源码) · L1329 var fieldCache sync.Map // map[reflect.Type]structFields func cachedTypeFields(t reflect.Type) structFields { if f, ok := fieldCache.Load(t); ok { return f.(structFields) } f, _ := fieldCache.LoadOrStore(t, typeFields(t)) return f.(structFields) }

上面那一整套 BFS + 排序 + 冲突消解,只有第一次 Marshal 某个类型时才会真正跑一遍;结果被塞进 fieldCache 这个 sync.Map 里,以后同一个类型直接查表。

真实实测
// 自己实现一份"不缓存,每次都重新走一遍反射"的版本,
// 和"用 sync.Map 缓存"的版本,对同一个 8 字段结构体各跑 200 万次

for i := 0; i < 2_000_000; i++ { naiveFields(t) }   // 每次都重新遍历字段、解析 tag
for i := 0; i < 2_000_000; i++ { cachedFields(t) }  // 命中缓存直接返回
2000000 calls, no cache (redo reflection walk every time): 875.4ms (437.7ns/call) 2000000 calls, with fieldCache-style sync.Map cache: 16.2ms (8.1ns/call) speedup: 54.1x (另外两次独立运行: 49.8x, 53.1x)

缓存把单次调用成本从 437.7ns 压到 8.1ns,50 倍左右的差距,三次独立运行结果一致。这就是为什么"每次请求都 json.Marshal 同一种结构体"在真实服务里完全不是性能问题——真正昂贵的反射遍历只发生一次,后面全是查表。

真实源码 + 真实实验:递归类型第一次被 Marshal 时,靠什么防止死循环

$GOROOT/src/encoding/json/encode.go(go1.26.6 本机源码) · L388 func typeEncoder(t reflect.Type) encoderFunc { if fi, ok := encoderCache.Load(t); ok { return fi.(encoderFunc) } // 为了应对递归类型,在真正构建完成之前,先往 map 里塞一个 // "间接调用" 函数占位。如果这个类型是递归的,第二次查找 // 会命中这个占位函数,而不会无限递归下去构建。 indirect := sync.OnceValue(func() encoderFunc { return newTypeEncoder(t, true) }) fi, loaded := encoderCache.LoadOrStore(t, encoderFunc( func(e *encodeState, v reflect.Value, opts encOpts) { indirect()(e, v, opts) })) if loaded { return fi.(encoderFunc) } f := indirect() encoderCache.Store(t, f) return f }

一个 Node 类型如果自己有一个 []*Node 字段,构建它的 encoder 时会需要构建 *Node 字段的 encoder,而那又需要 Node 自己的 encoder——这是构建期的递归,不是数据期的递归。解决办法是先占位再构建:用 encoderCache.LoadOrStore 先塞进去一个"转发到 indirect()"的占位函数,这样递归引用命中的是这个占位函数(此时 indirect 还没跑),等真正构建完成再替换成真正的 encoder。sync.OnceValue 保证了并发场景下同一个类型的构建函数只会真正执行一次——这跟上一篇 sync 系列里 Once 的用法完全一致。

真实实测
type Node struct {
    Val      int
    Children []*Node // 自引用字段
}
tree := &Node{...} // 这个进程第一次 Marshal *Node

var wg sync.WaitGroup
for i := 0; i < 50; i++ {
    go func() { json.Marshal(tree) }() // 50 个 goroutine 同时抢着第一次构建
}
wg.Wait()
// go run -race 跑
50 concurrent first-ever Marshal(*Node) calls, all identical output: true sample: {"Val":0,"Children":[{"Val":1,"Children":[{"Val":3,"Children":null},{"Val":4,"Children":null}]},{"Val":2,"Children":null}]} (go run -race: 未检测到任何 race)

50 个 goroutine 在这个进程第一次遇到 *Node 类型时同时调用 Marshal,全部拿到完全一致的正确输出,-race 检测器没有报出任何数据竞争——占位 + OnceValue 这套机制在真实并发场景下确实是安全的。

交互演示:字段冲突消解 + 缓存加速的真实数据回放

把上面几组真实实验按发生顺序串成一条演示。

encoding/json 实录未开始
点击"下一步"或"播放"开始。

全部数据来自本机真实 go run 的输出(tiebreak.go 的 3 组冲突场景、recursive_concurrent.go 的 50 并发构建、cache_speedup.go 的 3 次独立计时),Python 脚本用一份独立重写的"按深度、tag 状态分组取胜者"算法逐项核对冲突消解结果,并且从两个独立测得的 ns/call 数值重新算了一遍加速比,跟报告的加速比数字一致。

参考与说明

  • 本文源码引用(typeFields、排序函数、dominantFieldfieldCache/cachedTypeFieldsencoderCache/typeEncoder)均取自本机安装的 Go 1.26.6 自带标准库源码($GOROOT/src/encoding/json/encode.gotags.go)。这份文件带有 //go:build !goexperiment.jsonv2 构建标签——本机 go env GOEXPERIMENT 为空,确认这份就是本机 go run 实际编译进二进制的代码;Go 1.26 的标准库里其实已经并存了一份实验性的 v2_encode.go(同一个包内,靠构建标签切换),但默认没有启用。
  • 全部实验(tiebreak.gorecursive_concurrent.gocache_speedup.go)均为本机真实 go run 产生的输出,未做删改;并发构建那组额外用 -race 检测器跑过。
  • 演示数据的自检:三组字段冲突场景用一份独立重写的分组排序算法核对过,结果和真实输出逐项相等;递归类型的并发构建结果跟手动推导出的期望 JSON 逐字符比对;缓存加速比从两个独立测得的 ns/call 数值重新计算了一遍,跟报告的倍数一致。
  • 没有涉及:json.Decoder/Unmarshal 的反向过程(字符串到 struct 字段的匹配)、MarshalJSON/UnmarshalJSON 自定义接口的分发逻辑、omitzeroomitempty 的细节区别、流式编码器 Encoder
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电