通过替换序列化方式(JSON → MessagePack),吞吐量提升约 2.3--3.5 倍,延迟下降约 40--60%,CPU 占用降低约 30%。
1. 问题背景
我负责的用户画像服务,对外提供 RESTful JSON 接口,返回结构大约如下:
go
type UserProfile struct {
UID string `json:"uid"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Tags []string `json:"tags"`
FollowCount int64 `json:"follow_count"`
FanCount int64 `json:"fan_count"`
LastLogin int64 `json:"last_login"` // unix毫秒
IsVerified bool `json:"is_verified"`
Extend map[string]interface{} `json:"extend"` // 动态字段
}
日均 2k QPS,单节点 CPU 70% 左右,P99 延迟 120ms。连接复用、数据库缓存这些都已经搞过了,pprof 打出来一看,encoding/json.Marshal 竟然占了 18% 的 CPU。序列化这事儿比想象中更重。
于是动了心思:能不能换个更紧凑的协议?
目标很实际:
- 减少网络传输量,特别是那些重复来重复去的字段名
- 降低序列化/反序列化的 CPU 消耗
- 最好别动业务代码,只改响应写入层
2. 为什么是 MessagePack?
先对比了一圈:
| 特性 | JSON | MessagePack | Protobuf |
|---|---|---|---|
| 人类可读 | ✔ | ✘ | ✘ |
| Schema‑free(自描述) | ✔ | ✔ | ✘(需要 .proto) |
| 二进制紧凑度 | 较低(字符串+引号) | 高(变长整数、无引号) | 最高(需要预定义 schema) |
| 编解码速度 | 中等(反射/分配多) | 快(直接遍历字节) | 最快(静态代码生成) |
| 生态成熟度 | 极佳(内置标准库) | 良好(多语言库) | 良好(需 proto 生成) |
| 迁移成本 | 0 | 低(仅改包装层) | 中等(需定义 schema、升级) |
选 MessagePack 的理由很简单:
- 这是内部服务间调用,可读性没那么金贵
- 字段相对固定,但
extend是动态的,需要保留 Schema‑free - 不想让调用方都去生成 proto、升级版本,MessagePack 拿过来就能用,改动最小
3. 实现
3.1 引入依赖
bash
go get github.com/vmihailenco/msgpack/v5
这个库性能不错,API 也跟 json.Marshal 差不多,上手很快。
3.2 封装响应渲染函数
原来有个通用的 respondJSON:
go
func respondJSON(w http.ResponseWriter, r *http.Request, v interface{}, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// 错误处理省略
}
}
改成这样,支持协议协商:
go
func respond(w http.ResponseWriter, r *http.Request, v interface{}, status int) {
accept := r.Header.Get("Accept")
proto := r.Header.Get("X-Protocol")
useMsgPack := false
switch {
case proto == "msgpack":
useMsgPack = true
case strings.Contains(accept, "application/msgpack"):
useMsgPack = true
}
if useMsgPack {
w.Header().Set("Content-Type", "application/msgpack")
} else {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
w.WriteHeader(status)
if useMsgPack {
if err := msgpack.NewEncoder(w).Encode(v); err != nil {
// 错误处理省略
}
} else {
if err := json.NewEncoder(w).Encode(v); err != nil {
// 错误处理省略
}
}
}
就改了响应写入层,业务代码完全不动。客户端通过 Accept 头或 X-Protocol 头声明自己要什么格式,默认还是 JSON,不影响老用户。
3.3 客户端示例(Go)
go
resp, err := http.Get("https://api.example.com/user/123")
if err != nil { /* ... */ }
defer resp.Body.Close()
var up UserProfile
switch resp.Header.Get("Content-Type") {
case "application/msgpack":
if err := msgpack.NewDecoder(resp.Body).Decode(&up); err != nil { /* ... */ }
default:
if err := json.NewDecoder(resp.Body).Decode(&up); err != nil { /* ... */ }
}
3.4 测试
表驱动测试跑了两条路径,确保 JSON 和 MessagePack 出来的字段完全一致:
bash
go test -run TestHandlerJSON -count=10
go test -run TestHandlerMsgPack -count=10
4. 性能数据
8‑核 Intel Xeon、32 GB RAM,wrk 模拟 1k 持久连接,压 5 分钟:
| 指标 | JSON (baseline) | MessagePack | 提升幅度 |
|---|---|---|---|
| QPS(请求/秒) | 1 850 | 4 200 | +127% |
| 平均延迟 (ms) | 85 | 38 | -55% |
| P99 延迟 (ms) | 120 | 48 | -60% |
| CPU 用户时间 (%) | 68 | 46 | -32% |
| 每请求网络流出 (KB) | 1.84 | 0.92 | -50% |
| GC 次数/分钟 | 23 | 15 | -35% |
测试载荷是典型的用户画像返回,约 1.2KB JSON → 0.6KB MessagePack,将近一半。
响应体变小后,网络带宽降了,延迟自然下来。CPU 降主要是因为 JSON 那些反射分配、字符串转义的开销没了。
火焰图
- JSON:顶部大块是
encoding/json.(*encodeState).marshal、runtime.makeslice、runtime.mallocgc - MessagePack:热点在
msgpack.(*Encoder).Encode内部的整数变长编码,整体占用不到 JSON 的 40%
5. 注意点
向后兼容要做好,默认 JSON,客户端主动声明才切换。MessagePack 解码失败会报 msgpack: unexpected end of data,记得包装成业务错误码。
字段类型这块,尽量少用 interface{} 嵌套,会退化成 JSON 的开销。
大对象(几百 KB 以上)别塞 MessagePack,走对象存储。
跨语言开发的话,确认目标语言的 MessagePack 库维护状态,主流语言基本都有。
最后记得在网关加上 Content-Type 指标,看看实际使用比例。
6. 结论
就改了序列化这一层,业务逻辑完全没碰,实现了吞吐量翻倍、延迟降一半,CPU、网络、GC 压力都明显下来。
我的体会是:内部服务间通信,与其绞尽脑汁优化数据库、缓存,不如先换个更紧凑的协议,往往收益更大。
后续可能会在服务框架里统一加协议协商中间件,所有接口默认走 MessagePack。如果字段继续膨胀,考慮切到 protobuf(需要版本管理)。跨机房场景可以再加一层压缩,比如 zstd 或 snappy。
完整 handler 示例
go
package handler
import (
"net/http"
"strings"
"github.com/vmihailenco/msgpack/v5"
)
func respond(w http.ResponseWriter, r *http.Request, v interface{}, status int) {
accept := r.Header.Get("Accept")
proto := r.Header.Get("X-Protocol")
useMsgPack := false
switch {
case proto == "msgpack":
useMsgPack = true
case strings.Contains(accept, "application/msgpack"):
useMsgPack = true
}
if useMsgPack {
w.Header().Set("Content-Type", "application/msgpack")
} else {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
w.WriteHeader(status)
if useMsgPack {
if err := msgpack.NewEncoder(w).Encode(v); err != nil {
http.Error(w, "msgpack encode error", http.StatusInternalServerError)
return
}
} else {
if err := json.NewEncoder(w).Encode(v); err != nil {
http.Error(w, "json encode error", http.StatusInternalServerError)
return
}
}
}
func GetUserProfile(w http.ResponseWriter, r *http.Request) {
uid := r.URL.Path[len("/user/"):]
up, err := svc.GetUserProfile(uid)
if err != nil {
respond(w, r, map[string]string{"error": err.Error()}, http.StatusNotFound)
return
}
respond(w, r, up, http.StatusOK)
}
客户端请求时加上 Accept: application/msgpack 或 X-Protocol: msgpack 即可收到 MessagePack 响应,不加则默认 JSON。