2026 年 1 月某个周五下午 15:42,我们的支付网关 pay-gw 对接的某第三方银行接口突然 100% 超时(后来知道是他们机房故障)。我们的 pay-gw 在调用方那里设置的 timeout 是 10 秒——10 秒后失败重试。问题是上游 5 个服务(下单 / 退款 / 充值 / 提现 / 对账)都在调 pay-gw,它们的请求堆积在 pay-gw 等 10 秒,期间各自的 Tomcat 线程池被占满,连带它们自己也开始拒绝其他请求。整条链路就这样从"下游一个接口超时"演变成"全平台不可用",持续 35 分钟,直到第三方恢复。复盘会上,我们承认了一个不能再回避的事实:我们的微服务架构在"故障扩散"面前几乎完全没有抵抗力。
接下来 7 天,我们用 Resilience4j(Java)+ Polly(.NET)+ 类似机制全平台引入熔断 / 限流 / 降级三件套。3 个月后,类似的"下游某接口挂了"事件又发生过 3 次,每次都被局部隔离,影响只到那 1 个具体接口,核心链路完全正常——再没有全平台雪崩。这篇是完整复盘,涵盖三件套的具体设计、配置参数、踩过的坑、以及落地的《容错纪律》。这套思路是所有微服务架构的"标配",但很多团队(我们也是)是被事故教育后才补的课。
背景:这套微服务架构
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 多租户企业服务,涉及交易 / 计费 / 用户管理 |
| 规模 | 50+ 微服务,日均请求 6 亿 |
| 技术栈 | Java 17 + Spring Cloud + .NET 8(部分)+ Go(部分)+ K8s |
| 事故现象 | 第三方支付接口超时 → 上游链路雪崩 35 分钟 → 业务全平台不可用 |
| 损失 | 预估营收损失 18 万,客户投诉 200+ 单,工作日大促日 |
事故时间线:35 分钟的全链路雪崩
| 时刻 | 事件 |
|---|---|
| 15:42 | 第三方银行接口开始 100% 超时 |
| 15:42-15:45 | pay-gw 调用银行 timeout 10s,每次失败 retry 2 次,实际 30s 才 fail |
| 15:45 | pay-gw 自身线程池开始打满(配置 200 thread,全部卡在等银行) |
| 15:47 | 调用 pay-gw 的服务(下单 / 退款等)开始 timeout,各自线程池也打满 |
| 15:50 | 整条链路上的服务都"卡在等下游",雪崩 |
| 15:55 | 用户开始大面积投诉,客服群炸 |
| 16:08 | SRE 临时手动把 pay-gw 切换到"拒绝所有请求"模式,降级 pay 业务,其他业务恢复 |
| 16:17 | 第三方银行恢复 |
| 16:25 | 系统全恢复正常 |
| 下周 | 启动"容错三件套"专项 |
故障扩散的因果链
把这次"35 分钟雪崩"用因果链画出来,可以看清楚为什么"一个下游接口超时"会扩散成"全平台不可用":
这张图的核心洞察是"线程池是故障传染的载体"。当下游慢时,上游线程被卡住等待;上游线程池被吃光后,上游本身也开始慢/拒绝;再上游遭受同样命运。这种"自下而上的线程池雪崩"在没有故障隔离机制的微服务架构里是必然——只是触发的时间问题。
第一反应:"timeout 设短点不就行?"
事故复盘最初的方向是"调 timeout"——把 pay-gw 调银行的 timeout 从 10s 改成 3s。但这不解决根因:
- 3s timeout 仍然让线程池被慢慢吃掉(只是慢 3 倍)
- 如果银行 100% 超时,3s 一样雪崩
- 正常时银行响应 2s 也偶发,3s timeout 误杀
真正的根因是没有故障隔离机制。一个下游慢,所有调用它的服务必然受影响。要从设计上"隔离故障",必须引入熔断 / 限流 / 降级。
三件套的本质
| 组件 | 解决什么 | 触发条件 | 动作 |
|---|---|---|---|
| 熔断 Circuit Breaker | 下游持续失败时,快速失败,不浪费资源 | 错误率 / 慢响应比例 / 异常数超阈值 | 一段时间内直接 fail,不调下游 |
| 限流 Rate Limit | 保护自己 + 保护下游,防止流量洪峰 | QPS / 并发数超阈值 | 拒绝超出部分 |
| 降级 Fallback | 异常发生时给"次优"响应,而不是直接 fail | 熔断 / 限流 / 异常 | 返回缓存 / 默认值 / 简化逻辑 |
三者协同:限流防住流量打爆,熔断切断异常调用,降级保证用户体验。
实施 1:熔断(Resilience4j)
Java 微服务用 Resilience4j(Spring Cloud 默认):
// application.yml
resilience4j:
circuitbreaker:
instances:
payGateway:
slidingWindowSize: 100 # 评估最近 100 次调用
minimumNumberOfCalls: 20 # 至少 20 次才统计
failureRateThreshold: 50 # 失败率 50% 触发熔断
slowCallRateThreshold: 80 # 慢响应 80% 也触发
slowCallDurationThreshold: 3s # 超过 3s 算慢
waitDurationInOpenState: 30s # 熔断后 30 秒再尝试
permittedNumberOfCallsInHalfOpenState: 5 # 半开时试 5 次
automaticTransitionFromOpenToHalfOpenEnabled: true
业务代码:
@Service
public class PaymentService {
@CircuitBreaker(name = "payGateway", fallbackMethod = "payFallback")
@TimeLimiter(name = "payGateway")
public CompletableFuture<PaymentResult> pay(PaymentRequest req) {
return CompletableFuture.supplyAsync(() ->
payGwClient.charge(req)
);
}
// fallback 方法签名要和原方法一致 + 多一个 Throwable 参数
public CompletableFuture<PaymentResult> payFallback(PaymentRequest req, Throwable t) {
if (t instanceof CallNotPermittedException) {
// 熔断状态: 快速失败, 业务方决定怎么办
return CompletableFuture.completedFuture(PaymentResult.unavailable());
}
// 其他异常: 返回 "进行中, 稍后重试"
return CompletableFuture.completedFuture(PaymentResult.pending(req.getOrderId()));
}
}
核心思想:
- 熔断打开后,调用 pay 方法立刻 fail(没有等 timeout),线程立即返回
- 失败的请求走 fallback,业务方拿到"unavailable" 而不是"卡 10 秒后报错"
- 30 秒后熔断半开,放 5 个试探请求,如果成功就关闭(恢复正常),失败再开 30 秒
实施 2:限流(自身保护 + 下游保护)
限流要分两层:
层 1:服务自身入口限流
resilience4j:
ratelimiter:
instances:
payGateway:
limitForPeriod: 5000 # 单 Pod 最多 5000 QPS
limitRefreshPeriod: 1s
timeoutDuration: 100ms # 等不到许可 100ms 后 fail
每个 Pod 自己限流,超过 5000 QPS 直接拒绝。这是"我接得了多少"的硬限,防止被打爆。
层 2:对下游调用限流
resilience4j:
bulkhead:
instances:
bankApi:
maxConcurrentCalls: 50 # 同时调银行最多 50 个
maxWaitDuration: 100ms
bulkhead(舱壁)模式:对下游调用限制最大并发数。即使下游变慢,这里 50 个 thread 用满就开始排队(100ms 内)或拒绝——不会一直堆积到把整个线程池吃光。
这两层组合非常关键。事故时如果有 bulkhead,pay-gw 调银行最多 50 个并发,即使银行 timeout,也只有 50 个线程被卡住,剩余的 150 个线程可以继续处理其他请求(查询 / 退款 / 等)。
实施 3:降级(Fallback)
降级不只是"返回默认值",要按业务场景设计:
| 场景 | 降级策略 |
|---|---|
| 查商品详情 — 下游商品服务挂 | 返回缓存的旧数据(可能慢 1 小时) |
| 支付 — 银行接口挂 | 返回"系统繁忙稍后重试",写入对账系统稍后由人工 / job 处理 |
| 查用户画像 — 画像服务挂 | 返回默认画像(unknown user) |
| 发邮件 — SMTP 挂 | 写入 outbox 表稍后重发 |
| 调推荐算法 — 推荐服务挂 | 返回热销榜单(静态数据) |
关键原则:降级响应必须比"卡住"或"500"好。用户拿到"稍后重试"或"展示旧数据"远胜于"等 30 秒后看到错误页"。
实施 4:多语言统一(Polly for .NET / 自研中间件 for Go)
我们公司不只 Java,所以三件套必须各语言都有。.NET 8 用 Polly:
// Polly 配置
var policy = Policy
.HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.Or<HttpRequestException>()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)) // 5 次失败开熔断, 30s
.WrapAsync(Policy.BulkheadAsync<HttpResponseMessage>(50, 100)) // 限流 50 并发
.WrapAsync(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(3))); // 超时 3s
// 在 HttpClientFactory 里挂上(参考 026 那篇文章)
builder.Services.AddHttpClient("payGw")
.AddPolicyHandler(policy);
Go 服务用自研中间件 + golang.org/x/time/rate(限流) + sony/gobreaker(熔断)。代码模式类似。
验证:3 个月后的真实"故障演练"
三件套上线后 3 个月,我们故意触发了 4 次"局部故障演练":
| 演练场景 | 事故前预测 | 实际(三件套后) |
|---|---|---|
| 下游 user-service 100% timeout | 全链路雪崩 | 调 user-service 的 3 个上游服务降级返回 "unknown",其他业务正常 |
| 下游某 SaaS 接口 50% 错误 | 错误传染 | 熔断在 1 分钟内打开,失败请求走 fallback |
| 大促瞬时 QPS 翻 10x | 整体打爆 | 入口限流挡住超额,核心业务保护 |
| 某下游慢 500% (latency 5x) | 线程池打满 | bulkhead 限并发,慢 → fail,不会传染 |
每次演练都是局部影响,核心业务无感。这就是三件套的价值——把"任何下游问题都是全平台问题"变成"下游问题只影响那一块"。
三件套的协同决策树
三件套不是独立工作的,它们之间有明确的调用顺序和决策逻辑:
这张图最重要的洞察是三件套的"前置过滤"作用:在真正调用下游之前,限流挡掉超额流量、熔断挡掉已知失败、bulkhead 挡掉超额并发——三层过滤后才是真正的下游调用。这种"层层过滤"让无效请求的成本接近 0,而不是"每个请求都老实地等 10 秒 timeout"。事故时的核心问题就是没有这三层过滤,所有请求都"老实排队等下游",最终把自己的线程池耗尽。
容错三件套的隐藏前提:可观测性
三件套配置完只是开始,真正让它"持续有效"的是可观测性。三件套触发了你不知道,等于没有触发——某个熔断器一直 OPEN 你不知道,业务一直走 fallback 你不知道,这是非常危险的"沉默故障"。
我们落地的可观测性配置:
# Prometheus 指标暴露
management:
endpoints:
web:
exposure:
include: health,prometheus,metrics
metrics:
distribution:
percentiles-histogram:
resilience4j.circuitbreaker.calls: true
resilience4j.ratelimiter.available.permissions: true
resilience4j.bulkhead.available.concurrent.calls: true
# Grafana 告警规则示例
- alert: CircuitBreakerOpen
expr: resilience4j_circuitbreaker_state{state="open"} == 1
for: 2m
labels:
severity: warning
annotations:
summary: "Circuit breaker {{ $labels.name }} has been OPEN for 2 minutes"
- alert: HighFallbackRate
expr: rate(resilience4j_circuitbreaker_calls_total{kind="not_permitted"}[5m]) > 10
for: 5m
labels:
severity: critical
annotations:
summary: "Fallback rate high: {{ $value }} calls/s falling back"
这套监控的核心是"任何熔断器状态变化都要可见"。我们后来发现一个 bug:某个低频接口的熔断器一直 OPEN 了 3 天,但因为这个接口调用频次低(每天 100 次),没人发现。修复后我们加了"任何熔断器持续 OPEN 超过 10 分钟"的告警规则,无论调用频次高低。这就是"沉默故障"在可观测性维度的具体形式:不是没出问题,是问题被你的监控盲区藏起来了。
事故复盘里走错的 3 个判断
- "timeout 设短点就行" —— 这是事故复盘最初的方向,讨论了半天才意识到 timeout 调短只是"晚 7 秒雪崩",根因不在 timeout。任何"调参数"的修法都要先问"是否解决根因",否则只是把问题推迟。
- "重启 pay-gw 就能解决" —— SRE 在 16:08 之前一度想"重启 pay-gw 试试",DBA 阻止了:"重启之后流量进来,瞬间又被打爆,只是把雪崩重启一次"。这种"无效操作"在压力下很容易被提出,SOP 里要明确禁止"无诊断的重启"。
- "等第三方恢复就好" —— 16:00 时有人提议"反正第三方半小时后就好了,熬一下"。但用户投诉已经爆了,业务方决定 16:08 主动降级 pay 业务。"被动等待外部恢复"在用户侧体验上不可接受,主动降级是更负责的选择。
真实生产事故(3 个月内 3 次)
上线 3 个月里,真实发生了 3 次类似事故:
| 事故 | 三件套效果 |
|---|---|
| 某第三方 SDK timeout | 熔断打开,影响仅限调用方,5 分钟内 SDK 恢复后自动半开测试,完全恢复 |
| 内部 redis 集群 cluster 重哈希引起延迟 | bulkhead 防止 redis 调用堆积,降级返回 stale cache |
| 大促瞬时流量爆发 | 限流挡住超额流量,核心交易链路完整,挂掉的是"非核心"功能 |
| 合计 | 3 次"准事故"全部局部隔离,无全平台影响 |
对比事故前那次 35 分钟全平台雪崩,这是架构成熟度的质变。
立的《容错纪律》
- 所有跨服务调用必须有 timeout,核心场景 3 秒,后台场景最长 30 秒。不允许默认无 timeout。
- 所有跨服务调用必须有熔断,Resilience4j / Polly / gobreaker。配置:失败率 50% + 持续 30 秒触发,30 秒后半开。
- 所有跨服务调用必须有 bulkhead,最大并发数显式配置,根据下游容量定。
- 所有跨服务调用必须有 fallback:返回缓存 / 默认值 / "稍后重试",绝不让客户看到 500。
- 所有服务入口必须有限流,根据自身容量配 QPS / 并发上限。
- 熔断 / 限流 / 降级触发必须监控,告警 + 看板,任何持续触发要追因。
- 季度做故障演练:故意触发下游慢 / 错,验证三件套真的生效。Chaos Engineering 是必备实践。
- fallback 设计 review:不允许"fallback 返回 null 让上游自己处理",必须有明确的业务降级语义。
给读者的几条自查清单
- 你的微服务架构如果某个下游 100% 不可用 30 分钟,会怎样?如果答案是"全平台雪崩",你需要三件套。
- 看你最关键的几个跨服务调用,有 timeout 吗?有熔断吗?有 bulkhead 吗?有 fallback 吗?
- Java 用 Resilience4j;.NET 用 Polly v8;Go 用 sony/gobreaker;Node 用 cockatiel——都是社区标准。
- fallback 不只是"返回 null",要业务化设计——查询用 stale cache,写入用 outbox 异步,等等。
- 限流 + bulkhead 一定要做,不只是"保护自己",更是"保护下游"——你慢一点比下游被打爆好。
- 定期(季度)做 chaos engineering,故意 kill / 限速一个下游,看上游能不能"优雅降级"。
- 三件套的配置不是 set-and-forget,业务量 / 下游 SLA 变化时要重新评估参数。
三件套的参数调优经验
三件套参数不能"抄默认值",必须按下游 SLA 和自身容量调:
| 参数 | 怎么调 | 典型值 |
|---|---|---|
| 熔断器 slidingWindowSize | 评估窗口大小,小=灵敏但易误判,大=稳但反应慢 | 50-200 次调用 |
| 失败率阈值 | 下游 SLA 99.9% 的话 5% 算异常;90% SLA 的下游可以容忍 20% | 20%-50% |
| 慢响应阈值 | 设为正常 P99 的 1.5-2 倍 | 1-5 秒 |
| waitDurationInOpenState | 下游恢复 SLA 越好越短,云服务通常 30 秒,自建 60 秒 | 30-120 秒 |
| bulkhead maxConcurrentCalls | 下游 QPS 容量 × 平均 RT / Pod 数 | 10-100 并发 |
| 限流 limitForPeriod | 压测出来的单 Pod 最大 QPS × 0.8 余量 | 500-5000 QPS |
调参的核心心法是"参数要可解释"——任何配置必须能回答"为什么是这个数",不能"差不多就行"。我们在 wiki 里给每个配置项都写了"配置理由"注释,review 时必看。这样后续团队成员维护配置时知道改的是什么、为什么改。
容错的反模式清单
除了"什么该做",反过来梳理"什么不该做"同样重要:
- "全局熔断" —— 一个熔断器管所有下游。下游 A 挂触发熔断,下游 B 健康也被一起拒。每个下游必须独立熔断器,粒度到 endpoint。
- "fallback 抛异常" —— fallback 方法里又抛异常,等于没 fallback。fallback 必须保证返回成功结果(可能降级,但不抛)。
- "timeout 没设置或设太长" —— HTTP client 默认 timeout 通常是无限或几分钟。每个调用都必须显式 timeout,核心场景 3 秒以内。
- "熔断器配置硬编码" —— 改参数要重新编译部署。必须可热配置,Spring Cloud Config / Apollo / Nacos 都可以。
- "用 retry 代替熔断" —— 失败就 retry 3 次,实际上是把"1 次失败"放大成"4 次失败",加速雪崩。retry 是熔断的补充,不是替代。retry + 熔断必须同时用。
这些反模式我们都踩过,有些是上线后才发现。比如"全局熔断"那条,最初我们用一个 CircuitBreaker 管 pay-gw 的所有 endpoint,结果某个低频 endpoint 偶发失败触发熔断,把高频 endpoint 也一起拒了——影响完全不该影响的业务。后来拆成"每 endpoint 一个熔断器"才解决。
这次事故让我对"微服务"有了更深的理解:微服务的"好处"是"独立部署 / 独立扩展 / 独立故障",但前两个不做工作就能享受,最后一个"独立故障"必须靠工程纪律实现——没有熔断 / 限流 / 降级,微服务架构会因为"任何一处挂全局挂"成为反模式。这就是为什么微服务在 2010s 红极一时又在 2020s 被一些团队 walk back 到"模块化单体"——前者享受不到,只承担分布式系统的成本。
另一个心得:"容错"是种世界观,不是种技术。它要求开发者在写每一个调用时都问"对方如果挂了会怎样?",而不是只问"对方正常时怎么用"。这种 mindset 是分布式系统工程师和"传统单体工程师"的核心差异。培养这种 mindset 需要时间和事故教育——很可惜,大多数团队是被事故教育的。希望读完这篇能让你少一次事故。
事故落幕后的几周里,我反复思考一个更深的问题:为什么三件套这种"分布式系统标配"在我们团队上线 3 年后才被引入?根本原因不是技术能力不够,是组织对"基础设施可靠性"的优先级排序。业务功能 PR 一个接一个,容错改造这种"看不见短期收益"的工作总是被排到最后。直到 35 分钟全平台雪崩砸过来,才被迫加班补课。这种"先发生事故再补容错"的反复循环,本质是组织把可靠性当成"事后救火"而不是"事前预防"。要打破这个循环,需要把容错纳入设计阶段的 design review 必查项——任何新服务上线前,必须回答"如果下游挂了你怎么办",这个问题不通过就不允许上线。这种制度安排比任何技术方案都更重要,因为它把"容错"这件事从"工程师自觉"升级为"组织默认"。
另一个被低估的因素是Chaos Engineering 文化的引入。三件套配完只是"理论上能扛故障",真正能扛要看故障演练。我们后来每季度做一次"全公司故障日":SRE 团队按计划在生产环境注入故障(限流某个服务、kill 某个 Pod、断网某个区域),业务团队的容错机制必须自动响应。每次故障日都能发现 2-3 个"以为有容错实际没有"的盲区——这种"主动找问题"比"等事故发生"便宜 100 倍。Chaos Engineering 在 Netflix 2010 年代推出时被很多团队当成"玩具",但实践证明它是分布式系统时代必备的工程实践,跟单元测试同等重要。我们从 2025 年下半年正式把它纳入季度 OKR,反馈是"团队对生产环境的信心明显增强了",这种心理收益虽然难量化,但在 SRE 圈子里被广泛认同。
最后一句给所有写微服务的同学:微服务架构不是"把单体拆成多个服务"那么简单,它带着"分布式系统的全部代价"。这些代价包括网络不可靠、时钟不一致、部分失败、状态一致性,每一个都需要工程手段对抗。容错三件套只是其中一部分,但它是最基础、最容易立竿见影的部分。如果你的微服务架构还没引入这套机制,下次"全平台雪崩"只是时间问题。从今天开始,在每一个跨服务调用上加 timeout + circuit breaker + bulkhead + fallback——这四件事不做,微服务就是定时炸弹。
顺带一个工程师常忽视的视角:三件套不仅保护生产稳定,还保护工程师的睡眠质量。没有三件套的系统,任何下游抖动都会传染到自己,凌晨被叫醒的概率成倍增加;有了三件套,下游挂了至多影响一个 endpoint,核心业务无感,oncall 同学早晨醒来从 dashboard 看见熔断器自动恢复就 OK。这种"睡眠质量"的工程价值很少被量化,但任何被半夜叫醒过的同学都知道它的重要性。容错三件套是分布式系统工程师送给自己的最佳礼物——把"7x24 战战兢兢"变成"周末安心睡觉"。这个收益不写在 PRD 里、不出现在 OKR 里、但每一个干过 SRE 的人都心知肚明:容错改造完成的那一刻,团队真正进入了"现代分布式系统"的工程阶段,之前都只是"分布式实验"。这种从"实验"到"工程"的跃迁,是每个微服务团队必经的成熟路径——希望你的团队能够在事故之前主动完成它,而不是像我们这样被 35 分钟雪崩教育之后才被迫补课。三件套上线只是起点,养成"任何下游调用都默认假设它会挂"的设计直觉,才是分布式系统工程的真正心法所在。
—— 别看了 · 2026