性能实测与调优:序列化、并发与压缩

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~3x3~5x字段名每条重复,数字变文本
JSON(带缩进)4~6x5~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 验证。

笔记加载中…