下游只慢了 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
- 只重试可恢复且幂等的错误
- 关键依赖具备熔断、隔离和快速失败能力
超时和重试不是简单的两个配置项。
它们共同定义了一套故障时的流量控制策略。配置得好,系统能在瞬时抖动中恢复;配置得不好,重试会把一次局部变慢放大成全局雪崩。
留个问题
你们团队的超时和重试是全局统一配置,还是按接口分级设计?有没有遇到过"越重试,故障越大"的情况?欢迎在评论区聊聊。
如果你也在处理分布式系统的稳定性问题,也欢迎在微信公众号搜索「长安米粒贵」继续交流。