TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

流式传输、deadline、重试、keepalive:当调用变长或失败时

状态:草案(2026-10-17),部分实测。 参考版本 grpc/grpc-go v1.83.2, gRFC A6 客户端重试, grpc/grpc v1.84.0 doc/keepalive.md。实测获取于 2026-10-17 03 点左右(KST)的 Prometheus·kubectl logs·节点的 etcd /metrics 仅通过读取获得。未进行故意放大流量控制窗口、人为触发重试与 keepalive 的实验(未实测,所需操作:下载用于构建 grpc-go 示例服务器和客户端的 Go 模块)。

目录

1. 直观理解:电话通话与广播

这四种方式在传输层都是相同的 HTTP/2 流,区别仅在于 交互的消息数量以及 谁先发出结束信号。

2. 图解:四种方式

네 가지 호출 방식: 화살표 하나가 메시지 하나 단항 rpc Hit(Rally) returns (Score) C S 서버 스트리밍 returns (stream Flow) C S 클라이언트 스트리밍 rpc Upload(stream Chunk) C S 양방향 rpc Watch(stream) returns (stream) C S 진한 실선: 클라이언트→서버, 회색 점선: 서버→클라이언트
图解:四种调用方式的消息流
方式 proto 声明 小型集群中的示例
一元 rpc Range(RangeRequest) returns (RangeResponse) etcd Range·Txn, OTLP Export, CRI 全部
服务器流式 returns (stream …) Hubble Observer/GetFlows, Peer/Notify, etcd Maintenance/Snapshot
客户端流式 rpc …(stream …) 未在指标中找到
双向 rpc Watch(stream WatchRequest) returns (stream WatchResponse) etcd Watch·LeaseKeepAlive

(etcd 声明见 rpc.proto, Hubble 见 observer.proto·peer.proto确认)

流并非免费的。官方性能指南指出,流一旦开启便无法通过负载均衡进行迁移且难以调试,因此建议仅在收益明确时使用 (Performance Best Practices)。这就是第 5 篇中倾斜问题在流中更为严重的原因。

3. 流量控制:接收方决定速度

如果在服务器流式传输中,服务器每秒发送一万个,而客户端只能处理一千个,会发生什么呢?HTTP/2 通过 接收方开启“可以再发送这么多”的 窗口(window) 机制来进行拦截。每个流有一个窗口,整个连接有一个窗口,默认大小为 65,535 字节(RFC 9113)。当接收方消费数据后,会通过 WINDOW_UPDATE 重新打开窗口。如果窗口为 0,发送方的 Send 将会 被阻塞并等待。这就是 gRPC 流的背压(backpressure)。

grpc-go 的两个特点:

4. deadline 与取消的传播

deadline 은 시각이 아니라 남은 시간으로 건너간다 게이트웨이 점수 서비스 저장소 grpc-timeout 500m grpc-timeout 380m 남은 시간 막대 (0.5초 예산) 출발할 때 500ms 남음 120ms 처리 뒤 380ms 남음 저장소 도착 380ms 남음 시간이 다 되면: 클라이언트는 DEADLINE_EXCEEDED 를 받고, RST_STREAM(CANCEL) 이 서버로 가서 그쪽 ctx 도 취소된다. 서버가 다시 부른 다음 호출에도 같은 ctx 를 넘겨야 연쇄로 멈춘다.
图解:deadline 作为剩余时间进行传播
  1. 当客户端以 0.5 秒的 deadline 发起调用时, grpc-timeout: 500m 会被发出(第 3 篇第 3.1 节)。
  2. 服务器使用该值在其自己的上下文中设置期限。如果消耗了 120ms 并 以相同的上下文 调用下一个服务,grpc-go 会重新计算剩余的 380ms 并 grpc-timeout 将其发送。这意味着期限会沿着调用链逐级缩短并向下传递。
  3. 一旦期限超时,客户端将收到 DEADLINE_EXCEEDED,并在流中发送 RST_STREAM(CANCEL) 。由于服务器端的上下文被取消,该服务器继承上下文所调用的下游调用也会连锁停止。

其中的陷阱在于第 2 点的“以相同的上下文”。如果在 Go 中使用服务器处理器收到的 ctx 之外的上下文去进行下层调用,传播就会中断, context.Background() 导致上方已经放弃的工作在下方被执行到底。由于规范要求无期限的请求应无限期发送(PROTOCOL-HTTP2.md Requests 节),因此不能寄希望于默认值。

4.1 实测:kubelet 的 CRI 期限与实际延迟

kubelet 的 CRI 客户端在每次调用时都会设置期限。默认的 runtimeRequestTimeout 为 2 分钟, RunPodSandbox 为其两倍(4 分钟), StopContainer 为 2 分钟 + 终止宽限期(remote_runtime.go:204, 第 379 行)。相比之下, PullImage 没有期限,仅设置了取消(remote_image.go 的 PullImage, context.WithCancel)。 这是因为镜像下载根据大小可能需要几分钟,这是正常的。

基于 8 台机器的总和以及最近 6 小时的直方图计算得出。

histogram_quantile(0.99, sum by (operation_type, le) (rate(kubelet_runtime_operations_duration_seconds_bucket[6h])))
CRI 调用 p50 p99 设定的期限
pull_image 35.8 秒 116.6 秒 无(仅取消)
stop_container 23.7秒 47.2秒 2分钟 + 宽限期
exec_sync(exec 探针) 60ms 2.2秒 探针 timeoutSeconds
run_podsandbox 365ms 1.19秒 4分钟
create_container 12ms 976ms 2分钟
start_container 64ms 464ms 2分钟
list_containers 5ms 以下 5ms 以下 2分钟

stop_container 的 p50 为 24 秒并不是因为运行时慢,而是因为在 SIGTERM 之后的宽限期(通常为 30 秒)内等待应用结束(推测:其中混杂了关闭缓慢的 Pod)。由于桶边界为 0.005、0.0125 等比较稀疏,因此列表查询的“5ms 以下”意味着在第一个桶内。最近 6 小时内是否有 kubelet_runtime_operations_errors_total CRI 超时错误,由于没有错误类型标签而无法区分(未确认)。

4.2 实测:OTel Collector 的 5 秒超时限制

Collector 的导出操作默认对每个请求设置 5 秒超时限制(opentelemetry-collector v0.159.0 timeout_sender.go:31)。 在 Collector 日志(启动后的 2026-09-29 之后的所有日志, kubectl logs 读取)中,按状态码统计了导出到 Tempo 失败的情况。

日期(UTC) DEADLINE_EXCEEDED UNAVAILABLE 当时发生的事情
2026-10-05 14:39~16:05 2,049 27 Tempo 响应延迟(原因未明)
2026-10-09 18:25~18:27 34 32 短暂中断(原因未明)
2026-10-13 00:40~00:46 0 175 Tempo Pod 重启(00:42 终止,00:44 启动)

5. 重试与对冲(Hedging)

gRPC 允许通过 服务配置(service config) 中的每个方法的 retryPolicy 来让库自动重试。例如(A6 格式):

{"methodConfig": [{"name": [{"service": "tt.v1.Scoreboard"}],
  "retryPolicy": {"maxAttempts": 4, "initialBackoff": "0.1s", "maxBackoff": "1s",
                  "backoffMultiplier": 2, "retryableStatusCodes": ["UNAVAILABLE"]}}]}

在 grpc-go 中实际决定重试的函数是 csAttempt.shouldRetry 。阅读后会发现规则非常清晰。

if a.transportStream != nil {
	if !a.transportStream.TrailersOnly() {
		return false, err
	}
	sps := a.transportStream.Trailer()["grpc-retry-pushback-ms"]
	...
}
rp := cs.methodConfig.RetryPolicy
if rp == nil || !rp.RetryableStatusCodes[code] {
	return false, err
}
if cs.retryThrottler.throttle() {
	return false, err
}
if cs.numRetries+1 >= rp.MaxAttempts {
	return false, fmt.Errorf("max retries exhausted: failed after %d attempts: %w", cs.numRetries+1, err)
}

来源:grpc/grpc-go v1.83.2 stream.go 摘录第 727~768 行,Apache-2.0

hedging 是一种不等待响应、按固定间隔(hedgingDelay)向多个服务器提前发送相同请求并采用最快答复的方式(A6)。 它虽然能减少尾部延迟,但只能用于幂等方法。grpc-go 1.83.2 中只有 TODO(hedging) 注释(stream.go:634),并没有在服务配置中读取 hedgingPolicy 的代码。可以认为在 Go 中目前还无法使用(未确认其他语言的实现)。

6. keepalive:快速检测死连接

TCP 连接即使对方消失(断电、从 NAT 表中删除)也会在一段时间内看起来依然存活。gRPC 通过发送 HTTP/2 PING 并在没有收到回复时断开连接。

配置 grpc-go 默认值 依据
客户端 Time(Ping 间隔) 关闭(无限)。开启时至少 10 秒 defaults.go:32, keepalive.go 注释
客户端 Timeout 20 秒 同文件第 33 行
服务端 Time 2 小时 第 38 行
服务端允许的最小 Ping 间隔(EnforcementPolicy.MinTime) 5 分钟 第 40 行
无流时的 Ping 许可(PermitWithoutStream) 拒绝 keepalive.go
违规允许次数 2 次,第三次发送 GOAWAY http2_server.go:881 maxPingStrikes
连接最大寿命(MaxConnectionAge) 无限 defaults.go:36

最常见的事故是双方配置不一致。如果客户端每 30 秒发送一次 ping,而服务器坚持默认值(5 分钟),服务器就会认为 ping「过于频繁」并 GOAWAY(ENHANCE_YOUR_CALM, "too_many_pings") 断开连接(handlePing)。 客户端会将其视为 RESOURCE_EXHAUSTED 或 UNAVAILABLE,并且 grpc-go 客户端从这时起会将 ping 间隔翻倍(keepalive.go 注释)。

6.1 实测:apiserver 和 etcd 是如何协调的

6.2 实测:双向流能存活多久

sum by (instance) (grpc_client_started_total{job="apiserver",grpc_method="Watch"})
  - sum by (instance) (grpc_client_handled_total{job="apiserver",grpc_method="Watch"})

已启动但尚未结束的 Watch 流在三台 apiserver 上分别有 242、239 和 239 个。同一时间,控制节点 A 的 etcd 上的 etcd_debugging_mvcc_watch_stream_total 242, etcd_debugging_mvcc_watcher_total 为 242,与客户端侧的数字完全吻合。 在过去 1 小时内结束的 Watch 仅有每秒 0.002 个(每小时约 8 个),三台机器合计收到的 Watch 消息约为每秒 181 个。看起来单个流可以存活数天并持续传送变更事件。至于为什么流的数量刚好在 240 个左右,我们将在第 6 部分结合 TCP 连接数一起探讨。

7. 局限性

8. 与小型集群的连接

Titi 的话。 如果拉力赛变长了,裁判会喊「剩余时间 380ms!」并交接给下一个裁判。如果重新对准时钟(Background),比赛明明已经结束了,却还在一个人硬撑着打球!

参考

所有资料均查阅于 2026-10-17。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论