接口超时了,为什么重试反而把系统打垮?聊聊超时预算的 4 个误区

下游只慢了 1 秒,为什么整个系统会在几十秒内雪崩?

问题可能不在超时本身,而在每一次看起来都很合理的重试。

重试是分布式系统的常规能力。

下游网络抖动了,需要重试;服务发布瞬间不可用,需要重试;偶发的连接超时,需要重试。

很多团队会把超时和重试写成统一配置:

yaml 复制代码
timeout: 3s
retry:
  maxAttempts: 3
  backoff: 1s

重试不是可靠性开关,而是一份会沿调用链层层放大的流量预算。

配置很简单,但当故障发生时,系统可能反而更快崩塌。

一个下游服务原本只是变慢,超时后却被三倍流量继续冲击;网关、业务服务、HTTP Client 各自重试,最终放大成几十倍请求;大量请求同时进入重试窗口,形成周期性流量尖峰。

重试并不是免费的可靠性,它会增加下游压力和调用链复杂度。

下面聊聊超时与重试设计里最常见的 4 个误区。

误区一:每一层都有超时,却没有端到端预算

很多系统里,每一层都能看到超时配置:

text 复制代码
客户端:30 秒
网关:20 秒
业务服务:10 秒
HTTP Client:5 秒
数据库:3 秒

单看每一层都合理,但连起来之后,整个请求可能远远超过用户愿意等待的时间。

更重要的是,超时配置通常没有考虑调用次数。

如果业务服务调用下游三次,每次最多 5 秒,它的最坏耗时不是 5 秒,而是:

text 复制代码
3 次 × 5 秒 + 业务处理时间

如果网关还允许重试三次,最终可能变成:

text 复制代码
3 × (3 × 5 秒) + 其他耗时

这就是超时没有预算的问题。

更合理的方式是建立一条递减的超时预算:

text 复制代码
用户总预算:2s
网关总预算:1.8s
业务服务总预算:1.2s
单次下游调用:600ms
下游服务内部预算:400ms

可以用一个请求上下文维护 deadline:

ts 复制代码
type RequestBudget = {
  deadline: number;
  remaining(): number;
};

function createBudget(totalMs: number): RequestBudget {
  const deadline = Date.now() + totalMs;

  return {
    deadline,
    remaining() {
      return Math.max(0, deadline - Date.now());
    },
  };
}

async function callWithBudget(
  url: string,
  budget: RequestBudget,
) {
  const timeoutMs = budget.remaining();
  if (timeoutMs <= 0) throw new Error("budget exhausted");

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

这段代码的关键不是 AbortController,而是把剩余预算继续传给下游。

真正的端到端超时,不是每一层都设置一个固定数字,而是:

上游给多少时间,下游只能在剩余时间内工作,并且不能把超时错误当成无限重试的理由。

误区二:只计算一次请求,不计算重试放大

重试会放大流量。

假设一个下游服务正常只承受 1000 QPS,出现故障后,所有请求都超时重试两次:

text 复制代码
原请求:1000 QPS
第一次重试:+1000 QPS
第二次重试:+1000 QPS
总请求:约 3000 QPS

如果每层都配置重试,放大倍数会继续叠加:

text 复制代码
网关重试 × 业务服务重试 × 客户端重试

下游本来只是进入高负载状态,最后会被重试流量彻底压垮。

更危险的是,故障还会形成反馈回路:

text 复制代码
下游变慢
  -> 上游超时
  -> 上游重试
  -> 下游压力更大
  -> 延迟继续上升
  -> 更多重试

因此,重试策略至少要考虑:

  • 调用链中是否已经有其他层在重试;
  • 下游是否支持幂等;
  • 故障是快速失败还是超时;
  • 重试是否共享同一个 deadline;
  • 是否设置最大并发重试数;
  • 是否在系统过载时主动禁止重试。

一个通用原则是:

只在最接近业务语义、且能够判断幂等性的一层重试。

网关做无脑重试通常很危险;业务服务知道这个操作是否可重试,更适合决定策略。

误区三:固定间隔重试,导致请求同时撞上来

很多配置使用固定退避:

text 复制代码
失败后等待 1 秒重试

如果故障发生时有一万个请求几乎同时超时,它们也会在一秒后同时重试。

这就是"惊群"或"重试风暴"。

重试需要指数退避和随机抖动:

ts 复制代码
function retryDelay(
  attempt: number,
  baseMs = 200,
  capMs = 5000,
) {
  const exponential = Math.min(capMs, baseMs * 2 ** attempt);
  const jitter = 0.5 + Math.random() * 0.5;
  return exponential * jitter;
}

不同策略的效果不同:

text 复制代码
固定 3 秒:所有请求同时重试
指数退避:请求在一段时间内分散
指数退避 + 抖动:进一步降低同步概率

但退避不能无限制延长。

如果用户总预算是 2 秒,退避 5 秒已经失去了意义。重试延迟必须受总 deadline 约束:

ts 复制代码
const delay = retryDelay(attempt);

if (delay >= budget.remaining()) {
  throw new Error("no budget left for retry");
}

await sleep(delay);

还要注意:

  • 429 通常应该遵循 Retry-After;
  • 连接失败和业务失败的退避策略可能不同;
  • 高优先级请求和后台任务不能使用完全相同的重试策略;
  • 无界重试队列本身也可能变成内存和延迟风险。

误区四:所有错误都重试,却没有熔断和隔离

并不是所有错误都值得重试。

可以按错误类型粗略分类:

错误类型 是否重试 原因
网络抖动、连接重置 可以,注意幂等 可能是瞬时问题
502 / 503 / 504 可以,依赖预算 下游暂时不可用
429 可以,但要遵守限流提示 继续重试可能加剧过载
400 / 401 / 403 通常不重试 请求本身或权限错误
业务明确拒绝 不重试 重试不会改变结果
超时但结果未知 谨慎处理 可能已经产生副作用

当错误率达到一定比例后,继续重试通常只会制造更多失败。

这时需要熔断器。

熔断器不应该只在"连续失败 N 次"后打开,因为低流量服务可能很久才达到阈值。更实用的判断通常基于窗口:

text 复制代码
最近 30 秒内,请求数 >= 20
失败率 >= 50%
  -> 打开熔断器 10 秒

伪代码:

ts 复制代码
type CircuitState = "closed" | "open" | "half_open";

type CircuitPolicy = {
  windowMs: number;
  minimumRequests: number;
  failureRateThreshold: number;
  openMs: number;
};

function shouldOpen(
  metrics: { requests: number; failures: number },
  policy: CircuitPolicy,
) {
  if (metrics.requests < policy.minimumRequests) return false;
  return metrics.failures / metrics.requests >= policy.failureRateThreshold;
}

熔断打开后,请求应该快速失败,而不是继续把线程和连接池耗在下游。

同时,还要做资源隔离。

不同下游共享同一个连接池或线程池时,一个慢服务可能拖垮所有依赖它的功能。可以为核心依赖设置独立连接池、并发上限和队列上限:

ts 复制代码
type DependencyPolicy = {
  maxConcurrent: number;
  maxQueue: number;
  timeoutMs: number;
  circuitBreaker: CircuitPolicy;
};

隔离的目标不是让所有请求都成功,而是:

当某个依赖失效时,限制故障范围,不让它拖垮整个系统。

一套可落地的超时与重试配置

可以按下面的顺序设计。

1. 先确定用户总预算

例如登录接口要求 P95 小于 500ms,支付确认接口允许 2 秒。不同接口不能用同一个超时数字。

2. 从上游向下游递减

text 复制代码
用户预算
  > 网关预算
  > 业务总预算
  > 单次依赖预算
  > 数据库或下游内部预算

3. 只对可恢复错误重试

重试列表要明确,不要写成"任何异常都重试"。

4. 使用指数退避和抖动

同时确保重试延迟不会突破 deadline。

5. 在过载时停止重试

重试是恢复手段,不是对故障下游的额外攻击。

6. 为关键依赖设置熔断和隔离

没有隔离的熔断,只能保护一部分流量;没有熔断的重试,可能把局部故障扩大成全局故障。

最后检查这 7 项

  • 每个外部调用都有明确超时
  • 超时预算从用户请求向下游递减
  • 调用链中只有明确的一层负责重试
  • 重试次数、并发和总预算都有上限
  • 退避策略包含随机抖动并遵守 deadline
  • 只重试可恢复且幂等的错误
  • 关键依赖具备熔断、隔离和快速失败能力

超时和重试不是简单的两个配置项。

它们共同定义了一套故障时的流量控制策略。配置得好,系统能在瞬时抖动中恢复;配置得不好,重试会把一次局部变慢放大成全局雪崩。

留个问题

你们团队的超时和重试是全局统一配置,还是按接口分级设计?有没有遇到过"越重试,故障越大"的情况?欢迎在评论区聊聊。

如果你也在处理分布式系统的稳定性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。

相关推荐
1360967572341 分钟前
.env 的三个必查项
后端
程序员Sunday1 小时前
Spring @Transactional 没回滚?按代理调用、异常和传播行为排查
java·后端·spring
付威20231 小时前
我用 100 行核心代码,做了一个能接入飞书的 Hermes 式智能体
人工智能·后端
苏三说技术1 小时前
为什么越来越多人用 ZXing?
后端
Rain的Java大神之路1 小时前
🔥13年Java老兵转型AI Agent:90%的人挂在同一个坑,根本不用学Python!(万字实战复盘,建议收藏)
java·后端·面试
IT小番茄1 小时前
用Codex搭可视化大屏工作流 15个行业场景设计稿看完直接抄作业
后端
by————组态1 小时前
Ricon组态适用领域全景解析:工业制造、能源、市政民生与智慧城市四大场景落地实践
后端·物联网·数学建模·智慧城市·能源·制造·组态