一条调用链上,网关、业务服务、数据访问层各自配了「失败重试 2 次」,单独看每一层都很合理。下游数据库抖动几十秒,监控却显示数据库收到的请求量翻了十几倍,抖动变成了雪崩。这篇从架构层面拆一下多层重试的放大效应,以及重试预算、退避抖动、截止时间传递三件事怎么配合落地。
每一层「1 次原始请求 + N 次重试」,单层最多发出 N+1 次调用。层与层之间是嵌套关系:上层每重试一次,下层就完整地再走一遍自己的重试。所以 L 层、每层重试 N 次时,最底层最多收到:
(N + 1) ^ L 次调用
N = 2, L = 3 -> 3^3 = 27
N = 3, L = 3 -> 4^3 = 64更糟的是触发时机:重试恰好发生在下游已经变慢或出错的时候。下游的处理能力在下降,到达的请求却在成倍增加,排队时间继续拉长,更多请求超时,又触发更多重试。这是一个正反馈回路,下游即使恢复了,也会被积压的重试流量再次压垮。
所以讨论重试,先回答的不是「重试几次」,而是「在哪一层重试」和「整体最多允许多少额外流量」。
比较稳妥的做法是把重试集中在离故障最近、且能判断操作是否幂等的那一层,通常是直接调用下游的客户端(或服务网格的 sidecar)。上层收到明确失败后直接向上返回,不再叠加重试。
几个判断标准:
Retry-After。下游明确返回 429/503 并给出等待时间时,按它来,不要按自己的节奏重试。以 Nginx 作为入口为例,它默认会在 error timeout 时切到下一个上游,自 1.9.13 起不会对 POST 等非幂等请求这样做(除非显式加 non_idempotent)。如果后端服务自己已经有重试,入口这一层就应该收紧:
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 的集群熔断配置里直接有这个能力:
clusters:
- name: order-service
circuit_breakers:
thresholds:
- priority: DEFAULT
retry_budget:
# 并发重试数不超过(活跃请求 + 排队请求)的 20%
budget_percent:
value: 20.0
# 低流量时至少允许 3 个并发重试
min_retry_concurrency: 3配合路由上的重试策略:
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.5sgRPC 客户端也有同类机制,在 service config 里配置 retryThrottling:每次失败令牌减 1,每次成功加 tokenRatio,令牌数降到 maxTokens 的一半及以下时停止重试,下游恢复后自动放开:
{
"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 示意,把预算、退避抖动和截止时间放在一起:
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-timeout 头传给服务端,Go 里服务端从 ctx.Deadline() 读到,再用同一个 ctx 调下游即可自动继承。X-Request-Deadline 写绝对时间戳),每一跳读出后减去本地已用时间,作为下游调用的超时,并在剩余时间不足时直接失败。per_try_timeout)要小于总超时,否则第一次尝试就把时间用完了,重试配置形同虚设。重试策略只有在故障时才生效,平时看不出对错,建议在预发环境做一次故障注入:
# 在下游服务所在节点注入 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% 以内),而不是成倍增长;故障解除后重试比例在一两个窗口内回落;入口返回的错误是明确的失败,而不是长时间挂起后超时。
retry_budget、gRPC 的 retryThrottling 都是现成的。原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。