服务雪崩是怎么发生的?熔断、降级、限流有什么区别?

进阶高频场景题对比约 8 分钟读完

一句话回答

雪崩通常从一个下游变慢开始:调用方的线程、连接都卡在等待响应上,很快被耗尽,自己也变得不可用;上游再超时、重试,把流量放大好几倍,故障沿着调用链一路向上蔓延。防护手段各管一段:超时和有限重试避免无限等待和重试风暴;熔断在下游持续失败时直接快速失败,给它恢复的时间;降级是失败后返回兜底结果、关掉非核心功能;限流在入口控制进来的流量;隔离(舱壁)让一个依赖出问题时只占用自己那份资源。

详细解析

雪崩是怎么发生的

文本
用户 ──> 网关 ──> 订单服务 ──> 库存服务 ──> 数据库(慢查询,响应从 20ms 变成 5s)
                                  │
             库存服务的连接池、线程全部卡在等数据库,新请求排队
                     │
        订单服务调用库存超时,线程也被占满;客户端和网关超时后重试,请求翻倍
                     │
        订单服务不可用,依赖订单的其他服务也开始超时……

关键点:慢比挂更危险。下游直接挂了,调用方很快收到连接失败;下游变慢,调用方的资源会被一点点占满。

超时和重试

  • 超时:每次远程调用都必须设置超时,而且要比用户能等待的时间短。链路上游的超时应该大于下游,或者传递剩余的截止时间(deadline),避免上游已经放弃、下游还在算
  • 重试要克制:只重试幂等的操作和临时性错误(超时、连接失败、503),业务错误不重试;限制次数,并设置重试预算,比如重试请求不超过总请求的一小部分
  • 指数退避加抖动:重试间隔按 base * 2^n 增长并设上限,再在 [0, 上限) 内取随机值,避免大量客户端在同一时刻一起重试
  • 多层都重试会相乘:三层各重试 3 次,最底层可能收到 4 × 4 × 4 = 64 倍请求,所以一般只在一层重试

熔断器的三种状态

文本
          失败次数或失败率超过阈值
  CLOSED ──────────────────────────> OPEN(直接快速失败,不调用下游)
    ^                                   │
    │ 探测成功                           │ 经过一段冷却时间
    │                                   v
    └──────────────────────────── HALF_OPEN(放少量请求去探测)
                                        │ 探测失败
                                        └──────> 回到 OPEN

熔断的价值有两个:调用方不再浪费资源等待一个大概率失败的调用,下游也得到喘息和恢复的时间。实际实现通常按滑动窗口内的失败率或慢调用比例判断,并要求窗口内有最小请求数,避免样本太少时误判。例如 Java 的 Resilience4j、Sentinel,Node.js 的 opossum。

降级、限流、隔离

  • 降级:调用失败或熔断后的兜底,比如推荐服务不可用时返回热门商品,评论加载失败时隐藏评论区;大促时也会主动关闭非核心功能,把资源留给下单支付
  • 限流:在入口按 QPS 或并发数拒绝超出能力的请求,保护自己不被打垮,算法和实现见 Redis 限流
  • 隔离(舱壁):给每个下游依赖分配独立的线程池或并发数上限,一个依赖变慢只会占满自己那份,其他依赖不受影响
手段 作用位置 触发条件 保护谁
超时 / 重试 每次调用 单次调用慢或失败 调用方
熔断 调用方,针对某个下游 下游持续失败或变慢 调用方和下游
降级 调用方 熔断、失败、主动开关 用户体验和核心功能
限流 服务入口 流量超过阈值 服务自身
隔离 调用方的资源分配 始终生效 其他依赖和整体

代码示例:简化版熔断器

按连续失败次数熔断,半开时只放一个请求探测:

JavaScript
class CircuitBreaker {
  constructor(fn, { failureThreshold = 5, openMs = 10000, timeoutMs = 1000 } = {}) {
    Object.assign(this, { fn, failureThreshold, openMs, timeoutMs })
    this.state = 'CLOSED'
    this.failures = 0
    this.openedAt = 0
    this.probing = false
  }

  async call(...args) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.openedAt < this.openMs) throw new Error('熔断中,快速失败')
      this.state = 'HALF_OPEN' // 冷却时间到了,进入半开
    }
    if (this.state === 'HALF_OPEN') {
      if (this.probing) throw new Error('半开探测中,快速失败') // 半开只放一个请求去探测
      this.probing = true
    }
    try {
      const result = await this.withTimeout(this.fn(...args))
      this.failures = 0
      this.state = 'CLOSED'
      return result
    } catch (err) {
      this.failures++
      // 半开时探测失败,或者关闭状态下连续失败达到阈值,都进入打开状态
      if (this.state === 'HALF_OPEN' || this.failures >= this.failureThreshold) {
        this.state = 'OPEN'
        this.openedAt = Date.now()
      }
      throw err
    } finally {
      this.probing = false
    }
  }

  withTimeout(promise) {
    let timer
    const timeout = new Promise((_, reject) => {
      timer = setTimeout(() => reject(new Error('调用超时')), this.timeoutMs)
    })
    return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
  }
}

// 用法:熔断时走降级逻辑
const breaker = new CircuitBreaker(() => fetchRecommendations(userId), { failureThreshold: 3 })
const list = await breaker.call().catch(() => getHotProducts()) // 降级:返回热门商品

面试官可能追问

熔断阈值怎么定?

按失败率比按连续失败次数更稳,常见配置是"统计窗口内请求数达到最小值,且失败率或慢调用比例超过某个百分比"。具体数值要根据下游平时的错误率和延迟分布来定,并在压测和故障演练中验证。阈值太敏感会因为偶发抖动频繁熔断,太迟钝则起不到保护作用。

熔断和限流都会拒绝请求,有什么本质区别?

限流看的是自己的入口流量,超过自己的处理能力就拒绝,即使下游一切正常。熔断看的是某个下游的健康状况,下游异常时停止调用它,和自己的流量大小无关。一个保护自己不被外部压垮,一个避免被下游拖垮。

降级要注意什么?

降级逻辑本身要简单可靠,不能依赖同一个出问题的下游;降级的结果要让前端能识别(比如带一个标记),避免把兜底数据当真实数据缓存起来;大促前要演练降级开关,确认真的能关掉、关掉后核心链路正常。

易错点

  • 只设了重试没设超时,或者每一层都重试,反而放大故障
  • 认为熔断器打开后就永远不调用了,实际会在冷却后进入半开探测
  • 把熔断、降级、限流混为一谈:熔断是触发条件,降级是触发后的处理方式,限流管的是入口流量
  • 线程池共用,一个慢依赖占满所有线程,拖垮了与它无关的接口

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。