故障现象:服务突然变慢,请求大量失败
那天下午,我们运营的智能问答应用突然炸了——用户大面积报障,对话框半天没反应,有的请求直接返回错误。监控面板上,调用大模型 API 的 P99 延迟从平时的 5 秒一下子窜到了 45 秒,错误率也从 0.5% 飙升到 35%。业务方急得不行,说用户已经开始流失了,值班同事赶紧拉群排查。
这服务的架构其实挺简单的:用户请求先经过网关,然后业务服务通过 HTTP 去调第三方大模型 API(用的是 ClaudeAPI 兼容接入服务)拿结果。上游虽然有多条线路,但业务代码只配了一条默认路由,而且连个熔断或者降级机制都没开。
快速定位:超时和重试搞出来的连锁反应
我们一开始怀疑是上游大模型 API 出了问题。和平台那边沟通后,对方说当时确实有瞬时流量高峰,导致部分请求处理变慢了。但奇怪的是,我们这边业务服务器发出去的请求量比正常高出一大截——本来每秒大概 100 次请求,故障期间飙到了 300 多次。
接着查代码发现,业务服务用的 Go 标准库net/http,默认超时是 30 秒,重试策略是固定间隔重试 3 次,每次等 1 秒,而且没加任何抖动。也就是说,一条请求在 30 秒内没返回,客户端立马重试;要是上游响应还是慢,重试就会叠加。结果就是:大量并发请求同时超时 → 同时发起重试 → 上游压力猛增 → 响应更慢 → 更多超时和重试。这就是典型的重试风暴。
深入排查:重试风暴到底怎么来的
超时设置和重试策略
我们仔细翻了业务代码里 HTTP 客户端的配置:
连接超时:默认没单独设,用的是net/http的Transport默认值,实际连接超时大概 30 秒
读取超时:通过timeout字段统一设成了 30 秒
重试次数:硬编码 3 次,没有退避策略
重试间隔:固定 1 秒,没有抖动
这种配置碰上大模型 API 这种响应时间波动大的服务,风险非常高。大模型推理本身就可能因为输入长度、模型负载、网络节点这些因素时不时延迟一下。一旦某个时间窗口里多个请求同时遇到延迟,固定重试策略就会让请求量成倍放大。故障期间我们看了日志统计数据:有一条原文请求在 2 秒内被重试了 3 次,而下游 API 实际上 35 秒后返回了正确结果,但客户端早就超时放弃了这个连接,后端结果白白浪费了。
日志分析:锁定重试风暴的关键证据
我们把业务日志和 API 网关日志对了一下,还原出了时间线:
14:02:30:上游大模型 API 响应时间从 5 秒升到 12 秒
14:02:32:第一批超时请求开始重试,客户端并发量翻倍
14:02:35:上游 API 负载突破阈值,响应时间升到 40 秒
14:02:40:大量重试请求涌到上游,部分服务器开始丢请求,客户端错误率飙升
14:03:00:业务服务器连接池耗尽,新请求只能排队,系统眼看就要崩溃
日志里还能看到大量资源竞争的现象:连接池的Get操作阻塞时间从 0 毫秒涨到了 20 秒以上,说明客户端自己的线程资源也快被重试请求占满了。
重试风暴的典型危害
这次故障让我们切身体会到了重试风暴对系统稳定性的毁灭性打击:
下游服务雪崩:本来只是一次正常的负载波动,被重试机制放大成了拒绝服务攻击。上游 API 在故障期间,实际有效请求和重试请求的比例接近 1:3,大量算力都花在处理没意义的重试上了。
客户端资源耗尽:业务服务部署了 8 个 pod,每个 pod 最大连接数 50,总并发上限 400。重试风暴时实际并发请求达到了 900 多,大量 goroutine 被阻塞在等响应里,导致健康检查失效,k8s 开始重启 pod。
响应延迟急剧增加:用户那边感受到的等待时间从正常的 5 秒变成了 60 秒甚至直接超时,体验直接崩了。
解决方案:从源头掐断重试风暴
复盘之后,我们对重试机制做了系统性重构。核心思路就一条:重试不是免费的,每一次重试都可能伤害下游。
第一,合理设置超时
必须把连接超时和读取超时区分开。对大模型 API 调用来说,读取超时要根据模型最大推理时间来设,不能一刀切地用短超时。我们参考了第三方 ClaudeAPI 兼容接入服务推荐的超时建议(具体可以看平台最新文档),把读取超时调到了 60 秒,连接超时设成 5 秒。这样既能容忍正常的推理延迟,又能快速识别网络异常。
第二,采用指数退避和抖动
固定间隔重试是重试风暴的温床。我们换成了指数退避算法:第一次重试延迟 1 秒,第二次 2 秒,第三次 4 秒,最大延迟上限 16 秒。同时加了 ±25% 的随机抖动,避免多个客户端在同一时间点同时发起重试。比如,第一次重试的等待时间会在 0.75 秒到 1.25 秒之间随机选一个。
第三,限制最大重试次数
把重试次数从 3 次降到了 2 次,还设了全局重试上限。更重要的是,严格限制了重试条件:只有5xx服务端错误和timeout才触发重试,4xx客户端错误(比如 401、403)绝对不重试。原来的代码对所有非 2xx 状态码都重试,这明显不合理。
第四,熔断和降级
引入了熔断器模式:当错误率在 10 秒窗口内超过 50% 时,熔断器打开,所有请求直接返回降级结果(比如给缓存答案或者提示用户稍后再试)。熔断后每 30 秒尝试半开状态,放少量请求过去探测上游是否恢复。这样能有效切断重试风暴的传播链条。
第五,客户端连接池管理
把连接池大小从固定的 50 改成了根据 CPU 和内存自动限制,同时配了空闲连接超时和最大空闲连接数。另外每个请求都加了context超时控制,确保 goroutine 不会因为重试而无限阻塞下去。
代码层面的优化实践
下面是用 Go 实现的推荐配置片段,只是示意逻辑,不是完整生产代码:
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
client := &http.Client{
Timeout: 60 * time.Second,
Transport: transport,
}
type RetryPolicy struct {
MaxRetries int
BaseDelay time.Duration
MaxDelay time.Duration
JitterFactor float64
}
func (p *RetryPolicy) ShouldRetry(statusCode int) bool {
return statusCode >= 500 || statusCode == 0 // 0 表示连接超时
}
func (p *RetryPolicy) Backoff(attempt int) time.Duration {
delay := float64(p.BaseDelay) * math.Pow(2, float64(attempt))
if delay > float64(p.MaxDelay) {
delay = float64(p.MaxDelay)
}
jitter := delay * p.JitterFactor * (rand.Float64()*2 - 1)
return time.Duration(delay + jitter)
}
实际调用时,用context.WithTimeout包裹每次重试,确保总耗时不超过业务能接受的范围。
监控与告警:让故障不再重复
排查过程中我们发现,原有的监控体系只覆盖了服务端指标(CPU、内存、QPS),完全没有观察客户端的重试行为。我们补充了以下关键指标:
API 调用延迟分布:把首次请求和重试请求的 P50/P95/P99 分开统计
重试次数统计:按重试次数分桶,只要单次请求重试次数大于 0 就告警
连接池等待时间:超过 100 毫秒就触发低级别告警
熔断器状态:熔断器一打开,立刻发电话告警
另外,我们在日志里给每次重试加了X-Retry-Attempt和X-Retry-Reason头部,方便和上游平台一起排查。第三方 ClaudeAPI 兼容接入服务的技术团队也帮了忙,确认了当时上游负载波动的原因。
总结
这次大模型 API 故障的根源其实不复杂——就是一个小小的固定重试策略,在合适的条件下引爆了整个系统。重试风暴不是那种很少见的边缘情况,只要系统有一定波动,客户端重试策略又设计得不好,它就可能在任何规模的业务里上演。
关键教训有两点:
重试机制是系统稳定性的放大器:设计好了能容忍瞬时抖动,设计不好就会把小故障变成大事故。必须给每次重试加上指数退避、抖动、上限和熔断保护。
监控要覆盖客户端视角:服务端一切正常的时候,客户端可能已经在死循环重试了。重试次数、连接池状态、熔断器状态这些指标,比 CPU 占用率更能反映系统的真实健康状况。
后来我们在所有接入大模型 API 的服务里推广了这套重试策略模板,也写进了团队开发规范。如果你也在用第三方大模型 API 服务,建议重新审视一下自己的重试代码——也许一个微小的配置改动,就能避免一次灾难性的重试风暴。