一个第三方接口拖垮整条交易链路:一次服务雪崩与限流熔断治理的复盘

一个第三方优惠券接口从 50ms 变慢到 5s,顺着调用链把整条交易链路全线拖垮。根因是服务对下游故障毫无防护:没限流、没熔断、没降级、没隔离。几天专项治理:令牌桶限流、熔断器三态、fallback 降级、舱壁模式线程池隔离、Sentinel 统一落地、故障演练。

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 天

避坑清单

  1. 一个非核心依赖能拖垮核心链路,根因是没有限流、熔断、降级、隔离
  2. 限流控制进入系统的流量,令牌桶最常用,允许一定突发,超载即拒绝
  3. 限流不止限总 QPS,还要按接口、用户、热点参数多维度限
  4. 熔断器三态:CLOSED 正常统计、OPEN 跳闸快速失败、HALF_OPEN 探路恢复
  5. 熔断的核心价值:下游故障时把"等超时"变成"几微秒快速失败",解放线程
  6. 降级负责"失败之后给什么":fallback 兜底数据、返回旧缓存、关非核心功能
  7. 必须分清核心与非核心链路,故障时果断舍弃非核心保住核心
  8. 资源隔离用舱壁模式,每个下游依赖独立线程池,一个故障不淹全船
  9. 所有跨网络调用必须配连接超时和读超时,没有超时就是定时炸弹
  10. 降级逻辑平时不跑,必须定期故障演练,否则真出事时降级代码自己就是 bug

总结

这次雪崩事故给我上了印象最深刻的一课:在一个由许多服务相互调用编织成的分布式系统里,真正可怕的从来不是某一个服务出故障——故障是必然会发生的,第三方接口会变慢,网络会抖动,机器会宕机,这些都无法避免;真正可怕的是故障的"传染",是一个微不足道的、边缘的故障,顺着调用链像多米诺骨牌一样,一路把整个系统推倒。我们这次的起点,就是一个优惠券查询接口变慢,它本身只是详情页上一个无关痛痒的角标,可就因为我们的代码对它毫无防护,它的 5 秒卡顿被原原本本地传递给了调用它的详情服务,详情服务的线程被它占满后,又把这份"病"传染给了上游的下单服务,短短三分钟,整条交易链路全军覆没。复盘这件事,我把对下游故障的防护手段彻底想清楚了,它其实是层层递进的四道防线。第一道是限流,它管的是"入口",在流量超过系统承载能力时果断把多余的请求挡在门外,因为放进来一个系统处理不了的请求,不是帮它,是害它,也害了所有正常请求。第二道是熔断,它管的是"对下游的调用",当一个下游已经被监测到大面积失败或变慢时,继续一遍遍地去调它、傻等它超时,是一种自残行为,熔断器会果断"跳闸",让对它的调用从漫长的超时等待变成几微秒的快速失败,这一下就把被白白占用的线程全解放了出来。第三道是降级,熔断解决了"快速失败",而降级解决的是"失败之后拿什么交代",优惠券挂了就显示"暂无优惠券",推荐挂了就返回上次的缓存结果,让用户感受到的是功能的小小残缺,而不是整个页面的崩溃。第四道,也是我认为这次事故里最本质的一道,是隔离,我们犯的最根本的错误,是让优惠券这个非核心依赖,和核心交易流程共用了同一个线程池,于是它一卡,主流程的线程也跟着没了——舱壁模式就是为此而生,给每个下游依赖分配独立的线程池,就像给船舱装上隔水壁,某一个舱进水,水也淹不到别的舱,整条船不会沉。这四道防线背后,其实是一个更根本的思维转变:做高可用系统,不能再抱着"我的每个依赖都不会出问题"这种幻想去设计,而要从一开始就假设"我的每个依赖随时都可能挂掉",然后认真地问自己——它挂了,我还能不能活?能不能优雅地活?最后我想强调一点,这些防护手段最危险的地方在于,它们平时根本不会被执行,熔断不跳、降级不走,你永远不知道那段 fallback 代码到底写没写对;所以一定要定期做故障演练,主动把下游搞挂,亲眼看着限流、熔断、降级、隔离一道道生效,核心链路稳稳地活下来——只有被真实演练验证过的高可用,才是真的高可用。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

单表一亿三千万行:一次订单表分库分表落地的复盘

2026-5-20 13:22:07

技术教程

页面间歇性 502:一次 Nginx 配置排查的复盘

2026-5-20 13:27:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索