Skip to content

十个实例里一个变慢,十分之一的请求都超时了 ​

服务有十个实例,其中一个因为宿主机上的邻居抢资源,处理速度降到了原来的十分之一。它没有宕机,健康检查照常通过。监控上整体的错误率却到了 10%:请求超时。另外九个实例的 CPU 很空。

负载均衡把请求分给实例,默认的分法大多是轮询或随机:每个实例拿到同样多的请求。这个分法有一个隐含的前提,即每个实例的处理能力差不多。一个实例变慢之后,它仍然拿到十分之一的请求,却只处理得了其中的一小部分,剩下的在它那里排队直到超时。九个健康的实例有的是余量,但没有人把请求改送给它们。

换一种按实际负载选择的策略可以解决这个问题,但它在另一种故障下会适得其反。本文用一个固定种子的模拟把两种情况都量出来。实验见文末。

一、模拟的设置 ​

  • 10 个实例,每个同时处理 8 个请求,多出来的在实例上排队;正常处理时间 10 毫秒,单实例的上限是每秒 800 个请求。
  • 请求以平均每秒 4000 个的速率随机到达,是健康时总容量的一半,共 30 秒。
  • 其中一个实例异常,分两种情况:变慢(处理时间变成 100 毫秒,上限降到每秒 80 个);快速失败(1 毫秒就返回错误)。
  • 比较的策略:轮询、随机、最少在途请求、两次随机选择、最少在途加失败摘除。

「在途请求」指调用方已经发给某个实例、还没有收到响应的请求数。

实例变慢(处理时间 10 倍):送到它那里的请求轮询10.0%,9.9% 的请求超过 1 秒随机9.9%,9.8% 的请求超过 1 秒最少在途1.3%,没有请求超过 1 秒两次随机选择1.8%,没有请求超过 1 秒实例快速返回错误(1 毫秒):送到它那里的请求轮询10.0%随机9.9%最少在途42.7%两次随机选择18.3%最少在途 + 失败摘除0.03%在途请求数反映的是「多久能回来」,不反映「回来的是不是正确结果」
图 1 · 实测(固定种子的模拟):10 个实例里有 1 个异常。它变慢时,轮询照旧把十分之一的请求送过去,这些请求在它那里排起长队,约 10% 的请求超过 1 秒;按在途请求数选择的策略自动避开了它。它快速返回错误时情况反过来:出错的请求结束得最快,这个实例的在途数始终最低,「最少在途」把 43% 的请求送给了它。加上连续失败摘除之后,错误降到 0.03%。

二、实例变慢:平均分配让它排起长队 ​

策略送到慢实例p50p99超过 1 秒的请求
轮询10.0%10 ms108 s9.92%
随机9.9%10 ms106 s9.79%
最少在途1.3%10 ms100 ms0
两次随机选择1.8%10 ms100 ms0

轮询下,慢实例每秒收到 400 个请求,只处理得了 80 个,队列每秒增长 320 个。送到它那里的请求几乎全部超时,占总量的十分之一。p50 没有任何变化(另外九成请求一切正常),所以只看平均值或中位数的监控发现不了;p99 和超时率才显示出问题。

模拟里调用方超时后没有取消请求,慢实例的队列一直在长,所以 p99 是一百多秒这样的数字。真实系统里超时的请求会被取消或重试,队列不会无限增长,但重试又把更多的请求送进同一个轮询,其中十分之一再次落到慢实例上。重试的放大效应见 每层都重试三次,最底下的服务收到二十七倍请求。

最少在途请求的策略每次选当前在途数最少的实例。慢实例上的请求迟迟不返回,在途数很快高于其他实例,于是不再被选中,直到它消化掉一些。结果是它只收到 1.3% 的请求,与它实际的处理能力(80 对 7200,约 1.1%)相当。没有任何显式的「发现它慢了」的逻辑,分配自动跟上了能力的变化。

两次随机选择是它的简化版:随机挑两个实例,选在途数较少的那个。它不需要比较所有实例,效果接近(1.8%)。在多个调用方各自独立做选择、每一方只看得到自己发出的请求时,这种带随机性的做法还能避免所有调用方同时涌向同一个「看起来最闲」的实例。

p99 是 100 毫秒,因为仍有 1%—2% 的请求由慢实例处理,它们每个要 100 毫秒。按负载选择让慢实例不再积压,但不会让它变快。

三、实例快速失败:最闲的那个其实是坏的 ​

换一种故障:实例没有变慢,而是立刻返回错误(进程启动不完整、依赖的配置缺失、下游连不上直接抛异常)。

策略送到故障实例返回错误的请求
轮询10.0%10.00%
随机9.9%9.86%
最少在途42.7%42.72%
两次随机选择18.3%18.32%
最少在途加连续失败摘除0.0%0.03%

故障实例 1 毫秒就把请求打发了,它的在途数永远是最低的。按在途数选择的策略因此认为它最闲,把越来越多的请求送过去:最少在途把 43% 的请求送进了这个黑洞,错误率是轮询的四倍多。

在途请求数(以及响应时间)衡量的是「多久能回来」,不衡量「回来的是不是对的」。只要失败比成功快,任何偏向「快」的策略都会偏向失败的实例。按最低响应时间选择的策略有同样的问题,而且更严重。

所以按负载选择必须配合失败摘除:调用方统计每个实例的调用结果,连续失败达到阈值就把它暂时移出候选,过一段时间再放少量请求试探。模拟里用「连续 5 次失败摘除 5 秒」,错误率从 42.72% 降到 0.03%,30 秒内摘除了 6 次。

把「看起来最闲」和摘除的过程画出来:

实例 110 ms 处理在途 3实例 210 ms 处理在途 4实例 310 ms 处理在途 3故障实例1 ms 返回错误在途 0最少在途选择选在途数最低的总是选中它按调用结果摘除连续失败 5 次被动统计真实请求摘除 5 秒移出候选放少量请求试探成功则恢复错误 0.03%原来 42.72%同时摘除的比例要有上限:共同的下游故障让所有实例一起报错时,全部摘除等于自己制造不可用
图 2 · 实测(固定种子的模拟):故障实例 1 毫秒就返回错误,在途数几乎总是 0,「最少在途」把 42.7% 的请求送了过去。在途数和响应时间只衡量多久能回来,不衡量回来的是否正确。按调用结果连续失败 5 次摘除 5 秒、到期再放少量请求试探后,错误率降到 0.03%,30 秒内摘除 6 次

四、健康检查看不到这两种故障 ​

两种故障里,实例都能通过常见的健康检查:端口能连上,/health 返回 200。

  • 变慢的实例,健康检查的请求量极小,照样很快返回。
  • 快速失败的实例,失败的可能只是业务请求走到的某个依赖;健康检查不经过那里。

主动的健康检查回答的是「它活着吗」,判断一个实例能不能好好处理请求,要看真实请求的结果。这两个视角缺一不可:主动检查能发现没有流量的实例已经挂了;被动的结果统计能发现活着但不正常的实例。Kubernetes 里就绪探针的作用与局限,见 Running 不等于可用;从登记的地址到真正可达之间的差距,见 一次请求在集群里经过了什么。

摘除本身也要有限度。如果所有实例因为共同的下游故障一起变慢或报错,把它们全部摘除等于自己制造了一次完全不可用。通常要限制同时摘除的比例,超过之后宁可继续轮询。

五、你用的是哪一种 ​

各类负载均衡的默认策略大多是轮询或随机,按负载选择需要显式配置:

  • 反向代理(如 NGINX)默认按权重轮询,另有按最少连接、随机取两个再比较等可选方式。
  • Envoy 这类服务代理默认是轮询,可以配置按最少在途请求选择(实例多时用的就是两次随机选择),异常点检测(即失败摘除)是独立的配置。
  • Kubernetes 的 Service 由节点上的规则转发,后端的选择是随机或轮询,不感知后端的负载,也不看调用结果。
  • 客户端负载均衡的库默认大多也是轮询。

还要注意策略作用的单位。在四层上做的均衡分配的是连接:HTTP/2、gRPC 这类长连接上跑着大量请求,连接建立时选定了实例,之后所有请求都走它,轮询或最少连接都只在建连的那一刻起作用。要按请求均衡,得在七层上做,或者由客户端来做。

六、检查清单 ​

  1. 确认每一跳的负载均衡策略、作用的单位(连接还是请求),以及它是否感知后端的负载。
  2. 有长尾延迟要求的服务,用按在途请求选择的策略,并且同时打开失败摘除;只开前者不开后者,快速失败的实例会成为黑洞。
  3. 摘除要有恢复试探和比例上限。
  4. 监控按实例拆开看:每个实例的请求量、延迟分位、错误率。整体的平均值会把一个坏实例掩盖成「错误率 10%」。
  5. 调用方有超时与总预算,超时后取消在途请求;重试时避开刚失败的实例。
  6. 演练两种故障:让一个实例变慢,让一个实例快速报错。只演练「杀掉一个实例」验证不了这两种情况,而它们比宕机更常见。

选择策略之外,还要确定请求在哪一跳分流、响应从哪里返回,并单独核对代理对 POST 的重试行为。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权