This commit is contained in:
2026-05-24 11:42:38 +08:00
commit 30d312ac35
521 changed files with 146481 additions and 0 deletions
@@ -0,0 +1,308 @@
---
tags: [gRPC, Go, Dial, Connection, TransportCredentials, Keepalive, Retry]
create time: 2026-05-11 15:30
---
# Client 连接与 Dial
## 概述
gRPC client 的连接建立在 `grpc.Dial` 上,但 dial 只是起点——真正的连接管理、自动重连逻辑、负载均衡和地址解析都在背后的子系统里。理解这些机制能让你避免绝大多数生产环境下的连接问题,而不是把时间浪费在反复调试"为什么偶尔超时"上。
> [!question] 为什么一个 conn 就能代替连接池?
> HTTP/1.1 时代我们需要手动维护 http.Client 的 Transport 来复用 TCP 连接。但 gRPC 的设计哲学不同——它把连接生命周期完全封装在 `grpc.ClientConn` 内部,你只需要调好 dial options 就够了。后续章节会详细拆解这个内部是怎么工作的。
## Basic Dial
```go
import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
conn, err := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("dial failed: %v", err)
}
defer conn.Close() // 必须调用,释放底层 goroutine 和文件描述符
```
- **target** 支持多种 scheme:`dns:///host:port`、`unix:///path`、`ip://...`。不指定 scheme 时默认走 DNS resolver。
- `grpc.Dial()` 是 **non-blocking** 的——它立即返回 `conn` 对象,后台异步建立连接。此时不能立即发起 RPC,否则会排队等待连接就绪。
- **必须**在进程退出前调用 `conn.Close()`,否则 goroutine 泄漏(每个 subchannel 都会启动 keepalive timer、reconnect goroutine)。
- ❌ gRPC v1.35+ 已废弃 `grpc.WithInsecure()`,改用 `grpc.WithTransportCredentials(insecure.NewCredentials())`。
## Dial Options 分类表
gRPC 通过 Option pattern(函数式选项)配置客户端行为。按功能域分为以下类别:
| 类别 | Option | 用途 |
|------|--------|------|
| 传输安全 | `WithTransportCredentials()` | TLS / mTLS 配置 |
| **重连策略** | `WithConnectTimeout()`, `WithBackoffConfig()` | 底层 reconnect backoff(**见下文**) |
| 重试 | `WithDefaultServiceConfig()` | Service config JSON,含 retry policy |
| 负载平衡 | `WithBalancerName()` ⚠️已废弃 | v1.60+ 固定使用 `pick_first`;需改 service config 中的 `loadBalancingConfig` |
| 消息大小 | `WithMaxCallRecvMsgSize()` / `WithMaxSendMsgSize()` | 接收/发送上限(默认 recv 4MB) |
| 保持活 | `WithKeepaliveParams()` / `WithKeepaliveAuthority()` | Keepalive 心跳 + 覆盖 authority |
| 拦截器 | `WithChainUnaryInterceptor()` / `WithChainStreamInterceptor()` | 拦截器链 |
| 统计/观测 | `WithStatsHandler()` | StatsHandler 接口,用于 metrics 和 tracing |
| 元数据 | `WithUserAgent()` | 自定义 User-Agent header |
| 通信类型 | `WithInitialWindowSize()` / `WithInitialConnWindowSize()` | 调整 gRPC 层 flow control 窗口 |
> [!warning] WithBalancerName 已废弃
> gRPC v1.60.0 起移除了旧版 balancer API(`balancer.Register`)。负载均衡策略现在统一通过 service config JSON 的 `loadBalancingConfig` 字段配置——这也会在 "Default Service Config" 部分详述。
## Transport Credentials(证书配置)
```go
import (
"crypto/tls"
"crypto/x509"
"google.golang.org/grpc/credentials"
)
// 生产环境:标准 TLS
caBytes, _ := os.ReadFile("ca-cert.pem")
caPool := x509.NewCertPool()
caPool.AppendCertsFromPEM(caBytes)
cert, _ := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
creds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
})
conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(creds))
// 本地开发:insecure(仅本地!绝不能用于生产)
// conn, _ := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
```
mTLS 与普通 TLS 的区别:普通 TLS 只需验证服务端证书;mTLS 还要求客户端出示自己的证书。如果你的服务网格或零信任架构要求双向认证,就必须用 mTLS。
## Default Service Config(Retry Policy)
gRPC Go 内置的自动重试能力,通过 service config JSON 开启:
```go
config := `{
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "DEADLINE_EXCEEDED"]
}
}`
conn, err := grpc.Dial(addr,
grpc.WithDefaultServiceConfig(config),
)
```
retry policy 的核心机制:**指数退避**。第一次失败等 100ms,第二次 200ms,第三次 400ms,直到达到 maxBackoff 上限。
⚠️ 注意:service config 中同时可以设置 load balancing policy:
```json
{"loadBalancingPolicy":"round_robin","retryPolicy":{...}}
```
## Keepalive Parameters
```go
import "google.golang.org/grpc/keepalive"
ksp := keepalive.ClientParameters{
Time: 10 * time.Second, // ping 间隔
Timeout: 5 * time.Second, // 等待 pong 的超时
PermitWithoutStream: true, // 空闲时也发 ping
}
conn, err := grpc.Dial(addr,
grpc.WithKeepaliveParams(ksp),
)
```
为什么需要 keepalive:NAT 网关和云负载均衡器通常会在 30~60 秒无流量后主动切断 TCP 连接。如果不发送 keepalive ping,服务端认为连接已死,而客户端仍然以为活着,后续读写就会拿到 `i/o timeout` 这类难以排查的错误。
```mermaid
flowchart LR
subgraph "Client"
A["Keepalive Timer\nEvery 10s send ping"] --> B["HTTP/2 PING Frame"]
end
subgraph "Server"
B --> C["Receive PING"]
C --> D["Reply PONG"]
D --> E["Return to step A"]
end
F["NAT / LB\nNo traffic 30s close conn"] -.-> G["Connection alive"]
style A fill:#00D866,color:#fff
style G fill:#EE5A24,color:#fff
```
## Backoff / Reconnect Policy
Keepalive 负责探测"连接是否还活着",而 backoff 策略决定断连后**等多久重连**。这两者是协作关系:
```go
// gRPC v1.60+ 推荐方式:手动指定 backoff config
conn, err := grpc.Dial(addr,
grpc.WithConnectTimeout(5*time.Second),
grpc.WithBackoffConfig(backoff.BackoffConfig{
MaxDelay: 1 * time.Minute, // 最大重连延迟(指数退避上限)
BaseDelay: 1 * time.Second, // 初始 delay
Multiplier: 1.6, // 每次翻倍乘数
Jitter: 0.2, // 抖动范围 ±20%,防止 thundering herd
}),
)
```
默认行为与可配参数的对比:
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `BaseDelay` | 1s | 初次重连等待时间 |
| `Multiplier` | 1.6 | 指数退避系数 |
| `Jitter` | 0.2 | 随机抖动(防雪崩) |
| `MaxDelay` | 120s | 重连间隔上限 |
当 keepalive 检测失败(ping timeout),subchannel 进入 TransientFailure 状态并触发 backoff。背压按如下序列递增:
```mermaid
flowchart LR
A["Disconnected"] --> B["wait 1.0s"]
B --> C["reconnect failure"]
C --> D["wait 1.6s jitter"]
D --> E["reconnect failure"]
E --> F["wait 2.56s jitter"]
F --> G["exponential growth"]
G --> H["wait up to 120s jitter"]
H --> I["retry until connected"]
style B fill:#FFD43B
style G fill:#FF9F43
style H fill:#EE5A24,color:#fff
style I fill:#00D866,color:#fff
```
核心要点:
- **不要设得太激进**——如果服务端确实挂了,频繁重连只会放大 traffic。120s 上限给了运维人员修复窗口。
- **thundering herd 防护**:Jitter 让每个 client 的重连时间分散开来,避免大批量客户端在同一时刻同时重连压垮刚恢复的服务端。
- `WithConnectTimeout` 控制的是握手阶段的超时(TCP + TLS handshake),和 reconnection backoff 是两个独立的超时维度。
## Address Resolution(地址解析)
gRPC 通过 resolver 机制将目标字符串翻译为真实地址列表——这使得客户端不需要硬编码 IP,天然支持动态扩缩容和 Service Discovery。
```mermaid
flowchart LR
subgraph Target["target dns:///my-service.default.svc:50051"]
Resolver["Resolver\nDNS / etcd / Static"]
end
Resolver -->|"Address List"| Picker["Picker\nSelect subchannel for request"]
subgraph Conn["grpc.ClientConn"]
Picker --> SC1["SubChannel 1\n10.0.0.1:50051"]
Picker --> SC2["SubChannel 2\n10.0.0.2:50051"]
Picker --> SC3["SubChannel 3\n10.0.0.3:50051"]
end
style Resolver fill:#00B6BC,color:#fff
style Picker fill:#FFD43B
```
**核心组件:**
| 角色 | 职责 |
|------|------|
| **Resolver** | 把 target 字符串解析为 `address.Address` 列表,并 watch 更新 |
| **Balancer (Picker)** | 从子连接中选一个来发请求(pick_first / round_robin) |
| **SubChannel** | 封装单个后端连接的传输层,含 keepalive、reconnect 逻辑 |
**内置 Resolver 类型:**
- **DNS resolver**:`dns:///my-service.default.svc.cluster.local:50051`,自动解析 A/AAAA/TXT 记录并 watch 变化。当 DNS 返回多个 A 记录时,gRPC 会创建子连接并使用 picker 做负载均衡。
- **Static resolver**:传入裸 `IP:port`(如 `10.0.0.1:50051`),不会自动重连,适合单元测试或固定 IP 场景。
- **Discovery 插件**:etcd、Consul、Nacos 等有第三方 resolver 实现。
> [!tip] 为什么需要 TXT 记录?
> gRPC DNS resolver 同时读取 A 记录(后端地址)和 TXT 记录(service config)。你可以在 DNS TXT 中声明 retry policy 和 load balancing config,这样服务端就能统一管理配置而不需要改客户端代码。
## Client Lifecycle(连接生命周期)
```mermaid
stateDiagram-v2
[*] --> Idle: grpc.Dial() returns immediately
Idle --> Connecting: first call or reconnect
Connecting --> Ready: TCP + HTTP/2 handshake OK
Connecting --> TransientFailure: dial failed
state "Connecting" as C {
[*] --> TCPConnect
TCPConnect --> TLSHandshake: secure mode
TLSHandshake --> PrefaceSent
TCPConnect --> PrefaceSent: insecure mode
PrefaceSent --> Ready: SETTINGS ACK received
PrefaceSent --> ErrorTimeout: timeout
ErrorTimeout --> TransientFailure
}
Ready --> Busy: Create RPC Stream
Busy --> Ready: Stream completed
Busy --> Closing: context cancellation
TransientFailure --> Reconnecting: backoff expires
Reconnecting --> Ready: reconnect success
Reconnecting --> TransientFailure: reconnect fail
Closing --> [*]: conn.Close() graceful shutdown
TransientFailure --> [*]: fatal error (e.g., invalid addr)
note right of TransientFailure
DNS resolved but all
backends unreachable
end note
```
gRPC client 的生命周期分为以下状态:
1. **Idle** —— `grpc.Dial()` 立即返回,后台不自动发起连接。直到第一次 RPC 调用或某个 subchannel 需要重建时才触发连接。
2. **Connecting** —— 正在建立 TCP → TLS(如启用安全模式)→ HTTP/2 Preface 握手。这个阶段发来的 call 会被排队,由 `WaitForReady` 控制超时行为。
3. **Ready** —— 连接就绪,可以正常收发 RPC。picker 从可用的 subchannels 中选择一个来发送请求。
4. **TransientFailure** —— 所有后端都不可达时进入此状态。此时新 call 直接失败(除非设了 `WaitForReady`),但 gRPC 会在 backoff 到期后自动尝试重连。
5. **Closing** —— `conn.Close()` 被调用,启动优雅关闭流程:等待在跑的 stream 完成,然后终止所有子连接。
> [!question] Dial 到底是同步还是异步?
> 答案很微妙:`grpc.Dial()` 本身是同步的——它马上返回 `*grpc.ClientConn`。但连接的**建立过程是异步的**。这意味着你在 dial 返回后立刻发第一个 call 时,如果连接还没 ready,这个 call 会阻塞在排队中等待连接就绪。你可以用 `conn.WaitForStateChange()` 或者 StatsHandler 来主动监听状态变化。
## 生产最佳实践
- **永远在进程启动时建立一次连接,复用 `conn` 对象**——gRPC 内部是连接池,重新 dial 会创建新的子连接并浪费资源。
- **永远不要因某个 call 失败就重新 Dial**——gRPC 自带 transport-layer 重连逻辑(reconnect policy),比任何手动重试都可靠。
- **跨机房部署时必须配置 keepalive**,否则几乎必然遇到半死连接(NAT/LB 已断连但客户端不知情)。
- **使用 `grpc.StatsHandler` 监控连接状态变化**,将子连接数、transient failure 次数等指标接入 Prometheus/Grafana。
- **生产环境禁用 insecure credentials,务必使用 TLS**——这看起来是废话,但在 CI/CD 环境中我们见过太多忘记改的测试配置流入生产。
> [!tip] 预热连接(Connection Warm-up)
> DNS resolver 解析后不会立刻建立连接,直到第一次 RPC 调用才触发握手。如果需要降低首次调用的尾延迟,可以在进程启动后立即发一个健康检查 call:
> ```go
> // 利用 WaitForReady 避免首次调用的排队超时
> healthConn, _ := grpc.Dial(addr, opts...)
> defer healthConn.Close()
> healthClient := health.NewClient(healthConn)
> healthClient.Check(context.Background(), &healthpb.HealthCheckRequest{Service: "my.Service"})
> ```
## 关联笔记
- [[../2. gRPC 核心篇/07-HTTP2 传输原理]] — HTTP/2 frame、stream multiplexing 和 flow control 原理
- [[12-Call Options 与 Context]] — call-level options、retry policy 匹配规则、context 取消传播
- [[../3. 服务端实现/10-健康检查与反射]] — health check API,与 warm-up 技巧配合使用
- [[../6. 工程实践篇/20-性能优化与压测]] — 连接池复用、compression、gRPC-bench 压测手法
@@ -0,0 +1,303 @@
---
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 控制生命周期
@@ -0,0 +1,251 @@
---
tags: [gRPC, Go, Streaming, ServerStreamingClient, ClientStreamingClient, BidiStreaming, Recv, Send, ContextCancellation, GracefulShutdown]
create time: 2026-05-11 15:34
---
# Streaming Client
## 概述
客户端处理 stream 的方式跟 unary 截然不同。你不能简单地 `resp, err := client.Method()`,而是需要处理一个连续的收发消息循环。这里我们逐个拆解每种 stream 模式下客户端的正确写法。
> [!tip] Stream 不是魔法
> 本质上一根 HTTP/2 stream 就是一个 duplex channel。gRPC 只是把 Send 和 Recv 的时序通过代码组织起来。理解了这一点,就能理解所有 stream 变体的模式。
## Server Streaming Client
服务端返回多条消息,客户端逐条接收:
```go
stream, err := client.ListUsers(ctx, &pb.ListUsersRequest{})
if err != nil {
return err // call 启动失败
}
for {
resp, err := stream.Recv()
if err == io.EOF {
break // 正常结束
}
if err != nil {
return err // 真实错误
}
fmt.Println(resp.User)
}
```
要点:
1. **调用时返回 stream object**,而非单个 response
2. **Recv loop 直到收到 `io.EOF` 才算正常结束**,EOF 是服务端主动关闭发送端的信号
3. **全程只有两个 error 关注点**:initial error(call 启动时)和 final error(EOF 前),中间 Recv 成功不需要检查 err
这种模式适合列表类接口——数据量可能很大但服务端控制发送节奏,客户端不需要主动推数据。
## Client Streaming Client
客户端连续发送多条消息,服务端最后返回一条聚合结果:
```go
stream, err := client.UploadData(ctx)
if err != nil {
return err
}
chunks := splitIntoChunks(data)
for _, chunk := range chunks {
if err := stream.Send(&pb.Chunk{Data: chunk}); err != nil {
return err // 某条发送失败
}
}
result, err := stream.CloseAndRecv() // 关闭发送端并获取最终响应
if err != nil {
return err
}
fmt.Printf("uploaded %d bytes\n", result.TotalBytes)
```
要点:
1. **`Send` 循环中每次调用都可能报错**(网络断开、context 取消、服务端关闭等)
2. **`CloseAndRecv` 一步完成两件事**:关闭发送端 + 接收最终响应——减少一次 round-trip
3. **等价写法**是分开两步:先 `stream.CloseSend()` 再 `stream.Recv()`,但 CloseAndRecv 更简洁且语义更清晰
> [!question] CloseSend vs CloseAndRecv 怎么选?
>
> | 方法 | 语义 | 适用场景 |
> |------|------|---------|
> | `CloseAndRecv()` | 关发送 + 收响应,一步完成 | **大多数 client streaming 场景(推荐)** |
> | `CloseSend()` + `Recv()` | 分开执行,两步骤 | 需要在上一步之间做自定义逻辑(如日志、指标采集) |
`CloseAndRecv` 的优势在于原子性:从客户端视角看,"关闭发送端"和"获取响应"是一个操作。如果用两步写,两步之间存在微小的时间窗口——服务端可能在这期间发了响应但客户端还没调 `Recv()`,导致时序上的不确定性。虽然实际影响极小,但原子操作的语义更不容易出错。
## Bidirectional Streaming Client
双方互相发消息,收发通常是两个 goroutine:
```go
stream, err := client.ChatRoom(ctx)
if err != nil {
return err
}
done := make(chan struct{})
// recv goroutine
go func() {
defer close(done)
for {
msg, err := stream.Recv()
if err == io.EOF {
return
}
if err != nil {
log.Printf("recv error: %v", err)
return
}
display(msg.Text)
}
}()
// send goroutine
go func() {
for text := range userInputCh {
if err := stream.Send(&pb.Message{Text: text}); err != nil {
stream.CloseSend()
return
}
}
}()
<-done // 等待 recv 结束
```
要点:
1. **gRPC stream object 是线程安全的**——多个 goroutine 并发调用 Send/Recv 无需额外加锁
2. **ctx 取消会同时影响两端**——Send 和 Recv 都会立刻收到 context error,需要统一处理
3. **send goroutine 退出时务必调用 `CloseSend()`**——通知服务端发送端已关闭,否则服务端 Recv 永远阻塞等待
```mermaid
flowchart TB
subgraph "Client"
A["send goroutine<br/>Send loop"] -->|"HTTP/2 stream"| D["Server Handler"]
E["recv goroutine<br/>Recv loop"]
end
subgraph "Server"
D -->|"HTTP/2 stream"| F["reply Send"]
G["server Recv"]
end
F --> E
A -.->|"CloseSend when done"| D
style A fill:#00D866,color:#fff
style E fill:#4FC08D,color:#fff
style D fill:#FF9F43,color:#000
style F fill:#EE5A24,color:#fff
```
双向流的特殊性在于:**发送和接收是两个独立的 flow**。任何一方都可以随时发送而不阻塞对方,这也是为什么需要两个 goroutine 来管理生命周期。
## 错误分类速查表
| 错误类型 | 表现 | 处理方式 |
|----------|------|---------|
| `io.EOF` | Recv 返回 EOF | break loop,正常结束 |
| `context.Canceled` | Recv 返回 context error | 检查是否主动 cancel |
| `codes.Unavailable` | Recv/Send 返回 unavailable | 可能需要重试或 reconnect |
| `codes.ResourceExhausted` | Send 返回 resource exhausted | 背压 / 限流 / 暂停发送 |
| `codes.Internal` | Recv 返回 internal | 通常是服务端 bug,记录日志 |
当 stream 中出现非 EOF 错误后:
1. 后续所有 Send/Recv 会立刻返回同一个错误
2. 建议直接退出当前函数或返回上层处理
3. 不要尝试在同一个 stream 上恢复操作
## 批量发送优化
对于 client streaming 场景,减少 syscall 次数可以显著提升吞吐量:
```go
// 不好:每条消息都触发一次 syscall
for item := range items {
stream.Send(item) // N 次 syscall
}
// 好:聚合后批量发送
var buf []*pb.Item
for item := range items {
buf = append(buf, item)
if len(buf) >= 64 { // 批次大小按场景调优
stream.Send(&pb.Batch{Items: buf})
buf = buf[:0]
}
}
if len(buf) > 0 {
stream.Send(&pb.Batch{Items: buf})
}
```
另一种思路是利用 Protobuf 的 `repeated` 字段在服务端一次解包多条消息。将业务层的 batching 逻辑与协议层的设计对齐,可以减少网络往返次数并降低 CPU 开销。
## Context 取消与优雅退出
流式 RPC 的上下文是一个 `context.Context`——它的取消会**同时影响** Send 和 Recv:
```go
stream, err := client.ChatRoom(ctx)
if err != nil {
return err
}
// ctx cancel 后,Recv/Send 都会报错
defer stream.CloseSend() // ⚠️ defer 放这里而非 goroutine 内
```
> [!warning] Common Pitfall
> 在 bidirectional streaming 中,很多开发者会把 `CloseSend()` 放在 send goroutine 里——但如果函数先因其他原因返回,send goroutine 可能还没执行到 `CloseSend()`。**把 `defer stream.CloseSend()` 放在主函数的开头是最安全的做法**:它保证无论哪种路径退出,都会关闭发送端。
ctx 取消时的行为:
```mermaid
flowchart TD
A["ctx Cancelled"] --> B["Send 立即报错<br/>context.Canceled"]
A --> C["Recv 返回错误<br/>context.Canceled"]
A --> D["Server Handler 收到<br/>context.Canceled"]
B --> E["清理资源<br/>CloseSend + 退出"]
C --> E
D --> F["停止处理并 Return"]
style E fill:#00B6BC,color:#fff
style F fill:#FF9F43,color:#000
```
要点:
1. **不要在 Recv 循环中吞掉 `context.Canceled`**:它是主动取消的信号,不是网络异常,不需要重试
2. **`defer CloseSend()` 要放在主函数**,不在子 goroutine 里——确保一定被执行
3. **服务端收到客户端的 `CloseSend` 后**(即 `Recv` 返回 `io.EOF`),应正常结束 handler
> [!question] 如果 ctx 超时了,已经发出去的消息会不会丢?
> 不会。gRPC 底层走的是 HTTP/2,消息一旦写入操作系统内核 buffer 就算已发出。ctx 取消只是通知本地 gRPC 库:"不要再接收或发送新数据",但已在途的消息不受影响。
## 最佳实践速查
在实际项目中,这份 checklist 能帮你避开大部分坑:
1. **永远带 context**:每个 `client.Xxx(ctx, ...)` 的 ctx 应该携带 deadline 或 cancel,避免流永远挂起。
2. **defer CloseSend() 放主函数**:bidirectional stream 中确保退出路径一定关闭发送端。
3. **不要吞掉 context.Canceled**:主动取消不需要重试——重试只会制造重复流量。
4. **Recv 后检查 EOF,再检查 error**:EOF 是正常结束信号,不是错误,顺序不能反。
5. **goroutine 生命周期配对**:每开一个 recv goroutine 就要有一个 done channel 对应收取完毕。
> [!tip] 进阶方向
> - 需要重试机制?看 [[hhs/gRPC/4. 客户端开发/12-Call Options 与 Context]] 中的 retry policy
> - 需要拦截所有 stream 日志和指标?看 [[hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪]]
> - 想了解服务端对应写法?看 [[hhs/gRPC/3. 服务端实现/09-Streaming Handler]]
## 关联笔记
- [[hhs/gRPC/2. gRPC 核心篇/05-RPC 调用模式总览]] — 四种 RPC 调用模式全景对比
- [[hhs/gRPC/4. 客户端开发/11-Client 连接与 Dial]] — Stream 建立在 client connection 之上