实时通信 — WebSocket、gRPC 流式调用与 WebRTC
连接池一旦耗尽,快请求也得排队
一句话总结
连接池一旦枯竭,服务器明明很空闲,客户端的所有请求却都在排队,而原因通常不是池的大小,而是超时与废弃规则。
为什么需要它
HTTP 客户端、DB 驱动和 gRPC 通道,内部都有连接池。目的是不必为每个请求都付出创建连接的成本——TCP 握手、TLS 握手、认证。连接池通常安静而顺利地运转,却会在某一天突然全面崩溃,而崩溃时的表现,与原因相距很远。服务器 CPU 很空闲,服务器一侧的延迟指标也很正常,却只有客户端的请求要花几秒,或者大量涌出“无法从池中获取连接”的错误。
利特尔法则用一行话就解释了这种现象:同时需要的连接数,等于每秒请求数乘以单个请求的延迟。每秒 200 个请求、每个 50ms 处理完,只需要 10 个连接。如果后端有一个服务变慢,延迟变成 5 秒,同样的负载就需要 1,000 个连接。如果池的上限是 10,其余的就要排队。池的枯竭是结果,原因是延迟。
工作原理
构成连接池的数值有五个,每个防止的事故各不相同。
| 设置 | 防止的事情 | 没有的话 |
|---|---|---|
| 最大大小 | 向服务器无限制地打开连接 | 故障时客户端会猛敲服务器 |
| 获取等待时间 | 无休止地等待空位 | 调用线程全部停在池前 |
| 请求超时 | 慢调用无限期占着位置 | 几个慢调用就会占住整个池 |
| 空闲上限 | 重新使用对方已经关闭的连接 | 安静一段时间后,第一个请求因重置而失败 |
| 最大寿命 | 老连接偏向集中在一台服务器上 | 即使增加服务器,负载也不会转移 |
获取等待时间和请求超时经常被混淆。获取等待时间只能解救等待的一方,却无法让占着位置的一方结束。如果四个要花 5 秒的调用占满了大小为 4 的池,获取等待时间为 1.5 秒的快调用就会全部失败。必须给慢调用设置请求超时,位置才会回来。
更隐蔽的陷阱,是把已超时的连接放回池中。超时只意味着不再等待,并不意味着服务器不发送响应。迟到的响应会通过同一个套接字到达,之后借用那个套接字的请求,就会把别人的响应当作自己的来读取。在像 HTTP/1.1 这样只按顺序配对请求和响应的协议中,这就成了数据串位的事故。规则只有一条:状态不明的连接要丢弃。
空闲上限必须比对方的空闲上限更短。服务器和负载均衡器会先断开安静的连接。Node.js 的 HTTP 服务器的 keepAliveTimeout 默认值是 5 秒,所以空闲上限比它更长的客户端连接池,取出歇了 6 秒的连接来使用时,就会收到连接重置。AWS 的 Application Load Balancer 的空闲上限默认值是 60 秒。
最大寿命与负载均衡有关。连接池总想长时间使用一旦创建的连接,所以即使增加服务器,已有的连接仍停留在原来的服务器上。如果给连接设定寿命,用完就关闭、重新创建,新连接就会去往新的服务器,负载就会逐渐均匀地分散开。为了避免所有连接的寿命在同一时刻结束,还要给寿命掺入一点随机性。
在现场相遇的样子
最常见的场景是:一个外部 API 变慢之后,完全不相干的 API 也跟着变慢了。两者使用同一个 HTTP 客户端、同一个连接池,慢的一方占满了池。对策是为每个目的地划分连接池(舱壁),或者至少为每个目的地单独设置请求超时。
在 HTTP/2 和 gRPC 客户端中,连接池的形态略有不同。因为一个连接承载多个流,所以“借用”的不是连接而是流,上限由对方告知的并发流数量(SETTINGS_MAX_CONCURRENT_STREAMS)决定。一旦触到这个上限,新的调用就会在客户端内部排队,这同样在服务器指标中看不到。那些想靠一个连接硬撑、却被上限卡住的服务,有时通过多打开几个连接来解决。归根结底,同一个问题——什么东西最多能并发多少个,超出之后在哪里等待——要在每一层重新问一遍。
观测也必须在连接池处进行。服务器一侧的指标看不到队伍,所以客户端必须自己导出池中使用中、空闲中、等待中的连接数,以及获取等待时间和获取失败次数。没有这些数字,出故障时就只会反复说“服务器是好的”。尤其是获取等待时间,很容易混在请求延迟之中被一起报告,必须单独拆出来测量,才能分清变慢的到底是服务器,还是池前面的队伍。
下一项实验要做什么
用标准库构建一个带有上限、等待时间、废弃规则、指标和空闲上限的连接池。重现把已超时的套接字放回池中后,下一个请求读到上一个响应的场景,并用同一个测量工具,分别测量四个耗时 5 秒的调用把池耗干的场景,以及只靠一个 0.5 秒超时就恢复过来的场景。