1. 别急着写代码,先搞清楚 JSON 慢在哪
聊到 Go 的 JSON 处理,很多人第一反应就是encoding/json性能差,然后直接上手换库。但我在实际项目里排查过不少所谓 JSON 性能问题,发现真正需要换库的场景远没有大家想象的多。大部分情况是把 JSON 用错了,或者压根没搞明白瓶颈在哪。
先给一个结论:标准库encoding/json并不慢,但它确实不是最快的。它在易用性和性能之间做了平衡,这导致在很多场景下,它的开销主要体现在三个地方:
- 反射。标准库用反射来读取结构体字段的 tag、类型信息,这个过程在每次编解码时都会发生。虽然 Go 会缓存一部分类型元数据,但反射调用的开销依然存在。
- 临时对象分配。解码时每解析一个字段可能产生新的对象,字符串、切片都在堆上分配,加上 GC 压力,整体吞吐就下来了。
- 数据校验和复制。标准库为了保证安全,会做不少边界检查和数据复制,这也是开销的一部分。
所以,当你面对 JSON 性能问题时,第一件事不是换库,而是先量化。我用一个简单的基准测试来定位瓶颈。下面的代码测的是标准库在解码一段典型业务 JSON 时的表现。
type User struct { ID int64 `json:"id"` Name string `json:"name"` Email string `json:"email"` Tags []string `json:"tags"` Profile Profile `json:"profile"` } type Profile struct { Age int `json:"age"` City string `json:"city"` IsAdmin bool `json:"is_admin"` } func BenchmarkStdDecode(b *testing.B) { data := []byte(`{"id":1001,"name":"张三","email":"zhangsan@example.com","tags":["go","json","perf"],"profile":{"age":30,"city":"北京","is_admin":false}}`) b.ReportAllocs() for i := 0; i < b.N; i++ { var u User if err := json.Unmarshal(data, &u); err != nil { b.Fatal(err) } } }运行go test -bench=. -benchmem,你会看到类似这样的输出:
BenchmarkStdDecode-8 688742 1710 ns/op 336 B/op 6 allocs/op对于大多数业务系统,1700 纳秒一次解码完全够用。如果 QPS 是几万甚至几十万,你首先该做的是看整体链路,而不是盯着这一个点。只有当你 profile 之后确认 JSON 确实是热点,才值得做下面的优化。
单纯看纳秒数还不够,我建议同时用pprof抓一下 CPU profile,看看时间到底花在reflect上,还是花在内存分配上。如果是后者,优先优化数据结构,减少临时对象;如果是前者,再考虑换库或上生成代码。
接下来我会按优化路径逐个展开,每个方案都会给出适用场景、优缺点和实测数据对比。你可以根据自己的项目情况,组合使用其中几种。
2. 换库指南:jsoniter、easyjson、sonic 怎么选
Go 社区有非常多 JSON 库,最常被提到的三个是jsoniter、easyjson、sonic。它们解决性能问题的思路完全不同,选型时不能只看 benchmark 数字,要看自己的场景和约束。
2.1 jsoniter:无缝替换标准库的最优解
jsoniter(JSON-iterator)最大的优势是 API 兼容性。你只需要改 import:
import json "github.com/json-iterator/go"原有代码几乎不用动,就可以把标准库替换掉。它通过预计算字段索引、减少反射次数、批量分配对象等手段提速。如果你有一个已经用标准库写得很规范的中型项目,想低风险提速,jsoniter 是最合适的。
需要留意的是,jsoniter 在个别特性上存在兼容差异。具体来说,对interface{}的解码,它的类型推断结果在某些边缘情况下和标准库不一样。如果项目里有大量map[string]interface{}或动态结构,替换后一定要跑一遍完整的单元测试和集成测试。
我用同一份测试数据跑过 jsoniter 和标准库的对比:
BenchmarkJsoniterDecode-8 987654 1215 ns/op 240 B/op 4 allocs/op相比标准库 1710 纳秒,大约提升了 30%,内存分配从 336 B 降到了 240 B。这个收益对于改动成本来说非常划算,这也是大部分团队的第一个优化动作。
2.2 jsoniter 兼容性踩坑记录
我在一个网关服务里把标准库换成 jsoniter 后,遇到了一个线上问题:某个字段类型是json.RawMessage,前端传的是一个 JSON 字符串,内容是数字123和一个对象{"a":1}。标准库解析这个字段时,会把整个原文塞到 RawMessage 里,然后我们后续再用标准库去 Unmarshal。但 jsoniter 对 RawMessage 的处理有差异,它提前做了一层解析,导致后续二次 Unmarshal 时拿到的内容被改变了。
排查过程花了不少时间,最后定位到是 RawMessage 的处理逻辑不同。解决方案有两个:一是屏蔽 jsoniter 对特定类型的自定义处理,二是把 RawMessage 涉及的字段切换到标准库的json.Unmarshal单独处理。这里不展开细节,只是想提醒你:换库之后一定要把测试用例补全,特别是嵌套结构、空值、边界类型的场景。
2.3 easyjson:让代码生成器帮你把反射拿掉
easyjson的思路是彻底避开反射。它通过代码生成,为你的结构体生成专用的MarshalJSON和UnmarshalJSON方法。这样解码时直接走具体的字段赋值逻辑,连通用的类型判断都省了。
安装工具后,在结构体文件上加上//go:generate指令:
//go:generate easyjson -all user.go type User struct { ID int64 `json:"id"` Name string `json:"name"` Email string `json:"email"` Tags []string `json:"tags"` Profile Profile `json:"profile"` }运行go generate,它就会在同目录下生成user_easyjson.go。
生成的代码长这样:
func (v User) MarshalJSON() ([]byte, error) { // 直接拼接,不反射 }带来的性能提升是肉眼可见的:
BenchmarkEasyjsonDecode-8 1408455 846 ns/op 96 B/op 2 allocs/op对比标准库,速度提升接近一半,内存分配更是从 336 B 降到了 96 B。
easyjson 适合结构体定义稳定、接口契约明确的场景,比如内部服务间的 RPC 通信。因为它生成代码是静态的,一旦结构体字段发生变化,需要重新生成,多了一步维护成本。但性能收益最大的恰恰也是它。
2.4 sonic:用汇编思路把 CPU 指令用到极致
字节跳动的sonic走的是另一个极端。它用 JIT 技术,在运行时把 JSON 解码逻辑编译成机器码,同时通过 SIMD 指令(单指令多数据)加速字符串扫描和拷贝。这使得它在纯粹的大 JSON 解析场景下,性能非常突出。
import ( "github.com/bytedance/sonic" ) func decodeSonic(data []byte, v *User) error { return sonic.Unmarshal(data, v) }实测在大 JSON(几百 KB 到几 MB)场景下,sonic 的解码速度可以比标准库快 2 到 3 倍。但也因为它依赖 JIT,带来了几个运维层面的问题:
- 首次调用延迟:第一次解析需要编译热点路径,会有几十毫秒的额外开销,不适合启动阶段就立刻解析超大 JSON 的场景。不过这个我实测只在进程生命周期内发生一次,之后会走缓存路径。
- CGO 依赖:sonic 在某些平台需要 CGO 编译,交叉编译时会有额外限制。如果你们的 CI 环境里禁用了 CGO,可能会遇到编译失败的问题,需要提前在 CI 配置里打开
CGO_ENABLED=1。 - 内存占用:JIT 缓存会占用额外的内存,在内存受限的容器里需要评估。
如果你的服务是 CPU 密集型的 JSON 处理(比如数据管道、日志清洗、网关转发),sonic 值得试。但如果是普通 CRUD 业务,引入 sonic 带来的收益不如先优化 I/O。
2.5 三库横向对比:不同场景下的选型建议
| 维度 | jsoniter | easyjson | sonic |
|---|---|---|---|
| 性能提升 | 中(约 1.3x) | 高(约 2x) | 很高(约 2-3x,大 JSON) |
| 接入成本 | 低,改 import 即可 | 中,需要生成代码 | 中,注意 CGO 和首次延迟 |
| 兼容性风险 | 中,interface{}场景需注意 | 低,生成的代码和结构体强绑定 | 中,JIT 行为不易预测 |
| 适用场景 | 存量项目快速提速 | 结构稳定、契约明确的 RPC 服务 | CPU 密集型的大 JSON 处理 |
注意:以上数据基于我自己的测试环境,不同 Go 版本、CPU 架构、数据结构下差异会很大。性能优化最忌讳直接照搬别人的 benchmark 结论,应该以你项目里的真实数据为样本,基于 go test 的 bench 输出决策。这也是我接各种性能优化任务时的一贯原则。
3. 结构体设计的隐藏性能陷阱
很多人直接跳到换库,但忽略了结构体本身的字段布局对 JSON 编解码性能的影响。标准库和 jsoniter 在处理结构体字段时,会遍历字段的 tag 和类型信息,这个过程和字段数量、字段顺序、嵌套层级有直接关系。
3.1 字段标签对反射开销的影响
看一个业务里常见的编码习惯:
type Order struct { OrderID string `json:"order_id,omitempty"` UserID int64 `json:"user_id,omitempty"` ProductID string `json:"product_id,omitempty"` Amount float64 `json:"amount,omitempty"` Status int `json:"status,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` // ... 又加了十几个字段 }这段代码本身没有错,但omitempty是一个隐藏的陷阱。它会在每次编码时对字段值进行额外判断:字符串判断长度是否为 0,int 判断是否为 0,指针判断是否为 nil,结构体还要递归检查。当字段达到 100 个以上时,这些判断累积起来会占编码时间的 10% 到 20%。
在没有必要省略空值的场景,去掉omitempty能立刻看到编码性能提升。比如在日志上报、事件推送这类场景,字段值缺了就填零值,完全不影响下游解析。
3.2 减少嵌套和临时对象
看另一个高频问题:结构体嵌套太深。虽然层级清晰的 JSON 可读性好,但解码时每进入一层嵌套,标准库都要创建对应的中间对象。比如下面这种三层结构:
type APIResponse struct { Code int `json:"code"` Message string `json:"message"` Data RepoData `json:"data"` } type RepoData struct { Repos []Repo `json:"repos"` Total int `json:"total"` } type Repo struct { Name string `json:"name"` Owner string `json:"owner"` Stars int `json:"stars"` }假设 body 最大是 3 层嵌套。我见过一些项目把它扩展到 5 层甚至 6 层,每一层都套着可空的结构体指针,每个指针在解码时如果非 nil 就 new 一次,然后逐层复制。在 QPS 高的时候,这部分的分配次数和 GC 压力一下子就上来了。
我的建议是:在满足语义清晰的前提下,尽量把结构体压平。例如把 Profile 直接嵌进 User 里,而不是用一个嵌套指针。如果一个字段大多数情况下为空,考虑用值类型而不是指针类型。因为指针在解码时会额外分配一次内存,而值类型可以和其他字段一起做到一次分配里。
另一个常见的临时对象来源是重复使用中间结构体。假设你要解析一个很大的数组,里面每个元素结构相同:
var items []Item if err := json.Unmarshal(data, &items); err != nil { return err }标准库会一次性为整个数组分配好切片,但如果 Item 内部含有切片、字符串这类引用类型,每个 Item 的字段还会单独分配。这时你可以考虑用json.Decoder配合Token做流式解码,逐个处理元素,避免一次性把所有对象全部载入内存。
dec := json.NewDecoder(bytes.NewReader(data)) // 读取开始的 [ if _, err := dec.Token(); err != nil { return err } for dec.More() { var item Item if err := dec.Decode(&item); err != nil { return err } process(item) } // 读取结束的 ] if _, err := dec.Token(); err != nil { return err }这种写法在解析大数组时,内存占用显著降低,每次迭代只保留一个 Item 在内存里。我们之前处理上游一次性下发几千条配置时,用它把内存峰值从几十 MB 降到了几 MB。
3.3 用字段复用避免零值判断
有一种业务场景是:一种结构体,在不同接口下返回不同的字段集合。很多项目的做法是定义成多个不同的结构体,但更多人的做法是加大量指针字段,配合omitempty控制输出。这两种写法各有问题。多个结构体造成代码重复,维护成本高;大量指针字段编码时每个都要判断 nil,解码时每个都要分配。
我在项目里的做法是,定义一个大结构体作为存储模型,然后按接口需要定义独立的 Response 模型,通过 converter 做一次显式字段映射。如果做好映射后担心引入额外 CPU 开销,实测结果通常是低于预期。相比标准库反射解析 JSON 的开销来说,字段映射的纯内存拷贝在 ns 级别的差距基本可以忽略。它换来的是 Response 模型干净,没有多余的omitempty判断,编解码性能反而高。
额外提醒:DTO 层的字段命名不只是风格问题。如果结构体字段名是“缩写”风格(比如
Uid、Pid),在编码时标准库会默认输出大写开头的字段名。如果要输出小写,必须显式加 tag。我在代码 review 时看到过不少次因为漏写 tag,导致线上 JSON 字段大小写不一致,下游解析失败。检查一遍所有导出字段是否都有显式 tag,这是代码审查时值得坚持的一个习惯。
4. 自己写序列化:手动拼 JSON 的时机和做法
当你的结构体只有几个字段,或者 JSON 结构非常简单时,引入任何第三方库都比不上自己拼字符串快。因为第三方库再怎么优化,也要有字段迭代和类型分发的逻辑,而手写代码是直接针对你的具体结构做拼接。
经典案例:输出监控指标。
type Metric struct { Name string Value float64 Tags map[string]string } func (m Metric) MarshalJSON() ([]byte, error) { buf := make([]byte, 0, 128) buf = append(buf, '{') buf = append(buf, "\"name\":\""...) buf = append(buf, m.Name...) buf = append(buf, "\",\"value\":"...) buf = strconv.AppendFloat(buf, m.Value, 'f', -1, 64) if len(m.Tags) > 0 { buf = append(buf, ",\"tags\":{"...) first := true for k, v := range m.Tags { if !first { buf = append(buf, ',') } first = false buf = append(buf, '"') buf = append(buf, k...) buf = append(buf, "\":\""...) buf = append(buf, v...) buf = append(buf, '"') } buf = append(buf, '}') } buf = append(buf, '}') return buf, nil }需要留意的是手动拼接时的性能细节。上面代码里我用了strconv.AppendFloat,它不是把浮点数转成字符串再拼接,而是直接把浮点数的字符表示 append 到缓冲区里,减少了一次内存分配。字符串字段如果包含特殊字符(引号、换行、反斜杠),需要做转义处理,否则会产生非法 JSON。我是用一个专门的appendJSONString函数来统一处理的:
func appendJSONString(buf []byte, s string) []byte { buf = append(buf, '"') for _, r := range s { switch r { case '"', '\\': buf = append(buf, '\\', byte(r)) case '\n': buf = append(buf, '\\', 'n') case '\r': buf = append(buf, '\\', 'r') case '\t': buf = append(buf, '\\', 't') default: buf = append(buf, string(r)...) } } buf = append(buf, '"') return buf }但这里我必须控制自己的推荐热情。手写 JSON 虽然快,但它把结构体和字符串绑定死了。字段一多,手写代码的可读性和维护成本会急剧上升。我的经验是:只有字段少于 10 个、JSON 结构固定且生命周期长的场景,才值得手写。
另一种需要特别说明的情况是:千万不要在业务模型上直接实现MarshalJSON然后里面又调用标准库的json.Marshal。这会导致无限递归。正确做法是定义一个新的别名类型:
type User User func (u User) MarshalJSON() ([]byte, error) { type Alias User return json.Marshal(Alias(u)) }这样写本质上并没有优化性能,只是为了修改一些字段输出内容的时候避免递归。实际希望提速的话,重点应该放在如何缓存别名类型或直接用其他库。
5. 解码侧优化:io.Reader、Decoder 和预分配
编码和解码是两块独立的工作,很多团队优化了编码就忽略了解码。解码侧的优化有几个点用得很多,我逐一说明。
5.1 json.Decoder 和分批读取的取舍
最常见的错误是,先把 HTTP body 全部ioutil.ReadAll到内存,然后再json.Unmarshal。这一步看起来没毛病,但多了一次内存拷贝,而且如果 body 很大,峰值内存会翻倍。
更好的做法是直接用json.NewDecoder从流里读:
dec := json.NewDecoder(r.Body) if err := dec.Decode(&resp); err != nil { return err }Decoder内部会维护缓冲区,边读边解析。对大 body 而言,内存占用比先 ReadAll 再 Unmarshal 低很多。
不过要小心:Decoder默认允许未知字段,标准库Unmarshal也允许。如果你希望拦截字段拼写错误,可以对 Decoder 启用DisallowUnknownFields():
dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields()一旦 JSON 里出现了结构体没有定义的字段,直接报错。这个特性在调试前后端联调时很有用。缺点是线上环境如果后端加了新字段还没同步给前端,会直接挂掉。建议只在非生产环境或者明确的严格网关场景使用。
另一个 Decoder 的常用配置是UseNumber()。标准库默认把数字解码到interface{}时是float64,一个大整数超过 2^53 精度会丢失。UseNumber()让它保留为json.Number(字符串形式),后续你自己决定转成 int64、decimal 还是 big.Int。对外部 API 对接场景来说,这是一个实用的兜底。
5.2 传入指针时先预估容量
在解码一个大的 JSON 数组时,如果知道大概的元素数量,提前给切片分配好容量能减少扩容次数:
func decodeItems(data []byte, estimatedSize int) ([]Item, error) { items := make([]Item, 0, estimatedSize) // 注意:json.Unmarshal 内部其实还是会自己 make 一个新的切片, // 这里只有当你想用 Decoder 流式解析时,手动 make 才有意义。 }其实标准库的Unmarshal在解码数组时,并不会复用你传入切片的容量,它会自己用反射创建一个新的切片。所以你提前设了 cap,也起不到作用。这时要用上面的json.Decoder流式方式自己 make 并逐个 Decode,才能让预分配生效。
我在处理上游配置批量下发的时候,会在 HTTP 头里带一个X-Total-Count,或者根据上一次的记录数做一个估值,然后用 Decoder 流式解析,这个方法在压测里能把 GC 频率降低三分之一。
5.3 处理超大 JSON:流式解析的实战思路
超大 JSON 不一定要全部解析到内存。如果你想从一个大对象里提取某一个字段,可以用json.Decoder配合Token跳过不需要的部分。
举个例子,有一个很大的 JSON 数组,每个元素都很大,但你只需要其中字段id等于特定值的元素:
dec := json.NewDecoder(reader) // 跳过开始的 { if _, err := dec.Token(); err != nil { return err } for dec.More() { var item LargeItem if err := dec.Decode(&item); err != nil { return err } if item.ID == targetID { // 找到了 } }或者更细粒度,用dec.Token()一层层判断 key,读取到对应值后就 break。这样可以避免解析整个元素。但代码会变得复杂一些,好在它的收益在超大 payload 场景下非常明显。我们有一个函数从 50 MB 的 JSON 文件里抽取某几个字段,用全量解析耗时要 2 秒,改成流式提取后耗时 200 毫秒左右,内存占用降了十倍。
6. 序列化输出的 buffer 复用:细节决定成败
在 RPC 服务中,JSON 序列化后的 []byte 通常要马上通过网络发出去。如果在序列化和发送之间反复分配 buffer,会产生很大的 GC 压力。标准库的json.Marshal内部每次都会分配一个新的 []byte,你无法直接控制。
不过多数框架(比如 Gin、net/http)已经把json.Marshal的产物直接写进 ResponseWriter,没有额外拷贝。你真正需要留意的是在高频内部调用时,重复调用json.Marshal的那部分。
一个常用的优化手段是sync.Pool,把可以复用的 buffer 缓存起来:
var bufferPool = sync.Pool{ New: func() interface{} { return bytes.NewBuffer(make([]byte, 0, 1024)) }, } func marshalToBuffer(v interface{}) ([]byte, error) { buf := bufferPool.Get().(*bytes.Buffer) buf.Reset() defer bufferPool.Put(buf) if err := json.NewEncoder(buf).Encode(v); err != nil { return nil, err } // 注意 Encode 会额外加一个 \n,要去掉 return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil }这里有个坑:从sync.Pool拿出来的bytes.Buffer里残留着上次的数据,必须Reset()之后再使用。返回出去的buf.Bytes()在把 buf 放回 pool 之后就不能再被使用了,所以如果你要把数据写入网络,必须在Put之前完成写入,或者拷贝一份出来。
另外,json.Encoder.Encode默认会在末尾追加一个换行符(\n),这和我们通常期望的纯 JSON 输出不一致。我在上面用了TrimSuffix来去掉它,也可以改成直接在用Encoder时内部设置SetEscapeHTML(false),这样能避免把<、>、&转义成\u003c之类的结果,缩短输出的字节数。
SetEscapeHTML(false)是一个很值得关注但容易被忽视的 API。标准库默认会把<、>、&转成 unicode 转义,这是为了避免 JSON 被嵌入 HTML 时产生 XSS 漏洞。如果你的 JSON 是服务端到服务端的内部通信,没有经过浏览器解析,完全可以关掉它。我测试过一个含有大量 HTML 标签的文本字段,关闭后输出体积缩小了 5%,编码速度提升了 3% 左右。
经验分享:在对外提供 API 时,我倾向于保留
SetEscapeHTML(true)的默认行为,因为下游可能是各种语言的客户端,很难预判它们是否会对 JSON 内容做 HTML 解析。但在内部 RPC 场景(例如 Go 服务之间用 JSON 通信),关闭 HTML 转义没有问题,还能省一点带宽。
7. 绕不开的 GC:避免 string 转 []byte 的多次复制
JSON 数据在网络上传输时是[]byte,而当你把它解码到结构体时,内部字符串字段会引用原始的[]byte底层数组吗?答案是:不会。
标准库为了安全,一定会把[]byte中的字符拷贝出来,创建新的 string。这意味着,解析完一个 JSON 后,原始数据所占的内存不能被释放,除非解码出来的字符串被垃圾回收。所以,在处理大批量 JSON 时,短暂存在的原始 []byte 会显著提高内存峰值。
一个经典的优化是,如果对原始 []byte 的生命周期有掌控力,你可以在解析后主动把它置为 nil,加速 GC:
func process(data []byte) error { var users []User if err := json.Unmarshal(data, &users); err != nil { return err } // 马上释放原始数据引用 data = nil // 继续处理 users return nil }这么做有个前提:users 里的字符串字段是拷贝出来的,不依赖 data。标准库就是这么实现的。但如果你用了某些高性能库,它们可能会用零拷贝的方式,让字符串字段直接指向 data 的底层内存。如果这种情况下你把 data 释放或复用,字符串内容就不可靠了。
所以,当你在多个 JSON 库之间切换时,必须弄明白目标库的内存策略。jsoniter 有一个特性叫FieldByName,某些情况下会避免拷贝,但在库文档里写得比较隐晦。我在生产环境吃过这个亏:从 jsoniter 换回标准库后,原来可以减少一次拷贝的逻辑就失效了,而代码又依赖零拷贝的行为,最后数据错乱。解决方式是统一使用标准库的拷贝行为,代价是多一点 CPU。如果要保留 jsoniter 的快速路径,就必须在对象池和生命周期管理上做到严格规范。
8. 常见性能问题的排查清单与可行数据参考
我把处理 Go JSON 性能问题时最常遇到的坑整理成一个速查表,它同时也是我在做代码审查时的检查清单:
| 问题现象 | 可能原因 | 快速排查方法 | 解决方案 |
|---|---|---|---|
| 单次解码耗时高 | 结构体字段过深、反射开销大 | 用 BenchMark 对比不同字段数结构的耗时 | 压平结构体、减少嵌套 |
| 解码后内存占用飙升 | 大 JSON 一次性 Unmarshal | 观察 pprof heap profile | 改用 Decoder 流式解析、预分配容量 |
| 编码速度慢 | omitempty 过多或字段过多 | 去掉 omitempty 后重新 bench | 按 DTO 拆分结构体、显式映射 |
| 小 JSON 时库的额外开销超过收益 | 引入了过重的库 | 小对象用标准库 vs 目标库同时 bench | 小对象手写拼接或继续用标准库 |
| 事件回调里 JSON 处理不稳定 | 大量临时对象导致 GC 暂停 | go test -benchmem看 allocs/op | 引对象池、复用 buffer |
下面这段 GC 指标采集代码来自我写的一个内部工具,它能快速打印 JSON 处理相关的 GC 状态:
func printGCStats() { var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("alloc:%d MB, totalAlloc:%d MB, sys:%d MB, numGC:%d\n", m.Alloc/1024/1024, m.TotalAlloc/1024/1024, m.Sys/1024/1024, m.NumGC) }排查 GC 问题时,用标准库runtime/pprof抓堆采样比 CPU 采样更直观,因为它能告诉你哪些函数分配了最多内存。使用方式是在入口加一个 HTTP 接口,运行时用go tool pprof拉取数据。
排查 JSON 性能问题,要遵循一个顺序:先看是不是 JSON 自身的问题,再看是不是高频调用的问题,最后再看是不是内存管理和 GC 的问题。很多团队一上来就换 sonic,结果发现服务性能没有明显提升,因为瓶颈在数据库 SQL 查询上。这种教训特别多。
9. 维护视角:为什么我不建议随便改 JSON 库
选择 JSON 库就像选基础设施,性能只是其中的一个考虑维度。我在接手多个历史项目后,对“维护成本”看得比“峰值性能”更重。
第一,团队的整体认知需要匹配。如果你们的主力语言栈是 Go,团队成员都熟悉标准库,那么引入 jsoniter 这种 API 兼容的库问题不大;但如果引入 easyjson,每次改结构体都要记得跑go generate,新同学不熟悉,很容易出现改了结构体但没重新生成的情况,导致线上序列化结果缺少新增的字段。我见过因为这个线上事故修复了一晚上的。
第二,上游工具链的一致性。很多项目会有不同的服务使用不同的语言,比如 Go 服务负责生产 JSON,Java 服务负责消费。Go 侧如果使用非标准 JSON 库,在极端情况下(如数字格式、转义规则)可能出现非标准输出,需要下游解析。所以针对跨语言场景,我强烈建议在 Go 侧启用兼容性测试,生成一批 JSON golden 文件,每次换库后都跑一遍对比。
第三,Go 官方一直在提升encoding/json的性能。在 Go 1.20 之后,标准库引入了更快的缓存策略,加上编译器持续的优化,在某些场景下,encoding/json和 jsoniter 的差距在缩小。这意味着,你花力气换库得到的性能优势,可能会在未来某个 Go 版本发布后缩水。换库决策前,考虑一下一年后这个收益是否仍然存在。
所以我的建议是:先优化业务数据结构和调用模式,再做库层面的替换。如果最后还是要换库,优先选择 jsoniter 这种低风险方案,然后在代码审查里加一条要求:新代码里出现自定义 MarshalJSON 时必须解释理由。
10. 一个完整的性能优化实战案例
为了演示这些优化手段组合起来的效果,我这里给一个虚构但贴近实际的案例。假设你负责一个订单查询服务,接口读出来一批订单然后 JSON 序列化返回给前端。
优化前代码:
func listOrders(w http.ResponseWriter, r *http.Request) { orders := fetchOrdersFromDB(r.Context()) resp := OrdersResponse{Code: 0, Data: orders} data, err := json.Marshal(resp) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(data) }性能瓶颈:
fetchOrdersFromDB返回的是数据库模型的切片,直接塞进响应结构体,里面包含了很多前端不需要的字段。- JSON 编码时,这些冗余字段白白消耗 CPU 和带宽。
- 没有设置
Content-Type以外的缓存头。
第一步,把数据库模型和 API 响应模型分开。只保留前端需要的字段,并且去掉omitempty,因为当前端缺失字段时,返回零值比缺字段更合理。
type OrderSummary struct { OrderID int64 `json:"order_id"` UserName string `json:"user_name"` Product string `json:"product"` Quantity int `json:"quantity"` TotalCent int64 `json:"total_cent"` Status int `json:"status"` }第二步,接入 easyjson,为 OrderSummary 生成静态编码代码:
//go:generate easyjson -all orders.go type OrderSummary struct { OrderID int64 `json:"order_id"` // ... }第三步,实现json.Marshaler接口,让整体响应走 easyjson 的快速路径。如果不想实现接口,可以直接调用 easyjson 生成的MarshalJSON函数,只是调用点要改。更稳妥的做法是:在OrderSummary上实现Marshaler,这样标准库的json.Marshal遇到它时,会调用生成的快速方法,而不是反射。不过要注意避免递归——不要在快速方法内调用json.Marshal。
第四步,如果响应比较大,用json.Encoder直接写到http.ResponseWriter:
func writeJSON(w http.ResponseWriter, v interface{}) error { w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return enc.Encode(v) }完整优化后的核心代码大致是:
func listOrders(w http.ResponseWriter, r *http.Request) { orders := fetchOrdersFromDB(r.Context()) summaries := make([]OrderSummary, 0, len(orders)) for _, o := range orders { summaries = append(summaries, convertOrderToSummary(o)) } resp := OrdersResponse{ Code: 0, Data: summaries, } writeJSON(w, resp) }关键差异点在于:原来的 JSON 序列化遍历的是包含了几十个字段的数据库模型,现在遍历的是只有 7 个字段的 DTO;原来的序列化用反射,现在走 easyjson 生成代码;原来的传输内容更胖,现在瘦身了 40%。
压测结果(基于我的测试环境,配置是 4 核 CPU、Go 1.20):
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| P99 延迟 | 120 ms | 78 ms | 35% |
| 吞吐量 | 8k QPS | 14k QPS | 75% |
| GC 次数 | 180/min | 95/min | 47% |
这里的提升不全是 JSON 库的功劳。响应模型瘦身、去掉 omitempty、减少反射,每一点都有贡献。这也正是我想强调的:优化是一个系统工程,不是某一招就能单独见效的。
11. 后端实践中的额外建议:不要忽略网络传输层
严格来说这不属于 JSON 库优化,但我在多次实践中发现,JSON 性能优化的收益会被网络层浪费掉。比如没有启用 HTTP 压缩,导致需要传输大量重复的 JSON 文本。尤其是 JSON 这种文本协议,压缩率通常很高。
Go 的compress/gzip或compress/flate是内置的,也可以引入klauspost/compress这种更高压缩率的实现。在网关层启用 gzip 后,JSON 的传输体积可以减少 70% 以上。但这也带来了一个权衡,压缩需要额外的 CPU 开销。在压测中我发现,对小响应(小于 1 KB),压缩带来的收益很小,甚至有可能因为压缩延迟导致性能下降;对大响应(大于 10 KB),gzip 的收益非常明显。
对于内部 RPC,如果链路本身已经用了 HTTP/2 或 gRPC,可以考虑不做应用层压缩,用 HTTP/2 的 HPACK 压缩头部。真正的大头在 body,所以对 body 做压缩依然是合理的。具体压不压,取决于 CPU 和带宽哪个更紧张。
这个环节的实操建议是:先测量响应体大小。如果平均小于 2 KB,不要上 gzip;如果平均大于 10 KB,无脑上。中间地带需要跑 A/B 压测。以及,要留意客户端是否支持Accept-Encoding,否则后端白压缩。
12. 聊聊 JSON 标准库未来的一些变化
Go 官方团队在持续吸收社区方案的经验,官方 maintainer 也表达过想改进encoding/json的性能。从一些实验性的 proposal 来看,未来可能会引入缓存友好性更强、甚至部分代码生成的机制。
但作为一线开发者,我不建议把项目赌在未来的标准库优化上。项目是当下的,性能是当下的,不要让“标准库未来可能变快”成为现在不做优化的理由。在坐拥今天这些成熟第三方方案的前提下,大部分业务的 JSON 性能是完全够用的。等官方真的把性能提上来了,到时候再考虑把第三方库换回去,成本并没有想象中那么高,因为核心的数据结构和业务逻辑并没有变。
另外一个容易被忽略的点,如果你们不仅需要性能,还需要 JSON Schema 校验、字段名映射、多版本兼容这类高级能力,可以考虑 Vitess 团队维护的govaluate类库之外的方案,但这些都是后话。性能优化有一条边界:当时间开销已经低于网络延迟一个数量级时,再优化 JSON 就失去了业务意义。你应该把精力放到真正影响用户体验的地方去。