超时和重试,配错了比不配还危险
# 超时和重试,配错了比不配还危险
有次线上服务突然大面积超时,查到最后,根子不在某个接口慢,而在我们自己的重试逻辑:一个下游接口抖动,调用方疯狂重试,把下游彻底压垮,然后雪崩到整个链路。那次之后,我把超时和重试的配置重新理了一遍。
## 超时不是越长越安全
很多人怕超时,就把超时设得很大,觉得「多等等总能返回」。问题是,等待也是有代价的:线程被占着、连接被占着,下游越慢,上游堆积越多。
超时应该根据链路的实际耗时来定。一个接口 P99 是 200ms,超时设 2 秒就偏大了。合理的做法是:先看监控里的 P99,再留一点余量,比如 P99 的 1.5 到 2 倍。
```python
import requests
try:
resp = requests.get(url, timeout=(1, 3)) # 连接 1s,读取 3s
except requests.Timeout:
handle_timeout()
```
注意连接超时和读取超时要分开设。连接超时短一点,读取超时按业务定。
## 重试的三个大坑
**坑一:重试非幂等操作**
下单、支付这类操作,重试一次可能就多扣一次钱。重试之前先问:这个操作重复执行安全吗?不安全的话,要么别重试,要么用幂等键去重。
**坑二:没有退避**
失败后立刻重试,间隔 0,下游还没缓过来又被砸一次。正确做法是指数退避,间隔逐渐拉长。
**坑三:重试放大**
A 调 B,B 调 C,每层都重试 3 次。C 挂一次,实际收到 3×3×3 = 27 次请求。链路越长,放大越可怕。要么只在最外层重试,要么严格控制层数和次数。
## 退避加抖动
```python
import time, random
def call_with_retry(fn, max_retry=3):
for attempt in range(max_retry):
try:
return fn()
except TransientError:
if attempt == max_retry - 1:
raise
base = 0.2 * (2 ** attempt)
sleep = base + random.uniform(0, base) # 加抖动
time.sleep(sleep)
```
加抖动是为了避免大量请求在同一时刻一起重试,形成新的尖峰。
## 重试要区分错误类型
不是所有错误都值得重试:
- 网络超时、连接失败:可以重试
- 5xx:视情况,幂等的可以
- 4xx(参数错、鉴权失败):重试没用,别浪费
```python
if isinstance(e, (Timeout, ConnectionError)):
retry()
elif 500 <= status < 600:
retry()
else:
raise # 4xx 直接抛
```
## 熔断是重试的搭档
重试解决偶发失败,熔断解决持续故障。下游已经挂了,还一直重试,只会加重问题。
熔断的思路:连续失败达到阈值,直接快速失败一段时间,等下游恢复再放量。常用的库有 Resilience4j、Sentinel。
重试 + 熔断 + 超时,这三个要一起配,单配一个都不完整。
## 几个实践建议
- 超时按 P99 的 1.5~2 倍设,别拍脑袋
- 重试只对幂等操作和瞬时错误
- 一定要有退避和抖动
- 重试次数别太多,2~3 次足够
- 链路长时,限制重试层数
- 配熔断,防止持续故障放大
## 小结
超时和重试是保护系统的手段,但配错了会变成放大器。核心是三件事:超时按实际耗时定、重试只对幂等和瞬时错误、退避加抖动防风暴。再加上熔断兜底,才是一套完整的容错。
别等被打挂了才想起来配这些。平时多看一眼监控,比事故后复盘便宜得多。
- 点赞
- 收藏
- 关注作者
评论(0)