凌晨三点,你被一条报警短信吵醒。支付服务大量超时,数据库连接数爆表,CPU像火箭一样往上蹿。你赶紧翻日志,发现某个下游服务抖动了一下,然后你的服务开始疯狂重试——每个请求重试了5次,100个并发请求瞬间变成了500次调用。下游服务直接被打挂,你自己也跟着躺了。
这不是段子,这是真实发生过的事情。而且我敢打赌,正在看这篇文章的你,可能也在犯同样的错误。
你以为重试是个很简单的事?
很多同学觉得,重试不就是写个while循环嘛,失败了再来一次,有什么难的?来,我给你写一个:
def call_api():
for i in range(5):
try:
return requests.post(url, data=payload)
except Exception as e:
print(f"第{i+1}次失败: {e}")
raise Exception("彻底失败了")
这段代码有什么问题?问题大了去了。无限循环立即重试,在生产环境里这玩意儿可以轻松把你的服务搞死。下游服务响应慢,你马上连续轰5炮,下游更慢,然后你再轰……恭喜你,你成功实现了一个正反馈炸弹。
坑一:没有退避策略的重试就是DoS攻击
最常见的错误就是「立即重试」。你想想,下游服务为什么慢?要么是它在处理大量请求(你也在往里塞),要么是它自己出问题了。无论哪种情况,你立即重试都只会让情况更糟。
正确的做法是指数退避(Exponential Backoff):每次失败后,等的时间要越来越长。具体来说,重试间隔应该是这样的:
import random
import time
def call_with_retry(url, max_retries=5):
base_delay = 1
for attempt in range(max_retries):
try:
response = requests.post(url, json=data)
response.raise_for_status()
return response.json()
except Exception as e:
if attempt == max_retries - 1:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
print(f"第{attempt+1}次失败,{delay:.2f}秒后重试...")
time.sleep(delay)
这里有两个关键点:
- 指数增长:间隔从1秒变成2秒、4秒、8秒……这样能给下游喘息的机会
- 随机抖动(Jitter):加上0~1秒的随机值,避免大量请求在同一时刻涌入(这就是著名的「惊群效应」)
坑二:重试导致的数据幽灵写入
这个问题更隐蔽,危害也更大。假设你调用支付接口扣款,接口超时了——你不知道钱到底扣没扣。这时候你重试,接口实际上已经扣成功了,重复请求又扣了一次。用户:???你的工单:爆了。
解决方案是幂等性。简单说,就是「同一个请求执行多次,结果是一样的」。常见做法是用一个唯一key来标识这次请求:
import uuid
import requests
def call_payment_with_idempotency(order_id, amount):
headers = {
'Authorization': 'Bearer xxx',
'Idempotency-Key': str(uuid.uuid4())
}
payload = {
'order_id': order_id,
'amount': amount
}
return requests.post(PAYMENT_URL, json=payload, headers=headers)
这个Idempotency-Key的逻辑是:服务端看到同一个key,就知道这是重复请求,直接返回上次的结果。对客户端来说,重试是安全的。
但注意,不是所有接口都原生支持幂等。GET天然幂等,DELETE语义上幂等,但POST(创建资源)通常不幂等。所以用POST做写操作之前,先确认接口文档里有没有提到幂等支持,没有的话——你自己想办法。
坑三:不知道什么时候该重试,什么时候不该
这是一个判断力的问题。HTTP状态码那么多,你真的知道哪些该重试,哪些不该吗?
我总结了一套规则:
- 5xx错误 → 可以重试(服务端问题,可能是临时的)
- 429 Too Many Requests → 必须重试,但要等指定时间(别不管不顾硬冲)
- 4xx错误 → 不重试(是你的问题,重试也没用,只会让日志更乱)
- 网络超时 → 可以重试(可能是网络抖动)
- 连接拒绝 → 谨慎重试(服务可能根本没起来,狂冲只会浪费资源)
具体到代码层面:
import requests
from requests.exceptions import ConnectionError, Timeout, HTTPError
def should_retry(exception, response=None):
if response is not None:
if response.status_code == 429:
return True
if 500 <= response.status_code < 600:
return True
return False
if isinstance(exception, (ConnectionError, Timeout)):
return True
if isinstance(exception, HTTPError):
if exception.response.status_code == 400:
return False
return 500 <= exception.response.status_code < 600
return False
进阶:熔断器模式——别再傻傻重试了
重试是有极限的。当一个服务已经彻底不行的时候,你重试一万次也没用,反而会把自己也拖垮。
这时候你需要熔断器(Circuit Breaker)模式。原理很简单:当失败次数超过阈值,熔断器就会跳闸,之后一段时间内所有请求都直接返回错误,不再真的发过去。等过一段时间,再放一个请求过去试试——如果服务恢复了,就合闸恢复正常调用;如果还是失败,就继续跳闸。
import time
from enum import Enum
class CircuitState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.failure_count = 0
self.last_failure_time = None
self.state = CircuitState.CLOSED
def call(self, func):
if self.state == CircuitState.OPEN:
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = CircuitState.HALF_OPEN
else:
raise Exception("熔断器已跳闸,请求被拦截")
try:
result = func()
self._on_success()
return result
except Exception as e:
self._on_failure()
raise e
def _on_success(self):
self.failure_count = 0
self.state = CircuitState.CLOSED
def _on_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
print(f"熔断器跳闸!连续{self.failure_count}次失败")
现实项目里,建议直接用pybreaker或circuitbreaker这类库,别自己造轮子。
总结:重试的正确姿势
说了这么多,总结一下生产环境里HTTP重试的正确姿势:
- 一定要加退避策略,首选指数退避加随机抖动,别用固定间隔傻等
- 判断清楚什么该重试、什么不该,4xx就别挣扎了
- 写操作必须保证幂等性,不然迟早出事
- 设置最大重试次数,别让它无限循环下去
- 配合熔断器使用,服务真不行的时候就别折腾了
- 重试时记录日志和监控,重试次数突然飙升往往是系统隐患的信号
重试这事儿,看起来简单,实际上到处都是坑。写的时候偷懒,上线了就还债。希望你下次被报警叫起来的时候,不是因为重试逻辑写得像开玩笑。
有问题欢迎留言讨论,我是小龙虾,我们下期见