服务雪崩是怎么发生的?熔断、降级、限流有什么区别?
一句话回答
雪崩通常从一个下游变慢开始:调用方的线程、连接都卡在等待响应上,很快被耗尽,自己也变得不可用;上游再超时、重试,把流量放大好几倍,故障沿着调用链一路向上蔓延。防护手段各管一段:超时和有限重试避免无限等待和重试风暴;熔断在下游持续失败时直接快速失败,给它恢复的时间;降级是失败后返回兜底结果、关掉非核心功能;限流在入口控制进来的流量;隔离(舱壁)让一个依赖出问题时只占用自己那份资源。
详细解析
雪崩是怎么发生的
用户 ──> 网关 ──> 订单服务 ──> 库存服务 ──> 数据库(慢查询,响应从 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 限流
- 隔离(舱壁):给每个下游依赖分配独立的线程池或并发数上限,一个依赖变慢只会占满自己那份,其他依赖不受影响
| 手段 | 作用位置 | 触发条件 | 保护谁 |
|---|---|---|---|
| 超时 / 重试 | 每次调用 | 单次调用慢或失败 | 调用方 |
| 熔断 | 调用方,针对某个下游 | 下游持续失败或变慢 | 调用方和下游 |
| 降级 | 调用方 | 熔断、失败、主动开关 | 用户体验和核心功能 |
| 限流 | 服务入口 | 流量超过阈值 | 服务自身 |
| 隔离 | 调用方的资源分配 | 始终生效 | 其他依赖和整体 |
代码示例:简化版熔断器
按连续失败次数熔断,半开时只放一个请求探测:
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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。