实时通信 — WebSocket、gRPC 流式调用与 WebRTC
运营长连接 WebSocket
一句话总结
长时间保持连接的 WebSocket,必须具备上限、心跳和续传规则,才能承受慢订阅者、空闲超时、沉默的对端和集中重连。
为什么需要它
即使正确地实现了协议,WebSocket 服务也会随着时间的推移而崩溃。因为在连接存活的几个小时里,有四件事一定会发生:有人变慢,中间设备断开安静的连接,手机进入隧道后悄无声息地消失,而部署会一次性断开所有连接。在请求和响应很快结束的 HTTP 中,这四件事几乎看不到。
工作原理
慢订阅者。如果发布循环对每个订阅者依次调用 await send,那么一个人的发送缓冲区装满时,排在他后面的所有人都要等待。如果给每个订阅者单独设置一个队列和清空该队列的循环,等待就会按人分隔开。但如果队列没有上限,慢的那个人的份额就会在服务器内存中无限堆积。触到上限时可以做的事有三件——丢弃旧的、合并成一个最新值(行情、光标位置),或者断开连接。对于顺序和遗漏很重要的流,与其带着缺口继续发送,不如断开并让它重新连接,更为诚实,此时用关闭码 1013(稍后重试)留下原因。浏览器的 WebSocket API 没有背压,只能通过 bufferedAmount 看到积压的量,所以发送方必须亲自查看这个数字。
空闲超时。负载均衡器和代理会在一段固定的时间之后,断开安静的连接。AWS Application Load Balancer 的默认值是 60 秒,nginx 的 proxy_read_timeout 默认值也是 60 秒。Linux 的 TCP keepalive 默认要等两个小时才发出第一个探测包,所以对这个目的没有用处。因此应用程序要以比最短空闲上限更高的频率发送 ping。ping 在保持连接存活的同时,还通过是否收到应答(pong)来确认对方是否还活着。
沉默的对端。如果对方没有 close 就消失了,TCP 在相当长的一段时间内什么都不会知道。发送 ping 之后,如果在规定时间内没有 pong 就断开,是唯一的确认方法。这里有一个常见的缺陷:即使库已经关闭了连接,等待订阅者队列的协程在新消息到来之前也不会醒来。于是列表中仍留着已死的连接,使连接数指标虚高,并且还在接收并堆积消息。必须同时等待队列和连接关闭。
集中重连。一台服务器重启时,它上面的几万个连接会同时断开,按相同规则重试的客户端们会在同一时刻再次涌来。在指数退避中掺入随机性(AWS 架构博客所称的 full jitter 方式:在 0 到 min(上限, 基数 × 2^尝试次数) 之间均匀取值),能把这股浪潮摊开。关闭码也要用于判断。只有像 1001、1006、1011、1012、1013 这样,对方的情况有可能改变的,才重新连接,而因 1008(违反策略)或 1002(协议错误)断开的连接,即使重新连接,也会因同样的原因再次断开。
续传。SSE 在规范中有通过 Last-Event-ID 告知断开位置的规则,而 WebSocket 没有。要给消息加上序号,服务器把最近的消息放在环形缓冲区中,当客户端告知最后处理的序号时,就把它之后的内容重新发送。如果该位置已经被挤出缓冲区,不要悄悄留下缺口,而要告知“请从头重新接收”。客户端要告知的不是收到的序号,而是处理并记录下来的序号。
连接数的上限。一个连接占用一个文件描述符、内核缓冲区和应用程序的队列。Pod 的描述符上限(ulimit -n)和内存决定了并发连接数的天花板,所以在压力测试中,要一边增加连接数,一边测量每个连接占用的内存。
扩展。连接被绑定在一台服务器上,也是 WebSocket 的特性。如果发布者和订阅者连在不同的服务器上,就需要一个让服务器之间交换消息的地方(Redis 的 pub/sub、消息代理),用于续传的环形缓冲区也要放在那个共享存储中,而不是一台服务器的内存里,这样即使重新连接到另一台服务器,也能接续。序号按频道而不是按服务器来编,也是出于这个原因。
在现场相遇的样子
很常见这样的服务:每次部署时,服务器 CPU 会飙升几分钟,连接错误大量涌出。仔细一看,是重连没有抖动,并且重连之后立即重新下载整个状态。靠抖动和基于序号的续传这两点,这几分钟就会消失。在下线服务器时,用 1001 或 1012 关闭,让客户端知道“可以重新连接”,也会有帮助。
下一项实验要做什么
用 websockets 构建发布/订阅中心和重连客户端。在接入一个不读取数据的订阅者的情况下发布 64MB,确认内存和 1013;在空闲上限为 2.5 秒的中继器后面撑过 6 秒;在 6 秒之内清除不发送 pong 的订阅者;并查看即使在发布过程中两次断开连接,是否也能从序号 1 到末尾不遗漏地各记录一次。