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

集成与部署

把超时设得宽裕,反而把故障放大

在 TT Lab 中继续学习

一句话总结

超时不是为每次调用单独设定的数字,而是把分给一个上层请求的预算向下分配的事,预算不够时,不发起调用、直接失败是最便宜的选择。

为什么需要它

“把超时设得宽松一点”这句话看似稳妥,其实正相反。顺着对方变慢时会发生什么去推演,就能明白。

对方的响应平时是 200ms,某天变成了 30 秒。如果我们的超时是 60 秒,我们的工作进程会在 30 秒内被这次调用占住。如果有 50 个工作进程,每秒的处理量就会急剧下降,而从调用我们的一方来看,是我们变慢了。如果对方的超时也很宽松,同样的事情会在上一层再发生一次。由于这种一个系统的延迟向上蔓延的现象,超时并不是“体谅对方、等一等”,而是保护我们的断路器。

这里常见的第二个错误,是只把超时设成一个值。Python requests 的高级用法文档写道,超时可以用 (connect, read) 两个值来指定。之所以要把二者分开,是因为它们的含义完全不同。

连接失败的形态也不同。Connection refused 是数据包到达目的地又回来了,所以几乎立刻就结束;而在没有任何人应答的情况下,要一直等到上限,然后才会超时。做实验时,可以使用 RFC 5737 预留给文档使用的 192.0.2.0/24 之类的地址——它不会路由到任何地方,数据包会悄悄消失,可以安全地观察连接超时是什么样子。

工作原理

用预算的角度来想,规则可以缩减为三行。

第一,为上层请求确定总预算。如果画面必须在 2 秒内绘制完成,这个请求的预算就是 2000ms。

第二,每个下层调用的读取上限,就是那一刻剩余的预算。第一次调用给 2000ms,它用了 300ms,下一次调用就给 1700ms。这样一来,任何一次调用都不可能超出总预算。

第三,剩余预算小于下限时,不发起调用。只剩 200ms,却去发起平时要花 400ms 的调用,只是把失败推迟了 200ms。如果当场就失败,还能用这 200ms 做出哪怕一个部分响应。

重试会消耗预算。遗漏这项计算的实现非常多。读取上限 2 秒、重试 3 次,最坏情况下是 6 秒,再加上等待时间。等待按指数增长并加入随机数是标准做法(防止在同一时刻失败的客户端在同一时刻再次涌来),但这段等待也要从预算里出。所以在重试之前要问“现在等待再发起一次,能否落在预算之内”,如果不能,就停止。

剩余预算要向下传递。如果我们只剩 1200ms,而下游服务按自己的标准等了 5 秒,那多出来的 4 秒就被用来生成没有人看的答案。所以要把剩余预算放在请求头里发送,让接收的一方把它当作自己的上限。在状态码方面,RFC 9110 定义的 504(Gateway Timeout)表示“上游等不及,断开了”,RFC 6585 的 429(Too Many Requests)表示“请降低速度”。这是两种不同的信号,应对方式也不同。

예산 2000ms
  ├─ 호출 A  남은 2000 → 300ms 사용
  ├─ 호출 B  남은 1700 → 250ms 사용
  ├─ 재시도  남은 1450 → 대기 200 + 호출 400
  └─ 호출 C  남은  850 → 하한 1000 미만이면 걸지 않고 즉시 실패

在现场相遇的样子

第一,设置的超时实际上并没有生效的情况很常见。应用配置是 30 秒,而失败发生在 127 秒,说明那个值没有被应用,而是内核的 SYN 重传耗尽了。修改配置之前,先测量它实际在几秒时断开。

第二,超时与连接池是联动的。如果连接池的等待时间也没有上限,那么即使调用本身的超时很短,在池中等待也会使总时间变长。

第三,没有事先定好超出预算时要做什么。选项通常有三个。给出部分响应,给出缓存中的旧值,给出失败。选哪个都无所谓,但如果不选,代码就会选最糟糕的那个——等到底。

第四,没有留下数字的依据。如果没有写明“读取 3 秒”是怎么来的,就没有人能改。写上一行说明是用对方观测到的 P99 乘以余量得出的,就足够了。

下一项实验要做什么

启动一台会变慢的合作方服务器,亲自测量连接被拒绝、连接超时、读取超时这三种失败形态。制作把连接和读取上限分开设定的调用器,在它之上再叠加遵守总预算的调用器,让剩余预算直接成为下一次调用的上限。预算达不到下限时,不发起调用而直接跳过,并把重试消耗预算的计算也加进去。最后把剩余预算通过请求头向下传递,并用数字写出一个画面的预算表。