Go 反射的暗债:encoding/json 为什么不用代码生成

每次 json.Unmarshal 产生 23 次堆分配——用实测数据算清 GC、分配、初始化三笔账,追踪 v2 怎么还、sonic/jsoniter/easyjson 怎么转嫁,给出按场景选型的决策框架。

封面

导读:每次调用 json.Marshal,你都在为反射还一笔看不见的债。这篇文章用实测数据算清三笔账,追踪标准库怎么还、第三方库怎么转嫁,最后回答一个问题——你应该自己承担,还是把债搬到别处。

只关心选型决策?跳到第五章。


去年冬天,我负责的订单服务在高峰期出了一次 P99 延迟毛刺。排查到最后,pprof 火焰图上有一块刺眼的宽带——encoding/json.Unmarshal 路径吃掉了 42% 的 CPU。这是一个序列化密集型的网关服务,请求链路中 JSON 编解码占了绝对大头。往下看,reflect.Value.Fieldreflect.(*rtype).Name 这些调用散落在火焰图的每一层。

我们的 struct 不算大,30 个字段,混合着 int64、string、float64——和你们服务里的订单、事件、日志结构差不多:

type Order struct {
    ID            int64   `json:"id"`
    UserID        int64   `json:"user_id"`
    MerchantID    int64   `json:"merchant_id"`
    OrderNo       string  `json:"order_no"`
    // ...还有 26 个字段:金额、地址、设备信息、追踪 ID
    CreatedAt     string  `json:"created_at"`
}

每秒几万次 Marshal + Unmarshal,GC 被反复触发。单次暂停只有微秒级,但触发频率高到影响尾延迟——P99 从平时的 5ms 飙到 20ms+。当时的直觉是"换 sonic 就好了"。后来确实换了,延迟降了,事情过去了。

但一个问题留在脑子里:protobuf 有 protoc-gen-go,gRPC 有代码生成,为什么 encoding/json 偏要走反射路?这个"慢"到底慢在哪里,是偷懒还是有意为之?

带着这个问题,我把 encoding/json 的源码从头追了一遍。追完才明白:反射不是 Go 团队的失误,是一笔有意承担的暗债。下面把这笔账算给你看。


一、三笔账算清

反射的代价拆开看,在三个维度同时计息。

三笔账量化总览

第一笔:GC 压力

第一笔:GC 压力。 我用同一个 30 字段 struct 跑了 10 秒连续 Marshal+Unmarshal(Go 1.26.2, Apple M4 Pro),pprof 采样 8.76 秒有效数据。runtime.madvise(堆页面回收)和 runtime.(*mheap).allocSpan(堆扩张)合计占 CPU 的 24%。这是纯 JSON 负载下的极端值;生产环境中此比例取决于 JSON 在总体工作负载中的占比——开篇那个 42% 的真实服务恰好是序列化密集型场景。

这 24% 不是 encoding/json 直接消耗的,它是 GC 为回收 json 分配出来的对象而付出的代价。根源是 v1 的 Unmarshal 每次操作产生 23 次堆分配。每个字段的字符串解码要分配新 string、reflect.Value 的临时封装要装箱成 interface{}、指针字段的间接跳转要分配中间值。全部逃逸到堆上,交给 GC 回收。

10 秒内完成了 193 万轮 Marshal+Unmarshal,意味着 GC 要回收约 4400 万次分配。哪怕每次回收只花几纳秒,乘以这个数量级就是可测量的 CPU 占比。

第二笔:分配放大

第二笔:Unmarshal 路径的分配放大。 实测数据:

操作 ns/op allocs/op B/op
v1 Marshal 788 1 704
v1 Unmarshal 4,192 23 896

Marshal 只有 1 次分配(输出的 []byte 缓冲区)。但 Unmarshal 有 23 次。差距 22 倍。

这 22 次多出来的分配藏在三个位置。第一个是 (*decodeState).literalStore——它把 JSON 字符串转成 Go 值时,每次都 string(data[start:end]) 创建新字符串,这个字符串逃逸到堆上。第二个是 indirect 函数——处理指针字段(*string*int)时需要 reflect.New 分配新对象。第三个是 (*decodeState).object——遍历 JSON 对象的 key 时,每个 key 先做 string(key) 转换,再去字段名 map 里查找对应的 struct 字段索引。

为什么 Marshal 不用这么多分配?因为 Marshal 是"读"——从 struct 字段读值写入 buffer,不需要创建新对象。Unmarshal 是"写"——从 JSON 字节流解析出值,写入 struct 字段,每次写入都可能需要分配目标对象。反射本身不区分读写,但写路径的分配需求远大于读路径。

第三笔:初始化锐刺

第三笔:初始化锐刺。 encoding/jsonsync.Map 缓存编码器和字段索引。首次编码某类型时,typeFields 函数执行 BFS 遍历所有嵌套字段:

// encoding/json/encode.go:1093(简化示意,省略外层循环)
func typeFields(t reflect.Type) structFields {
    next := []field{{typ: t}}
    visited := map[reflect.Type]bool{}
    for len(next) > 0 {
        for i := 0; i < f.typ.NumField(); i++ {
            sf := f.typ.Field(i)      // 每字段一次 reflect 调用
            tag := sf.Tag.Get("json")  // 字符串解析 + 可能的分配
        }
    }
}

30 个字段 = 至少 30 次 reflect.Type.Field() + 30 次 StructTag.Get()。如果有嵌入字段(Go 里很常见),BFS 还会递归下去。这个成本被 cachedTypeFieldssync.Map 摊销掉了,只付一次:

// encode.go:1332
func cachedTypeFields(t reflect.Type) structFields {
    if f, ok := fieldCache.Load(t); ok {
        return f.(structFields)  // 命中缓存,几十纳秒
    }
    f, _ := fieldCache.LoadOrStore(t, typeFields(t))  // 首次走 BFS
    return f.(structFields)
}

但"只付一次"不意味着没人付。冷启动时第一批请求全部阻塞在这个 BFS 上。如果你的服务注册了 50 个 struct 类型用于 JSON 序列化,前几秒的请求延迟会比稳态高出一截。高并发网关的第一批流量打进来时最容易感受到这根"锐刺"。

更糟糕的是 LoadOrStore 的参数求值特性:typeFields(t) 在调用前求值,不管最终是 Load 还是 Store。两个 goroutine 同时首次 Marshal 同一类型时,typeFields 跑两遍,第二次的结果被丢弃。高并发冷启动时等于白算一次 BFS。

Unmarshal 调用链中的反射热点

三笔账合在一起:每次 Unmarshal 付 23 次堆分配 + GC 回收成本;首次使用付一根初始化锐刺。

这不是"反射实现得差",而是反射的本质属性——运行时获取类型信息,必须在运行时分配内存来承载这些信息。你用 reflect.Type.Field(i) 拿到的 StructField 包含字段名(string)和 tag(string),这两个字符串只要超过 16 字节就会分配到堆上。30 个字段,没有逃生通道。


二、标准库的还法

债在哪知道了,看 Go 团队怎么还。

v1 的策略是"缓存摊销"。 两级 sync.Map

var encoderCache sync.Map // map[reflect.Type]encoderFunc
var fieldCache   sync.Map // map[reflect.Type]structFields

encoderCache 存每个类型的编码函数——第一次 Marshal 时通过 reflect 构建整棵编码树(struct → 逐字段 encoder → 递归处理嵌套类型),之后直接查表调用。fieldCache 存字段索引——BFS 遍历的结果缓存起来,后续不再遍历。

v1 处理递归类型(比如链表节点 type Node struct { Next *Node })时,typeEncodersync.OnceValue 做延迟初始化,避免无限递归构建编码器。这比用 mutex 更轻量,但说明 encoding/json 的缓存逻辑远不是"查表返回"那么简单。

缓存能摊销"构建"成本。但摊销不了"使用"成本。每次 Unmarshal 仍然要:

  1. reflect.ValueOf(&target) — 获取目标值的反射句柄
  2. reflect.Value.Field(i) — 逐字段定位写入位置
  3. reflect.Value.Set(...) — 逐字段写入解析出的值

这三步每次都走,无法缓存。只要还用反射做值写入,分配就减不下去。

v2 的策略是"分层 + 池化"。 Go 1.25 引入 GOEXPERIMENT=jsonv2,在 Go 1.26 中继续可用。核心改动两条:

第一,把语法层拆到独立的 encoding/json/jsontext 包。源码注释写得很明确:

Package jsontext implements syntactic processing of JSON as specified in RFC 8259.

Token 扫描、转义处理、缩进格式化——这些操作占 Unmarshal 工作量的大头,但完全不需要 reflect。v1 把语法解析和语义映射混在同一个 decodeState 里,导致整个路径都被 reflect"传染",即不需要反射的纯字节操作也被迫通过 reflect 接口传递。v2 把它们拆开:jsontext 层只操作 []byteToken,零反射调用;encoding/json/v2 层专注 Go 值 ↔ JSON 的语义映射,用 reflect 但范围大幅收窄。

第二,sync.Pool 复用中间对象。v2 的 arshal.go 里有这样一段:

var stringsPools = &sync.Pool{New: func() any { return new(stringSlice) }}

func getStrings(n int) *stringSlice {
    s := stringsPools.Get().(*stringSlice)
    // 复用切片,避免每次 make
}

v1 每次 Unmarshal 都 make 新的临时切片,v2 从 Pool 取用后归还。这一条就砍掉了大量中间分配。

实测结果:

操作 v1 allocs v2 allocs 减少
Unmarshal 23 3 87%
Unmarshal ns/op 4,192 1,769 58%

3 次分配。从 23 到 3,不靠代码生成,纯靠架构分层和内存复用。延迟砍了 58%,GC 压力按比例降低。

补充说明:上表为 Unmarshal 方向数据。v2 对 Marshal 方向的改善相对有限(语法层拆分主要收益在 Unmarshal 的写路径),社区反馈 v2 Marshal 因新增 option 检查在部分场景有轻微退化。如果你的热路径是 Marshal 方向,建议实测后再做判断。

v2 的缓存也做了合并——lookupArshalerCache 把 marshal 和 unmarshal 函数合进同一个 arshaler 结构,一次查表同时获取两个方向的处理函数,比 v1 分两次查表更紧凑。

v1 与 v2 架构对比


三、转嫁者的还法

标准库的还法你已经知道了。如果不够快呢?三个第三方库各自把代价搬到不同的位置。

债务转嫁路径图

sonic

sonic(字节跳动):首次遇到某类型时用 JIT 生成专属汇编,后续直接按内存偏移量读写,不走反射调用链。速度最快(Unmarshal 1,099 ns/op,3.8x 于 v1),4 allocs。代价是平台限制:JIT 只支持 amd64 和 arm64,其他平台会 fallback 到标准库实现,性能回退到 v1 水平。二进制 +19%(打包了 JIT 编译器和 golang-asm 依赖)。

jsoniter

jsoniter(滴滴开源):用 unsafe.Pointer 直接操作内存偏移量,绕过 reflect.Value.Set 的类型安全检查,内部维护自己的类型描述缓存。Unmarshal 速度 3.2x 于 v1(1,317 ns/op,6 allocs/op,ConfigCompatibleWithStandardLibrary)。绕过 reflect 省下的 CPU 是主要收益来源。二进制 +26%。

easyjson

easyjson(Mailru):编译期代码生成——go generate 时扫描 struct 定义,为每个 struct 生成专属硬编码 switch-case。运行时完全不碰 reflect。代价全在构建期:每次改字段 → 跑 easyjson -all types.go → 重新生成 → 提交 *_easyjson.go → review diff → CI 检测同步。忘记 generate 就上线?运行时行为和你想的不一样,没有编译报错提醒你。

实测对比(同一 30 字段 struct,Unmarshal,Go 1.26.2, Apple M4 Pro):

ns/op allocs/op B/op 二进制增量 转嫁目标
std v1 4,192 23 896 (承担者)
std v2 1,769 3 224 0 (还款升级)
sonic 1,099 4 352 +19% JIT 复杂度 + 平台限制
jsoniter 1,317 6 240 +26% unsafe 风险
easyjson† ~800 0-1 ~0 +代码膨胀 构建期维护 + 代码审查

†easyjson 数据为官方 README 标称值,本实验未执行代码生成故未自跑。

四库 Unmarshal 性能对比

没有任何一个库"消灭"了反射的代价。它们只是把代价搬到了不同的地方。sonic 搬到平台兼容性和 JIT 复杂度;jsoniter 搬到 unsafe 风险;easyjson 搬到构建流程和维护成本。选库不是选"谁更好",是选"你更愿意在哪里付账"。


四、为什么 Go 不选 codegen

到这里一个问题绕不过去:easyjson 证明了 codegen 能做到零反射、最快速度,为什么标准库偏不走这条路?

一句话答案:成本结构不对。

我从 github.com/kubernetes/api 仓库拉了一份统计(2026-05-31 shallow clone)。Kubernetes 的 API 定义有 1,101 个 struct,分布在 58 个 types.go 文件中。单 core/v1/types.go 一个文件就有 240 个 struct。

如果标准库选 codegen 路,这意味着:

  • 每个 struct 生成约 100 行专属编解码代码 → 11 万行生成代码需要编译、审查、维护
  • 每次修改字段 → go generate → 重新生成 → diff 审查 → CI 检测生成代码是否过时
  • K8s 每个版本都有大量字段变动(新增/废弃/改类型),生成代码的维护是持续的、强制的

这不是一次性的工程投入。这是绑定在所有用户身上的维护税——只要你用 encoding/json,就必须在构建流程里加入 generate 步骤,每个 CI 都要跑一遍检查。Go 团队的设计哲学里有一条潜规则:标准库的复杂度应该由标准库自己承担,不能推给用户的构建流程。

Go 团队的选择是提升默认路径而非增加可选路径。v2 证明默认路径已经够快,可选 codegen 的维护收益不足以覆盖其 API 复杂度。

而 v2 用另一种方式证明了这条路可行——不改用户的构建流程,不加外部依赖,纯靠更智能的运行时策略:

指标 v1 → v2 改善 v1 → sonic 改善
Unmarshal allocs 减少 87%(23→3) 83%(23→4)
Unmarshal B/op 减少 75%(896→224) 61%(896→352)
Unmarshal 延迟降低 58% 74%
额外构建步骤
平台限制 amd64/arm64 only
二进制增量 0 +19%

v2 在 allocs 和 B/op 维度均优于 sonic(3 vs 4,224 vs 352)。延迟方面 v2 追近了 sonic(2.4x vs 3.8x),差距从 v1 时代的数倍缩小到约 1.6 倍,而 v2 不要求你换库、不限平台、不增加二进制大小。

v2 vs sonic 多维对比雷达图

这组数据反证了一个常见误解:“要快就必须 codegen"不是真的。 更精细的运行时策略——分层去反射、Pool 减分配、缓存合并——能把反射的代价压缩到和 JIT 同一水平线附近,同时不引入任何外部依赖和构建步骤。

Go 团队选择的不是"承受痛苦”,而是"在标准库内部用更聪明的方式还同一笔债"。这个选择让 Kubernetes 的 1,101 个 struct、字节跳动的微服务群、你和我的小项目,升级 Go 版本就够了,不改代码,不加依赖。

K8s 实际上主动选了 protobuf codegen 的路,但那是对序列化密集的 API 层的专项投资,不能要求所有使用 encoding/json 的项目都付这笔维护税。Go 标准库的设计选择是:让不做这个选择的人也能拿到足够好的性能。

成本结构象限图


五、决策:承担还是转嫁

知道了账怎么算、债怎么还,最后的问题只剩一个:你自己的项目怎么选。

不存在"最好的 JSON 库"。只有一个问题:哪种成本结构你付得起。

以下阈值为笔者经验粗略参考,不同服务类型差异极大——交易系统 5% 就该优化,日志系统 30% 也可能无所谓。以你的服务实测为准。

JSON 库选型决策流程图

继续用标准库(承担)的条件:

  • QPS < 10,000 或 JSON 不在热路径上
  • 需要跨平台编译(386、mips、wasm)
  • 团队不想引入第三方依赖管理的复杂度
  • 已升级到 Go 1.25+ 且可以开启 GOEXPERIMENT=jsonv2(v2 把 Unmarshal 代价压到原来的 42%)

⚠️ v2 行为差异提醒GOEXPERIMENT=jsonv2 改变了多项默认行为——字段匹配从大小写不敏感变为严格匹配、重复 JSON key 默认拒绝而非覆盖、time.Duration 编码从纳秒整数变为可读字符串(如 "1s")、omitempty 语义从 Go 零值变为 JSON 空值。这是实验性特性,尚无 Go 1 兼容性保证,建议先在 staging 环境跑通回归测试再上生产。

换 sonic/jsoniter(转嫁到运行时)的条件:

  • JSON 序列化在 pprof 里占比 > 15%
  • 部署平台固定(amd64/arm64),不需要跨架构
  • 能接受二进制体积 +20% 和额外依赖
  • 团队能接受 unsafe 相关的风险(jsoniter)或 JIT 不可调试(sonic)
  • 注意 jsoniter Go 版本自 2022 年后基本停止维护,新项目建议评估长期风险

上 easyjson(转嫁到构建期)的条件:

  • struct 定义稳定,字段不频繁变动
  • CI 中已有 go generate 流程,团队熟悉生成代码模式
  • 追求极致性能且零运行时分配
  • 能接受每次改字段后重新生成 + review 生成代码的 diff

最后一条建议:如果你的服务还在 Go 1.24 以前跑 v1,升级到 Go 1.25+ 并开启 GOEXPERIMENT=jsonv2,可能比换库更值:零代码改动,零依赖引入,Unmarshal 分配减少 87%,延迟降低 58%。注意 v2 的行为差异(见上文),在 staging 验证通过后再推生产。这是 Go 团队用标准库的方式帮你还了大半笔债。在决定"转嫁"之前,先看看"承担者"是否已经替你升级了还款方式。

做选择之前先量化——打开 pprof 看一眼 encoding/json 在你的服务里占多少。如果只有 2%,换库的收益约等于零,风险却不为零。如果超过 15%,该转嫁就转嫁,别硬扛。反射是暗债没错,但打开 pprof 看清账目的人,做选择时心里有数。


附录:实验代码和原始数据

本文 2 组实验的代码和 profile 原始文件已开源:

GitHub:zhiyulab-evidence/go-json-reflection-debt

  • json-bench/:四库 benchmark 对照(v1 / v2 / jsoniter / sonic),含 pprof CPU/heap 采样文件和原始输出。每个子目录有独立 README,说明如何复现
  • 二进制编译产物不入库,跑实验前自己 go build

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →