服务容错设计:重试、熔断、隔离、降级的正确组合顺序
一次下游抖动,被三层重试各放大 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
三个容易踩的点:
minimum-number-of-calls太小:启动时 2 次失败就熔断,误杀。- 窗口太短:
sliding-window-size: 10时,一次抖动就能让失败率过 50%。 - 只统计异常不统计慢调用:下游没报错但 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 执行次数 | 突增 → 排查 |
重试占比这个指标最容易被忽略,但它是最灵敏的"下游开始不行了"的信号——比失败率早出现。
八、小结
容错的本质不是"加了几个组件",而是三句话:
- 任何调用都必须有超时——否则线程会被无限期占用;
- 任何重试都必须有上限和退避——否则你会亲手把下游打死;
- 任何依赖都必须有降级路径——否则单个下游故障就是全站故障。
顺序上记住那张图:限流 → 熔断 → 隔离 → 超时 → 重试 → 降级。这个顺序保证每一层都在保护下一层,而不是互相抵消。
系列内链
- 关联:[08-微服务与架构/03-Sentinel熔断限流](03-Sentinel熔断限流.md)——Sentinel 的规则配置与持久化
- 关联:[08-微服务与架构/04-分布式事务方案](04-分布式事务方案.md)——重试与分布式事务的一致性配合
- 关联:[08-微服务与架构/07-接口幂等设计](07-接口幂等设计.md)——重试的前提是幂等
- 关联:[15-中间件与其他/07-限流算法与实现](../15-中间件与其他/07-限流算法与实现.md)——令牌桶/滑动窗口的具体实现
- 点赞
- 收藏
- 关注作者
评论(0)