摘要
- 链路超时控制
- 监听超时
- 超时时间传递
- 网络传输时间预估
- 时钟同步
RPC 超时控制
在微服务里面,超时控制分为两种:
- 单一服务超时控制
- 链路超时控制
例如 A -> B -> C 这种调用关系,假如说 A -> B 设置的超时时间是 1S,那么:
- 如果是链路超时控制,那么 B -> C 这个调用,超时时间是不允许超过 1s 的。
- 如果是单一服务超时控制,那么 B -> C 这个调用可以设置超过 1s 的超时控制。
绝大多数微服务框架的超时控制,都只支持单一服务超时控制。
- 在起始位置,会有代码设置好整个链路的超时时间,可能是 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 链路超时控制总结
- RPC 客户端需要监听 cx,并且要把剩余超时时间传递给 RPC 服务端。
- RPC 服务端收到请求之后,要检测元数据里面有没有携带剩余超时时间,然后重建 context.Context。
- 如果用户的业务里面用到了其它中间件,那么用户可能需要自己手动管理超时元数据,继续传递给其它中间件。
- 任何一个环节的超时时间,都不可能超过链路超时时间。
RPC 链路超时控制:监听超时
理论上来说,超时监听可以:
- 只在客户端监听
- 客户端和服务端同时监听
但不能只在服务端监听。
下图中,当服务端发现超时,而后返回超时响应的时候,如果网络故障,客户端收不到超时响应,那么就会一直在那里等。
我们暂时只在客户端上监听。只需要使用 select - case + channel 就可以达成目标。
理论上,我们可以在多个地方检测超时:
- 1 代表的是从连接池拿连接之前就先做一个检测。
- 2 代表的是拿到连接之后,在发送请求之前做检测,如果超时就不需要再发送请求了。
- 3 代表的是等待响应的过程中就超时,那么就不需要读取响应了。
- 4 代表的是读取响应之后超时了,那么就没必要解析结果了。
当然实际中,并不会做得那么复杂,而是只在 1 的时候检测一下,然后就发起调用,同时监听超时。
这种实现很简单也很清晰,不过它也有缺点:
- 超时之后并没有中断正在执行的调用,只是我们 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?
我们可以通过测试来判断在平均大小请求的条件下,两个节点之间的传输时间。测试要完全模拟线上环境,包括各种防火墙、网关。
网络传输时间极短,相比超时时间设置,基本可以忽略不计,比如说 10ms 相比 2000ms 实在是微不足道。
时间戳
传递的是过期的时间戳,例如在 1640970000000 (2022- 01-01 01:00:00.000)过期。
缺点:
- 时钟同步问题:即某一个特定的时刻,时钟读数在两台机器上是不同的。
- 时间戳一般传递 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 协议里面传递超时时间。注意,如果你是一个中间件设计者,还要考虑用户可能希望重置整个链路的超时时间,那么你要设计类似的接口。
![知识共享许可协议](https://image-1252109614.cos.ap-beijing.myqcloud.com/img/20210508215939.png)
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。