分布式调用失败了怎么办:重试、退避与幂等键的设计

举报
yd_295233921 发表于 2026/10/27 12:26:51 2026/10/27
【摘要】 分布式调用失败了怎么办:重试、退避与幂等键的设计调用失败分三类,再决定要不要重试。能重试的前提是幂等,没有幂等,重试就是在制造重复副作用。退避方式决定了重试会不会把一次抖动变成一波雪崩。 一、失败分三类,处理方式不一样我把一次远程调用的结果分成三类,代码里也按这三类分支:明确失败:对方返回了 4xx、5xx,或者连接被拒绝。调用方知道这次没生效,可以按规则重试,或者直接报错。成功:对方返回...

分布式调用失败了怎么办:重试、退避与幂等键的设计

调用失败分三类,再决定要不要重试。能重试的前提是幂等,没有幂等,重试就是在制造重复副作用。退避方式决定了重试会不会把一次抖动变成一波雪崩。

一、失败分三类,处理方式不一样

我把一次远程调用的结果分成三类,代码里也按这三类分支:

  1. 明确失败:对方返回了 4xx、5xx,或者连接被拒绝。调用方知道这次没生效,可以按规则重试,或者直接报错。
  2. 成功:对方返回 200,业务结果明确,不需要再做任何事。
  3. 未知结果:超时、连接中断、对方没回包。这是最麻烦的一类。对方可能已经把订单写进去了,也可能根本没收到。调用方只知道自己没拿到结果,不知道对方做了什么。

第三类是很多重试 bug 的根源。把超时一律当成失败去重试,等于默认对方没生效,这个假设在超时场景下不成立。

二、没有幂等,就不要重试

幂等的意思很具体:同一个请求发一次和发多次,对业务状态的影响是一样的。实现方式通常是一个幂等键:

  • 客户端在第一次调用时生成一个唯一键,随请求一起发过去,键和业务请求绑定,不能每次重试换一个新键。
  • 服务端收到请求查键:如果这个键已经处理过,直接返回第一次的结果,不再重复执行业务逻辑。
  • 键要设置过期时间。永久保存所有键,存储会一直涨;过期太短,重试窗口外的重复请求又会穿透。

没有这一步,重试次数越多,重复扣款、重复下单、重复发券的概率越高。幂等键不是可选项,是重试的前提。

三、退避:固定、指数、加抖动

重试间隔有三种常见写法,效果不一样:

  • 固定间隔:每次都等 0.5 秒。实现简单,缺点是所有失败的客户端会在同一时刻一起重试,容易把服务端的恢复期打满。
  • 指数退避:第一次等 0.5 秒,第二次 1 秒,第三次 2 秒,依次翻倍,并设一个上限,比如 4 秒。给服务端留出恢复时间。
  • 指数退避加抖动:在指数退避的基础上乘一个 0.5 到 1 之间的随机数。抖动的作用是把重试时刻打散,避免大量客户端在同一秒同时回来。

下面是一段可以直接跑的 Python 装饰器,演示指数退避加抖动和幂等键的配合。注意 idempotency_key 是调用方传入的,不是每次重试重新生成的。

import time
import random
import functools

def retry_with_backoff(max_attempts=4, base_delay=0.5, max_delay=4.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, idempotency_key=None, **kwargs):
            delay = base_delay
            last_error = None
            for attempt in range(max_attempts):
                try:
                    return func(*args, idempotency_key=idempotency_key, **kwargs)
                except TimeoutError as e:
                    # 未知结果:只能用同一个幂等键重试,不能换键
                    last_error = e
                except ConnectionError as e:
                    last_error = e
                if attempt == max_attempts - 1:
                    break
                # 指数退避加抖动,并设上限
                wait = min(delay, max_delay) * random.uniform(0.5, 1.0)
                time.sleep(wait)
                delay *= 2
            raise last_error
        return wrapper
    return decorator

这段代码有意只捕获超时和连接错误。业务上的明确失败,比如参数错误、余额不足,不应该进重试分支, retry 只会把一次明确错误重复四次。

四、本地模拟:三组对比

为了看清楚区别,我在本地跑了一组模拟,不是生产数据。参数写在前面:200 次调用,服务每次有 30% 明确失败概率,10% 超时概率,超时那次服务端已经生效了。最多重试 4 次,固定退避每次 0.5 秒,指数退避从 0.5 秒起步、上限 4 秒、乘 0.5 到 1 的抖动。随机种子固定为 42,方便复现。

结果如下,成功是指调用方最终拿到了明确的成功结果:

策略 调用方确认成功 总调用次数 重复副作用次数 平均等待时间
不重试 114 / 200 200 0 0 秒
固定 0.5 秒重试 196 / 200 321 25 0.302 秒
指数退避加抖动重试 198 / 200 300 18 0.256 秒

三点值得注意:

  1. 不重试的成功率最低,114 次。很多调用其实已经生效了,只是调用方不知道,等于把成功当成失败丢掉了。
  2. 重试把确认成功拉到了 196 次和 198 次,但没有幂等的情况下,重复副作用分别是 25 次和 18 次。重试次数越多,没有幂等的代价越明显。
  3. 指数退避加抖动在总调用次数和平均等待上都比固定退避低一点,198 次成功、300 次总调用、平均等待 0.256 秒。抖动没有让成功率爆炸式提升,它的作用是把重试时刻打散,减轻同一时刻的冲击。

如果服务端按幂等键去重,上面 25 次和 18 次重复副作用在设计上应该归零:同一个键第二次来,服务端直接返回第一次的结果,不再执行业务逻辑。重试的收益保留,重复的代价被幂等键挡住。

五、容易踩的坑

  • 超时叠加:单次调用超时 10 秒,重试 4 次,总耗时可能超过调用方的整体超时。重试次数和单次超时要一起算,不能只看单次。
  • 重试放大:一个请求失败后重试 4 次,等于把一次失败放大成 4 次请求。服务刚恢复时,大量重试同时回来,可能把服务再次压垮。这时应该降重试次数,或者配合熔断,停一会儿再放流量。
  • 换键重试:每次重试生成新幂等键,等于告诉服务端这是三个不同的请求,幂等设计直接失效。
  • 对明确失败重试:参数错了、权限错了,重试多少次结果都一样,只会浪费调用次数和日志。

六、上线前检查清单

  1. 这次失败属于明确失败、成功、未知结果中的哪一类?
  2. 这个接口有没有幂等键?重试时键是不是同一个?
  3. 单次超时、重试次数、退避上限加起来,总耗时在不在调用方的超时预算内?
  4. 退避有没有抖动?大量客户端同时失败时,会不会在同一秒一起回来?
  5. 幂等键的过期时间是多少?存储涨到多少需要清理?
  6. 重试失败后,调用方给用户的报错有没有说清楚:是明确失败,还是结果未知、需要人工核对?

把这六条过一遍,再决定重试策略。重试本身不难,难的是在超时和未知结果面前,不把一次失败变成两次副作用。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。