修复 gRPC 高负载下的 DEADLINE_EXCEEDED 雪崩

压力一上来 gRPC 客户端就 DEADLINE_EXCEEDED 满天飞。把 deadline 往下游传、按 SLO 定超时、用内建重试策略加 throttling、再给最慢的依赖加熔断器,单点慢不再雪崩。

中午延迟还正常,下午两点服务就起火了:gRPC 客户端到处 DEADLINE_EXCEEDED(状态码 4),上游也在拿到下游回的 DEADLINE_EXCEEDED,长尾延迟把仪表盘撑爆。套路几乎一模一样——下游变慢、deadline 没往下传、单次 RPC 超时设得太紧、一次性全爆。

**最快的止血:**先把客户端 deadline 抬到当前服务 p99 之上(给自己留口气),然后再修真正的病根——把上游的 ctx 传给每一个下游,别让谁还在跑用户早已放弃的请求;把手搓的重试换成内建 retryPolicyretryThrottling;在最慢的依赖上加熔断器,单点坏了就直接短路,而不是连环雪崩。

先把状态码看对

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
2deadline 没往链路下游传被调方在调用方早超时后还跑到底返回 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 仍然只有一条连接。

怎么确认修好了

  1. 按方法(不只是全局错误率)拉 DEADLINE_EXCEEDED 比例,确认在当初压垮它的同等负载下回到了基线。
  2. 重跑当初触发问题的压测;现在服务 p99 应该稳稳落在客户端 deadline 之下。
  3. 检查被调方对那些被调用方放弃的请求是否记的是 CANCELLED(而不是 OK)——这能证明传递确实生效了。
  4. 人为制造一次下游变慢,现在后端 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 上。大多数情况下,重试 UNAVAILABLERESOURCE_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 扇出。给更多时间只会让更多请求堆积。

External references: gRPC Deadlines guide, gRPC Retry guide, gRPC Status Codes, gRFC A6: client retries.

标签: #后端 #排查 #grpc