你的服务每秒在 JSON 上烧掉的 CPU,比你以为的多一个数量级

2026-10-31 10 0

你的服务每秒在 JSON 上烧掉的 CPU,比你以为的多一个数量级

先说个得罪人的话:很多团队做性能优化,第一反应是"加机器""上缓存""换更快的数据库"。而我见过的、被 profiler 揪出来的第一杀手,往往是那个不起眼的 json.Unmarshal。

你以为 JSON 解析是免费的?你以为"结构体转 map 再序列化"是标准操作?你以为 JSON 里写个 9223372036854775807 会安全地回来?

这篇就聊聊序列化这件"天天在用、没人细看"的事。


一、你的请求里,JSON 至少被编解码了三次

先做一道数学题。一个前端调用后端服务的请求,长什么样:

浏览器 --JSON--> 网关 --Protobuf--> 业务服务 --JSON--> 缓存/DB

网关做一次反序列化,业务服务做一次,业务服务出参再做一次序列化,缓存层序列化一次,链路追踪上报再序列化一次。这还只是正常路径。加上日志打印、重试、灰度双写,序列化次数轻松翻倍。

关键在于:这些操作全部发生在 CPU 核心里,和你的业务逻辑抢同一把椅子。你写的那个"看起来只有 8ms"的接口,可能有一半时间在跟 JSON 打架。

经验值:结构清晰的普通接口,序列化/反序列化通常占总耗时的 10%~30%;如果是嵌套深、字段多、类型杂的接口,40% 都不稀奇。而这还建立在"用了一个还算靠谱的库"的前提上。

二、JSON 慢在哪:三重隐形税

第一重税:反射。Go 的 encoding/json 靠反射遍历结构体字段,每个字段都要查一次类型信息、算一次 offset、拼一次字段名。字段越多、嵌套越深,反射成本线性上涨。Java 的 Jackson 好一些,但仍然是反射 + 动态字节码生成,冷启动那阵子还会疯狂生成类。

第二重税:any 装箱。这是最常见的自伤现场:

// 看起来很优雅,实际上是灾难
var data map[string]interface{}
json.Unmarshal(body, &data)
id := data["user_id"].(string)   // 崩
// 或者
m := struct{ UID int64 }{}
b, _ := json.Marshal(m)
b2, _ := json.Marshal(map[string]interface{}(m))  // 数字变成 float64

反序列化成 map[string]interface{},意味着每个字段都是一个 interface 装箱、一次堆分配、一次哈希表插入。字段多的时候,光是分配器就够你喝一壶。而最后你只是想把其中一个字段取出来。

第三重税:字符串与转义。JSON 规范要求字符串里的引号、反斜杠、控制字符必须转义,于是 "C:\Users\xxx" 这种路径在编码时要扫一遍、转一遍、解一遍。中文 emoji、代理对字符更贵。再加上 encoding/json 默认还会把 HTML 敏感字符转成 \u003c 这种形式——这在防 XSS 场景下有用,但绝大多数纯后端接口用它纯属白烧 CPU。

三、int64 的精度坟场

这是我最想吐槽的一个点。

JSON 没有整数类型,只有 number。而绝大多数 JSON 库(包括 JavaScript 的原生 JSON.parse)把 number 实现成了双精度浮点。于是:

// 后端
{"order_id": 9223372036854775807}

// 前端
JSON.parse('{"order_id":9223372036854775807}').order_id
// -> 9223372036854776000

订单号、雪花 ID、金额(单位是分且用 int64 存)——全都是重灾区。更阴险的是,雪花 ID 的高位集中在时间戳,低位才是机器号和序列号,丢掉的恰好是低位精度,导致不同订单变成同一个 ID,且只在数据量足够大时才暴露。

标准解法两条:接口文档里写死"超过 2^53 的整数必须用字符串传输",服务端序列化时给 int64 加 ,string tag。这不是洁癖,这是止损。

四、别用 fmt.Sprintf 拼 JSON

总有人图省事,手搓字符串拼 JSON。这在有转义、有引号、有中文的字段上,是一个稳定的 bug 制造机:

// 地雷
buf := fmt.Sprintf(`{"name":"%s","age":%d}`, name, age)

// 稍微好点,但依然慢
buf, _ := json.Marshal(map[string]any{"name": name, "age": age})

// 正确且快:直接用结构体
type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
buf, _ := json.Marshal(User{Name: name, Age: age})

结构体方案的好处不只是快:它把字段名和类型固定下来,让编译器帮你检查拼写错误,同时避免了 map 的无序(虽然现在多数库会保序,但别指望)。

如果你确实需要动态结构,Go 生态里 json-iterator、sonic(基于 JIT 和汇编)这类库能比标准库快好几倍,代价是引入平台依赖和一定的兼容性风险。这个取舍我一般建议:在网关这种纯 IO 密集、JSON 占比极高的位置值得上,业务内部普通接口不值得折腾。

五、Protobuf 不是银弹,但它的账算得清

不是说所有服务都该换 Protobuf。但你得知道它省在哪:

它的编码是字段编号 + varint,不是字段名。同一个字段名在整条链路上传输成本是 0,只占一个字节的 tag。这就是为什么 Protobuf 的 payload 通常只有 JSON 的三分之一到五分之一——流量省了,反序列化的反射也省了,因为按编号走的是生成好的代码路径。

但它有明确的坑:

  • 不可读。抓包看到的是乱码,排查问题全靠工具。你以为抓包工具很方便?那是 JSON 的特权。
  • 向前兼容靠纪律。字段编号一旦复用,行为就是静默的数据错乱,不报错,不崩溃,只是结果错了。这是最难查的一类 bug。
  • 调试链路断。中间件、网关、测试脚本全得跟着改。

所以我的建议很朴素:内部 RPC 用 Protobuf,对外开放 API 用 JSON。这不优雅,但很诚实——外部客户要的是能看懂、能 curl、能贴到工单里的东西,内部服务要的是 CPU 和带宽。

六、五条今天就能做的优化

  1. 能反序列化到结构体,就绝不用 map[string]any。这一条能砍掉一半以上的堆分配。
  2. 大整数一律用字符串。包括时间戳、金额、ID。
  3. 把不需要的字段砍掉。接口出参别直接返回数据库实体,一个 password_hash 泄漏出去,够你写三天的复盘。
  4. 流式处理超大数组。标准库自带的 json.Decoder 支持 token 级别的流式解析,别一次性把 50MB 的 JSON 读进内存再解析。
  5. 别忘了压缩那一层。gzip/zstd 之后的 JSON,在网络上传输的字节数可能比 Protobuf 还小(因为 JSON 的冗余多、可压缩性极高)。有意思的结论:CPU 上 Protobuf 赢,链路上 gzip 之后的 JSON 可能不输。这取决于你的瓶颈是 CPU 还是带宽。

七、怎么验证,而不是怎么猜

别靠直觉。三个工具就够:

# Go 侧基准测试
go test -bench=. -benchmem -cpuprofile cpu.out
go tool pprof -top cpu.out

# 看火焰图里 json 占比
go tool pprof -http=:8080 cpu.out

-benchmem 里的 allocs/op 是我最爱看的数字。序列化从 20 次分配降到 5 次,比"感觉快了一点"实在得多。火焰图里 encoding/json 那一坨如果比你的业务逻辑还红,那就是答案。

写在最后

序列化是典型的"没人设计、也没人优化、但天天掏你钱"的环节。大多数性能问题的排查路径是:数据库慢了?加索引。缓存没命中?调 TTL。连接池爆了?调参数。然后你绕了一大圈,发现火焰图里一半是 JSON。

所以,下次再抱怨"服务突然变慢",别急着加机器。先把火焰图打开,指着那一坨红说:你,出来聊聊。

编码和序列化看着是脏活,但省下来的都是真金白银的 CPU 和带宽。而且说实话,把一个 8ms 的接口优化到 3ms,比给集群再加 8 个节点有意思多了——后者你还得给它找机房。


如果这篇对你有帮助,欢迎点个赞、转发给那个正在手搓 JSON 字符串的同事。他不会知道是谁害的,但我会。

相关文章

数据库慢得像个拖拉机?先别骂数据库,这 7 个坑有一半是你自己挖的
Raft 里那个 300ms 的随机超时,可能是分布式系统里最反直觉的一行代码
Go 的调度器凭什么敢让十万个 goroutine 狂飙?GMP 三件套,一次讲透
你删掉了 100 万行,磁盘却没释放:数据库里的「尸体」不会自己消失
一个表情符号能把你的数据库搞瘫?MySQL 字符集 utf8mb4 的六宗罪
你的服务一重启,正在跑的请求就全死了?优雅停机,比你想的难写

发布评论