在分布式计算中,截止时间(deadline)和超时时间(timeout)是两个常用的模式。超时时间可以指定客户端应用程序等待 RPC 完成的时间(之后会以错误结束),它通常会以持续时长的方式来指定,并且在每个客户端本地进行应用。例如,一个请求可能会由多个下游 RPC 组成,它们会将多个服务链接在一起。因此,可以在每个服务调用上,针对每个 RPC 都指定超时时间。这意味着超时时间不能直接应用于请求的整个生命周期,这时需要使用截止时间。
截止时间以请求开始的绝对时间来表示(即使 API 将它们表示为持续时间偏移),并且应用于多个服务调用。发起请求的应用程序设置截止时间,整个请求链需要在截止时间之前进行响应。gRPC API 支持为 RPC使用截止时间,出于多种原因,在 gRPC 应用程序中使用截止时间始终是一种最佳实践。由于 gRPC 通信是在网络上发生的,因此在 RPC 和响应之间会有延迟。另外,在一些特定的场景中,gRPC 服务本身可能要花费更多的时间来响应,这取决于服务的业务逻辑。如果客户端应用程序在开发时没有指定截止时间,那么它们会无限期地等待自己所发起的 RPC 请求的响应,而资源都会被正在处理的请求所占用。这会让服务和客户端都面临资源耗尽的风险,增加服务的延迟,甚至可能导致整个 gRPC 服务崩溃。
在图 5-3 中,gRPC 客户端应用程序调用商品管理服务,而商品管理服务又调用库存服务。
客户端应用程序的截止时间设置为 50 毫秒(截止时间 = 当前时间 + 偏移量)。客户端和 ProductMgt 服务之间的网络延迟为 0 毫秒,ProductMgt 服务的处理延迟为 20 毫秒。商品管理服务(ProductMgt 服务)必须将截止时间的偏移量设置为 30 毫秒。因为库存服务(Inventory 服务)需要 30 毫秒来响应,所以截止时间的事件会在两个客户端上发生(ProductMgt 调用 Inventory 服务和客户端应用程序)。
ProductMgt 服务的业务逻辑将延迟时间增加了 20 毫秒。随后,ProductMgt 服务的调用逻辑触发了超出截止时间的场景,并且传播回客户端应用程序。因此,在使用截止时间时,要明确它们适用于所有服务场景。
客户端应用程序在初始化与 gRPC 的连接时,可以设置截止时间。当RPC 发送之后,客户端应用程序会在截止时间所声明的时间范围内等待,如果在该时间内 RPC 没有返回,那么该 RPC 会以DEADLINE_EXCEEDED 错误的形式终止。
接下来看在调用 gRPC 服务时使用截止时间的示例。在相同的OrderManagement 服务使用场景中,假设 AddOrder RPC 要耗费较长的时间才能完成(通过在 OrderManagement gRPC 服务的 AddOrder方法中引入延迟来模拟)。但是,客户端只会等待一定的时间,如果超过该时间,响应对它就没有用处了。假设 AddOrder 响应所占用的持续时间是 5 秒,但是客户端只等待 2 秒来获取响应。为了实现这一点(见代码清单 5-5 所示的 Go 代码片段),客户端应用程序可以通过context.WithDeadline 操作设置 2 秒的超时时间。这里使用了 status包来确定错误码,5.4 节会对其进行详细讨论。
代码清单 5-5 客户端应用程序的 gRPC 截止时间
conn, err := grpc.Dial(address, grpc.WithInsecure())
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
client := pb.NewOrderManagementClient(conn)
clientDeadline := time.Now().Add(time.Duration(2 * time.Second))
ctx, cancel := context.WithDeadline(context.Background(), clientDeadline) ➊
defer cancel()
// 添加订单
order1 := pb.Order{
Id: "101",
Items:[]string{"iPhone XS", "Mac Book Pro"},
Destination:"San Jose, CA",
Price:2300.00,
}
res, addErr := client.AddOrder(ctx, &order1) ➋
if addErr != nil {
got := status.Code(addErr) ➌
log.Printf("Error Occured -> addOrder : , %v:", got) ➍
} else {
log.Print("AddOrder Response -> ", res.Value)
}
- ❶ 在当前上下文中设置 2 秒的截止时间。
- ❷ 调用 AddOrder 远程方法并将可能出现的错误捕获到 addErr 中。
- ❸ 使用 status 包以确定错误码。
- ❹ 如果调用超出了指定的截止时间,它应该返回 DEADLINE_EXCEEDED类型的错误。
该如何确定理想的截止时间值呢?这个问题并没有固定答案,但是在做出决策之前,需要考虑几个因素,主要包括所调用的每个服务的端到端延迟、支持串行模式的 RPC、支持并行模式的 RPC、底层网络的延迟以及下游服务的截止时间。在确定了最初的截止时间值之后,再根据gRPC 应用程序的运行情况进行微调。
在 Go 语言中,设置 gRPC 截止时间是通过其中的 context 包实现的,其中 WithDeadline 是一个内置函数。Go 语言中的context 包通常用来向下传递通用的数据,使其能够在整个下游操作中使用。当 gRPC 客户端应用程序发起调用时,客户端的 gRPC 库就会创建所需的 gRPC 头信息,用来表述客户端应用程序和服务器端应用程序之间的截止时间。在 Java 语言中,这略微有所差异,其实现直接来源于 io.grpc.stub.* 包的存根实现。可以使用
blockingStub.withDeadlineAfter(long,java.util.concurrent.TimeUnit
) 设置 gRPC 的截止时间。也可以参考 Java 实现的源代码仓库了解更多信息
在 gRPC 的截止时间方面,客户端和服务器端都可以对 RPC 是否成功做出自己的判断,这意味着它们的结论可能会不一致。例如,在前面的示例中,当客户端满足 DEADLINE_EXCEEDED
条件的时候,服务器端可能依然会试图做出响应。因此,服务器端应用程序需要判断当前 RPC是否依然有效。在服务器端,还可以探测客户端何时达到调用 RPC 时所指定的截止时间。在 AddOrder
操作中,可以通过 ctx.Err() ==context.DeadlineExceeded
来判断客户端是否已经满足超出截止时间的状态,随后就可以在服务器端废弃该 RPC 并返回一个错误。在 Go语言中,这通常会通过非阻塞的 select
构造来实现。
与截止时间类似,在某些特定的情况下,客户端应用程序或服务器端应用程序可能要终止正在进行中的 gRPC 通信,这就要用到 gRPC 的取消功能了。