Files
cs-note/hhs/gRPC/6. 工程实践篇/20-性能优化与压测.md
T
2026-05-24 11:42:38 +08:00

460 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, performance, benchmark, compression, keepalive, optimization]
create time: 2026-05-11 17:00
---
# 性能优化与压测
## 概述
gRPC 天生就比传统 REST API 快,但要榨干它的性能上限还需要系统性的调优。从 Protobuf 序列化大小的字节级优化、连接复用策略、Compression 压缩的精确控制到 Keepalive 参数调校——再到用 Benchmark 压测验证每一个改动是否真正生效,这篇帮你建立完整的性能优化思维模型。
> [!question] gRPC 真的比 REST 快吗?
> 以典型 User Profile 消息为例(ID + Name + Email + Avatar URL):
>
> | 格式 | Payload 大小 | 编码开销 |
> |------|-------------|---------|
> | Protobuf (binary) | ~280 bytes | Tag + varint,无字段名冗余 |
> | JSON (UTF-8) | ~950 bytes | 键名重复出现,引号包裹 |
> | JSON (minified) | ~720 bytes | 去掉空白符后的极限 |
>
> Protobuf 约为 JSON 的 **1/3 ~ 1/4**,加上 HTTP/2 的多路复用和 binary framing,通常 QPS 高 2~5 倍。但如果你已经在高效使用 HTTP/1.1 + JSON,差距可能没那么惊人。**真正的优势在于确定性与低延迟的可预测性**。
## 序列化大小优化
Protobuf 序列化体积是带宽、GC 压力和磁盘 IO 的上游因素,每次减少几十字节都可能在高并发场景下带来可感知的改善。
### int32 vs int64
Varint 编码的大小取决于数值本身,而非声明的类型。选择合适的大小能省下不少字节:
```protobuf
// 好:大部分用户 ID 不超过 int32 范围
message User {
int32 id = 1; // varint, 1-5 bytes
}
// 差:没必要用 int64
message UserID {
int64 id = 1; // varint, 1-10 bytes
}
```
规则很简单:如果数值不超过 `2^31 - 1`(约 21 亿),用 `int32` 而不是 `int64`。这能节省最多 50% 的 varint 空间。
> [!tip] uint32 vs fixed32
> 固定大小的整数(如版本号、哈希值)可以用 `fixed32` / `fixed64`,解码时省去了 varint 变长解码步骤,速度更快——但代价是每个值固定占用 4/8 字节,无论数值多小。适合对性能极其敏感且数值分布广泛的场景。
### packed repeated
```protobuf
// 默认 packed,节省空间
repeated int32 tags = 1; // [1, 2, 3] -> 3 bytes instead of 9
// 取消 packing(几乎不需要)
repeated int32 tags = 1 [packed = false];
```
packed repeated 将连续的数值字段打包为变长整数序列,对于高频整型数组可节省 60~70% 的传输体积。
> [!important] string repeated 不能 pack
> `packed` 仅适用于数值类型和 `bytes`。`repeated string` 无法 packing,因为字符串长度不定,无法可靠分割。
### 避免不必要的嵌套
```protobuf
// 不好:多层嵌套增加 tag overhead
message Address {
Location location = 1;
}
message Location {
string city = 1;
string street = 2;
}
// 好:扁平化
message Address {
string city = 1;
string street = 2;
}
```
每多一层嵌套就多一组 tag+size header,对小消息影响显著。
### Field Number 分配策略
连续编号不会影响 serialized size(tag+varint 对于小于 16384 的编号都是 1 byte),但跳号会浪费可读性和后续扩展的空间规划:
```protobuf
// 好:预留扩展空间
message User {
string id = 1;
string name = 2;
string email = 3;
// 预留 4-10 给后续新增字段
}
```
> [!seealso] 深入了解
> 更多 Field Number 的兼容细节,参见 [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]。
### 序列化优化最佳实践速查表
| 策略 | 适用场景 | 预期收益 | 风险 |
|------|---------|---------|------|
| int32 代替 int64 | ID、状态码、计数器等 | 节省 20~50% varint 空间 | 数值溢出时需迁移 |
| packed repeated | 高频整型/字节数组标签列表 | 节省 60~70% 体积 | 需 protobuf v3 或 proto2 with `[packed=true]` |
| 扁平化消息结构 | 深层嵌套 (< 3 层) | 减少 tag overhead | 语义上是否合理 |
| 按需返回字段 | RPC 请求中指定要哪些字段 | 减少不必要的数据传输 | 需要 oneof 或 optional 支持 |
| string 替换 enum (small set) | 枚举值极少 (< 5 个) 且稳定 | 避免 tag 变化时的兼容问题 | 硬编码在二进制中 |
> [!tip] 先测量再优化
> 不要盲目猜测哪个字段最耗空间。用一个真实 payload 调用 `proto.Marshal()` 然后打印 `len()` 是最直接的诊断方式:
> ```go
> data, _ := proto.Marshal(&msg)
> fmt.Printf("Serialized: %d bytes\n", len(data))
> ```
> 逐个字段注释掉再量,就能定位"胖字段"。
## Connection Pooling
> [!warning] 最重要的一条
> **永远复用 conn,不要每次调用都 Dial。** 每次 Dial 建立新的 TCP/TLS 连接开销极大,在高并发场景下会导致端口耗尽和性能断崖式下跌。
### conn 管理原则
gRPC 内部已经实现了连接池(transport layer multiplexing),底层自动维护多路复用的 HTTP/2 连接。你只需要记住三个原则:
1. **一目标一连接**:同一个 target address 全局共享一个 `*grpc.ClientConn`
2. **进程生命周期内复用**:conn 应在应用启动时创建,退出的时候关闭
3. **并发安全**:`*grpc.ClientConn` 天生支持多 goroutine 同时使用
```go
var conn *grpc.ClientConn
func initClient(addr string) error {
var err error
conn, err = grpc.DialContext(context.Background(), addr,
grpc.WithTransportCredentials(credentials.NewTLS(nil)),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
}),
)
return err
}
// defer func() { conn.Close() }() // 退出时统一关闭
```
> [!note] 为什么不用 WithConnectTimeout?
> gRPC Go SDK 没有 `grpc.WithConnectTimeout` 这个选项。连接超时应通过 `context.WithTimeout` 配合 `grpc.DialContext` 控制;或者使用自定义 dialer 包装 `net.Dialer.Timeout`。
### 连接数与并发调优
#### MaxConcurrentStreams
```go
server := grpc.NewServer(
grpc.MaxConcurrentStreams(100), // default: 100
)
```
MaxConcurrentStreams 决定了单个 HTTP/2 连接上允许的最大并行流数量。调整依据:
| 场景 | 推荐值 | 说明 |
|------|--------|------|
| 高频短流(Unary) | 100(默认) | 默认值即可,新 Stream 立即关闭 |
| 低频长流(BiDi Streaming) | 10-50 | 降低以减少内存占用 |
| 超大流量(万级 QPS) | 500-1000 | 配合后端 capacity 调整 |
> [!danger] 不要设得太高
> 过大的 MaxConcurrentStreams 意味着每个连接上可能堆积大量未完成的 Stream,消耗服务器内存。生产环境建议设置为实际峰值需求的 1.5~2 倍。
## Compression(Gzip 压缩)
Per-call 级别的压缩控制:
```go
// Client 侧调用时指定压缩算法
resp, err := client.GetUser(ctx, req, grpc.UseCompressor(gzip.Name))
```
何时启用 gzip 压缩的判断条件:
| 条件 | 建议 |
|------|------|
| payload > 1KB | 启用,收益明显 |
| payload < 100B | 禁用,header 开销超过压缩收益 |
| CPU 受限的服务 | 谨慎启用,解压有 CPU cost |
| 延迟敏感型 API | 不加压缩,网络带宽通常不是瓶颈 |
> [!tip] Server-side 全局压缩
> gRPC 官方不支持 server-side 的全局压缩 interceptor(这是一个已知的 limitation)。如果需要全局压缩,有两种替代方案:
>
> 1. **在拦截器中对 Response 做 gzip 压缩后写入**——但需要客户端同步解压
> 2. **在 lbloadbalancer 或 ingress 层统一处理**——由 Nginx/envoy 承担压缩工作
>
> 多数情况下,推荐在 **Client 侧按接口特性选择性开启**,这样更精细可控。
```mermaid
flowchart TD
Start["是否需要压缩?"] --> Payload{"Payload > 1KB?"}
Payload -->|"否"| Disable["禁用压缩\n节省 CPU"]
Payload -->|"是"| CPU{"服务 CPU 充足?"}
CPU -->|"否"| Disable
CPU -->|"是"| Network{"带宽紧张?"}
Network -->|"否"| Decision["视延迟敏感度而定\n通常仍然值得压缩"]
Network -->|"是"| Enable["启用压缩\ngzip/zstd"]
Enable --> Zstd{"是否可用 zstd?"}
Zstd -->|"是"| Best["首选 zstd\n压缩率更高, 速度更快"]
Zstd -->|"否"| Gzip["使用 gzip\n兼容最广"]
style Best fill:#00D866,color:#fff
style Gzip fill:#4FC08D,color:#fff
style Disable fill:#FF6B6B,color:#fff
```
> [!question] gzip 和 zstd 怎么选?
> **zstd 是目前的最佳选择**:压缩率与 gzip 相当或更好,解压缩速度快 30%+。gRPC 自 v1.35 起原生支持 zstd compressor:
> ```go
> import "google.golang.org/grpc/encoding/zstd"
> // zstd 自动注册为 "zstd",直接在 call option 中使用
> client.GetUser(ctx, req, grpc.UseCompressor(zstd.Name))
> ```
## Keepalive 调优
连接保活是确保链路健康的关键,错误的 keepalive 参数是导致"偶发超时""连接静默断裂"等问题的常见原因。
### Client Parameters
```go
cap := keepalive.ClientParameters{
Time: 10 * time.Second, // 发送 ping 间隔
Timeout: 5 * time.Second, // 等待 pong 超时
PermitWithoutStream: true, // 空闲时也发送 ping
}
grpc.WithKeepaliveParams(cap)
```
| 参数 | 默认值 | 含义 | 推荐调整 |
|------|-------|------|---------|
| `Time` | 2h | 两次 ping 之间的间隔 | 内网 10~30s,跨云 30~60s |
| `Timeout` | 20s | 服务端无响应则断开 | 通常保持默认 |
| `PermitWithoutStream` | false | 即使无活动流也发送 ping | **强烈建议设为 true** |
> [!tip] 为什么要设 PermitWithoutStream?
> 默认值为 false 意味着:如果一个 Stream 完成后不再新建流,客户端将不再发送 ping。此时中间件(Nginx、Cloud LB、防火墙)可能认为连接已闲置而提前断开——等你下次发消息时才会发现连接断了,导致 `unavailable` 错误。设为 true 后可让 gRPC 自行维护连接健康状态。
### Server Parameters
```go
scp := keepalive.ServerParameters{
Time: 10 * time.Second,
Timeout: 5 * time.Second,
}
grpc.KeepaliveParams(scp)
```
| 参数 | 默认值 | 含义 | 推荐调整 |
|------|-------|------|---------|
| `Time` | 2h | 两次 ping 间隔 | 同上 |
| `Timeout` | 20s | 客户端无响应则断开 | 通常保持默认 |
| `MinTime` | 5m | 客户端最小 ping 频率 | 防止客户端频繁 ping |
> [!info] MinTime 保护服务端
> 如果客户端 keepalive Time 设置过小(比如 1s),服务端会通过 `MinTime` 拒绝太快收到 ping 的连接。这是防止恶意或配置错误的客户端造成服务端资源浪费的安全机制。
## Benchmark 方法学
标准 gRPC benchmark 模板:
```go
func BenchmarkGRPCUnaryCall(b *testing.B) {
lis, _ := net.Listen("tcp", "localhost:0")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, mockServer{})
go s.Serve(lis)
defer s.Stop()
conn, _ := grpc.Dial(lis.Addr(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)),
)
defer conn.Close()
client := pb.NewUserServiceClient(conn)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = client.GetUser(context.Background(), &pb.GetUserRequest{Id: "1"})
}
b.StopTimer()
}
```
运行:`go test -bench=. -benchmem -benchtime=5s`
关键 flag 说明:
- `-benchmem`: 打印内存分配统计
- `-benchtime=5s`: 至少跑 5 秒以确保数据稳定
- `-cpuprofile=cpu.pprof`: 导出 CPU profile 进一步分析
### 解读 Benchmark 结果
```
pkg: myapp/pb
BenchmarkGRPCUnaryCall-8 35421 33829 ns/op 4128 B/op 42 allocs/op
```
| 指标 | 含义 | 优化方向 |
|------|------|---------|
| `ns/op` | 单次请求平均耗时 | 降低 P99、优化串行逻辑 |
| `B/op` | 单次请求堆分配字节数 | 减少临时对象、复用 buffer |
| `allocs/op` | 单次请求 heap 分配次数 | 结合 `sync.Pool` 复用对象 |
### 不同模式的基准对比
| 模式 | 压缩 | Payload | 典型 QPS (单核) | 典型 Latency |
|------|------|--------|----------------|-------------|
| Unary | 无 | 500B | 18K-25K | 40-55 μs |
| Unary | gzip | 500B | 12K-18K | 55-80 μs |
| Unary | gzip | 5KB | 10K-15K | 80-150 μs |
| Unary | gzip | 50KB | 3K-6K | 200-500 μs |
| Server Stream | 无 | 每 chunk 1KB | 5K-8K | 120-200 μs |
| BiDi Stream | gzip | 每 chunk 2KB | 2K-4K | 300-600 μs |
> [!note] 注意事项
> 1. 服务端和客户端在同一个 benchmark 函数中启动和关闭——但这不代表你应该在生产环境中这样做。
> 2. 使用 `b.ResetTimer()` 排除 setup 耗时。
> 3. 忽略返回值 (`_ =`) 以测量 pure throughput,保留返回值以测量 real-world latency。
> 4. 以上数据仅作参考基准,实际性能取决于硬件、网络、Protobuf 消息结构和 handler 复杂度。
## 压测实战
### 端到端压测架构
```mermaid
flowchart LR
subloader["🧪 压测客户端"]
subclient["负载均衡"]
subservice["gRPC Service (N 副本)"]
subdb[("Database")]
subloader -->|"HTTP/gRPC"| subclient
subclient -->|"round-robin"| subservice
subservice -->|"query"| subdb
style subloader fill:#E1BEE7
style subclient fill:#BBDEFB
style subservice fill:#C8E6C9
style subdb fill:#FFCCBC
```
一个简单的端到端压测流程:
```
1. 准备 mock 数据 → 构造真实的 Proto Message
2. 部署 1-N 个 service pod
3. 用 hey/k6/wrk 发起压力测试
4. 记录 P50/P90/P99/Latency/SLO 达标率
5. 逐步增加并发直到 hitting bottleneck
```
### 常用工具推荐
| 工具 | 协议支持 | 特点 | 适用场景 |
|------|---------|------|---------|
| [hey](https://github.com/rakyll/hey) | HTTP/1.1, h2c | 简单快速,Go 编写 | 快速 sanity check |
| k6 | gRPC via JS API | 脚本灵活,带可视化 | 完整 E2E 压测 |
| ghz | gRPC native | 专为 gRPC 设计,YAML 配置 | Protocol-level benchmark |
| custom Go bench | gRPC native | 完全可控 | 开发阶段集成测试 |
```bash
# ghz 示例:一键压测已有 proto
ghz --call demo.UserService.GetUser \
--data '{"id": "test-001"}' \
-n 10000 -c 100 \
localhost:50051
```
## 性能调优 Checklist
| 优化项 | 预估提升 | 难度 | 优先级 |
|--------|---------|------|--------|
| 复用连接(不重新 Dial) | 50%+ | ⭐ | 🔴 Critical |
| 减少 proto 文件大小 | 20-60% | ⭐⭐ | 🔴 Critical |
| 批量 RPC(而非 N 次 unary) | 80%+ | ⭐⭐ | 🔴 Critical |
| Keepalive 调优 | 减少断连 | ⭐⭐ | 🟡 High |
| 开启 gzip(大 payload) | 30-80% | ⭐ | 🟡 Medium |
| MaxConcurrentStreams 调优 | 少量 | ⭐ | 🟢 Low (specialized) |
| Buffer pool 复用 (`sync.Pool`) | 5-15% | ⭐⭐⭐ | 🟢 Edge case |
> [!tip] 优化顺序建议
> 先做前两项(连接复用 + proto 瘦身),它们几乎零成本且回报最高。其余优化务必先用 benchmark 验证——"感觉变快了"不等于"数据上变快了"。
## 监控关键指标
使用 OpenTelemetry gRPC interceptor 自动采集指标:
```go
import _ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
// otelgrpc 自动产出的核心指标:
// rpc.server.duration.p50 / p99 — 服务端延迟分位
// rpc.client.sent.total_requests — 客户端发出请求总量
// rpc.server.received_messages_per_rpc — 每次 RPC 接收消息数均值
// grpc.transport.network.sent.bytes / received.bytes — 网络流量
```
这些指标接入 Prometheus 后,可以监控:
- P99 延迟是否高于预期
- 每秒请求量的突增/突降
- 网络发送/接收字节的异常波动
- 未解决的 stream 堆积数
```yaml
# prometheus scrape_config 示例
scrape_configs:
- job_name: 'grpc-services'
metrics_path: '/metrics'
static_configs:
- targets: ['service-a:8080', 'service-b:8080']
# otelgrpc 默认暴露 /metrics 路径
```
> [!note] 进阶链路追踪
> 除了延迟和吞吐量,还需要关注调用链上下文。详见 [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md]]。
## 典型问题诊断流程
```mermaid
flowchart TD
A["慢或超时"] --> B{"P99 > P50 * 10?"}
B -->|Yes| C["网络问题\n查 keepalive 和 DNS resolution"]
B -->|No| D["服务端处理慢\nProfile, DB query, serialization"]
C --> E{"K8s 或 LB 层?"}
E -->|Yes| F["调整 keepalive params\n放宽中间件 idle timeout"]
E -->|No| G["tcpdump + h2spec 抓包分析"]
D --> H["go test -bench\n定位瓶颈"]
style D fill:#FFD43B
style F fill:#00D866,color:#fff
style H fill:#4FC08D,color:#fff
```
## 关联笔记
- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义.md]]
- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解.md]]
- [[hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md]]
- [[hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial.md]]
- [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪.md]]