460 lines
17 KiB
Markdown
460 lines
17 KiB
Markdown
---
|
||
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]]
|