性能实测与调优:序列化、并发与压缩
gRPC 快不快不看你信不信,看数字。这一章给一套可复现的压测方法(ghz)、几条必须写清楚的口径(QPS、延迟分位、体积对比),以及真正能把数字提上去的调优项:序列化、并发模型、压缩、消息与窗口大小。
先立指标口径
| 指标 | 定义 | 容易踩的坑 |
|---|---|---|
| QPS | 每秒成功请求数 | 把错误请求算进去,数字虚高 |
| P50 / P90 / P99 | 延迟分位数 | 只报平均值,尾延迟被掩盖 |
| 错误率 | 非 OK 状态码占比 | 压到 15% 错误还在加并发,测的是崩溃点 |
| 每核 QPS | 单核吞吐 | 容量按「每核 QPS × 核数 × 余量」推 |
| 线上真实数据 | trace 采样 + 延迟直方图 | 压测是实验室,线上才是结论 |
三条纪律:客户端与服务端分开机器(同机测的是 CPU 抢占用);报告里必须写清并发数、连接数、消息大小、TLS 开关;每次只改一个变量。
ghz:一条命令跑 gRPC 压测
go install github.com/bojand/ghz/cmd/ghz@latest
# 一元调用:200 并发、跑 30 秒、底层 20 条 HTTP/2 连接
ghz --insecure --connections 20 -c 200 -z 30s \
-d '{"user_id":"1001"}' \
--call im.v1.UserService/GetUser 127.0.0.1:9000
# 从 proto 直接跑,带鉴权 metadata 并限制速率,适合对照真实线上流量
ghz --proto ./proto/im/v1/user.proto --import-paths ./proto,./third_party \
-m im.v1.UserService/ListUsers -d '{"page_size":20}' \
-H 'authorization: Bearer <token>' -c 50 -z 60s --rps 2000 127.0.0.1:9000
输出里要看的四行:
Summary:
Count: 200000 Total: 30.02 s Average: 2.9 ms Requests/sec: 6662.2
Latency distribution:
10% in 1.1 ms 50% in 2.2 ms 90% in 4.8 ms 99% in 18.6 ms
Status code distribution:
[OK] 199998 [DeadlineExceeded] 2
(这是量级示例,不是任何一台机器的结论,数字必须自己测。)-c 是并发 worker 数,--connections 是底层连接数:并发高、连接少才接近线上(客户端通常长连接复用);把连接数开到几百,测出来的多半是握手与调度开销。
protobuf 与 JSON 的体积对比
同一个用户对象(id、昵称、头像 URL、状态、时间戳、若干标签)在不同编码下的量级:
| 编码 | 单条体积 | 序列化耗时 | 说明 |
|---|---|---|---|
| protobuf 二进制 | 1x(基准) | 1x | 字段用编号编码,不带字段名,小整数走 varint |
| JSON(紧凑) | 2~3x | 3~5x | 字段名每条重复,数字变文本 |
| JSON(带缩进) | 4~6x | 5~8x | 只适合人看,别放进服务间调用 |
结论要看场景:结构越小、字段名越长,protobuf 的差距越明显;只传一个 int 的接口,两者差距可以忽略,这时选 gRPC 的理由是契约与流式能力,而不是省字节。大量文本、URL 列表这类高重复度载荷,压完差距会缩小。基准要写成 testing.B,别靠感觉:
func BenchmarkMarshalProto(b *testing.B) {
u := buildUser()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := proto.Marshal(u); err != nil { b.Fatal(err) }
}
}
// go test -bench=Marshal -benchmem ./...
// 输出:BenchmarkMarshalProto-8 5000000 240 ns/op 96 B/op 1 allocs/op
allocs/op 往往比 ns/op 更值得优化:它决定 GC 压力,也解释了 P99 为什么会抖。
压缩:gzip 的取舍
import _ "google.golang.org/grpc/encoding/gzip" // 注册后才可用
// 按调用开启:只压大响应
resp, err := client.GetUser(ctx, req, grpc.UseCompressor(gzip.Name))
// 全局默认压缩要谨慎,最好配合「小于阈值不压」的自定义压缩器
srv := grpc.NewServer(grpc.RPCCompressor(grpc.NewGZIPCompressor()))
| 判断依据 | 结论 |
|---|---|
| 消息 < 1KB | 不要压,压头与 CPU 成本大于收益 |
| 1KB~100KB 且可压缩 | 值得压,文本、JSON、结构化日志收益最大 |
| 已压缩数据(图片、视频、zip) | 不要压,几乎不缩,纯烧 CPU |
| CPU 已经饱和 | 不要压,先把 CPU 让给业务 |
| 跨机房或公网带宽受限 | 优先压缩,省的是真金白银 |
压缩代价直接落在尾延迟上:gzip 是 CPU 密集操作,高并发时会和业务抢处理器时间,所以阈值从 4KB 起步通常是个稳的起点。算法上 gzip 兼容性最好,zstd 压缩率与速度更优但要求两端都注册同名 compressor。
调优清单
| 方向 | 动作 | 效果 |
|---|---|---|
| 序列化 | 复用 message 对象、减少临时切片 | 降低分配与 GC 停顿,稳 P99 |
| 并发 | 按 GOMAXPROCS 起 worker,handler 里不做同步阻塞 IO | 提升每核 QPS |
| 连接 | 客户端复用一个连接(HTTP/2 多路复用),必要时才开小连接池 | 省握手、省内存、少 TIME_WAIT |
| 消息 | MaxRecvMsgSize / MaxSendMsgSize 调到与业务一致,别停在默认 4MB | 大消息不再报 ResourceExhausted |
| 窗口 | 大流式场景调 InitialWindowSize 与 InitialConnWindowSize | 提升流式吞吐 |
| 拦截器 | 不在拦截器里同步写磁盘、不打全量请求日志 | 去掉「工程税」 |
| keepalive | 客户端 ping 间隔与服务端 EnforcementPolicy 匹配 | 避免长连接被误判为攻击而断开 |
找瓶颈用 pprof,不靠猜:
go tool pprof -http=:8081 http://127.0.0.1:9000/debug/pprof/profile?seconds=30
# 压测同时开着它,才能把 CPU 花在哪个函数上和 QPS 对上号
常见误区
- 在
127.0.0.1上压测然后宣布「单机 10 万 QPS」:省掉了网卡、TLS 与真实路由,这种数字只能做版本间的相对比较。 - 压测客户端先到瓶颈:看两边 CPU,哪边先满就换机器,否则你测的是客户端。
- 只报平均值:平均 3ms、P99 200ms 的服务,用户体验由后者决定。
- 报告里不写消息大小:8KB 与 8MB 的「QPS」不是同一个指标。
- 用没开压缩、连接数为 1、无拦截器的配置当线上配置。
小结:压测先定口径(成功的 QPS、P50/P90/P99、错误率、每核 QPS),用 ghz 在分离的机器上跑并写清并发数与连接数;protobuf 相对 JSON 在小对象上是 2~3 倍体积、数倍序列化耗时,消息小于 1KB 就别开 gzip;调优按「序列化 → 并发 → 连接 → 消息与窗口 → 拦截器」推进,每一步都用压测数据与 pprof 验证。