TT Lab
开始
学习 学习路径 课程

实时通信 — WebSocket、gRPC 流式调用与 WebRTC

gRPC 的截止时间、取消、流量控制与 keepalive

在 TT Lab 中继续学习

一句话总结

截止时间要沿着调用链递减地传递,取消要亲自传递,流量控制不能绕开,而 keepalive、重试和终止则需要双方的规则相互吻合。

为什么需要它

实时服务中的调用是一条链。像浏览器 → 网关 → 语音识别 → 语言模型 → 语音合成这样连接起来,链上只要有一处违背了规则,症状就会出现在毫不相干的地方。前面用户已经放弃的请求,后面仍然处理到底;因为一个慢消费者,生产者的内存被填满;安静的流被中间设备切断;重试把结果生成了两次;每次部署流都会被断开。

工作原理

截止时间传播。前面的服务把以 0.6 秒截止时间收到的调用传给后面时,只应给后面剩余的时间。在 Go 和 Java 中,只要把传入的上下文原样传下去,截止时间就会跟着走,但在 Python 中,必须亲自把 context.time_remaining() 作为后面调用的 timeout 传递。对于没有截止时间的调用,这个值会是一个非常大的数,所以不要原样传递,而要不带截止时间地调用。

取消传播。正如 取消指南 所说明的,客户端取消调用后,服务器一侧的上下文会变为不活动。但是,如果那个服务器正阻塞着等待后面的服务,后面的调用并不知道发生了取消。在 Python 中,要把后面的调用以 future 发起,并通过 context.add_callback 让它在自己的调用结束时取消该 future,调用链才会一起停止。

流量控制。根据 流量控制指南,gRPC 通过 HTTP/2 流量控制,让发送方只发送接收方能承受的量。Python 服务器的流式生成器只有在窗口允许时才取出下一个值,所以消费者一停,生产也随之停止。如果另设一个生产线程,预先向无限制的队列中填充,就会绕开这一装置,使消费者的份额堆积在服务器内存中。在实验中,请求 3,000 条 64KiB 的消息,只读取一条就停住时,这一差别体现为几 MB 与几百 MB 之别。

keepalive。keepalive 指南 中的客户端设置有:ping 间隔(grpc.keepalive_time_ms)、等待应答的时间(grpc.keepalive_timeout_ms),以及没有调用时是否也发送 ping(grpc.keepalive_permit_without_calls)。服务器有权拒绝过于频繁的 ping。如果 ping 的频率高于服务器允许的最小间隔(grpc.http2.min_recv_ping_interval_without_data_ms),服务器就会在 GOAWAY 中带上 ENHANCE_YOUR_CALM 错误码和 too_many_pings,并断开连接。因此,间隔必须比中间设备的空闲上限更短,又比服务器允许的间隔更长。这两者由不同的团队确定,所以必须对齐核实。

重试。重试设计文档(gRFC A6) 中的重试,通过服务配置(service config)的 retryPolicy 来声明——最大尝试次数、第一次等待和最大等待、倍数,以及要重试的状态码列表。重要的约束是确定(commit)。一旦收到服务器的响应头或第一条消息,调用就被确定,此后的失败即使是同一个状态码,也不会重试。如果在应用程序中把流从头重新调用,就会把已收到的消息再收一遍。像 INVALID_ARGUMENT 这样,再做一次也会得到相同结果的错误,不要放进列表中。

优雅终止。Kubernetes 在下线 Pod 时会发送 SIGTERM,并在 terminationGracePeriodSeconds(默认 30 秒)之后发送 SIGKILL。如果没有处理器,Python 进程会在收到 SIGTERM 时立即死掉,进行中的流会全部以 UNAVAILABLE 断开。server.stop(grace) 会拒绝新调用,给进行中的调用一段宽限期,然后结束。

在现场相遇的样子

gRPC 连接存活时间很长,所以在 L4 负载均衡器后面,连接会一直停留在最初连上的那台服务器。即使增加服务器,新服务器上也不会有负载。如果服务器给连接设置最大寿命(grpc.max_connection_age_ms),并定期发送 GOAWAY,客户端就会重新连接,负载也随之分散。这与 keepalive 同属一类“连接寿命规则”。

下一项实验要做什么

构建:把截止时间和取消传给后面服务的中继服务器、遵守流量控制的流式服务器、声明了 keepalive 和重试策略的通道,以及收到 SIGTERM 时能优雅下线的服务器。判定依据是基准服务器收到的截止时间、调用次数、已完成的步骤数,以及你的服务器的内存。