常见坑:超时链、重试风暴、慢调用放大

微服务故障很少是单点“炸”出来的,多数是调用链上的小问题被层层放大。本章讲三个最常见的故障放大器——超时链、重试风暴、慢调用放大,以及对应的预防手段。

坑一:超时链——每一跳都超时,整体雪崩

服务 A 调 B、B 调 C,每层超时 1 秒:A 对用户的响应上限其实是“A→B 超时 + B→C 超时”的累加。链路越长,最坏延迟线性叠加;更糟的是默认配置常常是“永不超时”,下游挂了上游就无限等待,线程全部挂起。

// 反例:不设超时的 HTTP/Feign 调用,下游无响应时线程被永久占用
// 正例:显式设置连接超时与读取超时,如 Feign 客户端配置
@Configuration
public class FeignConfig {
    @Bean
    public Request.Options feignOptions() {
        return new Request.Options(
            /* connectTimeoutMillis */ 1000,
            /* readTimeoutMillis */    3000);
    }
}

预防清单:

  • 所有跨服务调用必须显式设超时:连接超时短、读取超时适中,两者分开。
  • 网关入口设整体超时与单路由超时,避免请求在网关层无限挂起。
  • 超时值沿链路“只减不增”:外层超时 ≥ 内层超时之和要留余量,内层别比外层还大。
  • 超时后的默认行为必须定义:快速失败返回降级结果,而不是把超时当成功继续。

坑二:重试风暴——一次失败,放大 N 倍流量

“失败就重试”看起来无害,但多级重试会指数放大:A 对 B 重试 3 次,B 对 C 也重试 3 次,一次原始失败最多触发 9 次调用;如果网关层、负载均衡层再各重试一次,瞬间流量可能是平时的几十倍,把已经故障的下游彻底打垮。

# 反例叠加:网关重试 + Feign 重试 + 业务层自写重试
# 正例:全局统一重试策略,明确“谁负责重试”,且只重试幂等请求
resilience4j:
  retry:
    instances:
      stockService:
        max-attempts: 2          # 一层最多 1 次重试
        wait-duration: 200ms

预防清单:

  • 全链路只在一层做重试(通常是最贴近故障源的调用方),其他层关闭或仅网关入口兜底。
  • 只对“临时失败”重试:超时、连接异常、5xx 可重试;4xx、业务失败、幂等冲突禁止重试。
  • 写操作重试必须有幂等保护(见幂等章节),否则重试 = 重复下单/重复扣款。
  • 重试要退避(间隔递增),并加随机抖动,避免重试请求同拍打向下游。

坑三:慢调用放大——不是挂了,只是变慢

比“挂了”更隐蔽的是“变慢”:下游响应 3 秒(没超时),上游线程/连接被长时间占用。当慢调用占比上升,线程池被占满,新请求开始排队,排队又加剧延迟——系统在“还没报错”时就已经瘫痪。

慢调用放大的传导:
下游变慢(3s)
  → 上游线程被占 3s
  → 线程池排队,RT 上升
  → 网关连接被占,整体吞吐下降
  → 健康检查/心跳超时,误判实例死亡
  → 重启/摘除引发二次抖动

预防清单:

  • 舱壁隔离:给不同下游分线程池/信号量(Bulkhead),慢下游只能拖垮自己的舱,不能传染全局。
  • 熔断兜底:下游延迟或错误率超阈值就快速失败(见 Resilience4j 章节),给下游恢复时间。
  • 线程池监控告警:队列深度、活跃线程、拒绝次数是“慢调用放大”的前置信号。
  • 别用“无限队列”:阻塞队列无界会让排队请求无限堆积,必须有界并配拒绝策略。
  • 慢查询治理:数据库慢 SQL 是慢调用的高发源头,慢日志要告警。

三个坑的共同根源

超时链、重试风暴、慢调用放大本质上都是“缺乏全局韧性预算”:没人定义一条请求在超时、重试、线程占用上的总预算。落地做法:

  1. 为每条核心链路画依赖图,标注每跳超时、是否可重试、是否幂等。
  2. 定链路级 SLO:总超时预算、总重试次数上限、降级触发条件。
  3. 用故障演练验证:人为注入慢调用/异常,看保护策略是否真的兜得住。

小结

超时链靠“显式超时 + 预算分配”,重试风暴靠“单层重试 + 幂等 + 退避”,慢调用放大靠“舱壁 + 熔断 + 有界队列”。把这三个放大器写进设计评审的检查项,多数线上雪崩都能在设计期被拦住。

笔记加载中…