gRPC 高负载下 DEADLINE_EXCEEDED 雪崩

gRPC 客户端在压力上来时 DEADLINE_EXCEEDED 满天飞。把 deadline 往下游传、调合理超时、加重试策略和熔断器。

中午延迟还正常,下午两点服务起火: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 比例告警、不只看错误率。

标签: #后端 #排查 #grpc