Files
cs-note/hhs/gRPC/4. 客户端开发/12-Call Options 与 Context.md
T
2026-05-24 11:42:38 +08:00

304 lines
12 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, Go, Context, Call Options, Metadata, Retry, Deadline]
create time: 2026-05-11 15:32
---
# Call Options 与 Context
## 概述
一个 gRPC 调用不只是 method name + params——你有丰富的 options 来配置超时、认证、路由、优先级等行为。而 Context 则是贯穿所有选项的灵魂:取消信号、deadline、元数据全部通过 context 传递。
> [!question] Context 应该用 Background 还是 Todo?
> 在 RPC 场景中,永远用 `context.Background()` 或从上游继承 context。`context.TODO()` 表示"还没想好该用什么",不应该出现在生产代码中。
## Context 基础
```go
ctx := context.Background()
// 推荐:显式设置 deadline,让下游知道剩余时间
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 不调用会泄漏 timer
```
Context 的传播链是单向的——每个 `With*` 函数生成新的 context,原始 context 不会被修改:
```mermaid
flowchart LR
A["Background"] -->|"WithTimeout"| B["WithTimeout\n5s"]
B -->|"Append Metadata"| C["AppendToOutgoingContext"]
C --> D["ClientMethod Call"]
B -.->|"cancel / deadline"\n到达 | E["RPC 被取消"]
E --> F["返回 context.DeadlineExceeded"]
style B fill:#74b9ff,color:#000
style E fill:#fdcb6e,color:#000
style F fill:#d63031,color:#fff
```
如果任何一层调用了 `cancel()`,整个链条上的 Recv/Send 都会立即感知到。
## Timeout / Deadline:由 Context 负责
你可能在其他 gRPC 库中看到过 `timeout` 这个参数,但在 Go 中**统一通过 context 传递**:
> [!success] 核心原则:超时走 Context,其余走 Call Option
> 这不是限制,而是设计哲学——gRPC Go 把一切生命周期管理都交给 context。所有选项本质上可以分成两类:
> - **Context 相关**:取消、超时、元数据、认证凭证
> - **Call Option 相关**:消息大小限制、压缩算法、重试策略、用户代理
```go
// ⚠️ gRPC 没有 grpc.WithTimeout() 这个 call option!
// 正确做法:用 context 控制超时
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req) // context 自带超时信息
```
## Unary Call Options
```go
import "google.golang.org/grpc"
resp, err := client.GetUser(ctx, &pb.GetRequest{Id: "123"},
grpc.WaitForReady(true), // 连接排队中时不拒绝,等待建立
grpc.MaxCallRecvMsgSize(10<<20), // 本 call 接收上限 10MB
grpc.UseCompressor(gzip.Name), // 强制 gzip 压缩请求体
)
```
每个 call option 的参数都是 `func(*callOpts)`——这就是 Go 的函数式选项模式。gRPC 内置了约 15 个选项,常用以上几种。
### 超时 vs Call Option 对比
| 维度 | 通过 Context | 通过 gRPC Option |
|------|-------------|-----------------|
| **取消信号** | ✅ `ctx.Done()` | ❌ |
| **超时控制** | ✅ `WithTimeout` / `WithDeadline` | ❌ |
| **元数据传递** | ✅ `AppendToOutgoingContext` | ❌ |
| **消息大小限制** | ❌ | ✅ `MaxCallRecvMsgSize` |
| **压缩算法** | ❌ | ✅ `UseCompressor` |
| **重试次数** | ❌ | ✅ `NumRetries`(需 service config) |
> [!question] 为什么超时不设计成 Call Option?
> 因为 context 不仅携带超时信息,还携带取消信号、值传递、认证信息等。如果超时散落在各个 call option 里,就无法统一管理整条调用链的生命周期。Go 的做法是:**一个 context,所有生命周期管理**。
| Option | 作用 |
|--------|------|
| `WaitForReady` | 连接不在 Ready 状态时等待而非直接失败(默认 false) |
| `MaxCallRecvMsgSize` | 覆盖该次调用的接收上限(默认 4MB) |
| `MaxCallSendMsgSize` | 覆盖该次调用的发送上限(默认无限制) |
| `UseCompressor` | 指定压缩算法(gzip / deflate),不指定则由 gRPC 自动协商 |
| `FailOnNonTempDialError` | 非临时 dial 错误立即返回,不再重试连接 |
| `NumRetries` | 显式指定重试次数(需配合 service config 使用) |
| `UserAgent` | 设置本次调用的 User-Agent header,用于服务端识别客户端 |
| `InitialCredentials` | 首次通信使用的 credentials(与 PerRPCCredentials 配合) |
> [!tip] 两个常用的遗漏选项
> - **`InitialGzip(true)`**:仅在本次调用中启用 gzip 压缩请求体(无需全局配置 compressor)。
> - **`ReturnRawServerStats()`**:开启后获取原始服务器遥测数据(用于监控和埋点)。
> [!warning] MaxCallRecvMsgSize 的层级关系
> Dial-level 设的是全局上限,call-level 设的是本次上限。两者取最小值生效。如果服务端发送的消息超过了你客户端的限制,你会收到 `received message larger than max` 错误。
## Metadata 注入与读取
Metadata 是 key-value 对,用于透传 token、trace-id、region 等上下文信息:
```go
// 注入 outgoing metadata
ctx = metadata.AppendToOutgoingContext(ctx,
"authorization", "Bearer "+token,
"x-trace-id", traceID,
"x-region", "cn-east",
)
// 发起 call,同时接收 Header 和 Trailer
resp, md, err := client.SecureMethod(ctx, req,
grpc.Header(&headerMD), // RPC 开始时的响应头
grpc.Trailer(&trailerMD), // RPC 结束时的尾随元数据
)
if err != nil {
// gRPC 错误详情实际上在 trailer 中
if cerr, ok := status.FromError(err); ok {
log.Println("Code:", cerr.Code(), "Details:", trailerMD.Get("grpc-status-details"))
}
}
```
### Metadata 关键注意事项
> [!important] Trailing Metadata 是获取错误详情的唯一途径
> gRPC 的错误信息(包括 protobuf Any 类型的详情)是通过 **Trailer** 传递的。如果你调用服务端接口失败,必须通过 `grpc.Trailer()` option 接收才能拿到完整错误信息。
| 字段 | 方向 | 常见用途 |
|------|------|---------|
| `Content-Type` | Outgoing + Incoming | `application/grpc` |
| `authorization` | Outgoing | Bearer Token / API Key |
| `x-trace-id` | Outgoing + Incoming | 分布式链路追踪 |
| `x-rate-limit-remaining` | Incoming (Header) | 限流计数 |
| `grpc-status-details` | Incoming (Trailer) | 结构化错误详情 |
> [!tip] Metadata 的键名规范
> gRPC metadata 的 key 统一使用小写——因为 HTTP/2 header 本身不区分大小写,gRPC 库会自动将驼峰转换为小写存储。
## Retry Policy 实战
gRPC Go 内置的重试机制需要在 service config 中声明:
```go
config := `{
"methodConfig": [{
"name": [{"service": "user.v1.UserService", "method": "GetUser"}],
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}`
conn, _ := grpc.Dial(addr, grpc.WithDefaultServiceConfig(config))
```
重试策略的匹配规则:
1. 优先匹配 `"service":"X","method":"Y"`(最精确)
2. 其次匹配 `"service":"X"`(服务级)
3. 最后匹配 `"{}"` (全局兜底,即没有 name 字段时生效)
⚠️ **幂等性警告**:只有 GET 类操作才应该交给自动重试。Write 操作(Create/Update/Delete)必须自行判断是否幂等,因为 retry 会导致重复写入。
```mermaid
flowchart TB
A["发送请求"] --> B{"收到响应"}
B -->|"OK 200"| C["返回结果"]
B -->|"UNAVAILABLE /\nDEADLINE_EXCEEDED"| D{"已达最大\n重试次数?"}
B -->|"其他错误码"| H["直接返回错误"]
D -->|"否"| E["等待 Backoff 时间"]
D -->|"是"| F["返回最终错误"]
E --> G{"Context 已取消?"}
G -->|"是"| I["提前退出:\ncontext.Canceled"]
G -->|"否"| A
style C fill:#00D866,color:#000
style F fill:#d63031,color:#fff
style I fill:#fdcb6e,color:#000
style H fill:#e17055,color:#fff
```
### 退避算法(Backoff)计算示例
假设配置 `initialBackoff: 100ms`, `maxBackoff: 1s`, `backoffMultiplier: 2`:
| 重试轮次 | 实际等待时间 |
|---------|-------------|
| 第 1 次 | 100ms |
| 第 2 次 | 200ms |
| 第 3 次 | 400ms |
| 第 4 次及以上 | 1000ms(被 maxBackoff 截断) |
> [!tip] 不要手动实现指数退避
> 服务配置会自动处理退避计算。如果需要在代码中做类似逻辑,直接使用 context 的 WithTimeout/WithDeadline 配合循环,或者使用 `golang.org/x/time/rate` 包。
## Context 取消传播
当客户端 context 被取消时,整个调用链的反应如下:
```mermaid
flowchart TB
A["context.Background()"] -->|"WithTimeout"| B["5秒 Timeout"]
B --> C["AppendToOutgoingContext MD"]
C --> D["ClientMethod Call"]
subgraph Server["服务端"]
E["Handler ctx.Done()"]
F["清理资源 / 中止计算"]
end
B -.->|"cancel / deadline\ndelivered"| E
E --> F
D -.->|"RPC cancelled"| G["返回\ncontext.Canceled"]
style B fill:#74b9ff,color:#000
style F fill:#00D866,color:#000
style E fill:#fdcb6e,color:#000
style G fill:#d63031,color:#fff
```
1. 服务端 handler 的 `ctx.Done()` channel 会关闭
2. 服务端应主动停止计算、释放资源
3. 客户端下一次 Recv 会收到 `context.Canceled` 错误
> [!warning] Cancel 是单向通知
> Client 调用 `cancel()` 后,服务端可能仍在继续处理已接收的消息。gRPC 没有双向联动机制——如果需要强制服务端在 client 取消时也退出,应在服务端 handler 中监听 `ctx.Done()` 并主动 return。
## Deadline vs Timeout
| 方法 | 参数类型 | 语义 | 适用场景 |
|------|----------|------|---------|
| `WithTimeout` | `time.Duration` | 相对当前时间的 duration | 简单场景 |
| `WithDeadline` | `time.Time` | 绝对时间点 | 跨调用链追踪 |
推荐使用 `WithDeadline` 的原因:当你把一个 context 传给下游 RPC 时,你可以从 context 中提取 deadline,计算出剩余时间,作为下一级 timeout。这样整条链路可以共享同一个总 deadline。
```go
// 第一层
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Second))
defer cancel()
// 传递给下游时减去缓冲时间
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline) - 500*time.Millisecond
ctx = context.WithDeadline(ctx, time.Now().Add(remaining))
}
```
## 流式调用的 Option 差异
流式(Streaming)的 Call Options 和 Unary 基本一致,但有一个关键区别——**Option 在 Stream 创建时就确定了,不能在过程中动态修改**:
```go
stream, err := client.FullDuplex(ctx,
grpc.MaxCallRecvMsgSize(10<<20), // 影响整个 stream 生命周期
grpc.UseCompressor(gzip.Name),
)
if err != nil {
return err
}
defer func() { _ = stream.CloseSend() }() // 关闭 send half
// 后续收发共享同一套配置
for {
req := <-reqChan
if err := stream.Send(req); err != nil { break }
resp, err := stream.Recv() // 超出 MaxCallRecvMsgSize 会失败
if err != nil { break }
_ = resp
}
```
### 流式调用的特殊注意事项
| 关注点 | Unary | Server Stream | Client Stream | Full Duplex |
|--------|-------|---------------|---------------|-------------|
| **超时控制** | context | context | context | context |
| **消息大小** | 单次限制 | 每帧独立限制 | 每帧独立限制 | 每帧独立限制 |
| **Cancel 时机** | 返回后立即 | Recv 循环结束后 | Send 循环结束后 | 全部完成后 |
| **必须关闭** | ❌ | `CloseAndRecv()` | `SendMsg` 后自动关半双工 | `CloseAndSend()` |
> [!tip] 流式调用的 Cancel 泄漏问题
> 如果服务端仍在发送数据而客户端 context 已取消,Recv goroutine 可能不会被回收。确保每个 Recv goroutine 都监听 `ctx.Done()` 并在收到取消信号后退出。
## 关联笔记
- [[11-Client 连接与 Dial]] — Dial-level 配置是 Call Options 的前置基础
- [[13-Streaming Client]] — Stream 调用同样依赖 Context 控制生命周期