服务容错设计:重试、熔断、隔离、降级的正确组合顺序

举报
yd_254019812 发表于 2026/11/07 11:19:26 2026/11/07
【摘要】 一次下游抖动,被三层重试各放大 3 倍,最终变成 27 倍的流量砸在一个已经半死的服务上。 大多数"雪崩"不是被下游拖垮的,是被自己的重试机制打死的。 结论先说 | 手段 | 解决的问题 | 用错的代价 | |---|---|---| | 限流 | 入口流量超过容量 | 限太狠 → 正常请求被拒 | | 熔断 | 下游持续失败,快速失败 | 阈值太敏感 → 抖动期误熔断 | | 隔离 | 一...

一次下游抖动,被三层重试各放大 3 倍,最终变成 27 倍的流量砸在一个已经半死的服务上。

大多数"雪崩"不是被下游拖垮的,是被自己的重试机制打死的。

结论先说

| 手段 | 解决的问题 | 用错的代价 |

|---|---|---|

| 限流 | 入口流量超过容量 | 限太狠 → 正常请求被拒 |

| 熔断 | 下游持续失败,快速失败 | 阈值太敏感 → 抖动期误熔断 |

| 隔离 | 一个下游拖垮所有调用 | 线程池太多 → 上下文切换开销 |

| 超时 | 无限等待占满线程 | 设太长 → 等于没设;设太短 → 误判失败 |

| 重试 | 偶发失败(网络抖动、主从切换) | 放大流量,是雪崩的头号帮凶 |

| 降级 | 保核心、弃非核心 | 兜底数据错了比不返回更糟 |

正确的组合顺序(从外到内):

请求进入
  → ① 限流(保护自己)
  → ② 熔断(判断下游是否还值得调用)
  → ③ 隔离(限定这个下游能用多少资源)
  → ④ 超时(限定最多等多久)
  → ⑤ 重试(在超时预算内重试)
  → ⑥ 降级(最终失败时的兜底)

顺序错了会互相抵消。比如"先重试再熔断",重试的流量会不断刷新熔断器的统计窗口,导致永远熔不断。


一、先算清重试的放大倍数

这是最容易被忽略的一笔账。

服务 A(3 次重试) → 服务 B(3 次重试) → 服务 C(3 次重试) → DB

一个用户请求,最坏情况下打到 DB 的请求数:

3 × 3 × 3 = 27 次

如果 QPS 是 1000,DB 瞬间要扛 27000 QPS。而此刻 DB 已经因为压力大而超时——重试把"慢"变成了"死"。

三条纪律

纪律一:只重试幂等操作。

GET、带幂等键的 POST、查询类 RPC 可以重试;POST /orders(无幂等键)、扣款、发短信绝对不能。

纪律二:指数退避 + 抖动。

// ❌ 立刻重试:三个副本同时失败,重试也同时到达,必然继续失败
for (int i = 0; i < 3; i++) {
    if (tryCall()) return;
}

// ✅ 指数退避 + 随机抖动,把重试流量摊开
long backoff = base * (1L << attempt) + ThreadLocalRandom.current().nextLong(jitter);
Thread.sleep(backoff);

没有抖动的退避,会让同一时刻失败的所有请求在同一时刻重试——这叫重试风暴(thundering herd)。

纪律三:给重试设预算。

Netflix 提出的 retry budget:重试请求数不超过正常请求数的 10%。超过就停止重试。

// 简化实现:滑动窗口统计
if (retryCounter.rate() > 0.1) {
    throw new RetryBudgetExceededException();   // 直接放弃重试,走降级
}

这条规则的价值在于:它把"重试"从"无上限的补偿"变成了"有配额的特权"。下游真的挂了的时候,重试不会把系统彻底压死。

该重试什么,不该重试什么

| 错误类型 | 是否重试 |

|---|---|

| 连接超时 / 连接被拒 | ✅ 重试(换个实例可能就好) |

| 读超时 | ⚠️ 谨慎(可能已成功,非幂等会重复) |

| 5xx | ✅ 幂等接口可重试 |

| 4xx(400/401/403/404) | ❌ 重试无用,只会浪费 |

| 429(限流) | ✅ 但必须退避,否则加剧 |

| 业务异常(余额不足) | ❌ 绝不重试 |


二、超时:所有容错的前提

没有超时的调用,等于把线程永久借给了别人。

一个反直觉的事实:很多 HTTP 客户端默认不设读超时。

// ❌ OkHttp 默认 readTimeout 是 10s,但 connectTimeout 也是 10s,链路长了会叠加
// ❌ JDK HttpClient 默认无限等待
HttpClient.newHttpClient().send(req, BodyHandlers.ofString());   // 可能永远不返回

// ✅ 必须显式设置
HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofMillis(500))
        .build();
HttpRequest req = HttpRequest.newBuilder(uri)
        .timeout(Duration.ofMillis(1000))
        .build();

超时预算要逐层递减

用户请求总预算 3000 ms
  └─ 网关 2800 ms
      └─ 订单服务 2500 ms
          ├─ 库存服务 800 ms
          ├─ 优惠服务 500 ms
          └─ 支付服务 1000 ms

每一层的超时必须严格小于上游剩余预算,否则内层还没超时,外层已经放弃了——白干一场,还占着资源。

更进一步是 deadline 传递(gRPC 原生支持):把"还剩多少毫秒"作为请求头传下去,下游自己算。

// gRPC:调用方设置 deadline,会自动向下传播
stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS).query(req);

经验值:超时时间设在 P99 的 1.5 倍左右。设成平均 RT 会导致大量正常请求被误判。


三、熔断:三态机与阈值

熔断器的本质是一个带记忆的开关。

           失败率超阈值
  ┌──────┐ ──────────→ ┌──────┐
  │CLOSED│             │ OPEN │  直接失败,不调用下游
  └──────┘ ←────────── └──────┘
      ↑     探测成功        │ 等待窗口结束
      │                     ↓
      │               ┌───────────┐
      └────────────── │ HALF_OPEN │  放少量请求试探
        探测失败 → OPEN └───────────┘

| 状态 | 行为 |

|---|---|

| CLOSED | 正常调用,统计失败率 |

| OPEN | 直接抛异常(快速失败),不占用资源 |

| HALF_OPEN | 放行 N 个探测请求,全成功 → CLOSED,任一失败 → OPEN |

参数怎么定

以 Resilience4j 为例:

resilience4j:
  circuitbreaker:
    instances:
      inventory:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 100            # 最近 100 次调用
        minimum-number-of-calls: 20         # 至少 20 次才统计(防样本太少)
        failure-rate-threshold: 50          # 失败率 > 50%
        slow-call-duration-threshold: 800ms # 慢调用也算失败
        slow-call-rate-threshold: 60
        wait-duration-in-open-state: 10s    # 10 秒后进半开
        permitted-number-of-calls-in-half-open-state: 5
        automatic-transition-from-open-to-half-open-enabled: true

三个容易踩的点:

  1. minimum-number-of-calls 太小:启动时 2 次失败就熔断,误杀。
  2. 窗口太短:sliding-window-size: 10 时,一次抖动就能让失败率过 50%。
  3. 只统计异常不统计慢调用:下游没报错但 RT 从 50 ms 涨到 3 s,熔断器毫无反应,线程照样被占满。slow-call-duration-threshold 必须配。

熔断 vs 限流

| | 限流 | 熔断 |

|---|---|---|

| 保护对象 | 自己(入口流量) | 下游(也可能间接保护自己) |

| 依据 | QPS / 并发数 | 失败率 / 慢调用率 |

| 关闭条件 | 流量下降 | 探测成功 |

| 典型实现 | Sentinel、Guava RateLimiter | Resilience4j、Sentinel、Hystrix |

两者是互补的:限流挡住"太多人",熔断挡住"对面已经不行了"。


四、隔离:别让一个下游吃掉所有线程

4.1 线程池隔离

给每个下游一个独立的线程池:

// 库存服务:最多 20 个线程
ThreadPoolExecutor inventoryPool = new ThreadPoolExecutor(
        20, 20, 0, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(100),
        new ThreadPoolExecutor.AbortPolicy());   // 满了直接拒绝,快速失败

好处是故障被物理隔离:库存服务卡住,只占这 20 个线程,订单服务的主线程池安然无恙。

代价是线程数暴增:10 个下游 × 20 线程 = 200 个线程,上下文切换成本上来了。

4.2 信号量隔离

不换线程,只计数:

Semaphore inventorySem = new Semaphore(20);

boolean acquired = inventorySem.tryAcquire(100, TimeUnit.MILLISECONDS);
if (!acquired) {
    return fallback();      // 拿不到许可直接降级
}
try {
    return inventoryClient.query(id);
} finally {
    inventorySem.release();
}

轻量,但无法中断已经开始阻塞的调用——如果调用本身没有超时,信号量也救不了。

4.3 怎么选

| 场景 | 选择 |

|---|---|

| 下游是同步阻塞的 HTTP/RPC | 线程池隔离(能超时中断) |

| 下游很快、调用量大 | 信号量隔离(省线程) |

| 虚拟线程环境 | 信号量优先(虚拟线程不该池化) |

如果你的项目上了虚拟线程,线程池隔离基本失去意义——虚拟线程本来就是"一个任务一个",隔离靠的是 Semaphore 限流。

4.4 舱壁(Bulkhead)

隔离的极致是按租户/按业务线分池:VIP 用户的请求走独立池,普通用户挂了不影响 VIP。代价是资源利用率下降,只适合有明确等级的业务。


五、降级:最后的兜底

降级不是"返回 null",而是提前设计好的、语义正确的替代方案。

| 降级方式 | 示例 | 注意事项 |

|---|---|---|

| 静态兜底 | 推荐位返回运营配置的默认商品 | 数据不能过期太久 |

| 缓存兜底 | 库存查不到就返回 5 分钟前的缓存 | 要标注"可能不准" |

| 功能裁剪 | 关闭评价、推荐模块 | 页面要能优雅缺块 |

| 异步化 | 下单成功但积分稍后补 | 必须有补偿任务 |

| 排队 | 秒杀请求入队,返回"排队中" | 需要前端配合轮询 |

降级最怕的不是"降错了",而是"根本没设计"——真出事时临时写 return null,上游 NPE 一片。

开关要能动态生效

@Component
public class DegradeSwitch {
    @Value("${degrade.recommend.enabled:true}")
    private volatile boolean recommendEnabled;   // 配合配置中心热更新

    public List<Item> recommend(long uid) {
        if (!recommendEnabled) return Collections.emptyList();
        try {
            return recommendClient.query(uid);
        } catch (Exception e) {
            return Collections.emptyList();      // 降级:返回空列表,页面自己处理
        }
    }
}

六、组合起来:一份可落地的配置

@Service
public class InventoryService {

    @CircuitBreaker(name = "inventory", fallbackMethod = "fallback")
    @Bulkhead(name = "inventory", type = Bulkhead.Type.SEMAPHORE)
    @TimeLimiter(name = "inventory")               // 超时交给 TimeLimiter
    @Retry(name = "inventory")                     // 注意:只能配合幂等接口
    public CompletableFuture<Stock> query(long skuId) {
        return CompletableFuture.supplyAsync(() -> client.query(skuId), executor);
    }

    private CompletableFuture<Stock> fallback(long skuId, Throwable t) {
        // 降级优先级:本地缓存 → 静态默认值 → 明确失败
        Stock cached = localCache.get(skuId);
        if (cached != null) {
            cached.setStale(true);                 // 打标,前端可提示"数据可能延迟"
            return CompletableFuture.completedFuture(cached);
        }
        return CompletableFuture.completedFuture(Stock.unknown(skuId));
    }
}

相关配置(顺序很关键,Spring 的注解执行顺序由 @Order 或切面顺序决定):

resilience4j:
  retry:
    instances:
      inventory:
        max-attempts: 2                 # 只重试 1 次,别贪多
        wait-duration: 100ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        retry-exceptions:
          - java.net.ConnectException
          - java.io.IOException
        ignore-exceptions:
          - com.example.BizException     # 业务异常绝不重试
  timelimiter:
    instances:
      inventory:
        timeout-duration: 800ms
  bulkhead:
    instances:
      inventory:
        max-concurrent-calls: 20
        max-wait-duration: 50ms

注解顺序上,熔断要在重试外层:先判断"这个下游还值不值得试",再决定是否重试。反过来会让重试流量不断刷新熔断统计,永远熔不断。


七、怎么验证容错真的有效

容错设计最大的问题是没人验证,上线后第一次真故障才发现配置写错了。

7.1 混沌演练清单

| 演练 | 方法 | 期望 |

|---|---|---|

| 下游超时 | 用 tc 注入延迟 3 s | 熔断在 10 s 内打开,主流程降级成功 |

| 下游 50% 失败 | 故障注入(ChaosBlade) | 失败率超阈值后熔断,不再打下游 |

| 下游全挂 | 直接停掉实例 | 主流程 P99 不劣化,降级返回合理 |

| 线程池打满 | 压测到 2 倍容量 | 拒绝策略生效,不出现 OOM |

| 时钟回拨 | 手动改时间 | 无异常扩散 |

# 注入 3 秒网络延迟
tc qdisc add dev eth0 root netem delay 3000ms
# 恢复
tc qdisc del dev eth0 root

7.2 必看的指标

| 指标 | 含义 | 报警阈值 |

|---|---|---|

| circuitbreaker.state | 熔断器状态 | 任一实例 OPEN → 告警 |

| circuitbreaker.failure.rate | 失败率 | > 30% 预警 |

| bulkhead.available.concurrent.calls | 隔离池余量 | 长期为 0 → 容量不足 |

| retry.calls.total | 重试次数占比 | > 10% → 检查下游 |

| 降级触发次数 | fallback 执行次数 | 突增 → 排查 |

重试占比这个指标最容易被忽略,但它是最灵敏的"下游开始不行了"的信号——比失败率早出现。


八、小结

容错的本质不是"加了几个组件",而是三句话:

  1. 任何调用都必须有超时——否则线程会被无限期占用;
  2. 任何重试都必须有上限和退避——否则你会亲手把下游打死;
  3. 任何依赖都必须有降级路径——否则单个下游故障就是全站故障。

顺序上记住那张图:限流 → 熔断 → 隔离 → 超时 → 重试 → 降级。这个顺序保证每一层都在保护下一层,而不是互相抵消。


系列内链

  • 关联:[08-微服务与架构/03-Sentinel熔断限流](03-Sentinel熔断限流.md)——Sentinel 的规则配置与持久化
  • 关联:[08-微服务与架构/04-分布式事务方案](04-分布式事务方案.md)——重试与分布式事务的一致性配合
  • 关联:[08-微服务与架构/07-接口幂等设计](07-接口幂等设计.md)——重试的前提是幂等
  • 关联:[15-中间件与其他/07-限流算法与实现](../15-中间件与其他/07-限流算法与实现.md)——令牌桶/滑动窗口的具体实现
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。