重试与退避策略完全指南:从一次"重试把下游彻底打挂"看懂为什么失败不能无脑重试

2023 年我给一个交易服务接了好几个下游依赖调支付网关查库存发短信通知这些下游平时都挺稳但网络总会偶尔抖动某个下游也偶尔会超时一下第一版我处理得很顺手给每个下游调用都加上重试调用失败了就再试我设了重试 3 次每次失败后固定等 1 秒再来本地我把下游故意改成偶尔抛错一看重试确实生效了我心里很笃定重试嘛就是失败兜底的万能解药多试几次总能成可等它一上线一串问题冒了出来第一种最先把我打懵支付网关只是负载高响应慢了一点结果我这边所有请求一起判定为失败一起重试叠加的流量把它彻底打挂了第二种最难缠有用户被重复扣了款那次调用其实成功了只是响应包丢了我判定失败就重试第三种最头疼某个下游慢下来大量请求卡在重试里把整个服务的线程池耗光第四种最莫名其妙下游明明已经挂了我的代码还老老实实重试 3 次每次还等 1 秒我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为重试就是失败兜底的万能解药可这个认知是错的本文从头梳理为什么失败就重试会把下游彻底打挂哪些错误可重试哪些不可指数退避与抖动怎么削平重试洪峰为什么只有幂等操作才能安全重试重试预算与熔断如何给重试装一个总闸以及一些把重试做扎实要避开的工程坑

2023 年我给一个交易服务接了好几个下游依赖——调支付网关、查库存、发短信通知。这些下游平时都挺稳,但网络总会偶尔抖动,某个下游也偶尔会超时一下。第一版我处理得很顺手:给每个下游调用都加上重试——调用失败了,就再试,我设了重试 3 次,每次失败后固定等 1 秒再来。本地我把下游故意改成偶尔抛错,一看,重试确实生效了,原本会失败的调用,重试一两次就成功了,我心里很笃定:重试嘛,就是失败兜底的万能解药,一个调用失败大概率是网络抖动这种偶发问题,多试几次总能成,把每个可能失败的调用都包上重试,系统成功率自然就上去了——这重试稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:有一次支付网关只是负载高了、响应慢了一点,结果我这边所有正在调用它的请求一起判定为"失败"、一起开始重试,重试的流量叠加在原本的流量上,瞬间把那个本来只是慢一点的支付网关彻底打挂了——重试非但没救场,反而把它推下了悬崖。第二种最难缠:有用户反馈被重复扣了款——后来查明,那次调用其实成功了,只是返回响应的网络包丢了,我这边判定为"失败"就重试,于是同一笔钱扣了两次。第三种最头疼:某个下游慢下来的时候,大量请求卡在重试里,重试占着线程和连接迟迟不放,慢慢地把整个服务的线程池耗光,连那些根本不依赖这个下游的功能也跟着一起瘫了。第四种最莫名其妙:有一次下游明明已经彻底挂了、每次都立刻返回一个清清楚楚的"服务不可用",我的代码却还是老老实实重试 3 次、每次之间还等 1 秒,用户白白多等了好几秒,才等来一个本来第一时间就能给的失败。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为重试就是失败兜底的万能解药——一个请求失败了,大概率是网络抖动这种一闪而过的偶发问题,我让它重试几次,总有一次能赶上下游正常的时候;重试次数设得够多、把每一个可能失败的调用都仔仔细细包上重试,系统的成功率就一定能上去;重试这个动作,只有好处、没有代价,多试总比不试强。可这个认知是错的。重试不是"免费的兜底",它是一把双刃剑。它的两面,取决于故障的性质。如果故障是偶发的、独立的——就是那种一个数据包恰好丢了、一次连接恰好抖了的随机事件,那么重试确实是良药:再试一次,大概率就避开了那个倒霉的瞬间。可如果故障是系统性的——下游过载了、下游挂了、整条链路拥塞了,那么重试就是毒药:此时下游本来就在勉力支撑,而你的重试,是在它最虚弱的时候,又给它压上一倍、两倍、三倍的流量。更要命的是,这种系统性故障下,所有调用方往往会同时遇到失败、同时开始重试,这些重试流量叠在一起,会形成一场"重试风暴",把一个本来只是"慢"的下游,活活推成"挂"。所以重试这件事,根上不是"失败了就再来一次"这么个简单动作,而是一整套需要精心设计的策略:要分清哪些错误该重试、哪些绝不能;要用指数退避加随机抖动,别让重试一窝蜂涌上去;要只对幂等的操作重试,守住"重试"和"重复"的边界;要给重试设一个总的预算,而不是无脑地试;要配合熔断,下游真挂了就果断别再试了。本文从头梳理:为什么"失败就重试"会把下游彻底打挂,哪些错误可重试、哪些不可,指数退避与抖动怎么削平重试洪峰,为什么只有幂等操作才能安全重试,重试预算与熔断如何给重试装一个总闸,以及一些把重试做扎实要避开的工程坑。

问题背景

先把重试这件事说清楚。一个服务调用另一个服务,中间隔着网络,网络是不可靠的——连接可能超时,响应包可能丢失,下游可能临时返回一个错误。重试(retry),就是在一次调用失败后,自动地再发起一次同样的调用,寄希望于这一次能成功。它的理论基础很朴素:很多故障是"瞬时"的,一闪而过,这一刻不行、下一刻可能就好了。这个想法本身没错,重试也确实是构建可靠系统不可或缺的一环。第一版的错,不在于"用了重试",而在于把重试理解成了一个无脑的、无代价的、放之四海皆准的动作,完全没意识到:重试会产生额外的流量,而这份额外流量,在错误的时机会变成压垮系统的最后一根稻草。

错误认知是:重试是免费的万能兜底,失败就多试几次,试得越多成功率越高。真相是:重试会放大流量;它对偶发故障是良药,对系统性故障(过载、宕机)是毒药;用对它,必须分错误类型、退避抖动、限定幂等、设预算、配熔断。把这一点摊开,第一版的几类问题就都能解释了:

  • 重试风暴打挂下游:下游一变慢,所有调用方同时判失败、同时重试,叠加的流量把它压垮。
  • 重复扣款:对非幂等接口重试,而那次调用其实成功了、只是响应丢了,于是操作执行了两次。
  • 重试拖垮自身:重试期间一直占着线程和连接,一个慢下游的重试把整个服务的资源耗光。
  • 该快速失败却傻等:下游已明确返回不可用,代码仍机械地重试、还在每次之间等待。

所以让重试真正可靠,核心不是"多试几次",而是一整套工程:区分可重试错误、指数退避加抖动、只重试幂等操作、设重试预算、配合熔断。下面六节,就从第一版"失败就重试"的想当然讲起。

一、为什么"失败就重试"会把下游彻底打挂

第一版的重试,逻辑朴素到极致:一个循环,失败了就 sleep 一下再来,试满 3 次为止。

# 反面教材:第一版重试 —— 失败就固定间隔再试,试满 3 次

import time

def call_with_retry(do_call, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            return do_call()          # 成功就直接返回
        except Exception as e:
            if attempt == max_retries:
                raise                 # 试满了,抛出最后一次的异常
            time.sleep(1)             # 致命:固定等 1 秒,然后无脑再试

# 本地我把下游改成偶尔抛错,一测:重试生效了,原本失败的
# 调用试一两次就成了,看着很美。
# 可一上线:支付网关只是负载高、慢了一点,我这边所有调用
# 一起判失败、一起重试 —— 叠加的流量把它直接打挂了。

问题就藏在这段代码"看起来人畜无害"的表象之下。它隐含了一个极其乐观的假设:失败是少数、是独立的、是别人家的事。可现实里,当一个下游开始变慢,它会让大量调用方在几乎同一时刻都遭遇超时;这些调用方,又会在几乎同一时刻一起进入重试。于是下游面对的流量,不再是原来的 1 倍,而是 2 倍、3 倍、4 倍。一个本来只是"有点慢、再扛一会就缓过来"的下游,就这样被重试流量直接按死。

这一节要建立的认知是:重试的本质,是"用更多的请求量,去交换更高的单次成功率"——它从来不是免费的,它花出去的成本就是实实在在的额外流量;而这笔交易划不划算,完全取决于下游此刻的状态:下游健康时,这点额外流量它毫无压力,交易稳赚;下游已经在过载边缘时,这点额外流量就是压垮它的最后一击,交易血亏。第一版最深的想当然,是把重试看成一个"纯粹利他、毫无副作用"的好心动作——我重试,只是为了让我自己这一次调用成功,这怎么会有错呢?可它忽略了一个系统层面的事实:你不是唯一一个在重试的人。当下游出问题,是成百上千个调用方同时在做"重试"这个决定。每一个调用方,单独看,它的重试都"很合理";可这成百上千个"合理"的重试汇聚到一起,就成了一场针对下游的、不约而同的洪水攻击。这就是"重试风暴":故障引发重试,重试引发更大的故障,更大的故障引发更多的重试——一个正反馈的死亡螺旋。理解了这一点,你对重试的整个态度都要变:你不能再问"这次失败了,我要不要重试"这种孤立的问题,你必须问"如果此刻所有人都和我一样在重试,下游受得了吗"。第一版的固定间隔重试,恰恰是这场风暴最完美的助燃剂——所有调用方失败后都等"恰好 1 秒",于是它们的重试请求,会在 1 秒后这个时间点上,严丝合缝地再次撞在一起。所以重试要做对,第一件事不是"重试得更努力",而是反过来,要时时刻刻想着怎么"克制"——克制重试的时机、克制重试的总量、克制重试的对象。而克制的第一步,是分清楚:这个失败,到底值不值得重试。

二、可重试与不可重试:不是所有失败都该重试

第一版的代码用了一个 except Exception——它把所有失败,一视同仁地全部重试。这是个大错。失败有两种性质完全不同的类型:一种是"瞬时的、再试可能就好"的(网络超时、连接被重置、下游返回 503 临时不可用);另一种是"确定性的、再试一万次也是这个结果"的(参数错误 400、未授权 401、资源不存在 404、业务规则不通过)。只有前一种才该重试。

# 正解:先判断这个错误"值不值得重试",再决定

import requests

# 明确"可重试"的错误:瞬时性的、再试有意义的
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}

def is_retryable(error):
    # 网络层错误:超时、连接断开 —— 典型的瞬时故障,可重试
    if isinstance(error, (requests.Timeout, requests.ConnectionError)):
        return True
    # HTTP 状态码:只有特定几个才可重试
    if isinstance(error, requests.HTTPError):
        return error.response.status_code in RETRYABLE_STATUS
    # 其余一律不可重试:400 参数错、401 没授权、404 找不到 ——
    # 这些是确定性错误,重试一百次结果还是一样,纯属浪费
    return False

def call_with_retry(do_call, max_retries=3):
    for attempt in range(max_retries + 1):
        try:
            return do_call()
        except Exception as e:
            # 不可重试 或 已试满 —— 立刻抛出,绝不再等、再试
            if not is_retryable(e) or attempt == max_retries:
                raise
            time.sleep(backoff(attempt))   # 退避算法见下一节

这一节的认知是:重试的前提,是这次失败"还有翻盘的可能"——重试能修复的,只有"运气不好"造成的失败;它修复不了"事情本身就不对"造成的失败。把这两类失败搅在一起,不仅是浪费,更是在伤害系统。第一版那个 except Exception 的潇洒,背后是一种偷懒:懒得去分辨失败的种类,干脆全都重试。可这一懒,代价极大。对一个 400 参数错误重试,是纯粹的浪费——你的请求参数就是错的,你重发一百遍,下游一百遍都会用同样的逻辑判定它错,你只是白白制造了一百次无效流量。这还只是"浪费"。更糟的是,这种无意义的重试,会在下游已经有压力时,雪上加霜——它和上一节说的重试风暴是一回事:你以为你在"努力让请求成功",其实你只是在给一个注定失败的请求,反复地、徒劳地冲击下游。所以重试之前,必须先做一道判断题:这个失败,是"瞬时"的还是"确定"的?判断的依据,就是错误的类型。网络超时、连接重置,这些是典型的瞬时故障,它们和你的请求内容无关,纯粹是运气,值得再赌一次。HTTP 状态码里,要尤其小心地挑——503(服务暂时不可用)、504(网关超时)、429(请求太多,稍后再来)这些是下游明明白白告诉你"我现在不行,等会儿再来",它们可重试;而 400、401、403、404 这些,是下游告诉你"你这个请求本身就有问题",它们重试一万次也没用。这里还有一个尤其需要点出来的角色:429。它是下游在向你喊话"你发太快了",对它最好的回应,不是"那我退避一下再重试",而往往是"立刻把我的发送速率降下来"——它是一个限流信号,不只是一个重试信号。把错误分门别类、只对真正瞬时的那一类重试,你才算是给重试装上了第一道闸。而对值得重试的那些,具体该隔多久再试,就是下一节的退避。

三、指数退避与抖动:别让重试一窝蜂涌上来

确定了"这个错误值得重试",下一个问题是:隔多久再试?第一版用的是固定间隔(每次都等 1 秒),这恰恰是最坏的选择。正确的做法是指数退避(exponential backoff):每重试一次,等待时间就翻倍。

# 指数退避:每重试一次,等待时间翻倍 —— 给下游喘息的空间

def backoff_exponential(attempt, base=0.5, cap=30.0):
    # attempt 从 0 开始:0.5s, 1s, 2s, 4s, 8s ... 直到封顶 cap
    delay = base * (2 ** attempt)
    return min(delay, cap)        # 封顶,别让退避时间无限涨下去

# 道理:如果第一次重试就失败了,说明下游的问题不是一闪而过,
# 而是still在持续 —— 这时更该给它多一点时间缓过来,
# 而不是像固定间隔那样,催命似的每隔 1 秒就捅它一下。

但光有指数退避还不够。如果一批调用方在同一时刻失败,它们用同样的指数退避公式,算出来的等待时间是完全一样的——于是它们还是会在同一时刻一起重试。解药是加入随机抖动(jitter)。

# 指数退避 + 抖动:在退避时间上加随机,把重试洪峰摊平

import random

def backoff_with_jitter(attempt, base=0.5, cap=30.0):
    # 先算出指数退避的上限
    exp = min(base * (2 ** attempt), cap)
    # 关键:在 [0, exp] 之间随机取一个值 —— 这就是"完全抖动"
    return random.uniform(0, exp)

# 没有抖动:1000 个调用方同时失败 -> 同时等 2 秒
#           -> 2 秒后 1000 个重试请求严丝合缝撞在一起。
# 有了抖动:1000 个调用方的重试,被随机摊在 0~2 秒这段时间里
#           -> 下游面对的是平缓的涓流,而不是一道洪峰。

这一节的认知是:退避要解决的,其实是两个不同层面的问题——指数退避解决的是"时间维度"的问题,即随着故障持续,主动拉长间隔、降低对下游的骚扰频率;而抖动解决的是"空间维度"的问题,即把众多调用方挤在同一时刻的重试,在时间轴上打散开来;少了任何一个,重试都还是危险的。第一版的固定间隔,在这两个维度上都错了。时间维度上,它是"恒定"的:第一次失败等 1 秒,第十次失败还是等 1 秒。可这不合理——如果你已经重试了好几次都还在失败,这强烈地说明下游的故障不是瞬时的,而是个还在持续的、严重的问题;面对一个越来越严重的问题,你理应越来越克制、把骚扰的间隔拉得越来越长,而不是雷打不动地每秒捅它一下。指数退避就是干这个的:它让"重试的频率"随着"故障持续的时长"自动地、指数级地衰减下去,这恰好契合了"故障越久越严重、越该克制"的直觉。但指数退避有一个它自己解决不了的盲区:它是一个确定性的公式。1000 个在同一秒失败的调用方,代入同一个 2 ** attempt 公式,会算出 1000 个一模一样的等待时间,于是它们重试的时刻,依然是齐刷刷地撞在一起——重试风暴里那个"同时"的问题,指数退避一点没解决,它只是把"撞车的时刻"从第 1 秒挪到了第 2 秒。真正解决"同时"的,是抖动:往退避时间里掺一个随机数,让每个调用方等的时间都各不相同,这样,原本一道陡峭的重试洪峰,就被摊平成了一段时间内平缓的涓流。所以这两件事必须一起做:指数退避负责"随时间降频",抖动负责"在调用方之间错峰",合起来,重试的流量才真正变得温和。而无论退避做得多温和,有一类操作,是从根上就不该被重试的——那是下一节。

四、只对幂等操作重试:重试与重复的边界

前面解决的都是"什么时候、隔多久重试"。但还有一个更要命的前提问题:这个操作,到底能不能被重试?第一版的"重复扣款",根子就在这里。重试的本质,是"把同一个操作再执行一次"。如果这个操作是幂等的——执行一次和执行多次,结果完全一样(比如"把订单状态查出来""把用户名改成张三"),那么重试很安全。可如果它不幂等——每执行一次都会产生一次新的效果(比如"扣 100 块钱""创建一个订单"),重试就是灾难。

# 危险:对一个非幂等操作直接重试 —— 可能重复扣款

# 反面:扣款接口,调用"失败"了就重试。
# 可"失败"有一种隐蔽情况:下游其实扣款成功了,
# 只是返回成功响应的网络包丢了 —— 你判定为失败、重试,
# 于是同一笔钱,扣了两次。
def pay_bad(order_id, amount):
    return call_with_retry(lambda: do_pay(order_id, amount))   # 危险

# 正解:带一个幂等键(idempotency key)。
# 这个 key 由调用方生成,一次业务操作自始至终只用同一个 key。
# 下游用它来识别:这个 key 我处理过了吗?处理过就直接返回
# 上次的结果,绝不重复执行。
def pay_safe(order_id, amount):
    # 同一笔订单的支付,idempotency key 是固定的、可复现的
    idem_key = f"pay-{order_id}"
    return call_with_retry(
        lambda: do_pay(order_id, amount, idempotency_key=idem_key))

# 有了幂等键:第一次请求扣款成功、响应丢了;重试带着
# 同一个 key 再来,下游一看"这个 key 我处理过了",
# 直接把上次的成功结果还给你 —— 钱只扣了一次。

这一节的认知是:重试这个动作,其安全性根本不是由"重试代码"本身决定的,而是由"被重试的那个操作"的性质决定的——重试只是忠实地"再执行一次",至于"再执行一次"是安然无恙还是酿成大祸,取决于那个操作是不是幂等的;所以"能不能重试"这个问题,要到被调用的接口那一侧去问。第一版有一个隐蔽的混淆:它把"重试"和"成功"画了等号,以为重试无非是"再给一次成功的机会"。可它漏掉了一种最阴险的情况——那次调用,其实成功了,只是你不知道它成功了。网络是双向的:你的请求发过去,下游处理完,响应再发回来。如果故障发生在"响应发回来"这一段——下游已经把钱扣了,但那个写着"扣款成功"的响应包在回来的路上丢了——那么在你这边看来,这次调用是"失败"的(你没收到响应、超时了),可在下游那边,它是实实在在"成功"的。这种"调用方以为失败、下游实际成功"的状态,叫做"不确定状态"。对一个非幂等操作,在不确定状态下重试,就是在赌博:赌那次其实没成功。赌输了,就是重复扣款、重复下单。解开这个死结的钥匙,是幂等键:让调用方为"一次业务意图"生成一个唯一的、且重试时保持不变的 key,把它随请求一起带给下游;下游则负责记住"这个 key 我处理过没有",处理过的,直接返回上次的结果,绝不重复执行真正的业务逻辑。这样,"重试"就和"重复"被彻底剥离开了:无论你因为不确定而重试多少次,那个真正有副作用的操作(扣款),下游都只会认准 key、只执行一次。所以在给任何一个调用加重试之前,你都必须先回答一个问题:这个接口幂等吗?幂等的,放心重试;不幂等的,要么先把它改造成幂等(加幂等键),要么就根本不要重试。而即便所有该重试的都安全地重试了,你还需要一个总的闸门,来防止重试在极端情况下失控——那是下一节。

五、重试预算与熔断:给重试装一个总闸

前面的每一招——分类型、退避、抖动、幂等——都是在优化"单次重试"的行为。但还差一个全局视角的控制:就算每一次重试都很规范,当下游大面积故障时,海量请求各自规范地重试,汇总起来的重试流量依然可能是巨大的。所以需要一个"总闸"。第一道总闸,是重试预算(retry budget):限制重试流量在总流量中的占比。

# 重试预算:让"重试请求"占总请求的比例不超过一个上限

import threading, time

class RetryBudget:
    # 思路:重试不是无限的,它有一个"预算池"。每次正常请求
    # 给池子充一点额度,每次重试消耗额度;额度光了,就不许再重试。
    def __init__(self, ratio=0.1):
        self.ratio = ratio          # 重试流量最多占正常流量的 10%
        self.tokens = 0.0
        self.lock = threading.Lock()

    def on_request(self):
        with self.lock:
            self.tokens += self.ratio        # 每个正常请求,充值一点

    def try_retry(self):
        with self.lock:
            if self.tokens >= 1.0:
                self.tokens -= 1.0           # 有额度:扣一个,放行重试
                return True
            return False                     # 额度耗尽:这次不许重试

# 意义:下游健康时,失败少、重试少,预算绰绰有余。
# 下游大面积故障时,失败激增、重试激增,预算会迅速耗尽 ——
# 此时系统自动地"几乎不再重试",从根上掐断重试风暴。

第二道总闸,是熔断器(circuit breaker)。它做的事更决绝:当它发现下游已经在持续大量失败,它会"跳闸"——在一段时间内,让所有调用立刻失败,连第一次尝试都不发了。

# 熔断器:下游持续失败时直接"跳闸",让调用立刻失败、不再尝试

class CircuitBreaker:
    def __init__(self, fail_threshold=20, recovery_time=30):
        self.fail_threshold = fail_threshold   # 连续失败多少次就跳闸
        self.recovery_time = recovery_time     # 跳闸后多久尝试恢复
        self.fail_count = 0
        self.opened_at = None                  # 跳闸的时刻

    def allow(self):
        # 已跳闸:在恢复期内,直接拒绝,连请求都不发
        if self.opened_at is not None:
            if time.time() - self.opened_at < self.recovery_time:
                return False                   # 闸是断的,立刻失败
            self.opened_at = None              # 恢复期到,放一个请求去试探
        return True

    def on_success(self):
        self.fail_count = 0                    # 成功一次,清零

    def on_failure(self):
        self.fail_count += 1
        if self.fail_count >= self.fail_threshold:
            self.opened_at = time.time()       # 失败太多,跳闸

# 熔断回答的是第一版第四个问题:下游已经挂了,就别再
# 傻乎乎地"尝试 + 重试 + 等待"了 —— 直接快速失败,
# 既不浪费用户的时间,也不再给已挂的下游补刀。

把一个调用进来后,重试逻辑如何与这些闸门配合、一步步决策的流程画出来,就是下面这张图:

[mermaid]
flowchart TD
A[发起一次调用] --> B{熔断器是断开的吗}
B -->|是 已跳闸| C[立刻失败 不发请求]
B -->|否| D[执行调用]
D --> E{调用成功了吗}
E -->|成功| F[返回结果]
E -->|失败| G{这个错误可重试吗}
G -->|不可重试| H[立刻抛出错误]
G -->|可重试| I{还有重试次数 且 预算够吗}
I -->|否| H
I -->|是| J[按退避加抖动等待]
J --> D

六、把重试做扎实,要避开的工程坑

前面五节讲清了重试的核心:分错误类型、退避加抖动、限定幂等、设预算、配熔断。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最容易被忽略的:重试会让一次调用的总耗时变得不可控,你需要的不是"每次重试各自超时",而是一个贯穿全程的总 deadline。

# 坑一:每次重试各自独立超时 —— 总耗时会失控;要的是一个"总 deadline"

import time

def call_with_deadline(do_call, is_retryable, total_deadline=3.0,
                       max_retries=3):
    start = time.monotonic()
    for attempt in range(max_retries + 1):
        # 每次调用前先看:总预算还剩多少时间
        remaining = total_deadline - (time.monotonic() - start)
        if remaining <= 0:
            raise TimeoutError('重试总预算耗尽')
        try:
            # 单次调用的超时,不能超过"总预算的剩余量"
            return do_call(timeout=remaining)
        except Exception as e:
            if not is_retryable(e) or attempt == max_retries:
                raise
            delay = backoff_with_jitter(attempt)
            # 关键:退避要等的时间,也得算进总预算里
            if delay >= total_deadline - (time.monotonic() - start):
                raise TimeoutError('退避会超出总预算,放弃重试')
            time.sleep(delay)

# 反面:给每次重试各设 1 秒超时、重试 3 次 —— 用户最坏要等
#       1+1+1 再加上两次退避,七八秒才等来一个失败。
# 正解:先定一个"总共最多等 3 秒"的 deadline,超时、重试、
#       退避全在这 3 秒里分配 —— 用户的等待有了一个硬保证。

第二个坑,是重试的"乘法效应"。如果一次调用要穿过好几层代码,而每一层都"贴心地"加了重试,这些重试次数会相乘,把下游的实际流量放大到一个吓人的数字。

# 坑二:每一层都加重试 —— 重试次数会相乘,流量被悄悄放大几十倍

# 反面:三层调用,每一层都"贴心地"加了 3 次重试
def dao_layer():        # 数据访问层:重试 3 次
    return call_with_retry(do_query, max_retries=3)

def service_layer():    # 业务层:又把上面那层重试 3 次
    return call_with_retry(dao_layer, max_retries=3)

def api_layer():        # 接口层:再把上面那层重试 3 次
    return call_with_retry(service_layer, max_retries=3)

# 真相:一次 api_layer 调用,最坏会把 do_query 打 3*3*3 = 27 次
#       —— 你以为只重试 3 次,下游实际被放大成了 27 次。

# 正解:重试只在"一层"做,通常选最贴近网络调用的那一层
def dao_layer_fixed():
    return call_with_retry(do_query, max_retries=3)   # 只有这里重试

def service_layer_fixed():
    return dao_layer_fixed()      # 上层只负责调用,绝不再重试

def api_layer_fixed():
    return service_layer_fixed()  # 上层只负责调用,绝不再重试

还有几个坑值得点一下。其一,重试一定要打日志、要能监控——每一次重试、每一次熔断跳闸、每一次因预算耗尽而放弃,都该留下记录,否则下游被你的重试打挂了,你都不知道是自己干的。其二,重试要尽量靠近故障源——在最底层、最贴近网络调用的那一层重试,信息最全、也最容易避免上面说的乘法效应。其三,异步重试别和同步重试混用——如果一个操作可以接受"稍后再做",把它丢进消息队列做异步重试,远比让一个线程卡在那里同步 sleep 等待要好。下面把"该不该给一个调用加重试"这件事,浓缩成一张速查:

重试决策五连问 —— 加重试前,先逐条过一遍

  要问的问题                  这一条不通过,就别加重试
  ------------------------------------------------------------
  这个错误是瞬时的吗          确定性错误,重试一万次纯属浪费
  这个操作幂等吗              非幂等操作,重试会导致重复执行
  退避加抖动了吗              固定间隔会制造一场重试风暴
  有总 deadline 和预算吗      没有上限的重试会反过来拖垮自己
  配了熔断吗                  下游已经挂了还重试,是在给它补刀

  原则:重试不是免费的兜底,是一把双刃剑 ——
        对偶发故障是良药,对系统性故障是毒药

这一节这几个坑,串起来是同一个意思:重试从来不是一个"局部的、孤立的"小动作,它的影响会沿着调用链向下传导、会随着时间累积、会和其它调用方的重试叠加——你必须站在"整个系统"的高度去看它,而不是只盯着"我这一次调用"。第一版对重试的理解,始终是"局部"的:我这次调用失败了,我重试一下,这是我和下游之间的私事。可这一节的每个坑都在说同一件事——重试根本不是私事。总耗时失控,是因为重试在"时间"这个维度上累积:你只看单次超时,却没算上多次重试加多次退避叠起来的总和,于是用户的等待时间彻底失控。乘法效应,是因为重试在"调用链"这个维度上累积:每一层都觉得自己只重试了 3 次,可这些 3 次乘到一起,下游承受的是 27 次。还有那个贯穿全文的重试风暴,是因为重试在"调用方"这个维度上累积:你一个人重试无伤大雅,可成百上千个调用方同时重试,就是一场洪水。所以把重试做扎实,最终靠的是一个视角的转变:你不能再问"我这次该不该重试",你要问的是"如果时间拉长、如果调用链叠加、如果所有人都和我一样,这个重试还安全吗"。给重试设总 deadline,是在时间维度上设防;只在单层重试,是在调用链维度上设防;退避、抖动、预算、熔断,是在调用方维度上设防。重试要可靠,这三个维度,一个都不能漏。

关键概念速查

概念 说明
重试 retry 一次调用失败后自动再发起同样的调用,寄望于瞬时故障此刻已恢复
瞬时故障 网络抖动、丢包等一闪而过的随机故障,重试对它有效,是良药
系统性故障 下游过载、宕机等持续性故障,重试只会雪上加霜,是毒药
可重试错误 超时、连接重置、503/504/429 等瞬时性错误;400/401/404 不可重试
指数退避 每重试一次等待时间翻倍,让重试频率随故障持续而指数级衰减
抖动 jitter 在退避时间上加随机,把众多调用方挤在同一刻的重试错峰摊平
幂等性 一个操作执行一次和多次结果相同,只有幂等操作才能安全重试
幂等键 调用方生成的唯一 key,让下游识别重复请求、绝不重复执行
重试风暴 下游变慢引发大量同步重试,叠加流量形成正反馈把下游彻底压垮
熔断器 下游持续失败时跳闸,在一段时间内让调用立刻失败、连试都不试

避坑清单

  1. 不要用 except Exception 一把抓:先判断错误可不可重试,确定性错误绝不重试。
  2. 不要用固定间隔重试:用指数退避,让重试频率随故障持续而衰减。
  3. 不要忘了加抖动:没有抖动,众多调用方的重试会齐刷刷撞在一起。
  4. 不要对非幂等操作重试:先用幂等键改造,否则会重复扣款、重复下单。
  5. 不要无脑重试:给重试设一个预算,过载时自动停止重试。
  6. 不要在下游已挂时还重试:配熔断器,跳闸后直接快速失败。
  7. 不要只给单次调用设超时:要有一个贯穿全程的总 deadline。
  8. 不要每一层都加重试:重试次数会相乘,只在单层重试。
  9. 不要让重试悄无声息:每次重试、每次熔断都要打日志、能监控。
  10. 不要把 429 当普通重试信号:它是限流信号,该降速而非简单退避重试。

总结

回头看第一版那个"失败就重试 3 次、每次固定等 1 秒"的代码,它的失控很典型。它不在某一行语法,而在一个对重试的根本误解:以为重试是免费的万能兜底,失败了多试几次总能成,重试这个动作只有好处、没有代价。真相是,重试是一把双刃剑——它对偶发的、独立的瞬时故障是良药,对系统性的过载与宕机却是毒药;一个固定间隔的无脑重试,会在下游最虚弱的时候,叠加成一场把它彻底压垮的重试风暴。

而把重试做对,工程量并不小。它不是"失败了再来一次"那么简单,而是要分清可重试与不可重试的错误、要用指数退避加随机抖动削平重试洪峰、要只对幂等操作重试并守住幂等键这条边界、要给重试设一个总预算、要配合熔断在下游真挂时果断止损、还要设一个贯穿全程的总 deadline、把重试收敛到单独一层、给每次重试都留下日志。一套真正可靠的重试,是这些环节一个不少地拼起来的。

这件事其实很像给一个总占线的客服热线打电话。第一版的做法,像是电话一占线就立刻重拨,而且雷打不动地每隔 1 秒拨一次。如果只是线路偶尔抖了一下,这么重拨一两次,确实就拨通了——这是瞬时故障,重拨是良药。可如果是大家都在同一时刻打这个热线(比如刚发完一条故障通知),那么所有人都会同时占线、又都会在 1 秒后同时重拨,这些重拨叠在一起,把本就繁忙的热线彻底打死——这就是重试风暴,而你那个"恰好 1 秒"的固定间隔,是这场风暴最完美的节拍器。聪明的打法是怎样的?第一,占线了别立刻重拨,等一会儿,而且每占线一次就多等一阵——占线越久说明那头越忙,你越该克制(这就是指数退避)。第二,别和别人卡着同一个点重拨,自己随机错开几秒(这就是抖动)。第三,如果你打这个电话是为了"改一下预约时间",那重拨没风险;可如果是为了"下一笔订单",你就得先问清楚——重拨会不会变成下了两笔单(这就是幂等)。第四,如果你已经听到"该号码已停机"这种明确答复,就别再一遍遍拨了,那是确定性的失败,拨一万次还是停机(这就是快速失败与熔断)。一个电话能不能打通,靠的从来不是"拨得越勤越好",而是你懂不懂得在什么时候、用什么节奏、对什么样的占线,去重拨。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测重试,无非是把下游故意改成偶尔抛个错,然后看着重试把它兜了回来,觉得"重试真好用"。可你本地只有你一个调用方,你制造不出"成百上千个调用方同时重试"的重试风暴;你的下游是你自己改的,它要么正常、要么抛错,绝不会出现"其实成功了、只是响应丢了"那种最阴险的不确定状态,所以你也撞不见重复扣款;你测的那几次,下游也从不会真的被你打到过载,所以你永远看不到重试是怎么把一个"慢"的下游推成"挂"的。真正会把问题撑爆的,是上线后的真实流量:真实的下游一定会过载、会被你和所有人的重试一起打挂;真实的网络一定会丢响应包,制造出"调用方以为失败、下游其实成功"的不确定状态;真实的高并发,会让"所有人同时重试"从一个理论变成每天都在发生的事。这些场景,你本地一个都模拟不到。所以如果你正在给一个调用加重试,别等下游被你的重试风暴打挂、别等用户拿着重复扣款的账单来投诉,才回头怀疑你那个 for 循环。在写下重试代码的第一行时就想清楚:这个错误该不该重试、这个操作幂等不幂等、我的退避有没有加抖动、我有没有给重试设预算和总 deadline、下游真挂了我会不会熔断——把"让一个失败的调用再试一次"和"让重试在真实的过载、丢包和高并发下依然安全"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM Agent 多步规划完全指南:从一次"Agent 绕圈子停不下来"看懂为什么不能让模型自己跑

2026-5-22 23:14:17

技术教程

向量数据库选型完全指南:从一次"向量库把服务拖到 OOM"看懂为什么不能随便挑一个

2026-5-22 23:33:18

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索