微服务雪崩复盘:一个超时配置错误,我怎么把链路从 20 个服务缩到 3 个

凌晨两点,手机震得我差点从床上掉下来。

不是 P0 事故群,是 Prometheus 告警风暴。整整 20 个服务同时飘红,CPU 没爆,内存没炸,报错只有一个:java.net.SocketTimeoutException: Read timed out

我以为是代码发布出了问题。问了值班同学,今晚根本没发版。那只能是线上配置被改了。

背景:一条超长的调用链

我们这套系统拆得不算狠,也就 20 来个微服务,用的是 Spring Cloud + OpenFeign。链路最长的请求大概要穿透 7 层服务,从网关进来,依次经过鉴权、订单、库存、支付、风控、对账,最后落库。

平时跑得挺稳,P99 稳定在 200ms 左右。但今晚,这条链路彻底崩了。

排查时间线:从 20 个服务缩到 1 个配置

00:05 —— 告警轰炸开始。

服务 A(网关层)开始大量超时。我登录 Grafana,发现 A 的线程池全部占满,等待队列堆积到 500+。但 A 的下游服务 B 的 QPS 并没有暴涨,甚至有点低。

这说明什么?说明 A 的请求发出去了,但收不回来。

00:15 —— 开始逐层下钻。

我用 SkyWalking 拉了一条完整链路,发现几乎所有慢请求都卡在服务 F(对账服务)。F 的平均响应时间从平时的 150ms 飙到了 28 秒。

F 挂了?我看了 F 的 Pod 状态,全部 Running,CPU 不到 10%。

00:25 —— 找到黑洞。

F 有个外部依赖,调用第三方支付平台的对账接口。我查了这个接口的监控,发现它的 P99 今晚确实慢,大概要 25 秒才能返回。

但问题是:F 调用这个接口的超时配置,什么时候从 2 秒改成 30 秒了?

翻了下配置中心的历史记录,原来昨天下午,运维同学接到第三方通知,说他们的对账接口在高峰期可能会慢到 20 秒以上,建议我们放宽超时。运维本着"宁大勿小"的原则,直接把 Feign 的 readTimeout 改成了 30 秒。

00:35 —— 雪崩的真正原因找到了。

超时设成 30 秒,听起来是给了下游更多时间。但在微服务链路里,这就是一颗雷。

F 的线程池默认 200 个线程。当第三方接口变慢时,F 的线程被大量占用,每个线程要挂 30 秒才能释放。新来的请求不断进来,线程池迅速打满。F 开始排队,上游 E 等 F 等到超时,E 的线程池也打满,然后 D、C、B、A,一层层传上来,整条链路全部卡死。

这就是经典的级联故障(Cascading Failure)

00:45 —— 止血。

我没急着重启服务,而是直接把 F 的那个超时配置从 30 秒改回 2 秒,同时加了一行降级逻辑:如果第三方对账接口超时,直接走本地缓存的昨日对账单,允许 T+1 补对。

改完配置,30 秒内,20 个服务的告警全部恢复。

根治:超时不是越宽松越好

第二天白天,我们开了复盘会。结论是:单靠改回 2 秒不够,必须建立一套超时治理规范

1. 超时漏斗

在微服务架构里,超时必须像漏斗一样,越往下越短:

  • 网关层:3 秒(用户最多等 3 秒,再长就流失了)
  • 服务间调用:2 秒(留给下游足够的缓冲)
  • 数据库 / 缓存:1 秒(DB 如果 1 秒还查不出来,说明索引或 SQL 有问题)

如果反过来,下游超时比上游还长,上游都超时断开了,下游还在跑,白白浪费资源。

2. 熔断与降级

超时改回去,只能防止线程池被打满。但如果第三方接口真的挂了,F 还是会不断报错,影响用户体验。

所以我们引入了 Resilience4j 做熔断:

resilience4j:
  circuitbreaker:
    instances:
      reconciliationService:
        slidingWindowSize: 100
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
        permittedNumberOfCallsInHalfOpenState: 10
  timelimiter:
    instances:
      reconciliationService:
        timeoutDuration: 2s

配置很简单:100 个请求里失败率超过 50%,熔断器打开,30 秒内直接走降级逻辑,不再调用下游。

降级逻辑怎么写?我们的做法是返回最近一次成功的对账结果,并打一条延迟对账的异步任务,等第三方恢复后再补偿。

3. 线程池隔离

Spring Cloud 默认用的是共享线程池,一个慢接口能把整个服务的线程吃光。

我们给 F 的第三方调用单独开了一个线程池:

@Bulkhead(name = "reconciliationService", type = Bulkhead.Type.THREADPOOL)
public ReconciliationResult fetch(ReconciliationRequest req) {
    return thirdPartyClient.fetch(req);
}

这样即使第三方接口再慢,也只会占满这个小线程池,不会影响 F 的其他接口。

踩坑记录

坑 1:超时设得越大越安全?

完全相反。超时是资源占用的时间上限。设成 30 秒,意味着一个线程要被占用 30 秒。高并发下,几秒就能把线程池打光。超时应该根据业务能容忍的最大等待时间来定,而不是根据下游的最大响应时间。

坑 2:只改下游,不改上游

运维同学只改了 F 的超时,但没改上游 E、D、C 的超时。导致 E 等了 2 秒就断了,F 还在跑,结果 F 做了无用功,还占了线程。超时治理必须是全链路的。

坑 3:熔断开了,但没配降级

很多团队配了熔断,结果 fallback 方法里直接抛了个异常,或者返回 500,这和没熔断没啥区别。熔断的目的是优雅降级,不是优雅报错

坑 4:线程池不设边界

Spring Boot 默认的 Tomcat 线程池是 200。很多人以为 200 够了,但在级联等待的场景下,200 个线程可能几秒就被占满。核心接口和非核心接口必须做线程池隔离,慢调用和快调用必须分开。

写在最后

微服务拆分只是第一步,治理才是重头戏

超时、熔断、隔离,这三件事听起来都是基础设施的"小事",但一个配置改错,就能让 20 个服务在凌晨两点同时崩溃。

从那以后,我们团队定了一条铁律:任何超时配置的变更,必须走 CR(变更评审),并且要在链路图上标出影响范围。因为在这个架构里,没有"只影响一个服务"的配置。

如果你也在维护微服务,建议今晚就检查一下你们的超时配置——尤其是那种"为了兼容慢接口"而改大的超时。它可能正在睡觉,等着在某个凌晨把你叫醒。

更多推荐