我给下游调用加了失败自动重试本想让系统更可靠,结果某次下游只是变慢,重试却把流量放大了好几倍直接把它压垮:一次重试风暴拖垮整个链路的深度复盘

我有个服务要调下游接口,之前偶尔因下游抖动失败,我想加个重试吧、失败自动重试3次、成功率能高不少。平时确实好用。可那天下游因 GC 卡顿只是变慢了(响应 50ms 涨到 2 秒、没挂),我却眼睁睁看着它在几十秒内被打成彻底挂掉、整条链路雪崩。复盘才倒吸凉气:下游一变慢,大量请求超时失败,每个失败的请求立刻重试3次,本来1倍的流量瞬间变成3~4倍,把本来还撑得住的下游直接压垮;压垮后失败更多、重试更多、流量更大,形成正反馈的重试风暴。根因是我的重试没退避、没上限、没熔断,在下游已过载时不仅没帮忙反而放大流量、加速崩溃——本该减压时我反而加了压。这篇复盘从故障现场讲到重试风暴的成因(局部理性全局灾难)、安全重试的关键要素,再到指数退避+抖动、次数上限、熔断、只重试幂等+可重试错误、重试预算的完整正解,以及其他过载时反而加压的坑,和个体理性不等于集体理性、局部正确的策略群体同步时可能酿成灾难、要从群体视角推演并为危机设计退让的认知。

我给下游调用加了失败自动重试本想让系统更可靠,结果某次下游只是变慢,重试却把流量放大了好几倍直接把它压垮:一次重试风暴拖垮整个链路的深度复盘

那次雪崩,是我"好心加的重试"亲手点燃的。我有个服务要调下游的一个接口,之前偶尔会因为下游抖动而失败,我想"加个重试吧,失败了自动重试 3 次,成功率能高不少、系统更可靠",于是加上了。平时确实好用:偶发失败重试一下就成功了。可那天下午,下游服务因为一次 GC 卡顿、只是变慢了(没挂,只是响应从 50ms 涨到了 2 秒),接着我就眼睁睁看着:下游从"变慢"在几十秒内被打成了"彻底挂掉",我的服务也跟着雪崩,整条链路瘫痪。复盘监控时我倒吸一口凉气:问题出在我那个"看似让系统更可靠"的重试上。下游一变慢,大量请求开始超时/失败;每个失败的请求,都立刻重试,而且重试 3 次;于是本来 1 倍的流量,瞬间变成了 3~4 倍(原始请求 + 每个都重试几次)——下游本来只是"有点慢、还撑得住",被这突然翻几倍的流量,直接彻底压垮;压垮后失败更多→重试更多→流量更大→更压垮……形成了正反馈的"重试风暴(retry storm)",几十秒就雪崩了。根本原因是:我加的重试没有退避、没有全局上限、没有熔断,在下游"已经过载"时,重试不仅没帮忙,反而放大了流量、加速了它的崩溃——本该减压的时候,我反而加了压。问题的根,是重试在故障/过载时放大了负载(本来该退让却加压),且无退避、无上限、无熔断,形成正反馈把链路拖垮。这篇就把这次"重试风暴"的坑,从头到尾复盘一遍。

故障现场:好心的重试,放大成了灾难

问题在于重试在过载时放大流量、形成正反馈:

// 我加的"重试", 看起来很合理, 出问题时却是灾难放大器:
public Response callDownstream(Request req) {
    int maxRetry = 3;
    for (int i = 0; i <= maxRetry; i++) {
        try {
            return httpClient.call(downstreamUrl, req);   // 调下游
        } catch (TimeoutException | IOException e) {
            if (i == maxRetry) throw e;
            // ✗ 立刻重试, 没有任何等待(退避), 没有判断下游是不是已经过载!
            log.warn("调用失败, 立刻重试第{}次", i + 1);
        }
    }
    throw new IllegalStateException();
}

/*
灾难是怎么发生的(下游因GC只是变慢, 响应50ms→2s):
  1. 下游变慢 → 我的调用开始超时(假设我超时设1s, 下游要2s) → 大量请求"失败";
  2. 每个失败的请求 → 立刻重试, 重试还失败 → 再重试 → 共发4次(1原始+3重试);
  3. 于是: 本来100 QPS的流量, 瞬间变成 ~400 QPS 压向"已经很吃力"的下游;
  4. 下游本来只是慢(还能勉强处理), 被这4倍流量直接压垮 → 真的挂了;
  5. 挂了之后, 所有请求都失败 → 都重试4次 → 流量持续4倍 → 下游永远起不来;
  6. 我的服务: 每个请求都要重试4次、每次等超时, 线程/连接全被占满 → 我也雪崩;
  7. 正反馈: 失败越多→重试越多→流量越大→失败更多……"重试风暴"几十秒拖垮整条链路。

致命点: 重试是"局部"决策(我这一个请求想成功), 但当"所有请求"在下游过载时
   都做这个决策, 汇总成的"全局"效果是: 在系统最脆弱时, 给它狠狠加了几倍的压。
   重试本该是"偶发失败"的解药, 却成了"系统性过载"的毒药——它放大了负载、加速了崩溃。
*/

看着监控上那条"下游 QPS 在它变慢的瞬间不降反升、飙到平时 4 倍"的曲线,我又懊悔又后怕:"我加重试是想让系统更可靠啊,怎么反倒成了压垮它的最后一根稻草?早知道重试在过载时会这样放大流量……"这个坑最反直觉的地方在于:重试这个措施,动机和单次效果都是好的(提高单个请求的成功率),在偶发、独立的故障下也确实有效;它的危害只在"系统性过载/下游变慢"这个特定场景下才暴露,而且是以"正反馈放大"的形式急剧爆发——平时它是解药,关键时刻它是毒药下面就来拆解,重试到底该怎么加。

第一件事:搞懂重试风暴与"过载时该退让而非加压"

我顺着这次事故,把重试的正确姿势彻底理清了。

重试为什么会变成"风暴"? 怎么加才安全?

【核心: 重试在"系统性过载"时会放大流量、形成正反馈雪崩; 安全的重试要有退避+抖动、次数上限、
   熔断、只重试幂等+可重试错误、重试预算——过载时系统该"退让减压"而非"加压"】

1. 重试风暴的成因: 局部理性, 全局灾难
   - 单个请求重试: 理性(想成功); 但所有请求在下游过载时都重试 → 全局流量翻几倍;
   - 偏偏这时下游最脆弱 → 翻倍流量压垮它 → 更多失败 → 更多重试 → 正反馈雪崩。

2. 安全重试的几个关键要素:
   ① 退避(backoff): 别立刻重试, 等一会再试; 用"指数退避"(等1s、2s、4s…), 给下游喘息时间;
   ② 抖动(jitter): 退避时间加随机, 避免所有客户端"同时"重试造成新的流量尖峰(惊群);
   ③ 次数上限: 重试2~3次就够, 别无限重试; 总耗时也要有上限(别让一个请求重试到天荒地老);
   ④ 熔断(circuit breaker): 下游连续失败到一定比例, 直接"熔断"——一段时间内不再调用(快速失败),
      给下游恢复时间; 这是"过载时主动退让"的关键, 防止重试风暴;
   ⑤ 只重试该重试的: 只对"幂等操作"+"可重试错误(超时、503、连接失败)"重试;
      对"非幂等操作(如扣款)"或"明确的业务错误(如参数错误400)"重试是有害/无用的;
   ⑥ 重试预算(retry budget): 限制"重试流量占总流量的比例"(如不超过10%), 从全局上限制放大倍数。

3. 更深的原则: 过载时, 系统该"退让"而非"加压"
   - 健康的分布式系统, 在某部分过载时, 应该"减少对它的压力"(让它恢复), 而不是"加大压力";
   - 重试、无脑切换、客户端不断刷新, 都是"加压"行为, 会让过载恶化;
   - 限流、熔断、退避、降级, 都是"减压/退让"行为, 帮系统恢复——这才是过载时该做的。

4. 重试 vs 不重试的权衡:
   - 重试提高单次成功率, 但增加下游负载和延迟; 不是越多越好;
   - 对"读、幂等、偶发失败": 适度重试(带退避)有益;
   - 对"系统性故障(下游挂了/过载)": 重试无益且有害, 该靠熔断快速失败 + 告警 + 降级。

一句话: 重试在系统性过载时会放大流量、正反馈雪崩(重试风暴); 安全的重试必须带指数退避+抖动、次数上限、
   熔断、只重试幂等+可重试错误、重试预算; 根本原则是"过载时系统该退让减压, 而非加压"。

这套认知,是整个坑的根。重试风暴的成因:单个请求重试是理性的,但所有请求在下游过载时都重试,全局流量翻几倍,偏偏这时下游最脆弱→压垮→更多失败→更多重试→正反馈雪崩安全重试的关键要素:指数退避(给喘息)+抖动(避免惊群)、次数上限、熔断(过载时主动退让)、只重试幂等+可重试错误、重试预算(限制放大倍数)更深的原则:过载时系统该"退让减压"(限流/熔断/退避/降级)而非"加压"(重试/无脑切换/狂刷新)重试 vs 不重试的权衡:对读/幂等/偶发失败适度重试有益,对系统性故障重试有害、该熔断快速失败。一句话:重试在系统性过载时会放大流量、正反馈雪崩(重试风暴);安全的重试必须带指数退避+抖动、次数上限、熔断、只重试幂等+可重试错误、重试预算;根本原则是"过载时系统该退让减压,而非加压"。

第二件事:正解——退避 + 抖动 + 上限 + 熔断

知道了重试风暴的成因,正解就清楚了:让重试"克制",并在过载时靠熔断退让。

// 正解1: 指数退避 + 抖动, 限制次数(别立刻重试、别同时重试)
public Response callWithBackoff(Request req) {
    int maxRetry = 2;                  // 上限2次(不是越多越好)
    long base = 100;                   // 基础退避100ms
    for (int i = 0; i <= maxRetry; i++) {
        try {
            return httpClient.call(downstreamUrl, req);
        } catch (RetryableException e) {            // 只对"可重试错误"重试
            if (i == maxRetry) throw e;
            long backoff = base * (1L << i);         // 指数: 100、200、400ms
            long jitter = (long) (backoff * Math.random() * 0.5);  // 抖动: 加随机, 避免惊群
            sleep(backoff + jitter);                 // 等一会再试, 给下游喘息
        }
    }
    throw new IllegalStateException();
}

// 正解2: 熔断器——过载时主动"退让", 快速失败, 给下游恢复时间
//   (用 Resilience4j / Sentinel 等成熟组件, 别自己造)
CircuitBreaker cb = CircuitBreaker.of("downstream", config);
public Response callWithCircuitBreaker(Request req) {
    return cb.executeSupplier(() -> {
        // 熔断器统计失败率: 失败率超阈值(如50%) → 打开熔断 →
        // 一段时间内直接快速失败(不再真正调下游), 让下游喘息恢复 →
        // 半开状态试探性放几个请求 → 成功则关闭熔断、恢复正常。
        return httpClient.call(downstreamUrl, req);
    });
}

// 正解3: 只重试"幂等 + 可重试"的; 非幂等/业务错误不重试
boolean shouldRetry(Throwable e, Request req) {
    if (!req.isIdempotent()) return false;            // 非幂等(如扣款): 不重试, 避免重复执行
    if (e instanceof TimeoutException) return true;   // 超时: 可重试
    if (e instanceof HttpException he) {
        int code = he.statusCode();
        return code == 503 || code == 502 || code == 429;  // 服务不可用/限流: 可重试
        // 400参数错误、401未授权、404: 重试也没用, 不重试。
    }
    return false;
}

// 正解4: 治本——连接复用 + 限流 + 降级(同547连接复用、355超时)
//   - 设合理超时(355), 别让请求堆积; 复用连接(547);
//   - 入口限流: 保护自己不被打垮; 降级: 下游不可用时返回兜底数据/默认值。

// 核心: 重试要"克制"(退避+抖动+上限+只重试该重试的); 过载时靠"熔断"主动退让快速失败;
//   配合限流、降级、合理超时——让系统在故障时"减压恢复", 而不是"加压雪崩"。

这套正解的关键,是让重试从"无脑立刻重试"变成"克制、退让"指数退避 + 抖动:别立刻重试(给下游喘息)、退避加随机(避免所有客户端同时重试造成新尖峰)。次数上限:重试 2~3 次就够,别无限重试。熔断:这是过载时主动退让的关键——失败率超阈值就熔断、一段时间快速失败,给下游恢复时间。只重试该重试的:只对幂等操作 + 可重试错误(超时/503/限流)重试,非幂等(扣款)和业务错误(400)不重试。而治本之策,是配合合理超时(避免堆积)、连接复用、入口限流、降级兜底,让系统在故障时整体走向"减压恢复"而非"加压雪崩"。

第三件事:其他几个"过载时反而加压"的坑

顺着重试风暴,我把"本想救火、反而浇油"的几类操作也一并理了:

几类"过载时不该做、却本能想做"的加压行为:

坑1: 无脑故障切换(failover)——主挂了所有流量切到从, 从也被同样的流量打垮(多米诺)。
   正解: 切换也要配合限流; 考虑流量本身就是病因时, 切换只是换个受害者。

坑2: 客户端疯狂刷新/用户狂点重试按钮——页面卡了用户狂刷新, 流量更大, 服务更卡。
   正解: 前端也要退避(按钮置灰、提示稍后)、错误页别自动刷新。

坑3: 健康检查太激进——下游慢, 健康检查判定不健康、摘除节点, 流量压到更少节点上, 更快垮。
   正解: 健康检查阈值要宽容, 别因短暂变慢就大面积摘除。

坑4: 缓存击穿后全部打到DB——热点key过期瞬间, 所有请求同时回源DB, 压垮DB(同缓存击穿)。
   正解: 互斥重建缓存(只让一个请求回源)、逻辑过期、空值缓存。

坑5: 超时设太长 + 不重试也堆积——下游慢, 请求都卡在那等超时, 线程/连接耗尽(355无超时)。
   正解: 合理超时 + 快速失败 + 隔离(舱壁模式), 别让一个慢下游拖垮整个服务。

共同的根: 系统过载/故障时, 很多"本能的、想提高可用性的"动作(重试、切换、刷新、回源),
   其实是在"加压", 会让过载恶化、形成正反馈雪崩; 正确的方向是"减压/退让"
   (退避、熔断、限流、降级、隔离), 给系统恢复的空间。

这些坑看似不同,根却是同一个:系统过载时,人(和系统)的本能反应往往是"加压"(重试、切换、刷新、回源——都是"更努力地想成功");可过载的系统需要的恰恰相反——它需要"被减压、被退让",才能恢复认清这个根("过载时该退让减压,而非加压"),就能对各种"救火反浇油"的操作有警觉了。

第四件事:无脑重试 vs 安全重试——一张对照表

我把"我之前的无脑重试"和"安全的重试"逐项对照,整理成一张表,贴在了团队的稳定性规范里:

维度 无脑重试(灾难) 安全重试(正解)
重试时机 失败了立刻重试 退避后重试(指数 + 抖动)
重试次数 固定多次 / 无上限 2~3 次上限,总耗时也限
哪些错误重试 所有错误都重试 只重试可重试错误(超时/503/限流)
幂等性 不区分,扣款也重试 只对幂等操作重试
过载保护 无,过载时照样狂重试 熔断:连续失败就快速失败退让
全局放大 流量瞬间翻几倍 重试预算限制放大比例
过载时的行为 加压(雪上加霜) 退让减压(给系统恢复空间)
故障时结果 正反馈,几十秒雪崩 快速失败 + 降级,系统存活

这张表里,我犯的几乎是左边每一条:失败立刻重试、固定 3 次、所有错误都重、不分幂等、没有熔断、没有预算——于是过载时每一条都在"加压"右边的每一条,核心都指向一件事:让重试"克制、有节制",并在系统过载时能主动退让(熔断)而非一味加压

第五件事:重试相关的几组容易混淆的概念

这次事故也让我厘清了几组关于重试与可靠性的、容易想当然的概念:

直觉以为 实际上
加重试 = 系统更可靠 不当的重试在过载时会放大故障、降低可用性
重试次数越多越容易成功 过载时重试越多压力越大,越不可能成功
失败了就该立刻重试 立刻重试会瞬间加压,应退避后再试
所有失败都值得重试 非幂等操作、业务错误重试有害或无用
下游慢,我多试几次总能成 下游慢往往因过载,你的重试正是过载之源
熔断是放弃,不够努力 熔断是主动退让,是让系统整体存活的智慧
稳定性靠每个请求都拼命成功 稳定性靠系统在故障时优雅降级、快速失败

这张表里,我栽的是第一行和第五行:把"加重试"等同于"更可靠",又在下游变慢时以为"多试几次总能成",却没意识到我的重试正是把它从""推向""的推手厘清这些,核心是一个意识:可靠性不是"每个请求都拼命成功"堆出来的,而是"系统在局部故障/过载时,能优雅地退让、降级、快速失败,保住整体不雪崩"换来的;有时候,"果断地放弃一个请求(熔断/快速失败)",恰恰是为了"救活整个系统"。

第六件事:加重试/做容错时,我现在的自检习惯

现在每当我要给一个调用加重试、或做容错设计,我都会先按这张图问自己:

这张图的精髓,是"幂等才重试、退避加抖动、设上限、加熔断、过载时减压而非加压"先问幂等吗、再问退避抖动了吗有上限吗有熔断吗、最后过载时是加压还是减压这套习惯,让我从"失败了就多试几次"变成了"克制地重试、过载时退让"——核心始终是:重试在系统性过载时会放大流量、正反馈雪崩;安全的重试要带指数退避+抖动、次数上限、熔断、只重试幂等+可重试错误;根本原则是过载时系统该退让减压而非加压。

我立下的几条规矩

这场"好心的重试酿成雪崩"的事故,换来了我做容错设计时,刻进骨子里的几条铁律:

  1. 重试在系统性过载时会放大流量、形成正反馈雪崩(重试风暴),好心可能办坏事。
  2. 重试必须带指数退避 + 抖动,别失败了立刻重试、别所有客户端同时重试。
  3. 重试次数和总耗时都要有上限,2~3 次足矣,别无限重试。
  4. 只对幂等操作 + 可重试错误(超时/503/限流)重试;非幂等、业务错误不重试。
  5. 用熔断器:连续失败就快速失败、暂停调用,给下游恢复时间(过载时主动退让)。
  6. 配合合理超时、连接复用、入口限流、降级兜底、舱壁隔离做整体容错。
  7. 系统过载时,默认方向是"减压退让"而非"加压努力"——重试/切换/刷新要警惕。

附:容错的一套配置清单

借这次重试风暴的坑,我把团队里调用下游时的容错配置,整理成了一份清单,作为新接口接入的检查项。

# ====== 调用下游的容错配置清单 ======
downstream-call:
  timeout:
    connect: 500ms        # 连接超时(355篇: 必须设, 别无限等)
    read: 1s              # 读超时(根据下游P99设, 别设太长导致堆积)
  retry:
    max-attempts: 2       # 重试上限2次(不是越多越好)
    backoff:
      type: exponential   # 指数退避: 100ms、200ms...
      base: 100ms
      jitter: true        # 加抖动, 避免所有客户端同时重试(惊群)
    retry-on: [timeout, 503, 502, 429]   # 只重试可重试错误
    retry-only-idempotent: true          # 只对幂等操作重试
  circuit-breaker:
    failure-rate-threshold: 50%   # 失败率超50%打开熔断
    wait-duration-in-open: 10s    # 熔断后10s快速失败, 给下游恢复
    half-open-calls: 5            # 半开放5个试探
  bulkhead:
    max-concurrent: 50    # 舱壁隔离: 限制并发, 别让一个下游占满所有线程
  fallback:
    enabled: true         # 降级: 下游不可用时返回兜底数据

这份清单的每一项,都对应着这次事故的一个教训:合理超时(不堆积)、克制重试(退避+抖动+上限+只重试该重试的)、熔断(过载退让)、舱壁(隔离)、降级(兜底)。把这些固化成"接入下游的标准配置",比每个人各自踩一遍重试风暴的坑,要划算得多。

写在最后

回头看,这场由"一个好心加的重试"引发的、把下游从""推向""的雪崩,真正教给我的,远不止"重试要带退避和熔断"这一个技巧。它让我对"一个'局部看完全理性、动机完全善良'的行为, 当'所有个体在同一时刻都这么做'时, 汇总出的'全局后果'可能是灾难性的; 个体理性 ≠ 集体理性",有了一次刻骨的体会。我栽跟头,是因为我只站在"单个请求"的视角看重试——对那一个请求来说,"失败了多试几次以求成功"完全合理;可我没看到:当下游过载、"所有"请求都在"多试几次"时,这些"各自理性"的重试汇总成了一股把下游彻底压垮的洪流;每个请求都在"为自己的成功而努力", 合起来却"让所有请求都更不可能成功"——这是一场典型的"公地悲剧"。这让我领悟到一个关于"局部与全局、个体与集体"的深刻认知:评估一个行为/策略的好坏,不能只看"它对单个个体、在孤立情况下的效果",更要看"当大量个体在相关联的情况下都采取它时, 汇总出的系统级效果"——很多在"个体、局部、平时"层面正确的事(重试、抢购、踩刹车、囤货), 在"群体、全局、危机"层面会因为'同步、放大、正反馈'而走向反面;"每个人都做对自己最有利的事", 不一定导向"对整体最好的结果", 有时恰恰相反这给了我一种设计系统(乃至理解社会现象)时的清醒:设计任何"每个个体都会执行的策略/机制"时,一定要做一次"从单体视角切换到群体视角"的推演——问自己"如果所有节点/用户/请求在同一时刻(尤其是系统已经出问题的时刻)都执行这个策略, 会发生什么?会不会形成正反馈、相互放大?";"跳出单体视角、用'群体同时行动'的镜头审视每一个看似无害的局部策略, 并为'危机时刻的集体行为'设计退让机制(退避、熔断、限流)",是避免'个体理性汇成集体灾难'的关键认清个体理性不等于集体理性、局部正确的策略群体同步时可能酿成灾难、要从群体视角推演并为危机设计退让——这,是我用一次重试风暴的雪崩,换来的、关于分布式系统、也关于如何理解局部与全局之关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给某个调用加重试时,顺手想一想"下游过载、所有请求都重试会怎样"、并给它配上退避和熔断,那我对着那条"变慢瞬间 QPS 不降反升 4 倍"的曲线复盘的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我明明给表建了联合索引,有些查询却还是慢得像全表扫描,EXPLAIN 一看根本没走索引,因为我的查询条件不符合最左前缀:一次数据库联合索引最左前缀的深度复盘

2026-6-2 23:43:16

技术教程

我给容器设了 CPU limit,监控上 CPU 用量明明远没到上限,服务却偶发严重的延迟尖刺和卡顿,查了半天才发现它一直在被悄悄节流:一次容器 CPU limit 与 CFS 配额节流的深度复盘

2026-6-2 23:56:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索