登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go JSON 编解码慢如何区分 CPU 和分配问题

来源:17golang原创

时间:2026-10-14 06:37:08 102浏览 收藏

Go 的 JSON 编解码慢,不能只看一条 ns/op 就断定是 CPU 问题。真正有用的判断是把同一份输入拆成三组信号:每次耗时、每次分配字节数、每次分配对象数,再用 CPU profile 和内存 profile 对照热点。这样可以区分“解析逻辑本身占 CPU”“短命对象太多”和“结果对象一直存活”这三种完全不同的情况。

官方地址:https://pkg.go.dev/encoding/json

先用固定输入跑 go test -bench . -benchmem,再分别生成 CPU 和内存 profile。只有当 ns/op、B/op、allocs/op 与 profile 指向同一类热点时,才开始改 JSON 代码。

先把 Marshal 和 Unmarshal 的输入固定下来

基准最容易犯的错误,是在循环里创建结构体、拼接字符串或生成随机数据。那样测到的是“准备输入 + JSON 编解码”的总成本,无法知道慢点到底在哪里。下面把两条路径拆开,编码只接收已经准备好的结构体,解码只读取已经准备好的 JSON 字节。

package jsonbench

import (
    "encoding/json"
    "testing"
)

type Order struct {
    ID       int64    `json:"id"`
    Customer string   `json:"customer"`
    Items    []string `json:"items"`
    Paid     bool     `json:"paid"`
}

var (
    sampleOrder = Order{
        ID:       1001,
        Customer: "demo",
        Items:    []string{"book", "cable", "adapter"},
        Paid:     true,
    }
    sampleJSON = []byte(`{"id":1001,"customer":"demo","items":["book","cable","adapter"],"paid":true}`)
    sinkBytes   []byte
    sinkOrder   Order
)

func BenchmarkMarshal(b *testing.B) {
    b.ReportAllocs()
    b.ResetTimer() // 排除测试数据构造和计时器初始化成本
    for i := 0; i 

这里的全局结果变量只是防止编译器消除调用,不代表生产代码应该使用全局状态。编码和解码要分开命名,否则后面看 profile 时很难把热点对应回具体路径。

用 benchmem 先区分时间和分配

运行基准时同时打开分配统计:

# 只重复基准,不把普通单元测试输出混进结果
go test -run '^$' -bench 'Benchmark(Marshal|Unmarshal)$' -benchmem -count=5

重点记录三列:ns/op 是单次耗时,B/op 是单次分配字节数,allocs/op 是单次分配次数。比较两个实现时,先保证输入、Go 版本、CPU 绑核方式和基准命令一致,不要拿一次运行的绝对数值跨机器下结论。

可以用下面的判断顺序:

  • ns/op 明显变大,但 B/op 和 allocs/op 基本不变:优先查 JSON 结构复杂度、反射路径、字符串转义和业务层预处理。
  • B/op 或 allocs/op 明显上升,同时耗时也上升:优先查临时切片、重复转换、过大的中间结构和每次重新分配的缓冲区。
  • 分配数不高,但请求处理后堆仍持续增长:不要把它归因于单次编解码,继续用内存 profile 查谁持有了解码结果或缓存引用。
Go JSON 基准中耗时与分配指标的静态关系说明图
图1:JSON 基准指标的静态关系说明图,展示输入、Marshal/Unmarshal 与 ns/op、B/op、allocs/op 的对应关系,不是运行截图或运行证据。

用 CPU profile 确认到底是谁在耗时

当 ns/op 指向 CPU 方向时,先生成一个足够长的 CPU profile。单次 profile 太短,采样点很少,容易把偶然函数误判成热点。

# 让基准运行一段固定时间,并保存 CPU 采样
go test -run '^$' -bench BenchmarkMarshal -benchtime=20s -cpuprofile=cpu.out

# 查看累计采样最多的函数,再按调用关系展开
go tool pprof -top jsonbench.test cpu.out
go tool pprof -http=:0 jsonbench.test cpu.out

先看 flat 和 cum:前者表示样本直接落在函数自身,后者包含它调用的下层函数。若热点集中在编码器内部,才值得比较字段数量、字符串转义和自定义 MarshalJSON;若热点落在业务层的对象转换或日志拼接,优化 JSON 调用本身通常收益很小。

解码也要单独采样:

# 解码路径使用独立 profile,避免和编码热点混在一起
go test -run '^$' -bench BenchmarkUnmarshal -benchtime=20s -cpuprofile=decode-cpu.out
go tool pprof -top jsonbench.test decode-cpu.out

用内存 profile 判断是分配多还是活得久

内存 profile 至少要看两种口径:累计分配空间适合回答“谁制造了很多短命对象”,存活对象适合回答“谁让堆一直下不来”。同一个函数在两种口径下排名不同,并不矛盾。

# 采集编码路径的内存样本;输入和基准入口保持不变
go test -run '^$' -bench BenchmarkMarshal -benchtime=20s -memprofile=mem.out

# 用累计分配空间观察短命对象制造者
go tool pprof -top -sample_index=alloc_space jsonbench.test mem.out

# 用累计对象数观察分配次数密集的调用者
go tool pprof -top -sample_index=alloc_objects jsonbench.test mem.out

# 对照仍存活的堆对象;它回答的是保留关系,不是总分配量
go tool pprof -top -sample_index=inuse_space jsonbench.test mem.out

如果 alloc_space 很高而 inuse_space 不高,常见方向是短命中间对象或重复缓冲;如果两者都高,则要检查结果对象、缓存、队列或闭包是否长期持有引用。不要仅凭函数名判断泄漏,profile 只能告诉你采样中的分配与存活关系,真正的生命周期还要回到代码的所有权。

Go JSON CPU 与内存剖析指标的静态关系说明图
图2:CPU profile、内存 profile 与优化决策的静态关系说明图,区分累计分配、对象数量和存活堆,不是终端截图。

根据证据选择优化动作

确认问题后再改实现,顺序通常是先减少无效工作,再减少重复分配:

  1. 先裁剪不需要输出的字段,避免为了传输或日志编码完整领域对象。
  2. 把重复的格式化、字符串拼接和临时转换移出热点循环;不要为了“少一次 Marshal”把错误处理变得不可见。
  3. 确实需要复用缓冲区时,再评估 bytes.Buffer 或 json.Encoder 的生命周期,并保证复用边界不会把旧数据带到下一次请求。
  4. 如果热点来自自定义 MarshalJSON,先用基准证明它比默认路径更快且语义一致,再考虑维护成本。
  5. 优化后重新跑同一组 benchmark 和 profile;只改变输入规模或机器环境,就不能把前后结果直接相减。

最后记住:ns/op 是“花了多少时间”,B/op 和 allocs/op 是“制造了多少分配”,inuse_space 是“现在还留着多少”。三组数据一起指向同一热点时,优化才有可靠方向。

常见问题

为什么 benchmark 的 B/op 很高,但 CPU profile 不在 encoding/json?

可能是输入构造、转换层或日志路径分配更多。先把准备输入移出计时区,再分别给业务转换函数和 JSON 函数建 benchmark。

只看 allocs/op 能判断内存泄漏吗?

不能。allocs/op 只描述单次操作的分配次数,不能说明对象是否被长期引用。要结合 inuse_space、对象持有关系和请求生命周期继续定位。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>