Go猜想录
大道至简,悟者天成
手搓 RPC 框架 -- #6 超时控制

摘要

- 链路超时控制
    - 监听超时
    - 超时时间传递
        - 网络传输时间预估
        - 时钟同步

RPC 超时控制

在微服务里面,超时控制分为两种:

  • 单一服务超时控制
  • 链路超时控制

例如 A -> B -> C 这种调用关系,假如说 A -> B 设置的超时时间是 1S,那么:

  • 如果是链路超时控制,那么 B -> C 这个调用,超时时间是不允许超过 1s 的。
  • 如果是单一服务超时控制,那么 B -> C 这个调用可以设置超过 1s 的超时控制。

绝大多数微服务框架的超时控制,都只支持单一服务超时控制。

rpc-19.png

  • 在起始位置,会有代码设置好整个链路的超时时间,可能是 Web,可能是 BFF。
  • ctx 会传递到 RPC 客户端,而后 RPC 会监听 ctx 超时。
  • RPC 客户端会把剩余的超时时间通过 meta 传递给 RPC 服务端,下图假设还剩下 2.5s。
  • RPC 服务端收到请求,用剩余超时时间来重建整个 ctx,下图假设还剩下 2s。
  • RPC 服务端把 ctx 传递给业务代码,如果这时候业务代码脱离了 RPC 框架,那么用户需要手动管理链路超时时间,例如下图是手动设置了超时时间到 HTTP HEADER(timeout=2s)。
  • 业务再一次发起 RPC 调用,ctx 会再被传到 RPC 客户端,RPC 客户端也会监听 ctx 超时。
  • RPC 客户端继续把剩余超时时间传递给 RPC 服务端,下图假设还剩下 1.5s。

rpc-20.png

RPC 链路超时控制总结

  • RPC 客户端需要监听 cx,并且要把剩余超时时间传递给 RPC 服务端。
  • RPC 服务端收到请求之后,要检测元数据里面有没有携带剩余超时时间,然后重建 context.Context。
  • 如果用户的业务里面用到了其它中间件,那么用户可能需要自己手动管理超时元数据,继续传递给其它中间件。
  • 任何一个环节的超时时间,都不可能超过链路超时时间。

RPC 链路超时控制:监听超时

理论上来说,超时监听可以:

  • 只在客户端监听
  • 客户端和服务端同时监听

但不能只在服务端监听。

下图中,当服务端发现超时,而后返回超时响应的时候,如果网络故障,客户端收不到超时响应,那么就会一直在那里等。

rpc-21.png

我们暂时只在客户端上监听。只需要使用 select - case + channel 就可以达成目标。

理论上,我们可以在多个地方检测超时:

  • 1 代表的是从连接池拿连接之前就先做一个检测。
  • 2 代表的是拿到连接之后,在发送请求之前做检测,如果超时就不需要再发送请求了。
  • 3 代表的是等待响应的过程中就超时,那么就不需要读取响应了。
  • 4 代表的是读取响应之后超时了,那么就没必要解析结果了。

当然实际中,并不会做得那么复杂,而是只在 1 的时候检测一下,然后就发起调用,同时监听超时。

rpc-22.png

这种实现很简单也很清晰,不过它也有缺点:

  • 超时之后并没有中断正在执行的调用,只是我们 RPC 客户端丢掉了这后面的响应而已,
  • 每次都要新创建一个 channel,性能损耗比较大。
func (c *Client) Invoke(ctx context.Context, req *message.Request) (*message.Response, error) {
	if ctx.Err() != nil {
		return nil, ctx.Err()
	}

	ch := make(chan struct{})
	defer func() {
		close(ch)
	}()
	var (
		resp *message.Response
		err  error
	)
	go func() {
		resp, err = c.doInvoke(ctx, req)
		ch <- struct{}{}
	}()

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case <-ch:
		return resp, err
	}
}

RPC 跨端传递超时时间

现在完成了本地的超时控制,我们需要将链路超时时间传递给服务端。

类似于前面传递的单向调用,也是在元数据部分,带一个 key - value 过去。

那么问题来了,超时时间传什么内容呢?

  • 传递剩余超时时间,类似于 1s、3s 这种。
  • 传递超时时间戳,类似于某时某刻过期。

剩余超时时间

剩余超时时间,就是传递类似于 1s、2s、1500ms 这种数值。

一般传递一个单位为毫秒的数值。

缺点:难以估计网络传输时间。

例如在 RPC 客户端计算剩余超时时间的时候,还有 2000ms,发送到 RPC 服务端的时候,RPC 服务端需要考虑扣减网络传输时间,比如说 10ms,那么 RPC 服务端实际剩下的超时时间就只有 1990ms 了。

难就难在,怎么知道花了 10ms?

我们可以通过测试来判断在平均大小请求的条件下,两个节点之间的传输时间。测试要完全模拟线上环境,包括各种防火墙、网关。

rpc-23.png

rpc-24.png

网络传输时间极短,相比超时时间设置,基本可以忽略不计,比如说 10ms 相比 2000ms 实在是微不足道。

rpc-25.png

时间戳

传递的是过期的时间戳,例如在 1640970000000 (2022- 01-01 01:00:00.000)过期。

rpc-26.png

缺点:

  • 时钟同步问题:即某一个特定的时刻,时钟读数在两台机器上是不同的。
  • 时间戳一般传递 Unix 时间戳,至少需要 64 位。在我们的设计里面传递的是字符串,需要更多的字节。

这里我们采用时间戳的方案。两个方案之间,我觉得没有特别大的优劣之分,看喜好选择就可以。

客户端设置 meta

fn := func(args []reflect.Value) (results []reflect.Value) {
	ctx := args[0].Interface().(context.Context)
	...
	meta := make(map[string]string, 2)
	// 设置了超时
	if deadline, ok := ctx.Deadline(); ok {
		meta["deadline"] = strconv.FormatInt(deadline.UnixMilli(), 10)
	}
	...
}

服务端重建 context

// 还原调用信息
req := message.DecodeReq(reqBs)

ctx := context.Background()
cancel := func() {}
if deadlineStr, ok := req.Meta["deadline"]; ok {
	log.Println(deadlineStr)
	if deadline, er := strconv.ParseInt(deadlineStr, 10, 64); er == nil {
		log.Println(deadline)
		ctx, cancel = context.WithDeadline(ctx, time.UnixMilli(deadline))
	}
}
oneway, ok := req.Meta["one-way"]
if ok && oneway == "true" {
	ctx = CtxWithOneway(ctx)
}
resp, err := s.Invoke(ctx, req)
cancel()

总结

  • RPC 怎么控制超时?客户端控制、服务端控制的特点。
  • 为什么要使用超时控制?及时释放资源。
  • 超时时间没有设置好,会有什么问题?过短:大部分请求超时;过长:浪费资源,甚至引起 goroutine 泄露。
  • 超时时间在链路中传递,传递的是什么?剩余超时时间或者超时时间戳。进一步可以讨论超时时间戳与时钟同步的问题。
  • 超时之后可以中断业务执行吗?不可以,我们已经讨论过这个问题好几次了。
  • 链路超时怎么实现?核心就是在链路中传递超时时间,这个主要依赖于在 RPC 协议里面传递超时时间。注意,如果你是一个中间件设计者,还要考虑用户可能希望重置整个链路的超时时间,那么你要设计类似的接口。

知识共享许可协议

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。