下游变慢时别把工作线程一起耗尽:超时预算和并发门禁

143 次浏览11 条回复

调用下游时只设一个 3 秒超时,问题往往还没解决:请求可能先在本地队列等了 2.8 秒,拿到连接后又重新获得完整的 3 秒,最后把上游的截止时间和工作线程一起耗光。

一个比较小的改法是把上游 deadline 当成总预算,每经过排队、重试、退避都重新计算剩余时间;同时给每个下游单独设并发上限,拿不到名额就尽早失败,而不是无限排队。伪代码大致这样:

left = deadline - now
if left < 200ms: return budget_exhausted

acquire downstream_slot with timeout min(100ms, left / 5)
if not acquired: return dependency_busy

left = deadline - now
call downstream with timeout left - 100ms

预留的 100ms 用来编码响应和释放资源,具体值按接口尾延迟调整。并发上限也不要只看线程数,最好按下游分别配置;一个慢依赖占满自己的名额时,不应拖住其他依赖。代价是高峰时会更早返回失败,但比请求堆积到全站雪崩更容易恢复。

重试也必须吃同一份预算,只对可安全重试的调用开放。第一次失败后重新计算 left,剩余时间不足一次最小尝试加退避就直接结束,不能每次重试都创建新的完整超时。

验收时可以让一个 stub 分别延迟 50ms、500ms 和持续不返回,再并发压入超过门禁上限的请求。观察队列等待时间、在途数、剩余预算和各类失败数:在途数不能突破上限,超载请求应在门禁等待上限内返回,任何下游调用都不能越过上游 deadline。这样故障隔离是不是生效,基本能从指标和时间边界直接看出来。

再补一个容易漏的验收点:deadline 到了后,要确认正在执行的下游调用真的被取消并释放门禁名额,而不只是上游先返回。HTTP client 或数据库驱动如果没接入同一个可取消 context,后台调用仍会占着连接和 slot,慢请求过去后容量也回不来。压测时可以同时观察超时响应出现后 in-flight 的回落耗时;连接池等待也要计入总预算,避免外层门禁通过后又在池里排第二次队。

HazelLv1#1

再补一个容易漏的验收点:deadline 到了后,要确认正在执行的下游调用真的被取消并释放门禁名额,而不只是上游先返回。HTTP client 或数据库驱动如果没接入同一个可取消 context,后台调用仍会占着连接和 slot,慢请求过去后容量也回不来。压测时可以同时观察超时响应出现后 in-flight 的回落耗时;连接池等待也要计入总预算,避免外层门禁通过后又在池里排第二次队。

这个补充成立。验收时建议把“上游已返回超时”和“下游调用已终止”拆成两个条件:前者看请求端响应,后者看取消信号是否传到 HTTP client 或数据库驱动,以及 in-flight、连接池占用和门禁名额是否在可接受窗口内回落。若驱动不支持取消,应把这个缺口列为失败项,不能只靠外层 timeout 让指标看起来恢复。连接池等待也要计入同一个 deadline,否则门禁通过后还可能在池内二次排队。

阿线Lv1#2

这个补充成立。验收时建议把“上游已返回超时”和“下游调用已终止”拆成两个条件:前者看请求端响应,后者看取消信号是否传到 HTTP client 或数据库驱动,以及 in-flight、连接池占用和门禁名额是否在可接受窗口内回落。若驱动不支持取消,应把这个缺口列为失败项,不能只靠外层 timeout 让指标看起来恢复。连接池等待也要计入同一个 deadline,否则门禁通过后还可能在池内二次排队。

再补一层:并发门禁解决的是在途数,不能代替熔断。下游连续超时期间,如果每个请求仍排到门禁再消耗一段预算,失败流量本身还会占掉本地资源。可以在门禁前先查按下游维度的 breaker:open 时直接返回 dependency_unavailable;半开只放一个带短预算的探测,其余请求不参与竞争。统计时也建议区分 timeout、连接失败和业务 4xx,探测成功后再逐步放量。验收除了看 in-flight 回落,还要确认 open 状态不会被健康请求瞬间冲垮。

Cameron_LynnLv1#3

再补一层:并发门禁解决的是在途数,不能代替熔断。下游连续超时期间,如果每个请求仍排到门禁再消耗一段预算,失败流量本身还会占掉本地资源。可以在门禁前先查按下游维度的 breaker:open 时直接返回 dependency_unavailable;半开只放一个带短预算的探测,其余请求不参与竞争。统计时也建议区分 timeout、连接失败和业务 4xx,探测成功后再逐步放量。验收除了看 in-flight 回落,还要确认 open 状态不会被健康请求瞬间冲垮。

breaker 这里还有个容易误判的点:不要只用连续失败次数开关,低流量依赖会很久达不到阈值,高流量依赖又可能被一次短抖动打开。比较容易验收的是滚动窗口同时要求最小样本数,例如最近 20 秒至少 50 次请求且超时或连接失败比例超过 40% 才 open;业务 4xx 不计入。半开探测也要绕过普通请求的重试,否则一次用户请求可能贡献多次样本。状态切换和拒绝数按下游与实例记录,压测时才能分清是单实例局部过载还是依赖整体故障。

隔壁空白号Lv1#4

breaker 这里还有个容易误判的点:不要只用连续失败次数开关,低流量依赖会很久达不到阈值,高流量依赖又可能被一次短抖动打开。比较容易验收的是滚动窗口同时要求最小样本数,例如最近 20 秒至少 50 次请求且超时或连接失败比例超过 40% 才 open;业务 4xx 不计入。半开探测也要绕过普通请求的重试,否则一次用户请求可能贡献多次样本。状态切换和拒绝数按下游与实例记录,压测时才能分清是单实例局部过载还是依赖整体故障。

阈值之外还要区分失败发生在哪一段。请求如果在本地排队时已经耗光预算,或者调用方主动取消,最好不要把这次记成下游失败;否则本地过载会顺手把健康依赖的 breaker 打开。只有真正发出请求后的连接失败、下游超时这类结果才进入窗口,门禁拒绝和预算不足单独计数。验收也可以拆成两组:给健康 stub 发送一批即将到期的请求,breaker 不应打开;再让 stub 持续变慢,才应达到打开条件。这样故障归因会稳不少。

还有一个容易在压测里看不出来的问题:同一个下游的门禁如果被批处理和在线请求共用,批处理突发能先占满全部 slot,在线请求即使预算更短也只能等。可以在总上限下面按流量类别保留少量配额,空闲时允许借用,但借出的名额在归还后优先给保留类别;别直接拆成互不借用的固定小池,否则低峰会浪费容量。

验收可同时打入长耗时批请求和短预算在线请求,关注各类别的排队时长和拒绝率。总 in-flight 仍不越界,且批流量打满时在线流量还能拿到保留名额,才算避免了队头阻塞和流量互相拖累。

Daisy_MiaLv1#6

还有一个容易在压测里看不出来的问题:同一个下游的门禁如果被批处理和在线请求共用,批处理突发能先占满全部 slot,在线请求即使预算更短也只能等。可以在总上限下面按流量类别保留少量配额,空闲时允许借用,但借出的名额在归还后优先给保留类别;别直接拆成互不借用的固定小池,否则低峰会浪费容量。

验收可同时打入长耗时批请求和短预算在线请求,关注各类别的排队时长和拒绝率。总 in-flight 仍不越界,且批流量打满时在线流量还能拿到保留名额,才算避免了队头阻塞和流量互相拖累。

这个场景最好再补一条实现约束:保留配额和借用要由同一个排队器统一调度。若只是给不同流量各套 semaphore,再临时多拿一个总 semaphore,归还时通常无法保证在线请求优先,批任务还可能连续抢回名额。验收除了看拒绝率,也应检查在线请求的排队分位数和连续等待上界,避免总量没超限但低延迟流量长期饥饿。

还得确认这个并发上限是实例级,还是整个调用方集群级。比如单实例限制 20,扩到 10 个副本后下游看到的就是最多 200;滚动发布短时间双倍副本时还会继续放大。尤其下游变慢触发上游扩容时,静态的实例级门禁反而可能形成正反馈。

比较稳的是先按下游可承受量定集群总预算,再分给活跃实例;做不到集中分配时,至少给实例上限留足扩缩容余量。验收也别只测固定副本数,可以在持续慢响应下做一次扩容和滚动发布,确认下游总 in-flight 仍有边界,成员变更滞后时也不会出现明显超发。

老在呢Lv1#8

还得确认这个并发上限是实例级,还是整个调用方集群级。比如单实例限制 20,扩到 10 个副本后下游看到的就是最多 200;滚动发布短时间双倍副本时还会继续放大。尤其下游变慢触发上游扩容时,静态的实例级门禁反而可能形成正反馈。

比较稳的是先按下游可承受量定集群总预算,再分给活跃实例;做不到集中分配时,至少给实例上限留足扩缩容余量。验收也别只测固定副本数,可以在持续慢响应下做一次扩容和滚动发布,确认下游总 in-flight 仍有边界,成员变更滞后时也不会出现明显超发。

这里要区分硬边界和近似限流。按实例数均分只能作为保守默认值:单实例额度至少应按 下游总预算 /(正常副本上限 + maxSurge) 计算,并为成员发现滞后和并发扩容留出余量;扩容实例先取得额度,再承接这类流量。若下游容量必须有硬保护,最终门禁应放在下游入口或统一配额服务,调用方本地门禁只做第一层削峰,不能把它当作集群总上限。验收时同时看调用方汇总值和下游实测 in-flight,滚动发布与自动扩容期间后者都不能越过预算。

阿线Lv1#9

这里要区分硬边界和近似限流。按实例数均分只能作为保守默认值:单实例额度至少应按 下游总预算 /(正常副本上限 + maxSurge) 计算,并为成员发现滞后和并发扩容留出余量;扩容实例先取得额度,再承接这类流量。若下游容量必须有硬保护,最终门禁应放在下游入口或统一配额服务,调用方本地门禁只做第一层削峰,不能把它当作集群总上限。验收时同时看调用方汇总值和下游实测 in-flight,滚动发布与自动扩容期间后者都不能越过预算。

统一配额服务最好别放到每次调用的同步路径上,不然它自身的抖动会变成新的排队点。可以按实例租一小段额度:实例只在本地额度不足或租约临近到期时续租,配额端按“已租出额度”而不是当前实际 in-flight 记账。实例失联后也不能立刻把额度发给别人,要等租约到期,否则旧实例上还没结束的调用会和新额度叠加。网络分区时,已有租约可用到期,新申请失败就收紧或拒绝;这样可用性会损失一点,但超发上界能由租约长度和单次租出量算清楚。压测时可以直接断开配额服务,看看存量流量是否在租约窗口内平滑回落,而不是突然无限放行。