四种调用与十六种失败方式
一句话总结
gRPC 调用有一元、服务端流式、客户端流式、双向四种,每次调用都会以十七个状态码之一结束。截止时间(deadline)没有默认值,可能无限等待;而重试只有写了策略才会真正生效。
为什么需要它
前两个模块处理的是一条消息里的字节。然而 gRPC 的事故,比起消息内部,更常出在调用的边界上——响应永远不来、却没有人去切断的调用;服务器说成功了、客户端却看到失败的调用;把同一笔支付发送两次的重试。核心概念文档把这条边界明确写了下来:“客户端和服务器各自独立地判断成功与否,而且它们的结论可能不同”。服务器可以得出“响应已经全部发出”的结论,而客户端可以得出“在截止时间之后才到达”的结论。接受了这句话,其余的规则就都顺理成章了。
工作原理
四种调用。 服务定义是在 .proto 中用 rpc 来写的。
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order); // 단항
rpc ListOrders (ListRequest) returns (stream Order); // 서버 스트리밍
rpc UploadEvents (stream Event) returns (UploadSummary); // 클라이언트 스트리밍
rpc Chat (stream ChatMessage) returns (stream ChatMessage); // 양방향
}
一元(unary)就像函数调用——一个请求,一个响应。服务端流式是一个请求对应以流的形式接收多个响应,客户端流式则正好相反。双向的两个流相互独立,读写顺序由双方随意决定——服务器可以全部收完再回答,也可以收一个回一个,像乒乓球那样。文档所保证的,是一次调用内部的消息顺序。每个流内部的顺序会得到保证,但两个流之间没有顺序。
状态码。 状态码文档定义了从 0 到 16 的十七个状态码。重要的是,哪些由库产生,哪些只由应用产生,是有区分的。INVALID_ARGUMENT、NOT_FOUND、ALREADY_EXISTS、FAILED_PRECONDITION、ABORTED、OUT_OF_RANGE、DATA_LOSS 绝不会由库产生——看到这些状态码,就一定是服务器代码返回的。
| 状态码 | 含义 | 由谁产生 |
|---|---|---|
| OK 0 | 成功 | |
| CANCELLED 1 | 调用方取消 | 库、应用 |
| INVALID_ARGUMENT 3 | 与系统状态无关的错误参数 | 仅应用 |
| DEADLINE_EXCEEDED 4 | 未能在截止时间内完成——也许已经完成 | 库、应用 |
| NOT_FOUND 5 | 请求的对象不存在 | 仅应用 |
| PERMISSION_DENIED 7 | 没有权限(不用于资源耗尽) | |
| RESOURCE_EXHAUSTED 8 | 配额或容量耗尽 | |
| FAILED_PRECONDITION 9 | 修复状态之前禁止重试 | 仅应用 |
| ABORTED 10 | 在更高层级重试(重新开始事务) | 仅应用 |
| UNIMPLEMENTED 12 | 方法不存在 | |
| INTERNAL 13 | 不变式被破坏——仅用于严重错误 | |
| UNAVAILABLE 14 | 暂时性的,退避后可以重试——对非幂等调用可能不安全 | |
| UNAUTHENTICATED 16 | 缺少认证凭据 |
文档里直接给出了区分这三个状态码的准则。只需重做这一次调用,就用 UNAVAILABLE;需要把读取-修改-写入的顺序从头重做,就用 ABORTED;在人工修复系统状态之前不能重试,就用 FAILED_PRECONDITION。rmdir 在非空目录上失败,就属于最后一种情况。另外还要记住 DEADLINE_EXCEEDED 说明中的一句话——如果是会改变状态的操作,即使操作已经成功完成,也可能返回这个状态码。也许只是响应来得晚了而已。
错误处理文档还用表格给出了由库产生的状态码所对应的情形。服务器处理函数抛出异常时是 UNKNOWN,方法不存在时是 UNIMPLEMENTED,服务器正在下线时是 UNAVAILABLE,无法解析请求 protobuf 时是 INTERNAL。标准错误模型只有状态码和字符串消息,如果需要结构化的详细信息,就要使用把 google.rpc.Status 放进 trailer 的扩展模型——不过文档也写明了代价:代理和日志记录器看不到其中的内容,HTTP/2 头部压缩的效率也会下降。
截止时间。 截止时间文档的第一条规则是“gRPC 没有默认的截止时间”。如果客户端不设定,就可能永远等下去,所以文档要求始终设定一个现实的值。截止时间是时刻(point in time),超时是时长(duration),不同语言的 API 各不相同,但含义是一样的。截止时间一过,客户端就会以 DEADLINE_EXCEEDED 让调用失败,服务器则会自动取消该调用(CANCELLED)。但是,让服务端应用停下正在做的事,是服务器代码的责任——库没有办法中断处理函数,所以耗时较长的任务要定期检查是否已被取消。
传播是关键。我的服务器调用另一台服务器时,必须把原客户端的截止时间传下去。文档写道,Java 和 Go 默认会传播,C++ 需要手动开启。如果把时刻原样传过去,两台服务器的时钟可能不一致,所以 gRPC 会换算成减去已经流逝时间的超时再传递。文档中的例子就是这样——客户端给了 2 秒,用户服务器用掉 0.5 秒之后调用计费服务器,计费服务器收到的是 1.5 秒。
取消。 根据取消文档,客户端可以随时通知它已经不再关心,截止时间到期和 I/O 错误也会引发取消。取消最好能向上游传播,所以 Java、Go、C++ 会自动取消发出去的调用。核心概念文档有一句警告——在取消之前已经改变了的东西,不会被撤销。
重试。 重试文档把很多人误解的部分讲清楚了。重试默认是开启的,但没有默认策略。 没有策略时,gRPC 只做“透明重试”——如果调用还没有离开客户端,次数不限;如果已经到了服务端库,但应用逻辑还没有看到,就只重试一次。只要服务器有可能已经处理过,就不重试。一旦收到响应头,调用就确定下来(committed),不再重试。
策略是按方法写在服务配置里的。
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
退避会带上 ±20% 的抖动,所以初始 0.1 秒时,实际会落在 80 到 120ms 之间。为了不让重试再次把服务器压垮,还有 retryThrottling(maxTokens、tokenRatio),每次失败令牌减 1,每次成功增加 tokenRatio,一旦降到一半以下就停止重试。此外,状态码文档中 UNAVAILABLE 说明里的那一句,就是策略设计的全部——对于非幂等操作,重试可能是不安全的。要把创建支付这样的调用放进 retryableStatusCodes,服务器就必须已经用幂等键防住了重复。
健康检查与元数据。 健康检查文档定义了标准服务 health/v1。一元的 Check 用于集中监控,流式的 Watch 则是客户端连上去接收状态变化。它按服务名称报告 SERVING 和 NOT_SERVING,空字符串表示整个服务器。客户端一旦开启 healthCheckConfig,在 Watch 报告健康之前就不会发送请求,变得不健康后就停止发送。如果 Watch 以 UNIMPLEMENTED 失败,就关闭健康检查。元数据文档介绍了以 HTTP/2 头部形式携带的键值对——键是 ASCII、不区分大小写、不能以 grpc- 开头,二进制值的键以 -bin 结尾。头部在第一条消息之前发送,trailer 则在服务器关闭调用时发送。
在现场相遇的样子
代价最高的事故,是没有截止时间的调用。下游服务卡住之后,上游的线程全都堆积起来等待响应,最后连上游也挂了。原因是不知道 gRPC 没有默认截止时间这句话。只要设定并传播截止时间,即使下游很慢,上游也会在规定时间内以 DEADLINE_EXCEEDED 返回。
第二种是“成功了却失败”。创建订单在服务器上已经提交,但响应超过了截止时间,客户端收到了 DEADLINE_EXCEEDED,而重试策略里恰好包含这个状态码,于是同一个订单被创建了两次。这正是文档在 DEADLINE_EXCEEDED 的说明中写下“也许已经完成”所指的那种情形。没有幂等键,就不能扩大重试列表。
第三种是对健康检查的误解。负载均衡器调用 Check,却把服务名称写错了,总是收到 NOT_SERVING,流量变成了 0。如果知道空字符串表示整个服务器,这只是一行就能解决的配置。
下一项测验要确认什么
考查四种调用的区别、库绝不会产生的状态码、UNAVAILABLE、ABORTED、FAILED_PRECONDITION 的区分、截止时间如何避开时钟误差、没有重试策略时实际会发生什么,以及健康检查中的空字符串和元数据键的规则。