首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >多层重试的放大效应:重试预算、退避抖动与截止时间传递

多层重试的放大效应:重试预算、退避抖动与截止时间传递

原创
作者头像
一只特立独行的兔先生
发布于 2026-11-11 09:52:35
发布于 2026-11-11 09:52:35
190
举报

一条调用链上,网关、业务服务、数据访问层各自配了「失败重试 2 次」,单独看每一层都很合理。下游数据库抖动几十秒,监控却显示数据库收到的请求量翻了十几倍,抖动变成了雪崩。这篇从架构层面拆一下多层重试的放大效应,以及重试预算、退避抖动、截止时间传递三件事怎么配合落地。

放大效应是乘法,不是加法

每一层「1 次原始请求 + N 次重试」,单层最多发出 N+1 次调用。层与层之间是嵌套关系:上层每重试一次,下层就完整地再走一遍自己的重试。所以 L 层、每层重试 N 次时,最底层最多收到:

代码语言:plaintext
复制
(N + 1) ^ L 次调用
N = 2, L = 3  ->  3^3 = 27
N = 3, L = 3  ->  4^3 = 64

更糟的是触发时机:重试恰好发生在下游已经变慢或出错的时候。下游的处理能力在下降,到达的请求却在成倍增加,排队时间继续拉长,更多请求超时,又触发更多重试。这是一个正反馈回路,下游即使恢复了,也会被积压的重试流量再次压垮。

所以讨论重试,先回答的不是「重试几次」,而是「在哪一层重试」和「整体最多允许多少额外流量」。

原则一:只在一层重试,其他层快速失败

比较稳妥的做法是把重试集中在离故障最近、且能判断操作是否幂等的那一层,通常是直接调用下游的客户端(或服务网格的 sidecar)。上层收到明确失败后直接向上返回,不再叠加重试。

几个判断标准:

  • 只重试幂等操作。读请求、带幂等键的写请求可以重试;没有幂等保护的创建订单、扣款,重试一次就可能多扣一次。
  • 只重试可恢复的错误。连接被拒、连接重置、503、429 可以重试;400、401、404、业务校验失败重试多少次结果都一样。
  • 尊重 Retry-After。下游明确返回 429/503 并给出等待时间时,按它来,不要按自己的节奏重试。

以 Nginx 作为入口为例,它默认会在 error timeout 时切到下一个上游,自 1.9.13 起不会对 POST 等非幂等请求这样做(除非显式加 non_idempotent)。如果后端服务自己已经有重试,入口这一层就应该收紧:

代码语言:nginx
复制
location /api/ {
    proxy_pass http://backend;
    # 只在连接阶段失败时换上游,不对超时重放请求
    proxy_next_upstream error;
    # 最多尝试 2 个上游,整体换上游的时间不超过 3 秒
    proxy_next_upstream_tries 2;
    proxy_next_upstream_timeout 3s;
}

proxy_next_upstream_tries 和 proxy_next_upstream_timeout 默认都是 0,即不限制,这两个值建议显式写上。

原则二:用重试预算给额外流量设上限

「每个请求最多重试 2 次」限制的是单个请求,限制不了总量:下游全部失败时,流量依然是原来的 3 倍。重试预算换一个角度:重试流量不能超过正常流量的某个比例。Google SRE 书「Handling Overload」一章给出的经验是单个客户端的重试比例控制在 10% 以内。

用服务网格时,Envoy 的集群熔断配置里直接有这个能力:

代码语言:yaml
复制
clusters:
- name: order-service
  circuit_breakers:
    thresholds:
    - priority: DEFAULT
      retry_budget:
        # 并发重试数不超过(活跃请求 + 排队请求)的 20%
        budget_percent:
          value: 20.0
        # 低流量时至少允许 3 个并发重试
        min_retry_concurrency: 3

配合路由上的重试策略:

代码语言:yaml
复制
retry_policy:
  retry_on: "connect-failure,reset,retriable-status-codes"
  retriable_status_codes: [503]
  num_retries: 2
  per_try_timeout: 0.5s
  retry_back_off:
    base_interval: 0.05s
    max_interval: 0.5s

gRPC 客户端也有同类机制,在 service config 里配置 retryThrottling:每次失败令牌减 1,每次成功加 tokenRatio,令牌数降到 maxTokens 的一半及以下时停止重试,下游恢复后自动放开:

代码语言:json
复制
{
  "methodConfig": [{
    "name": [{"service": "order.OrderService", "method": "GetOrder"}],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE"]
    }
  }],
  "retryThrottling": {"maxTokens": 10, "tokenRatio": 0.1}
}

注意 maxAttempts 包含第一次调用,并且 gRPC 会把大于 5 的值按 5 处理。

原则三:退避加随机抖动

固定间隔重试会让大量客户端在同一时刻再次打过来,形成一波波尖峰。指数退避解决「越来越慢」,随机抖动解决「同时到达」。AWS 架构博客「Exponential Backoff And Jitter」比较过几种抖动方式,常用的是 Full Jitter:等待时间在 0 到当前退避上限之间均匀随机。

下面是一个可以直接运行的 Python 示意,把预算、退避抖动和截止时间放在一起:

代码语言:python
复制
import random
import time


class RetryBudget:
    """滑动窗口内重试次数不超过正常请求的 ratio,低流量时至少允许 min_retries 次。"""

    def __init__(self, ratio=0.1, min_retries=3, window=10.0):
        self.ratio, self.min_retries, self.window = ratio, min_retries, window
        self.requests, self.retries = [], []

    def _trim(self, now):
        cutoff = now - self.window
        self.requests = [t for t in self.requests if t > cutoff]
        self.retries = [t for t in self.retries if t > cutoff]

    def on_request(self):
        self.requests.append(time.monotonic())

    def can_retry(self):
        now = time.monotonic()
        self._trim(now)
        allowed = max(self.min_retries, self.ratio * len(self.requests))
        if len(self.retries) < allowed:
            self.retries.append(now)
            return True
        return False


class Retryable(Exception):
    pass


def call_with_retry(fn, budget, deadline, attempts=3, base=0.05, cap=1.0):
    budget.on_request()
    for attempt in range(attempts):
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("deadline exceeded")
        try:
            return fn(timeout=remaining)
        except Retryable:
            last = attempt == attempts - 1
            if last or not budget.can_retry():
                raise
            sleep = random.uniform(0, min(cap, base * 2 ** attempt))
            if time.monotonic() + sleep >= deadline:
                raise
            time.sleep(sleep)

几个细节:预算耗尽时直接把错误抛给调用方,而不是排队等预算;每次重试前都检查剩余时间,等待之后已经超过截止时间就不再发起。

原则四:截止时间要沿链路传下去

每层各自配超时还有一个问题:网关 3 秒超时已经给用户返回了错误,后端还在用自己的 5 秒超时继续重试,这些请求的结果已经没有人要了,纯属浪费下游容量。

正确的做法是在入口确定一个总截止时间,往下传的是「还剩多少时间」:

  • gRPC 原生支持,客户端设置的 deadline 会通过 grpc-timeout 头传给服务端,Go 里服务端从 ctx.Deadline() 读到,再用同一个 ctx 调下游即可自动继承。
  • HTTP 服务没有统一标准,可以约定一个请求头(例如 X-Request-Deadline 写绝对时间戳),每一跳读出后减去本地已用时间,作为下游调用的超时,并在剩余时间不足时直接失败。
  • 单次尝试超时(per_try_timeout)要小于总超时,否则第一次尝试就把时间用完了,重试配置形同虚设。

上线前怎么验证

重试策略只有在故障时才生效,平时看不出对错,建议在预发环境做一次故障注入:

代码语言:bash
复制
# 在下游服务所在节点注入 30% 丢包,持续观察 5 分钟(eth0 换成实际网卡)
tc qdisc add dev eth0 root netem loss 30%
sleep 300
tc qdisc del dev eth0 root netem

观察时对比三组数字:入口 QPS、下游实际收到的 QPS、重试次数占比。合格的表现是:下游 QPS 的上涨被限制在预算比例内(比如 20% 以内),而不是成倍增长;故障解除后重试比例在一两个窗口内回落;入口返回的错误是明确的失败,而不是长时间挂起后超时。

小结

  • 先数清楚链路上有几层在重试,算一下最坏情况的 (N+1)^L。
  • 重试只放在一层,只重试幂等操作和可恢复错误。
  • 用重试预算限制总量,Envoy 的 retry_budget、gRPC 的 retryThrottling 都是现成的。
  • 退避加 Full Jitter,避免同步重试的尖峰。
  • 截止时间沿链路传递,单次尝试超时小于总超时。
  • 用故障注入验证,看下游流量放大倍数而不是只看成功率。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 放大效应是乘法,不是加法
  • 原则一:只在一层重试,其他层快速失败
  • 原则二:用重试预算给额外流量设上限
  • 原则三:退避加随机抖动
  • 原则四:截止时间要沿链路传下去
  • 上线前怎么验证
  • 小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档