2024 年我们经历过一次很典型的服务雪崩。事故的起点小得不能再小:一个第三方的优惠券查询接口变慢了,从平时的 50ms 涨到了 5 秒。可就是这么一个边缘接口的卡顿,顺着调用链一路把整个交易主流程拖垮了——商品详情页打不开、下单按钮点不动,核心交易几乎全线瘫痪。一个非核心依赖的抖动,凭什么能掀翻整个系统?复盘下来,根因是我们的服务对下游故障毫无防护:没有限流、没有熔断、没有隔离,所有请求都老老实实地排队等那个 5 秒的接口,线程池被瞬间占满,然后雪崩就发生了。投了几天做限流熔断降级的专项治理,本文复盘这次实战。
问题背景
业务:电商交易链路,Spring Cloud 微服务
事故现象:
- 第三方优惠券查询接口 RT 从 50ms 涨到 5s
- 商品详情服务调它,线程被大量占用
- 详情服务线程池被打满 -> 详情接口也开始超时
- 上游下单服务调详情服务,也跟着被拖垮
- 不到 3 分钟,整条交易链路全线超时
现场排查:
# 1. 详情服务线程栈,几乎全卡在调优惠券接口
$ jstack | grep "couponClient" | wc -l
198 # 200 个 Tomcat 线程,198 个卡在等优惠券接口
# 2. 调用链路
下单服务 --> 商品详情服务 --> 优惠券服务(第三方,5s)
# 一个慢,前面全部跟着慢,线程层层耗尽
# 3. 关键问题:
# - 调优惠券接口没有超时控制 / 没有限流 / 没有熔断
# - 优惠券只是详情页一个角标,挂了本不该影响主流程
# - 但代码里它和主流程共用线程池,它一卡,主流程线程也没了
根因:
1. 没有限流:突发流量 / 慢调用会无限占用线程,直到耗尽
2. 没有熔断:下游已经挂了,还在持续不断地调它、等它超时
3. 没有降级:优惠券挂了就该返回"暂无优惠券",而不是整页崩
4. 没有隔离:非核心依赖和核心流程共用线程池,一损俱损
修复 1:限流 —— 控制进入系统的流量
// === 限流:超过系统承载能力的流量,拒绝掉 / 排队,保护系统不被压垮 ===
// === 算法 1:固定窗口计数器 ===
// 每个时间窗口(如 1 秒)内计数,超过阈值就拒绝。
// 缺点:窗口边界处会突刺 —— 上一窗口末尾和下一窗口开头
// 挤在一起,瞬时可能达到 2 倍阈值。
// === 算法 2:滑动窗口 ===
// 把 1 秒切成多个小格,窗口随时间平滑滑动,
// 解决了固定窗口的边界突刺问题。
// === 算法 3:漏桶(Leaky Bucket)===
// 请求像水进桶,桶以【恒定速率】漏water(处理)。
// 桶满则溢出(拒绝)。特点:输出速率绝对平滑,但不允许突发。
// === 算法 4:令牌桶(Token Bucket)—— 最常用 ===
// 以恒定速率往桶里放令牌,请求来了先取令牌,取到才放行。
// 桶能存令牌 -> 允许一定程度的突发流量(攒着的令牌可一次用掉)。
// Guava RateLimiter 就是令牌桶:
private final RateLimiter limiter = RateLimiter.create(2000); // 2000 QPS
public Result query(Req req) {
if (!limiter.tryAcquire()) { // 取不到令牌,直接拒绝
return Result.fail("系统繁忙,请稍后再试");
}
return doQuery(req);
}
// === 限流维度:不只是限总 QPS ===
// - 接口级:某个接口单独限流
// - 用户级:单个用户 / IP 限流,防刷
// - 参数级:针对热点参数(如某个爆款商品 id)限流
// === 限流后的处理:不是只有"拒绝" ===
// - 直接拒绝:返回"系统繁忙"(最常见)
// - 排队等待:漏桶式排队,适合写操作
// - 降级:返回兜底数据
修复 2:熔断 —— 下游挂了就别再调了
// === 熔断器:像电路保险丝,下游故障时"跳闸",停止调用 ===
// 核心思想:下游已经明显不行了,继续调它只会浪费自己的线程、
// 拖垮自己。不如快速失败,等它恢复了再试。
// === 熔断器的三个状态 ===
// CLOSED(关闭):正常,请求放行,同时统计失败率
// OPEN(打开) :失败率超阈值 -> 跳闸,后续请求【直接失败】,
// 不再真正调下游(快速失败,不占线程)
// HALF_OPEN(半开):OPEN 一段时间后,放几个"探路"请求试试,
// 成功 -> 回到 CLOSED;失败 -> 重新 OPEN
// === 状态流转 ===
// CLOSED --(失败率 > 50%)--> OPEN
// OPEN --(冷却 10s 后)--> HALF_OPEN
// HALF_OPEN --(探路成功)--> CLOSED
// HALF_OPEN --(探路失败)--> OPEN
// === 用 Resilience4j 配置熔断 ===
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 失败率 > 50% 跳闸
.slowCallRateThreshold(50) // 慢调用率 > 50% 也跳闸
.slowCallDurationThreshold(Duration.ofSeconds(1)) // 超 1s 算慢调用
.waitDurationInOpenState(Duration.ofSeconds(10)) // OPEN 冷却 10s
.permittedNumberOfCallsInHalfOpenState(5) // 半开放 5 个探路
.minimumNumberOfCalls(20) // 至少 20 次调用才统计
.build();
CircuitBreaker cb = CircuitBreaker.of("couponService", config);
public CouponInfo queryCoupon(Long userId) {
return cb.executeSupplier(() -> couponClient.query(userId));
// 熔断打开时,这里会直接抛 CallNotPermittedException,
// 几微秒返回,完全不碰下游、不占线程
}
// 关键价值:熔断打开后,对下游的调用从"等 5 秒超时"
// 变成"几微秒快速失败",自己的线程瞬间被解放出来。
修复 3:降级 —— 给故障一个体面的兜底
// === 降级:服务不可用时,返回一个"可接受的次要结果",而不是报错 ===
// 熔断负责"快速失败",降级负责"失败之后给什么"。
// === 降级方式 1:fallback 兜底数据 ===
public CouponInfo queryCoupon(Long userId) {
return cb.executeSupplier(() -> couponClient.query(userId),
throwable -> CouponInfo.empty()); // 熔断/异常时返回"暂无优惠券"
}
// 详情页优惠券角标,挂了就显示"暂无优惠券",
// 用户照样能浏览、能下单 —— 主流程不受任何影响。
// === 降级方式 2:返回缓存 / 旧数据 ===
public List- getRecommend(Long userId) {
try {
return recommendService.compute(userId);
} catch (Exception e) {
// 推荐服务挂了,返回上次缓存的推荐结果(略旧但能用)
return recommendCache.getLast(userId);
}
}
// === 降级方式 3:关闭非核心功能(开关降级)===
// 大促高峰,主动关掉"猜你喜欢""浏览历史"等非核心功能,
// 把服务器资源全部让给交易主链路。
if (degradeSwitch.isOn("recommend")) {
return Collections.emptyList(); // 推荐功能被手动降级
}
// === 核心原则:分清核心链路与非核心链路 ===
// 核心链路(浏览-下单-支付):要不惜代价保住
// 非核心链路(优惠券角标/推荐/评价数):故障时果断降级、舍弃
// 事故里最痛的教训就是:让一个非核心依赖,有能力拖垮核心链路。
// === 降级要演练 ===
// 降级代码平时不跑,真出事时才第一次执行 —— 很容易本身就有 bug。
// 定期做故障演练,主动关掉下游,验证降级逻辑真的有效。
修复 4:资源隔离 —— 不让一个依赖拖垮全部
// === 事故的核心:优惠券调用和主流程共用同一个线程池 ===
// 优惠券接口卡 5 秒,把共用线程池占满,主流程也没线程了。
// === 隔离方式 1:线程池隔离(舱壁模式 Bulkhead)===
// 给每个下游依赖分配【独立的线程池】,像船的隔水舱:
// 一个舱进水(一个依赖故障),不会淹掉整条船。
private final ExecutorService couponPool =
new ThreadPoolExecutor(20, 20, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(50),
new ThreadPoolExecutor.AbortPolicy());
public CouponInfo queryCoupon(Long userId) {
Future f = couponPool.submit(
() -> couponClient.query(userId));
try {
return f.get(500, TimeUnit.MILLISECONDS); // 最多等 500ms
} catch (TimeoutException e) {
f.cancel(true);
return CouponInfo.empty(); // 超时降级
}
}
// 优惠券接口再慢,最多耗尽它自己那 20 个线程,
// 主流程的线程池毫发无伤。
// === 隔离方式 2:信号量隔离 ===
// 用一个计数信号量限制"同时调用某依赖的并发数",
// 不额外开线程,开销比线程池隔离小,但没有超时控制能力。
private final Semaphore couponSemaphore = new Semaphore(20);
public CouponInfo queryCouponBySemaphore(Long userId) {
if (!couponSemaphore.tryAcquire()) {
return CouponInfo.empty(); // 并发已满,直接降级
}
try {
return couponClient.query(userId);
} finally {
couponSemaphore.release();
}
}
// 线程池隔离:能控超时,适合慢调用风险高的依赖,开销略大
// 信号量隔离:轻量,适合快速调用、本地调用,但管不了超时
// === 隔离方式 3:超时一定要配 ===
// 所有跨网络调用都必须设连接超时 + 读超时,
// 没有超时的调用,本质就是一颗定时炸弹。
修复 5:用 Sentinel 统一落地
// === Sentinel:阿里开源的流量治理组件,限流/熔断/降级/隔离一站式 ===
// === 定义资源,挂上规则 ===
@SentinelResource(value = "queryCoupon",
fallback = "queryCouponFallback", // 降级方法
blockHandler = "queryCouponBlocked") // 被限流时的方法
public CouponInfo queryCoupon(Long userId) {
return couponClient.query(userId);
}
// 被限流 / 熔断时走这里
public CouponInfo queryCouponBlocked(Long userId, BlockException ex) {
return CouponInfo.empty(); // 返回"暂无优惠券"
}
// 业务异常时走这里
public CouponInfo queryCouponFallback(Long userId, Throwable t) {
log.warn("优惠券查询降级", t);
return CouponInfo.empty();
}
// === 代码里初始化规则(也可在 Sentinel 控制台动态配)===
public void initRules() {
// 限流规则:queryCoupon 这个资源,QPS 限 2000
FlowRule flow = new FlowRule();
flow.setResource("queryCoupon");
flow.setGrade(RuleConstant.FLOW_GRADE_QPS);
flow.setCount(2000);
FlowRuleManager.loadRules(Collections.singletonList(flow));
// 熔断规则:慢调用比例 > 50% 触发熔断
DegradeRule degrade = new DegradeRule("queryCoupon");
degrade.setGrade(RuleConstant.DEGRADE_GRADE_RT); // 按响应时间
degrade.setCount(1000); // 超 1000ms 算慢
degrade.setSlowRatioThreshold(0.5); // 慢调用比例阈值
degrade.setTimeWindow(10); // 熔断 10 秒
degrade.setMinRequestAmount(20);
DegradeRuleManager.loadRules(Collections.singletonList(degrade));
}
// Sentinel 优势:规则可在控制台动态调整、实时生效,
// 大促前不用改代码、不用发版,直接在控制台收紧限流阈值。
修复 6:流量治理监控
# 限流熔断的效果要可观测,否则不知道防护有没有真的在工作
groups:
- name: flow-control
rules:
# 1. 限流触发量突增(流量超出承载,或遭遇攻击)
- alert: RateLimitTriggered
expr: rate(sentinel_block_total{type="flow"}[5m]) > 100
for: 3m
annotations:
summary: "{{ $labels.resource }} 限流触发频繁,排查流量来源或扩容"
# 2. 熔断器打开(下游依赖故障)
- alert: CircuitBreakerOpen
expr: sentinel_circuit_breaker_state{state="open"} == 1
annotations:
summary: "{{ $labels.resource }} 熔断器已打开,下游依赖故障"
# 3. 降级调用量过高(大量请求走了 fallback)
- alert: FallbackRateHigh
expr: rate(sentinel_fallback_total[5m]) / rate(sentinel_pass_total[5m]) > 0.1
for: 5m
annotations:
summary: "{{ $labels.resource }} 降级比例 > 10%,核心依赖可能异常"
# 4. 隔离线程池打满(某依赖耗尽了它的隔离池)
- alert: BulkheadPoolExhausted
expr: bulkhead_available_concurrent_calls == 0
for: 2m
annotations:
summary: "{{ $labels.name }} 隔离池耗尽,该依赖响应变慢"
优化效果
指标 治理前 治理后
=============================================================
下游接口变慢的影响 雪崩,全链路瘫痪 仅该依赖降级,主流程正常
限流 无 令牌桶,接口/用户多维限流
熔断 无 失败/慢调用率超阈自动跳闸
熔断后单次调用耗时 等 5s 超时 熔断态几微秒快速失败
降级 无,直接报错 fallback 返回兜底数据
资源隔离 共用线程池 每依赖独立线程池/信号量
跨网络调用超时 部分没配 全部强制配超时
规则调整 改代码发版 Sentinel 控制台动态生效
流量治理可观测 无 限流/熔断/降级全监控
故障演练验证:
- 演练前(治理后第一次):主动让优惠券接口超时
- 结果:优惠券角标降级为"暂无",详情页与下单完全正常
- 核心链路 P99 从演练前的 45ms 仅升到 52ms
治理过程:
- 复盘雪崩根因 + 梳理核心/非核心链路:1 天
- 接入 Sentinel,配置限流规则:1.5 天
- 熔断 + 降级 fallback 改造:2 天
- 关键下游线程池隔离改造:2 天
- 故障演练 + 监控接入:1.5 天
避坑清单
- 一个非核心依赖能拖垮核心链路,根因是没有限流、熔断、降级、隔离
- 限流控制进入系统的流量,令牌桶最常用,允许一定突发,超载即拒绝
- 限流不止限总 QPS,还要按接口、用户、热点参数多维度限
- 熔断器三态:CLOSED 正常统计、OPEN 跳闸快速失败、HALF_OPEN 探路恢复
- 熔断的核心价值:下游故障时把"等超时"变成"几微秒快速失败",解放线程
- 降级负责"失败之后给什么":fallback 兜底数据、返回旧缓存、关非核心功能
- 必须分清核心与非核心链路,故障时果断舍弃非核心保住核心
- 资源隔离用舱壁模式,每个下游依赖独立线程池,一个故障不淹全船
- 所有跨网络调用必须配连接超时和读超时,没有超时就是定时炸弹
- 降级逻辑平时不跑,必须定期故障演练,否则真出事时降级代码自己就是 bug
总结
这次雪崩事故给我上了印象最深刻的一课:在一个由许多服务相互调用编织成的分布式系统里,真正可怕的从来不是某一个服务出故障——故障是必然会发生的,第三方接口会变慢,网络会抖动,机器会宕机,这些都无法避免;真正可怕的是故障的"传染",是一个微不足道的、边缘的故障,顺着调用链像多米诺骨牌一样,一路把整个系统推倒。我们这次的起点,就是一个优惠券查询接口变慢,它本身只是详情页上一个无关痛痒的角标,可就因为我们的代码对它毫无防护,它的 5 秒卡顿被原原本本地传递给了调用它的详情服务,详情服务的线程被它占满后,又把这份"病"传染给了上游的下单服务,短短三分钟,整条交易链路全军覆没。复盘这件事,我把对下游故障的防护手段彻底想清楚了,它其实是层层递进的四道防线。第一道是限流,它管的是"入口",在流量超过系统承载能力时果断把多余的请求挡在门外,因为放进来一个系统处理不了的请求,不是帮它,是害它,也害了所有正常请求。第二道是熔断,它管的是"对下游的调用",当一个下游已经被监测到大面积失败或变慢时,继续一遍遍地去调它、傻等它超时,是一种自残行为,熔断器会果断"跳闸",让对它的调用从漫长的超时等待变成几微秒的快速失败,这一下就把被白白占用的线程全解放了出来。第三道是降级,熔断解决了"快速失败",而降级解决的是"失败之后拿什么交代",优惠券挂了就显示"暂无优惠券",推荐挂了就返回上次的缓存结果,让用户感受到的是功能的小小残缺,而不是整个页面的崩溃。第四道,也是我认为这次事故里最本质的一道,是隔离,我们犯的最根本的错误,是让优惠券这个非核心依赖,和核心交易流程共用了同一个线程池,于是它一卡,主流程的线程也跟着没了——舱壁模式就是为此而生,给每个下游依赖分配独立的线程池,就像给船舱装上隔水壁,某一个舱进水,水也淹不到别的舱,整条船不会沉。这四道防线背后,其实是一个更根本的思维转变:做高可用系统,不能再抱着"我的每个依赖都不会出问题"这种幻想去设计,而要从一开始就假设"我的每个依赖随时都可能挂掉",然后认真地问自己——它挂了,我还能不能活?能不能优雅地活?最后我想强调一点,这些防护手段最危险的地方在于,它们平时根本不会被执行,熔断不跳、降级不走,你永远不知道那段 fallback 代码到底写没写对;所以一定要定期做故障演练,主动把下游搞挂,亲眼看着限流、熔断、降级、隔离一道道生效,核心链路稳稳地活下来——只有被真实演练验证过的高可用,才是真的高可用。
—— 别看了 · 2026