十个实例里一个变慢,十分之一的请求都超时了
服务有十个实例,其中一个因为宿主机上的邻居抢资源,处理速度降到了原来的十分之一。它没有宕机,健康检查照常通过。监控上整体的错误率却到了 10%:请求超时。另外九个实例的 CPU 很空。
负载均衡把请求分给实例,默认的分法大多是轮询或随机:每个实例拿到同样多的请求。这个分法有一个隐含的前提,即每个实例的处理能力差不多。一个实例变慢之后,它仍然拿到十分之一的请求,却只处理得了其中的一小部分,剩下的在它那里排队直到超时。九个健康的实例有的是余量,但没有人把请求改送给它们。
换一种按实际负载选择的策略可以解决这个问题,但它在另一种故障下会适得其反。本文用一个固定种子的模拟把两种情况都量出来。实验见文末。
一、模拟的设置
- 10 个实例,每个同时处理 8 个请求,多出来的在实例上排队;正常处理时间 10 毫秒,单实例的上限是每秒 800 个请求。
- 请求以平均每秒 4000 个的速率随机到达,是健康时总容量的一半,共 30 秒。
- 其中一个实例异常,分两种情况:变慢(处理时间变成 100 毫秒,上限降到每秒 80 个);快速失败(1 毫秒就返回错误)。
- 比较的策略:轮询、随机、最少在途请求、两次随机选择、最少在途加失败摘除。
「在途请求」指调用方已经发给某个实例、还没有收到响应的请求数。
二、实例变慢:平均分配让它排起长队
| 策略 | 送到慢实例 | p50 | p99 | 超过 1 秒的请求 |
|---|---|---|---|---|
| 轮询 | 10.0% | 10 ms | 108 s | 9.92% |
| 随机 | 9.9% | 10 ms | 106 s | 9.79% |
| 最少在途 | 1.3% | 10 ms | 100 ms | 0 |
| 两次随机选择 | 1.8% | 10 ms | 100 ms | 0 |
轮询下,慢实例每秒收到 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 次。
把「看起来最闲」和摘除的过程画出来:
四、健康检查看不到这两种故障
两种故障里,实例都能通过常见的健康检查:端口能连上,/health 返回 200。
- 变慢的实例,健康检查的请求量极小,照样很快返回。
- 快速失败的实例,失败的可能只是业务请求走到的某个依赖;健康检查不经过那里。
主动的健康检查回答的是「它活着吗」,判断一个实例能不能好好处理请求,要看真实请求的结果。这两个视角缺一不可:主动检查能发现没有流量的实例已经挂了;被动的结果统计能发现活着但不正常的实例。Kubernetes 里就绪探针的作用与局限,见 Running 不等于可用;从登记的地址到真正可达之间的差距,见 一次请求在集群里经过了什么。
摘除本身也要有限度。如果所有实例因为共同的下游故障一起变慢或报错,把它们全部摘除等于自己制造了一次完全不可用。通常要限制同时摘除的比例,超过之后宁可继续轮询。
五、你用的是哪一种
各类负载均衡的默认策略大多是轮询或随机,按负载选择需要显式配置:
- 反向代理(如 NGINX)默认按权重轮询,另有按最少连接、随机取两个再比较等可选方式。
- Envoy 这类服务代理默认是轮询,可以配置按最少在途请求选择(实例多时用的就是两次随机选择),异常点检测(即失败摘除)是独立的配置。
- Kubernetes 的 Service 由节点上的规则转发,后端的选择是随机或轮询,不感知后端的负载,也不看调用结果。
- 客户端负载均衡的库默认大多也是轮询。
还要注意策略作用的单位。在四层上做的均衡分配的是连接:HTTP/2、gRPC 这类长连接上跑着大量请求,连接建立时选定了实例,之后所有请求都走它,轮询或最少连接都只在建连的那一刻起作用。要按请求均衡,得在七层上做,或者由客户端来做。
六、检查清单
- 确认每一跳的负载均衡策略、作用的单位(连接还是请求),以及它是否感知后端的负载。
- 有长尾延迟要求的服务,用按在途请求选择的策略,并且同时打开失败摘除;只开前者不开后者,快速失败的实例会成为黑洞。
- 摘除要有恢复试探和比例上限。
- 监控按实例拆开看:每个实例的请求量、延迟分位、错误率。整体的平均值会把一个坏实例掩盖成「错误率 10%」。
- 调用方有超时与总预算,超时后取消在途请求;重试时避开刚失败的实例。
- 演练两种故障:让一个实例变慢,让一个实例快速报错。只演练「杀掉一个实例」验证不了这两种情况,而它们比宕机更常见。
选择策略之外,还要确定请求在哪一跳分流、响应从哪里返回,并单独核对代理对 POST 的重试行为。
配套实验
- codesphere-labs/distributed/load-balancing-slow-instance:固定种子的离散事件模拟(JDK 25.0.4.1):10 个实例中 1 个变慢或快速失败时,轮询、随机、最少在途、两次随机选择与带摘除的最少在途各自送给异常实例的请求比例、延迟分位、超时与错误比例(验证记录)
参考资料