中午延迟还正常,下午两点服务就起火了:gRPC 客户端到处 DEADLINE_EXCEEDED(状态码 4),上游也在拿到下游回的 DEADLINE_EXCEEDED,长尾延迟把仪表盘撑爆。套路几乎一模一样——下游变慢、deadline 没往下传、单次 RPC 超时设得太紧、一次性全爆。
**最快的止血:**先把客户端 deadline 抬到当前服务 p99 之上(给自己留口气),然后再修真正的病根——把上游的 ctx 传给每一个下游,别让谁还在跑用户早已放弃的请求;把手搓的重试换成内建 retryPolicy 配 retryThrottling;在最慢的依赖上加熔断器,单点坏了就直接短路,而不是连环雪崩。
先把状态码看对
DEADLINE_EXCEEDED 是在客户端侧报出来的——调用没能在客户端设的 deadline 内跑完。而在服务端侧,同一个 deadline 一过,gRPC 会取消正在跑的处理器,报的是 CANCELLED(状态码 1),不是 DEADLINE_EXCEEDED。所以:
- 同一条 trace 上,调用方看到
DEADLINE_EXCEEDED、被调方看到CANCELLED= deadline 传递是正常的,只是下游确实太慢。 - 调用方
DEADLINE_EXCEEDED,但被调方却跑到底返回了OK= deadline 没传下去,就是下面的原因 #2。
deadline 是一个绝对时间点,但 gRPC 不会把墙上时钟时间戳发过去(两台机器时钟对不齐)。它把剩余预算扣掉已耗时间后,作为相对时长写进 grpc-timeout 请求头,服务端收到后再重建出 deadline。可以用 grpcurl -vv 或检查 metadata 确认这个头存在。
常见原因
按踩坑频率排序。
| # | 原因 | 标志信号 |
|---|---|---|
| 1 | 服务真的比客户端 deadline 慢 | 对应窗口里服务 p99 > 客户端 deadline |
| 2 | deadline 没往链路下游传 | 被调方在调用方早超时后还跑到底返回 OK;inflight goroutine 涨 |
| 3 | 没有重试策略 | 小抖动就错误率猛涨、恢复很慢 |
| 4 | 没 throttle、没熔断就盲目重试 | 下游慢的时候 RPS 不降反升(重试风暴) |
| 5 | 单条 HTTP/2 连接 head-of-line 阻塞 | 只有一个 endpoint 慢,所有 endpoint 的 p99 一起恶化 |
1. 服务真的比客户端超时慢
服务压力下 p99 = 1.5 s;客户端 deadline 是 1 s。每个 p99 请求都失败。客户端越多越糟,因为没人退避。
怎么判断:对应时间窗口里服务 p99 > 客户端 deadline。
2. Deadline 没往链路下游传
客户端给服务 A 设了 5 秒 deadline。A 调 B 时却传了个全新的 context.Background()(没 deadline,默认无限)。A 超时了,B 还在干一件没人等的事,白白浪费容量。
怎么判断:B 从来收不到来自上游取消的 CANCELLED;用户放弃后请求还在跑完。inflight goroutine / 线程往上涨。
3. 没有重试策略
一个偶发抖动直接变成永久失败,因为客户端 deadline 一到就放弃了。
怎么判断:抖动期错误率猛涨、恢复很慢。
4. 没 throttling、没熔断就盲目重试
每次失败都重试。慢下游因此扛了 3 倍负载——每条请求重试两次。重试风暴。
怎么判断:下游慢的时候 RPS 不降反升。
5. 单连接 head-of-line 阻塞
HTTP/2 在一条连接上多 stream 共享 flow control。一条慢 stream 拖累整条连接,而默认 gRPC channel 往往每个 subchannel 只有一条 TCP 连接。
怎么判断:只有一个 endpoint 慢,所有 endpoint 的 p99 一起恶化。
最短修复路径
Step 1: 每一跳都传 deadline
Go 服务端处理器:ctx 已经带了上游的 deadline,把同一个 ctx 传给下游。千万别换成 context.Background() 或 context.TODO()。
func (s *server) GetOrder(ctx context.Context, req *pb.GetOrderReq) (*pb.Order, error) {
// ctx 已经带了上游 deadline。千万别换成 context.Background()——
// 那会把下游从取消链路上摘下来。
user, err := s.userClient.GetUser(ctx, &pb.GetUserReq{Id: req.UserId})
if err != nil { return nil, err }
// ...
}
如果确实需要给某个下游留个子预算(比如留 200 ms 做收尾),也要从入站 ctx 派生,别从头来:
dctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
resp, err := s.userClient.GetUser(dctx, req)
Node 客户端:deadline 设成一个绝对 Date 即可。@grpc/grpc-js 会自动帮你换算成相对的 grpc-timeout 头,不用手动扣已耗时间。要避免的错是每一跳都从头算一个完整长度的 deadline,而不是把剩余预算往下带。
import { credentials, Metadata } from '@grpc/grpc-js';
const deadline = new Date(Date.now() + 2000); // 2 秒预算
client.getOrder({ id: '...' }, { deadline }, (err, res) => { /* ... */ });
Step 2: 按 SLO 定每次 RPC 的超时
| 调用类型 | 典型 deadline |
|---|---|
| 同步面向用户读 | 200-500 ms |
| 同步面向用户写 | 1-2 s |
| 后台批量 | 30-60 s |
| 流式 | 整条流不设 deadline,每次迭代单独算预算 |
在 service config 里设默认值,单次调用再 override。官方建议是先基于经验估个值(网络延迟 + 服务处理时间),再用压测验证,而不是随手拍个整数。
Step 3: 用内建重试策略(配 throttling)
gRPC 支持 service config 驱动的重试策略(定义在 gRFC A6 里)。别手搓——它自带指数退避,并自动加 +/- 20% 的 jitter,你不用自己抖动。initialBackoff: 0.1s 时,第一次等待是 [80ms, 120ms] 区间内均匀分布的随机值,正是避免重试洪峰同步撞车想要的那种离散度。
{
"methodConfig": [{
"name": [{ "service": "shop.OrderService" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.05s",
"maxBackoff": "1s",
"backoffMultiplier": 2.0,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
},
"timeout": "2s"
}],
"retryThrottling": {
"maxTokens": 10,
"tokenRatio": 0.1
}
}
这里大家最常踩三个坑:
maxAttempts默认上限是 5。截至 2026 年 6 月,gRPC 重试规范(gRFC A6)定义的客户端最大值就是 5:超过 5 的值会被静默钳到 5(连校验错误都不算),所以写maxAttempts: 10也只当5。这个上限可以通过 channel argument 调高,但几乎从来都不是正确做法。你设的这个数把第一次调用也算进去(4 = 1 次首发 + 3 次重试)。- **
retryThrottling是你内建的防重试风暴机制。**它在每个 channel 上维护一个令牌桶:每次失败的 RPC 花一个令牌,每次成功补回tokenRatio个。桶空了就停止重试,直到有成功把它填回来。这是针对原因 #4 的 channel 级防线,开重试就一并加上。 DEADLINE_EXCEEDED可以配进重试码,但要小心。gRPC 文档确实允许把它放进retryableStatusCodes,但只有在整体 deadline 还有剩余预算时重试才会触发。如果单次尝试的预算和整体预算一样大,重试会立刻死在同一个 deadline 上。只在幂等方法上加它,并且把单次timeout设得比整体调用 deadline 小。
接上(Go):
const cfg = `{ "methodConfig": [...], "retryThrottling": {...} }`
conn, _ := grpc.NewClient("dns:///orders:50051",
grpc.WithDefaultServiceConfig(cfg),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
grpc-go 在 v1.63 弃用了 grpc.Dial,改用 grpc.NewClient(懒连接、不在连接时阻塞);还在用 Dial 的话尽快迁移。迁移时有个坑:grpc.NewClient 默认用 dns 解析器,而 grpc.Dial 当年默认用 passthrough。如果你传的是裸 host:port,现在应当写成 dns:///host:port(或者用 passthrough:///host:port 保留旧行为),否则名字解析和负载均衡的表现会跟 Dial 时代不一样。
Step 4: 在慢路径上加熔断器
重试 throttling 能减慢风暴,熔断器能直接掐断。把下游调用包起来:窗口内连续失败 N 次后开路 M 秒,直接短路返回。用 github.com/sony/gobreaker/v2(v2 模块基于泛型,需要 Go 1.21+;旧的用 interface{} 的不带版本号 import 是 legacy):
import "github.com/sony/gobreaker/v2"
cb := gobreaker.NewCircuitBreaker[*pb.Order](gobreaker.Settings{
Name: "orders",
MaxRequests: 1,
Interval: 30 * time.Second,
Timeout: 10 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
return c.Requests >= 20 && float64(c.TotalFailures)/float64(c.Requests) > 0.5
},
})
res, err := cb.Execute(func() (*pb.Order, error) {
return client.GetOrder(ctx, req)
})
熔断打开时它立刻返回 gobreaker.ErrOpenState;把它映射成给你自己调用方的快速 UNAVAILABLE,别让他们干等到 deadline 走完。这种快速失败正是阻断雪崩的关键。如果想把重试、熔断、超时、hedging 一处编排,failsafe-go 把这些都打包好了。
Step 5: 找到那条慢的 span
把症状收敛成一条可优化的 span。
# 直接对着服务复现这次调用
grpcurl -d '{"id":"abc"}' -plaintext orders:50051 shop.OrderService/GetOrder
在 trace UI(Jaeger、Tempo、Honeycomb)里按 status_code = DEADLINE_EXCEEDED(被调方则按 CANCELLED)过滤,打开最长的那条子 span。要优化的就是它。常见嫌疑:被锁卡住的同步 DB 查询、没自带超时的同步第三方 API 调用、把一个请求扇出成几十个的 N+1 RPC。
Step 6: 把负载摊到多条连接上
conn, _ := grpc.NewClient("dns:///orders.example.com:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
多 subchannel(每个解析出的地址一个)做 round_robin,避开单条 HTTP/2 连接上的 head-of-line 阻塞。但前提是 DNS 名能解析出多个后端(或者改用 xDS / pick_first 之类方案);单个静态 host:port 仍然只有一条连接。
怎么确认修好了
- 按方法(不只是全局错误率)拉
DEADLINE_EXCEEDED比例,确认在当初压垮它的同等负载下回到了基线。 - 重跑当初触发问题的压测;现在服务 p99 应该稳稳落在客户端 deadline 之下。
- 检查被调方对那些被调用方放弃的请求是否记的是
CANCELLED(而不是OK)——这能证明传递确实生效了。 - 人为制造一次下游变慢,现在后端 RPS 应该下降(熔断打开),而不是飙升。
预防
- 每一跳都传 deadline,处理器里禁止
context.Background()。 - 单次 RPC 超时按 SLO 加压测定,别凭感觉。
- 用内建
retryPolicy限制次数(maxAttempts<=5)并配retryThrottling;除非缩小单次 timeout 且方法幂等,别重试DEADLINE_EXCEEDED。 - 每个外部依赖都有熔断器。
- 默认开 tracing,对按方法的
DEADLINE_EXCEEDED比例告警,不只看全局错误率。
常见问题
为什么服务端显示 CANCELLED,客户端却是 DEADLINE_EXCEEDED?
这正是健康、正确的配对。客户端到了 deadline(码 4),gRPC 随即取消了正在跑的服务端处理器(码 1),因为没人在等结果了。说明 deadline 传递是好的。该修的是让下游更快或抬高预算,而不是去追那个 cancellation。
该把 DEADLINE_EXCEEDED 放进 retryableStatusCodes 吗?
只在幂等方法上、且单次 timeout 小于整体调用 deadline 时才放。重试只在整体 deadline 还有剩余时间时才触发,所以两个预算一样大时重试会立刻死在同一个 deadline 上。大多数情况下,重试 UNAVAILABLE 和 RESOURCE_EXHAUSTED,把 DEADLINE_EXCEEDED 排除在外。
我写了 maxAttempts: 10,但好像没重试那么多次。
做不到。截至 2026 年 6 月,gRPC 重试规范设的客户端默认上限就是 5,超过 5 一律钳到 5。这个上限能通过 channel argument 调高,但你一旦想动它,几乎可以肯定该修的是下游,而不是堆更多重试。
重试反而让情况更糟了,为什么?
你多半没加 throttle。没有 retryThrottling,每次失败的调用都在给本已很慢的后端加倍负载——经典的重试风暴。加上令牌桶 retryThrottling,再配一个失败率越过阈值就快速失败的熔断器。
把 deadline 调大也没用,接下来怎么办? deadline 调大只在工作确实只是稍微晚一点完成时才有用。如果 p99 在无上限地往上爬,那你是容量或争用瓶颈:去 trace 最长的那条 span(Step 5),找锁、找没超时的第三方调用、找 N+1 RPC 扇出。给更多时间只会让更多请求堆积。
Related
- Backend Postgres connection pool exhausted
- Backend JWT expired clock skew
- Backend RabbitMQ consumer stuck
- Backend GraphQL rate limit cascade
- Edge function timeout
- Rate limit issue
External references: gRPC Deadlines guide, gRPC Retry guide, gRPC Status Codes, gRFC A6: client retries.