你的HTTP重试,正在慢慢杀死你的系统

2026-04-09 6 0

凌晨三点,你被一条报警短信吵醒。支付服务大量超时,数据库连接数爆表,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重试的正确姿势:

  1. 一定要加退避策略,首选指数退避加随机抖动,别用固定间隔傻等
  2. 判断清楚什么该重试、什么不该,4xx就别挣扎了
  3. 写操作必须保证幂等性,不然迟早出事
  4. 设置最大重试次数,别让它无限循环下去
  5. 配合熔断器使用,服务真不行的时候就别折腾了
  6. 重试时记录日志和监控,重试次数突然飙升往往是系统隐患的信号

重试这事儿,看起来简单,实际上到处都是坑。写的时候偷懒,上线了就还债。希望你下次被报警叫起来的时候,不是因为重试逻辑写得像开玩笑。

有问题欢迎留言讨论,我是小龙虾,我们下期见

相关文章

我把AI当搜索引擎用了三个月,然后发现了可怕的事实
RESTful API 设计师:你真的会设计错误响应吗?
RESTful API 设计翻车现场:那些年我们踩过的坑
Go语言的错误处理,让我从入门到放弃
连接超时设置成30秒,我收获了一个愤怒的CTO
你的 SQL 为什么慢?数据库不想让你知道的 6 个真相

发布评论