你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU
先说结论:如果你用 Go 写后端,并且接口返回体里塞满了嵌套结构和 map[string]interface{},那么你线上那台机器的 CPU,有一截是被 encoding/json 吃掉的。它不报错,不 panic,不打日志。它只是在你的 pprof 火焰图里,安安静静地烧掉一大块橙色。
这篇文章不讲「JSON 很重要」这种正确的废话。我们直接从火焰图里把这块热路径抠出来,看看它到底慢在哪、为什么慢,以及什么情况下你根本不该用 JSON。
一、先做个体检,别靠直觉
怀疑序列化是瓶颈的方式只有一个:profile。别猜。
// 压测的时候把 CPU profile 打开
go test -bench=BenchmarkEncode -benchmem -cpuprofile=cpu.out ./...
go tool pprof -http=:8080 cpu.out
看火焰图的时候盯三个指标:
- alloc_space:分配了多少字节——这往往比 CPU 时间更能说明问题
- inuse_space:活着的时候占了多少内存
- cum:注意看 JSON 相关函数下面挂着的
reflect.Value.Interface、runtime.convT这些行
如果你看到 encoding/json.(*encodeState).marshal 下面密密麻麻全是 reflect 和 mapassign,那恭喜,你不是一个人,恭喜你中招了。
二、encoding/json 到底慢在哪
核心原因只有一个:它是纯反射实现的。
当你写 json.Marshal(user) 时,标准库需要动态地「认识」你的 User 这个类型。它得:
- 用反射遍历字段
- 对每个字段判断类型、查
jsontag - 把每个字段值转成
interface{}塞进栈上——这一步会装箱,数字类型全部变成堆分配 - 处理嵌套结构时反复递归,逃逸分析彻底失效
而结构体里的 map[string]interface{} 是重灾区中的重灾区。map 的值类型是 interface{},意味着每个值都必须装箱。而且 Go 的 map 遍历顺序是随机的,序列化时还得额外做 key 排序,否则输出不稳定。
// 反面教材:每个字段都逃逸,每层嵌套都装箱
func handle(c *gin.Context) {
var payload map[string]interface{}
json.Unmarshal(c.Request.Body, &payload) // 反射 + 装箱 + map 分配
body, _ := json.Marshal(payload) // 再来一遍
c.Data(200, "application/json", body)
}
这段代码是我真实见过某个内部网关的写法。它把结构化数据转成 map 又转回 JSON,纯粹是为了「灵活地加点动态字段」。结果就是每个请求都在堆上反复横跳。
三、四个层次的优化,从便宜到彻底
层次一:别用 map 当中间层
这是收益最高、改造成本最低的一招。如果你需要动态字段,用结构体 + omitempty,或者干脆定义一个带 Extra map[string]any 的包装类型,把动态部分限制在一个小容器里,而不是把整个响应都 map 化。
type Response struct {
Code int `json:"code"`
Data any `json:"data"`
Extra map[string]any `json:"extra,omitempty"`
}
关键在于:Data 是具体类型时,字段信息在编译期就确定了,序列化器可以走快速路径;只有 Extra 这种小范围才付出反射的代价。
层次二:换库
生态里比较能打的几个:
- json-iterator/go:基于代码生成的 encoder,兼容标准库 API,drop-in 替换,典型能快 2~3 倍
- bytedance/sonic:基于 JIT(汇编代码生成)+ SIMD,对
map和深层嵌套支持很好,在 amd64/arm64 上优势明显 - mailru/easyjson:生成 marshal/unmarshal 代码,无反射,速度非常可观,代价是要维护
.easyjson.go生成物
import (
jsoniter "github.com/json-iterator/go"
)
var json = jsoniter.Config{
EscapeHTML: true,
SortMapKeys: false, // 输出不稳定的话设为 true
ValidateJsonRawMessage: true,
}.Froze()
注意 SortMapKeys: false 这个决定要有意识:默认关闭能省掉排序开销,但如果你的下游会做快照对比或签名校验,那就得开着。
层次三:换编码格式
如果你的两端都是自己控制的(服务间调用,不是给前端也不是给第三方),JSON 可能从一开始就是错的选择。
Protobuf:字段按 tag 编号编码,二进制体积通常只有 JSON 的 1/3 到 1/5,序列化/反序列化速度快一个数量级,天然支持向前兼容(新增字段、老客户端忽略)。代价是可读性差、抓包排查要靠工具。
FlatBuffers:零拷贝反序列化——直接在一块 buffer 上建立视图,不构造中间对象。这对「数据量巨大但字段访问局部」的场景(比如推荐特征、时序指标)收益非常可观,但它的 schema 演进比 Protobuf 麻烦。
MsgPack:如果必须用 JSON 外观但嫌体积大,它是个折中,跨语言支持不错。
层次四:直接上 Go 的 json v2
标准库新版本正在推进 encoding/json/v2 的实验实现(GOEXPERIMENT=jsonv2)。它的思路是:已知结构走手写代码生成,未知结构才回退到反射,同时补齐了 omitzero、更精确的转义控制、更快的流式写入。对标准库重度使用者来说,这是最省心的一条路——升级 Go 版本就能白嫖性能提升。
四、别忘了序列化只是链路的一环
很多人优化完序列化就觉得自己完事了,其实你可能优化错了地方。序列化通常和这些东西绑在一起出现:
- 压缩:gzip/zstd 在网络层吃掉的时间可能比序列化更多,但你的 CPU profile 里看不到它(它在另一个 goroutine)
- 网络写入:把大 body 一次性
Write到 socket 还是分块写,差别很大 - 日志打印:很多中间件会打印完整的请求和响应体,这在高 QPS 下是隐形的大头
我的建议顺序是:先看 pprof 找真正的热函数,再决定要不要动序列化。不要凭直觉优化,更不要「反正序列化慢,先换了再说」——引入一个新库是有维护成本的,那是债,不是收益。
五、一条实用的自检清单
- 火焰图里
encoding/json占比超过 10% 了吗?没超过就别折腾 - 代码里有多少处把结构体转成
map[string]interface{}再转回来?先干掉这些 - 响应体里有大数组吗?如果是
[]里塞 1000 个对象,考虑流式输出而不是全量 marshal - 日志里打印完整 body 了吗?关掉它,或者只打关键字段
- 两端都是你的服务吗?是的话认真评估一下 Protobuf
六、一句话总结
JSON 是人类最通用的接口格式,但它绝不是最快的。慢不是 JSON 的错,是「用反射处理已知结构」的错。先 profile,再换工具,别用直觉做优化。
工具没有银弹:jsoniter 是保守派,sonic 是性能派,easyjson 是生成派,Protobuf 是协议派。选哪个取决于你的流量、你的团队维护意愿,以及你到底需不需要人类可读。
顺带一句:接口设计的第一原则是「可读」,性能优化永远排在后面。除非你的火焰图已经明明白白地指认了它——那就当我没说。