中午延迟还正常,下午两点服务起火:gRPC 客户端到处 DEADLINE_EXCEEDED,上游也在拿到下游的 DEADLINE_EXCEEDED,长尾延迟把仪表盘撑爆。套路几乎一模一样——下游变慢、deadline 没传下去、单次 RPC 超时设得太紧、一次性全爆。修法:deadline 正确传递;单次超时按用户预期来定;用内建的重试策略加退避;在最慢的路径上加熔断器,单点慢就断了,不再雪崩。
常见原因
按踩坑频率排序。
1. 服务真的比客户端超时慢
服务压力下 p99 = 1.5 s;客户端 deadline 是 1 s。每个 p99 请求都失败。客户端越多越糟,因为没人退避。
怎么判断:在对应窗口里服务 p99 > 客户端 deadline。
2. Deadline 没往链路下游传
客户端给服务 A 设了 5 秒 deadline。A 调 B 没设 deadline——默认无限。A 超时了,B 还在干一件没人等的事,白浪费容量。
怎么判断:B 看不到来自取消的 DEADLINE_EXCEEDED,用户放弃后请求还在跑完。inflight goroutine 涨。
3. 没有重试策略
一个偶发抖动直接变成永久失败,因为客户端 deadline 一到就放弃了。
怎么判断:抖动期错误率猛涨、恢复很慢。
4. 没熔断器就盲目重试
每次失败都重试。慢下游被搞慢 3 倍——一条请求重试两次。重试风暴。
怎么判断:下游慢的时候 RPS 不降反升。
5. 单连接 HTTP/2 head-of-line 阻塞
HTTP/2 在一条连接上多 stream 共享 flow control。一条慢 stream 拖累整条连接。默认 gRPC channel 往往每个 subchannel 就一条 TCP 连接。
怎么判断:一个 endpoint 慢的时候,所有 endpoint 的 p99 一起恶化。
最短修复路径
Step 1: 每一跳都传 deadline
Go 服务端处理器——ctx 已经带了上游 deadline,往下游传别替成 context.Background()。
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 }
// ...
}
Node 客户端——别每跳都从头算 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,每次迭代设 |
channel 上设默认,单次 override。
Step 3: 用 gRPC 内建重试策略
gRPC 支持 service config 驱动的重试策略。别手搓。
{
"methodConfig": [{
"name": [{ "service": "shop.OrderService" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.05s",
"maxBackoff": "1s",
"backoffMultiplier": 2.0,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
},
"timeout": "2s"
}]
}
注意:除非同时延长超时,否则别把 DEADLINE_EXCEEDED 加进 retryableStatusCodes——重试也会死在同一个 deadline 上。
接上(Go):
const cfg = `{ "methodConfig": [...] }`
conn, _ := grpc.Dial("orders:50051",
grpc.WithDefaultServiceConfig(cfg),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Step 4: 在慢路径上加熔断器
把下游调用包起来。在窗口内连续失败 N 次后开路 M 秒,直接短路返回。
import "github.com/sony/gobreaker"
cb := gobreaker.NewCircuitBreaker(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() (interface{}, error) {
return client.GetOrder(ctx, req)
})
熔断打开时返回快速 UNAVAILABLE,不再等 deadline——雪崩到此为止。
Step 5: 找到那条慢的 span
# 本地起 OpenTelemetry collector
grpcurl -d '{"id":"abc"}' -plaintext orders:50051 shop.OrderService/GetOrder
trace UI 里按 status_code = DEADLINE_EXCEEDED 过滤,看最长的子 span。优化那条路径就是了。常见嫌疑:同步 DB 查询被锁住、同步调第三方 API 没自带超时、N+1 RPC。
Step 6: 把负载摊到多条连接上
conn, _ := grpc.Dial("dns:///orders.example.com:50051",
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
多 subchannel round-robin,避开单条 HTTP/2 连接的 head-of-line 阻塞。
预防
- 每一跳都传 deadline,处理器里禁止
context.Background()。 - 单次 RPC 超时按 SLO 定,别凭感觉。
- 用 gRPC 内建重试策略,限制次数;除非延长 budget,别重试
DEADLINE_EXCEEDED。 - 每个外部依赖都有熔断器。
- 默认开 tracing,对
DEADLINE_EXCEEDED比例告警、不只看错误率。