一次跨服务调用失败了,该怎么办?大多数人的第一反应是"重试一下"。这个直觉不算错,但没有配套的超时设计和退避策略,重试就是把小故障放大成雪崩的加速器。超时和重试是分布式系统里最基础也最容易被敷衍的两个参数——很多系统里它们要么是框架默认值,要么是某次故障后拍脑袋改的数字。这篇文章讲清楚这两个参数背后的设计逻辑:时间预算。
超时不是参数,是预算分配
把一次用户请求看作一笔时间预算。用户愿意等两秒,这两秒就是总预算,链路上每一跳都在花这笔钱。入口网关花掉50毫秒,业务服务花掉200毫秒,剩下的才轮得到下游的数据库和第三方接口。
问题出在预算没有自上而下分配的时候。常见的失败模式是:每个服务各自设置自己觉得合理的超时,入口设2秒,中间服务设3秒,最底层的数据库客户端设5秒。层级越深超时越长,意味着底层还在勤勤恳恳地等待时,上层早就放弃并向用户返回错误了。底层等出来的结果没有任何人消费,纯粹是资源浪费——连接被占着、线程被挂着,故障期间这些无效等待会迅速耗尽资源池。
正确的方向恰好相反:超时应该逐层递减。这条原则听起来显而易见,但在多团队各自维护一段服务的现实里,几乎没有系统能天然满足——因为从来没有人把全链路的超时配置放在同一张表里对过账。上游给下游的时间,必须小于自己剩余的预算,还要预留网络往返和自身处理的开销。更进一步的做法是把剩余预算随请求传递(deadline propagation),每一跳读取剩余时间并据此决定自己的超时——gRPC 的 deadline 机制就是这个思路的标准实现。做不到全链路传递时,至少要保证静态配置满足"下游超时 < 上游超时"这条不等式。
重试的前提:幂等与错误分类
重试之前要先回答两个问题:这个操作重试安全吗?这个错误值得重试吗?
第一个问题关于幂等。查询可以随便重试,但"创建订单"重试一次可能就是两笔订单。非幂等操作要么不重试,要么先通过唯一请求ID把它改造成幂等的——服务端见到重复ID直接返回上次的结果。很多重复扣款事故的根源,就是在非幂等接口上挂了自动重试。尤其阴险的是超时后的重试:请求超时不代表失败,下游可能已经执行成功只是响应没回来,此时重试就是重复执行。
第二个问题关于错误分类。网络抖动、连接超时、503,这些是暂时性错误,重试有意义;参数错误、权限不足、404,这些是确定性错误,重试一万次结果也一样,只会浪费资源并污染下游日志。重试逻辑必须建立在错误分类之上,对确定性错误立即放弃。分不清的错误宁可不重试——把它暴露出来,比静默重试掩盖问题更健康。
退避与抖动:别让重试变成攻击
假设下游服务短暂过载,一千个客户端的请求同时失败。如果它们都立即重试,下游收到的是又一波一千个请求的冲击——刚要缓过来又被打倒。这就是重试风暴:故障本身不严重,但同步化的重试让它无法恢复。
标准解法是指数退避加随机抖动。第一次失败等1秒,第二次等2秒,第三次等4秒,让重试压力随时间指数衰减;再在等待时间上叠加随机量,把原本同步的重试波峰打散成均匀的涓流。抖动不是可选的优化,而是必需品——没有抖动的指数退避,所有客户端仍然在相同的时间点集体冲锋。
另一个必须设的是重试预算:限制单位时间内重试请求占总请求的比例,比如不超过10%。当失败率高到重试预算耗尽时,说明下游是真的出了问题,此时继续重试没有意义,应该快速失败并触发熔断,把恢复的空间留给下游。
重试的位置:只在一层做
链路有五层,如果每层都配置三次重试,最底层的一次故障会被放大成三的五次方——两百多次请求。这种指数放大足以把一次抖动变成全链路过载。
原则是:重试集中在一个层级做,其他层级快速失败。通常选择最靠近故障边界、最了解错误语义的那一层——比如访问第三方接口的适配层。入口层可以保留极少量针对整体请求的重试,中间层则原则上不重试。审计一下你的系统里有多少层各自带着默认重试,往往会发现放大系数大得惊人。
超时之后:熔断是重试的止损线
超时和重试解决的是偶发失败,但当下游进入持续性故障时,再精巧的退避也只是延缓消耗。这时需要熔断器接管:统计窗口内的失败率超过阈值后,直接切断对下游的调用,快速返回降级结果,隔一段时间放少量探测请求试探下游是否恢复。
熔断和重试是一对搭档而不是二选一。重试处理"这一次失败了,再试可能成功"的场景;熔断处理"最近一直在失败,试了也白试"的场景。没有熔断的重试体系,在下游长时间故障时会持续制造无效流量;没有重试的熔断体系,会把本可自愈的瞬时抖动直接升级成用户可见的错误。两者的参数还要互相咬合:熔断的失败统计应该把重试耗尽后的最终失败计入,而不是把每次重试尝试都算一次失败,否则重试次数越多熔断越容易误触发。
降级路径同样要提前设计。熔断打开之后返回什么?缓存的旧数据、默认值、还是明确的"服务暂不可用"?这个问题在故障发生时现想就晚了。时间预算的完整含义,不只是"等多久放弃",还包括"放弃之后给用户什么"。
落地检查清单
给一条调用链做超时与重试的体检,看五件事:总预算是否明确(用户或上游到底愿意等多久);超时是否逐层递减且留有余量;重试是否只对暂时性错误、且操作幂等;退避是否带抖动、是否有重试预算兜底;重试是否集中在单一层级。
这五件事没有一件需要引入新组件,全是对现有参数的重新审视。审视的成本很低——翻一遍配置文件、画一张链路图、算几条不等式;不审视的成本则在某次故障里一次性结清:超时倒挂导致的资源耗尽、重试风暴导致的二次雪崩、无退避重试把第三方接口打到封禁。
超时与重试的设计水平,本质上反映的是一个团队对"失败是常态"这件事的接受程度。把失败当例外的系统,参数是默认值,故障是意外事件;把失败当常态的系统,每一毫秒的等待都有预算依据,每一次重试都有明确的理由和上限。预算意识越清晰,系统在故障面前就越从容。