韧性架构三件事:超时、重试和熔断

0阅读10分钟

从一次线上事故说起

去年我负责的一个电商中台,因为下游库存服务 P99 延迟从 50ms 涨到 2 秒,导致上游网关的连接池被慢慢耗尽。整个过程没有报错,没有告警,只是「慢」——结果 15 分钟后所有服务全部不可用。

事后复盘发现三个问题:

  1. 调用库存服务没有设超时,HTTP Client 用的是默认值(有些语言根本没有默认超时)
  2. 网关层有重试逻辑,但没有区分错误类型——超时的请求也被重试了
  3. 没有任何熔断机制,下游明明已经撑不住了,上游还在源源不断发请求

这三个问题分别对应韧性架构的三个核心模式:超时、重试、熔断。它们不是什么高深的理论,是分布式系统里最基础的防线。但我在 Code Review 里见到的大多数代码,这三件事要么没做,要么做错了。

这篇文章把我这些年踩过的坑和总结的经验整理出来,希望对你有帮助。

理论基础:这些模式从哪来

韧性架构的核心思想来自几个来源:

Michael Nygard 的《Release It!》 是最早系统性讨论生产环境软件稳定性的书。他把超时、重试、熔断、舱壁隔离等模式归纳为「稳定性模式」,核心观点是:分布式系统的失败不是异常,是常态。架构设计必须把失败作为默认路径来考虑。

Martin Fowler 在 2014 年写的 Circuit Breaker 文章 把熔断器模式从工程实践提升到了设计模式的层面。他用电路熔断器做类比:正常时闭合(closed),故障积累后断开(open),冷却后进入半开(half-open)探测恢复。这个三态模型至今是大多数实现的基础。

Google SRE 的 Error Budget 理念 从另一个角度提供了约束:系统允许的失败是有预算的。重试会消耗预算,熔断会保护预算,超时则是预算的基本计量单位。Linkerd 的 Matt Klein 提出了「重试预算」的概念——重试流量不应超过正常流量的 20%,否则重试本身就会成为压垮下游的最后一根稻草。

Temporal.io 在 2025 年的一篇实践中总结了一个分层策略:

重试处理瞬时故障,熔断处理持续性问题,降级处理无法恢复的场景,超时是所有这些机制的触发前提。

这四个模式不是独立的,它们是一条链路上的四道防线。

第一道防线:超时

没有超时的调用是定时炸弹

一个跨进程调用(HTTP、gRPC、数据库查询、消息队列)如果没有超时,最坏情况下会无限等待。等待期间占用的连接、线程、内存都不会释放。当这类「僵尸请求」积累到一定数量,上游服务就被拖死了。

超时该怎么设

超时的值不能拍脑袋。我见过太多项目里所有接口统一用 30 秒超时,这等于没有超时。合理的做法是:

  • 根据下游的 P99 或 P999 延迟来定,比如 P99 是 200ms,超时设 500ms 留余量
  • 不同接口区分对待,查询接口和写入接口的容忍度不同
  • 有级联关系时要算「超时预算」——用户请求总超时 3 秒,经过 3 层服务调用,每层的超时加起来不能超过 3 秒

坏做法 vs 好做法

# ❌ 坏做法:没有超时,默认值可能是无限等待
import requests
 
def get_user_profile(user_id):
    resp = requests.get(f"http://user-service/api/profile/{user_id}")
    return resp.json()
# ✅ 好做法:显式超时 + 根据下游能力设置合理值
import requests
 
def get_user_profile(user_id, timeout_ms=500):
    try:
        resp = requests.get(
            f"http://user-service/api/profile/{user_id}",
            timeout=timeout_ms / 1000
        )
        resp.raise_for_status()
        return resp.json()
    except requests.Timeout:
        logger.warning("user-service profile timeout", extra={"user_id": user_id})
        raise ServiceTimeoutError("user-service profile request timed out")
// ❌ 坏做法:context 没有超时,goroutine 可能永久阻塞
func GetUser(ctx context.Context, id string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("http://user-service/api/%s", id))
    // 如果 user-service 挂掉,这个 goroutine 永远不会返回
}
// ✅ 好做法:用 context.WithTimeout 控制超时,并传播截止时间
func GetUser(ctx context.Context, id string, timeoutMs time.Duration) (*User, error) {
    ctx, cancel := context.WithTimeout(ctx, timeoutMs*time.Millisecond)
    defer cancel()
 
    req, _ := http.NewRequestWithContext(ctx, "GET",
        fmt.Sprintf("http://user-service/api/%s", id), nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("user-service timeout after %dms", timeoutMs)
        }
        return nil, err
    }
    defer resp.Body.Close()
    // ...
}

超时的关键原则是:每一个跨进程调用都必须有显式超时,超时值基于下游的实际延迟分布来定,而不是用一个全局默认值。

第二道防线:重试

重试是双刃剑

瞬时故障(网络闪断、连接池短暂耗尽、下游 GC 停顿)通过重试确实能恢复。但如果重试不加控制,它会把一次故障放大成雪崩。

假设一个请求调用 3 层服务,每层重试 3 次,一次初始失败会变成 3×3×3 = 27 次调用。这就是 Temporal 文章里说的「exponential pain」。

重试的正确姿势

重试必须满足几个条件:

  1. 操作必须幂等——非幂等操作(比如扣款)重试会造成重复执行
  2. 只对特定错误重试——5xx、超时、连接拒绝可以重试;4xx(参数错误、权限不足)重试没有意义
  3. 有退避策略——指数退避 + 抖动(jitter)是标准做法,防止重试请求在同一时刻集中打到下游
  4. 有最大次数限制——无限重试等于无限等待
  5. 有重试预算——重试流量不超过正常流量的 20%

坏做法 vs 好做法

# ❌ 坏做法:无条件重试、没有退避、没有区分错误类型
def call_payment_service(order_id):
    for i in range(5):
        try:
            resp = requests.post("http://payment/charge", json={"order_id": order_id})
            return resp.json()
        except Exception:
            continue  # 所有异常都重试,包括参数错误;无间隔,立即重试
    raise PaymentError("payment failed after 5 retries")
# ✅ 好做法:区分错误类型、指数退避 + 抖动、最大次数限制
import random
import time
 
def call_payment_service(order_id, max_retries=3, base_delay_ms=100):
    for attempt in range(max_retries + 1):
        try:
            resp = requests.post(
                "http://payment/charge",
                json={"order_id": order_id, "idempotency_key": f"{order_id}-{attempt}"},
                timeout=2.0
            )
            # 4xx 错误不重试——参数问题重试没有意义
            if resp.status_code < 500:
                resp.raise_for_status()
                return resp.json()
            # 5xx 才进入重试逻辑
            if attempt < max_retries:
                delay = base_delay_ms * (2 ** attempt)
                jitter = random.randint(0, base_delay_ms)
                time.sleep((delay + jitter) / 1000)
        except requests.ConnectionError:
            if attempt < max_retries:
                delay = base_delay_ms * (2 ** attempt)
                jitter = random.randint(0, base_delay_ms)
                time.sleep((delay + jitter) / 1000)
                continue
            raise
    raise PaymentError(f"payment failed after {max_retries} retries")

这里有两个关键细节值得注意:

  • 幂等键(idempotency_key):即使操作本身不是天然幂等的,通过传递唯一键让下游去重,也能让重试变得安全
  • 抖动(jitter):AWS 在「超时、重试和带抖动的退避」文档中详细解释过,纯指数退避会导致所有客户端在同一时刻同步重试,抖动打破这种同步

重试预算

Linkerd 提出的重试预算概念值得单独说说。它的核心思想是:用一个令牌桶(token bucket)限制重试流量的比例。

重试预算令牌 = 正常 QPS × 预算比例 × 时间窗口

比如正常 QPS 1000,预算比例 20%,时间窗口 10 秒,那么 10 秒内最多允许 2000 次重试。预算耗尽后,新的失败直接返回错误,不再重试。

这个机制可以在应用层实现,也可以在 Service Mesh(如 Linkerd)的基础设施层统一控制。

第三道防线:熔断和降级

什么时候需要熔断

重试处理的是「瞬时故障」——偶尔一次网络抖动、一次 GC 停顿。但当下游持续不可用时(比如数据库主节点宕机、第三方服务全线故障),继续重试只会浪费资源。

熔断器的作用是:检测到下游持续失败后,快速断开调用,直接返回错误或降级结果,给下游喘息的时间。

熔断器的三态模型

流程图画布 · 115%
Mermaid 流程图加载中...

三个状态的含义:

状态行为类比
Closed(闭合)正常放行请求,统计失败率电路正常通电
Open(断开)所有请求直接失败,不调用下游电路跳闸
Half-Open(半开)放少量探测请求,测试下游是否恢复电工试送电

熔断器的关键参数

参数含义建议值
failure_rate_threshold失败率阈值,超过后从 Closed 转为 Open50%(10 秒窗口内)
slow_call_rate_threshold慢调用率阈值80%(响应超过 timeout 的比例)
wait_duration_in_openOpen 状态持续时间10-30 秒
permitted_calls_in_half_openHalf-Open 状态允许的探测请求数5-10
sliding_window_size统计窗口大小100 次请求或 10 秒
minimum_number_of_calls触发统计的最小调用数10(避免少量请求就误触发)

坏做法 vs 好做法

// ❌ 坏做法:没有熔断,下游挂了还在不断调用
public UserProfile getUserProfile(String userId) {
    // 每次请求都直接调用下游,即使下游已经挂了 10 分钟
    return restTemplate.getForObject(
        "http://user-service/api/profile/" + userId,
        UserProfile.class
    );
}
// ✅ 好做法:使用 Resilience4j 的熔断器包装调用
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
 
import java.time.Duration;
 
// 配置熔断器
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)                    // 失败率超过 50% 触发熔断
    .slowCallRateThreshold(80)                   // 慢调用超过 80% 也触发
    .slowCallDurationThreshold(Duration.ofMillis(500))
    .waitDurationInOpenState(Duration.ofSeconds(15))  // 熔断 15 秒后尝试恢复
    .permittedNumberOfCallsInHalfOpenState(5)    // 半开状态放 5 个探测请求
    .slidingWindowSize(100)                      // 统计最近 100 次调用
    .minimumNumberOfCalls(10)                    // 至少 10 次调用才开始统计
    .build();
 
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker circuitBreaker = registry.circuitBreaker("userService");
 
// 使用熔断器包装调用
public UserProfile getUserProfile(String userId) {
    return CircuitBreaker.decorateSupplier(circuitBreaker, () ->
        restTemplate.getForObject(
            "http://user-service/api/profile/" + userId,
            UserProfile.class
        )
    ).get();
}

降级:Plan B

熔断之后怎么办?降级是熔断的后续动作。降级的核心原则是:用更简单的逻辑返回一个「能用」的结果,而不是让用户体验到「不可用」。

常见的降级策略:

策略适用场景示例
返回缓存数据可以容忍短暂过期商品详情页返回上次的缓存数据
返回默认值非关键数据推荐位显示「热门商品」而不是个性化推荐
关闭非核心功能功能不影响主流程暂时关闭「收藏」「分享」功能
简化逻辑复杂计算可以降级运费计算从实时报价降级为固定运费
快速失败 + 友好提示无法降级时返回「服务繁忙,请稍后再试」,而不是 30 秒白屏
# ✅ 降级示例:推荐服务不可用时返回热门商品缓存
def get_recommendations(user_id):
    try:
        return recommend_service.get_personalized(user_id, timeout_ms=200)
    except ServiceTimeoutError:
        # 降级:返回全局热门商品缓存
        logger.info("recommend service timeout, fallback to hot items")
        return cache.get("global_hot_items")
    except CircuitBreakerOpenError:
        # 熔断:快速失败,返回缓存
        logger.info("recommend circuit open, fallback to cache")
        return cache.get(f"user_hot_items:{user_id}")

降级逻辑必须比正常逻辑更简单、更可靠。如果你的降级逻辑本身还要调用三个服务,那就失去了降级的意义。

组合使用:四层防线的协同

这四个模式不是互相替代的,是层层递进的。一个典型的请求处理链路:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程图展示了一条关键的决策链:

  1. 超时是所有判断的前提——没有超时,后面的重试和熔断都没有触发条件
  2. 重试只针对可恢复的错误,且受预算约束
  3. 熔断是全局保护——当下游持续不可用时,直接切断调用
  4. 降级是最后手段——当所有恢复手段都失败时,给用户一个「能用」的结果

主流工具对比

不同语言生态都有成熟的韧性库,核心模式一致,API 风格各异:

维度Resilience4j (Java)Polly (.NET)failsafe-go (Go)Spring Cloud Circuit Breaker
支持模式熔断、重试、限流、隔离、超时、降级重试、熔断、超时、降级、限流、对冲重试、熔断、超时、降级、隔离、限流封装 Resilience4j / Sentinel
API 风格装饰器 / 函数式流式构建器函数链式注解 + 配置
与 Spring 集成原生支持不适用不适用原生支持
指标采集Micrometer 集成OpenTelemetryPrometheusActuator + Micrometer
成熟度高,Hystrix 的替代者高,.NET 官方推荐中等,社区驱动高,Spring 生态标准
配置方式代码 / YAML代码 / 配置代码YAML 为主
Service Mesh 替代可选可选可选Istio / Linkerd 可在基础设施层实现
模式应用层实现(如 Resilience4j)基础设施层实现(如 Istio)
超时代码级别,灵活控制每个调用全局或 VirtualService 级别
重试可以精细控制重试条件声明式配置,粒度较粗
熔断可以结合业务逻辑判断基于 HTTP 状态码,不感知业务
降级可以实现复杂的降级逻辑只能返回固定响应或转发到其他服务
隔离线程池 / 信号量隔离连接池隔离

选择建议:如果团队使用 Java/Spring,Resilience4j 是标准选择;.NET 项目用 Polly;Go 项目用 failsafe-go 或者直接用标准库的 context.WithTimeout 配合自定义重试逻辑。如果已经上了 Service Mesh,可以把超时和重试下沉到基础设施层,但熔断和降级建议保留在应用层,因为它们通常需要业务上下文。

生产环境检查清单

设计阶段

  • 所有跨进程调用(HTTP、gRPC、数据库、消息队列)都有显式超时
  • 超时值基于下游 P99/P999 延迟设定,不是全局默认值
  • 级联调用的超时预算已经计算过,总和不超过用户请求的总超时
  • 只有幂等操作或带幂等键的操作才配置了自动重试
  • 重试只对 5xx、超时、连接错误生效,4xx 不重试
  • 重试策略包含指数退避 + 抖动

实现阶段

  • 重试次数有上限(建议 2-3 次)
  • 配置了重试预算,重试流量不超过正常流量的 20%
  • 关键下游服务配置了熔断器
  • 熔断器参数(失败率阈值、冷却时间、半开探测数)经过压测验证
  • 降级逻辑已实现并测试,降级结果对用户可接受
  • 降级逻辑不依赖外部服务,只用缓存、默认值或简单规则

运维阶段

  • 超时率、重试率、熔断器状态有监控和告警
  • 降级触发时有日志和指标,可以被追踪
  • 做过故障注入测试(如 ChaosBlade、Chaos Monkey)验证韧性机制
  • 定期 Review 超时和熔断参数,随流量和业务变化调整

小结

韧性架构不是让每次请求都成功——那在分布式系统里做不到。它是让局部失败不扩散成全站故障,让用户在部分功能不可用时仍能完成核心操作。

超时、重试、熔断、降级,这四件事的优先级也是递进的:

  1. 先确保每个调用都有超时——这是零成本、零风险的基础
  2. 再为可恢复的错误加上克制的重试——退避、抖动、预算缺一不可
  3. 然后为关键依赖配置熔断——防止下游的持续故障拖垮上游
  4. 最后想清楚降级方案——当一切恢复手段都失败时,给用户一个交代

不要等线上事故来了才做这些事。在代码 Review 时检查超时,在设计评审时讨论降级,在压测时验证熔断——把韧性当作工程习惯,而不是应急措施。

参考资料

评论 0

0 / 1000