超时与取消:deadline 传递与级联取消

IM 的投递链路是「接入层 → 逻辑层 → 路由层 → 推送」四跳,只要有一跳没有超时,一次下游卡顿就会把上游的 goroutine、连接和线程池慢慢吃干,这就是雪崩的起点。gRPC 的答案是 deadline(截止时间)随调用一路传递:客户端设置,服务端用 ctx.Deadline() 感知剩余时间,上游一取消,下游调用自动级联取消。

客户端:用 ctx 设超时,剩余时间由框架送出去

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel() // 必须调用,否则 ctx 与其定时器泄漏(go vet 会报 lostcancel)

resp, err := cli.Push(ctx, &pb.PushReq{Uid: 1, Text: "hi"})
switch status.Code(err) {
case codes.OK:
	log.Println(resp.GetMsgId())
case codes.DeadlineExceeded:
	log.Println("800ms 内没返回;服务端可能仍在跑,取消是尽力而为")
case codes.Unavailable:
	log.Println("连接不可用:默认 fail-fast,不会傻等到 deadline")
}

WithTimeout 表达的是「还允许花多久」,gRPC 把它换算成 grpc-timeout 头(单位 H/M/S/m/u/n,最长 8 位数字)随请求发出;服务端读到的 ctx.Deadline() 就是按这个头算出来的。默认 WaitForReady(false):连接不可用时立刻返回 Unavailable,而不是等满 800ms。

服务端:用 ctx.Deadline() 感知剩余预算

服务端拿到的 ctx 已经带着上游的截止时间,不要用 context.Background() 覆盖它,否则取消链在这里就断了。要做的是读出剩余时间,再决定给下游留多少:

func (s *logicServer) Push(ctx context.Context, req *pb.PushReq) (*pb.PushResp, error) {
	dl, ok := ctx.Deadline()
	if !ok {
		// 上游没传 deadline:服务端兜底,避免无界等待
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, 500*time.Millisecond)
		defer cancel()
		dl, _ = ctx.Deadline()
	}
	// 预算 = 总剩余 - 自己的处理与回包开销
	budget := time.Until(dl) - 50*time.Millisecond
	if budget <= 0 {
		return nil, status.FromContextError(context.DeadlineExceeded)
	}
	callCtx, cancel := context.WithTimeout(ctx, budget) // 父 ctx 是上游 ctx,取消会级联
	defer cancel()

	route, err := s.route.GetRoute(callCtx, &pb.RouteReq{Uid: req.GetUid()})
	if err != nil {
		return nil, err // 已是 status error,原样上抛即可保留 DeadlineExceeded
	}
	if err := s.gateway.Deliver(callCtx, route.GetConnId(), req.GetText()); err != nil {
		return nil, err
	}
	return &pb.PushResp{MsgId: "m-1"}, nil
}

关键是 context.WithTimeout(ctx, budget) 的父 ctx 必须是上游 ctx:父与子谁先到期就听谁的;客户端中途断线,callCtx 立刻被取消,下游 RPC 随之中断。真正耗时的循环里也要定期自检:

select {
case <-ctx.Done():
	return nil, status.FromContextError(ctx.Err()) // 得到 DeadlineExceeded 或 Canceled
default:
}

超时给多少:逐跳递减的预算

环节预算依据
客户端 → 接入层800ms弱网下仍可接受,再久用户就会以为卡死
接入层 → 逻辑层500ms接入层留 300ms 做重连与回包
逻辑层 → 路由层200ms只是查路由表,正常 TP99 应在 20ms 内
逻辑层 → 推送剩余预算减去 50ms留收尾余量,别让自己先被杀

四跳都写 3s 等于没有预算:最下游会把上游的时间全部吃掉。每一跳都必须严格短于上游剩余时间。

取消是怎么传导的

客户端 cancel() 或 deadline 到期时,gRPC 会向 HTTP/2 流发 RST_STREAM,服务端对应的 ctx 立即被取消;服务端如果是被取消的一方,它在自己的下游调用里也会看到同一个信号,这就是级联取消。四种触发源的表现:

触发方客户端看到服务端看到
客户端 deadline 到期DeadlineExceeded(本地产生)ctx.Err() == context.DeadlineExceeded
客户端主动 cancel()Canceledctx.Err() == context.Canceled(收到 RST_STREAM)
服务端返回超时错误收到 DeadlineExceeded 状态码无(是自己返回的)
连接层断开Unavailable传输层关闭该 stream

DeadlineExceeded 与 Canceled 都意味着这次调用的结果不可信:请求可能已经被下游执行过。所以要重试必须先确认幂等(第 10 章)。

常见坑

  • 忘写 defer cancel():WithTimeout 的定时器在到期前不会释放,长连接服务上会持续泄漏内存与 goroutine。
  • ctx 当字段存、或者中间层另造一个:ctx 必须沿调用链显式传参;随手写 context.Background() 会同时丢掉 deadline 与取消信号。
  • 下游超时大于上游剩余时间:下游永远等不到自己的超时,只会被上游掐掉,白占资源;逐跳递减预算才是正确姿势。
  • 超时后复用同一个 ctx 重试:第一次已经把剩余时间吃光,重试会立刻失败;每次重试要有独立的 WithTimeout,多次重试的总预算用 WithDeadline 约束。
  • 直接 return ctx.Err():它是 context 错误不是 status 错误,会被归成 Unknown;用 status.FromContextError(ctx.Err()) 才能得到 DeadlineExceeded/Canceled。
  • 给订阅类长流设短 deadline:deadline 覆盖整条流而不是单条消息,Subscribe 这种常驻流设了 1 分钟就会每分钟被掐一次;这类流靠心跳与重连维持,或干脆不设 deadline。
  • 以为取消等于回滚:服务端收到 ctx.Done() 后要做的是停止后续工作并保证幂等,而不是假设自己什么都没做。

小结:客户端用 context.WithTimeout 设定预算,框架通过 grpc-timeout 把剩余时间传给服务端;服务端用 ctx.Deadline() 读剩余时间、以传入 ctx 为父创建下游 ctx 实现级联取消,用 status.FromContextError 把 context 错误转成正确的状态码;DeadlineExceeded 与 Canceled 都代表结果不可信,重试必须建立在幂等之上。

笔记加载中…