韧性架构三件事:超时、重试和熔断
从一次线上事故说起
去年我负责的一个电商中台,因为下游库存服务 P99 延迟从 50ms 涨到 2 秒,导致上游网关的连接池被慢慢耗尽。整个过程没有报错,没有告警,只是「慢」——结果 15 分钟后所有服务全部不可用。
事后复盘发现三个问题:
- 调用库存服务没有设超时,HTTP Client 用的是默认值(有些语言根本没有默认超时)
- 网关层有重试逻辑,但没有区分错误类型——超时的请求也被重试了
- 没有任何熔断机制,下游明明已经撑不住了,上游还在源源不断发请求
这三个问题分别对应韧性架构的三个核心模式:超时、重试、熔断。它们不是什么高深的理论,是分布式系统里最基础的防线。但我在 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」。
重试的正确姿势
重试必须满足几个条件:
- 操作必须幂等——非幂等操作(比如扣款)重试会造成重复执行
- 只对特定错误重试——5xx、超时、连接拒绝可以重试;4xx(参数错误、权限不足)重试没有意义
- 有退避策略——指数退避 + 抖动(jitter)是标准做法,防止重试请求在同一时刻集中打到下游
- 有最大次数限制——无限重试等于无限等待
- 有重试预算——重试流量不超过正常流量的 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 停顿。但当下游持续不可用时(比如数据库主节点宕机、第三方服务全线故障),继续重试只会浪费资源。
熔断器的作用是:检测到下游持续失败后,快速断开调用,直接返回错误或降级结果,给下游喘息的时间。
熔断器的三态模型
三个状态的含义:
| 状态 | 行为 | 类比 |
|---|---|---|
| Closed(闭合) | 正常放行请求,统计失败率 | 电路正常通电 |
| Open(断开) | 所有请求直接失败,不调用下游 | 电路跳闸 |
| Half-Open(半开) | 放少量探测请求,测试下游是否恢复 | 电工试送电 |
熔断器的关键参数
| 参数 | 含义 | 建议值 |
|---|---|---|
| failure_rate_threshold | 失败率阈值,超过后从 Closed 转为 Open | 50%(10 秒窗口内) |
| slow_call_rate_threshold | 慢调用率阈值 | 80%(响应超过 timeout 的比例) |
| wait_duration_in_open | Open 状态持续时间 | 10-30 秒 |
| permitted_calls_in_half_open | Half-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}")降级逻辑必须比正常逻辑更简单、更可靠。如果你的降级逻辑本身还要调用三个服务,那就失去了降级的意义。
组合使用:四层防线的协同
这四个模式不是互相替代的,是层层递进的。一个典型的请求处理链路:
这个流程图展示了一条关键的决策链:
- 超时是所有判断的前提——没有超时,后面的重试和熔断都没有触发条件
- 重试只针对可恢复的错误,且受预算约束
- 熔断是全局保护——当下游持续不可用时,直接切断调用
- 降级是最后手段——当所有恢复手段都失败时,给用户一个「能用」的结果
主流工具对比
不同语言生态都有成熟的韧性库,核心模式一致,API 风格各异:
| 维度 | Resilience4j (Java) | Polly (.NET) | failsafe-go (Go) | Spring Cloud Circuit Breaker |
|---|---|---|---|---|
| 支持模式 | 熔断、重试、限流、隔离、超时、降级 | 重试、熔断、超时、降级、限流、对冲 | 重试、熔断、超时、降级、隔离、限流 | 封装 Resilience4j / Sentinel |
| API 风格 | 装饰器 / 函数式 | 流式构建器 | 函数链式 | 注解 + 配置 |
| 与 Spring 集成 | 原生支持 | 不适用 | 不适用 | 原生支持 |
| 指标采集 | Micrometer 集成 | OpenTelemetry | Prometheus | Actuator + 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 超时和熔断参数,随流量和业务变化调整
小结
韧性架构不是让每次请求都成功——那在分布式系统里做不到。它是让局部失败不扩散成全站故障,让用户在部分功能不可用时仍能完成核心操作。
超时、重试、熔断、降级,这四件事的优先级也是递进的:
- 先确保每个调用都有超时——这是零成本、零风险的基础
- 再为可恢复的错误加上克制的重试——退避、抖动、预算缺一不可
- 然后为关键依赖配置熔断——防止下游的持续故障拖垮上游
- 最后想清楚降级方案——当一切恢复手段都失败时,给用户一个交代
不要等线上事故来了才做这些事。在代码 Review 时检查超时,在设计评审时讨论降级,在压测时验证熔断——把韧性当作工程习惯,而不是应急措施。
参考资料
- Michael Nygard - Release It! (Pragmatic Programmers) — 稳定性模式的经典著作
- Martin Fowler - Circuit Breaker — 熔断器模式的标准定义
- Temporal.io - Error Handling in Distributed Systems — 重试与熔断的分层策略
- AWS - Timeouts, Retries, and Backoff with Jitter — 指数退避 + 抖动的详细分析
- Layrs - Retry Budgets: Exponential Backoff & Jitter in Practice — 重试预算的概念与实现
- Resilience4j 官方文档 — Java 韧性库的完整参考
- Polly 官方文档 — .NET 韧性库的完整参考
- codecentric - Resilience Design Patterns: Retry, Fallback, Timeout, Circuit Breaker — 四种模式的综合对比
- Google SRE Workbook - Error Budget Policy — 错误预算与 SLO 管理