第 16 章 背压、超时与降级:宁可慢,不要死
这是第四部”可靠性”的最后一章,也是整本书最”实用”的一章之一。
前几章讲了:出错(崩溃)、资源耗尽(隔离配额)。这一章讲一个更微妙的场景:系统过载了。
过载时,最危险的不是”慢”,而是”看起来还正常,但已经注定失败”。因为慢会累积,累积会雪崩。而避免雪崩的三个武器,就是这一章的标题:背压(backpressure)、超时(timeout)、降级(degradation)。
引子:一个”看起来正常”的系统,是怎么雪崩的?
复盘一次经典的线上事故(几乎每个做后端的人都遇到过类似的):
1. 数据库突然变慢(比如大表扫描、锁竞争)
→ 查询从 10ms 涨到 1s
2. 服务 A 调用数据库变慢,请求开始堆积
→ A 的线程池/goroutine 被慢请求占满
3. A 变慢,上游 B 调用 A 也变慢
→ B 的请求也堆积
4. 客户端超时重试
→ 重试流量翻倍,A 和数据库更慢
5. 最终:整个链路雪崩,所有服务都"假死"
(都在等,都在重试,没有一个请求能成功)
这个雪崩的每一步,都能被”背压、超时、降级”中的某一个拦下:
- 第 2 步:如果 A 有”背压”(限制在途请求数),就不会被慢请求占满
- 第 3 步:如果 B 有”超时”,就不会无限等 A
- 第 4 步:如果客户端”重试有退避 + 熔断”,就不会重试风暴
- 第 5 步:如果各层有”降级”,就不会全体假死
这一章,我们逐个拆这三个武器。
给你一句总纲:
系统过载时,正确的应对不是”硬扛”,而是”优雅地慢下来,或者快速失败”。
背压:让上游慢下来(限制速率,防止堆积)。 超时:让等待有个上限(无限等 = 无限堆积)。 降级:放弃不重要的(保住核心)。
三者的共同哲学:宁可慢,不要死;宁可失败,不要雪崩。
本章路线图
第一幕:队列与堆积
Q1 为什么"无限队列"是架构的原罪?
Q2 利特尔法则:队列长度和延迟是什么关系?
第二幕:背压
Q3 背压是什么?TCP 的拥塞控制是怎么做背压的?
Q4 你的服务里怎么实现背压?(有界队列、信号量、拒绝)
第三幕:超时
Q5 超时时间怎么设?为什么有"三重矛盾"?
Q6 Go 的 context 是怎么把超时串起来的?
第四幕:降级与熔断
Q7 熔断器为什么必须有"半开"状态?
Q8 重试风暴是怎么把故障放大的?怎么防?
Q9 降级的层次和原则是什么?
第一幕:队列与堆积
Q1 为什么”无限队列”是架构的原罪?
无限队列 = 一个可以无限增长的缓冲区。
例:
- 无界 channel(Go 里 make(chan T) 加无限缓冲)
- 无限增长的线程池任务队列
- 无限增长的日志缓冲
- 消息队列里的无限积压
为什么它是”原罪”?因为无限队列会”延迟”并”放大”问题。
问题一:无限队列延迟了"失败"。
如果队列有界,满了就"快速失败"(拒绝、报错),
调用方立刻知道"系统过载了",可以降级、重试、告警。
如果队列无限,请求一直"排队",
调用方以为"在处理"(其实在排队),
等它发现超时,已经晚了。
→ 无限队列把"立刻的失败"变成"延迟的失败"。
问题二:无限队列放大"雪崩"。
过载时,请求堆积在无限队列里,
队列越来越长,延迟越来越高,
更多请求超时,更多重试,更多堆积。
→ 正反馈循环,直到内存耗尽、系统崩溃。
问题三:无限队列消耗内存。
每个排队请求占内存(goroutine、栈、请求上下文),
无限队列 = 无限内存消耗 = 迟早 OOM。
一个经典的教训:
很多系统"优化"时,把有界队列改成"无限队列",
以为"这样就不会丢请求了"(拒绝请求听起来很糟)。
但结果是:
- 过载时,无限队列让所有请求"排队等死"
- 延迟从 100ms 涨到 10s,所有请求都超时
- 相比之下,"有界队列 + 快速失败"反而更好:
少数请求被拒绝,但大多数请求快速成功
核心原则:队列必须有界,满载必须”快速失败”。
因为:
- 无限队列把”立刻的失败”变成”延迟的失败”(更糟)
- 有界队列 + 快速失败,让系统”优雅地拒绝”,而不是”慢慢等死”
“快速失败”不是”不友好”,而是”诚实”——告诉调用方”我现在忙不过来”,让调用方有机会降级、重试、或稍后再来。
Q2 利特尔法则:队列长度和延迟是什么关系?
利特尔法则(Little’s Law):
L = λ × W
L = 系统中的平均任务数(在途 + 排队)
λ = 到达率(每秒多少个请求)
W = 平均响应时间(每个请求在系统里待多久)
这个公式看似简单,但极其深刻,它揭示了”延迟”和”堆积”的关系:
推导:
如果到达率 λ 固定,响应时间 W 变长(比如数据库变慢),
那么 L(在途请求数)必然增加。
例:
到达率 1000 QPS,响应时间 10ms → 在途 = 1000 × 0.01 = 10 个
响应时间涨到 1s → 在途 = 1000 × 1 = 1000 个
看!响应时间涨 100 倍,在途请求数也涨 100 倍。
利特尔法则的两个推论(关键):
推论一:延迟变慢,会导致堆积(反之亦然)。
下游变慢(W 增加)→ 在途请求堆积(L 增加)
→ 堆积的请求占满线程/连接 → 系统更慢(W 再增加)
→ 恶性循环
推论二:有界系统里,延迟和吞吐是耦合的。
如果系统有界(在途请求数有上限 L_max),
那么:
吞吐 = L_max / W
即:在途上限固定时,响应时间越慢,吞吐越低。
例:
在途上限 100 个,响应时间 10ms → 吞吐 = 100 / 0.01 = 10000 QPS
响应时间涨到 1s → 吞吐 = 100 / 1 = 100 QPS
→ 吞吐暴跌 100 倍!
利特尔法则给我们的启示:
1. 延迟和堆积是"同一枚硬币的两面":
堆积多了,延迟就高;延迟高了,堆积就多。
2. 要控制"堆积"(有界队列),就要接受"延迟变慢时,吞吐下降":
这是物理规律,无法违背。
3. 过载的本质:
到达率 λ > 系统能承受的吞吐(L_max / W),
多出来的请求要么排队(堆积),要么拒绝(快速失败)。
无限队列 = 无限排队 = 延迟无限增长 = 雪崩。
有界队列 = 限制堆积 = 满了拒绝 = 可控。
利特尔法则是理解”过载”的钥匙:
系统过载时,你无法”既保持低延迟,又不拒绝请求”——这是物理规律。
你只能二选一:
- 让请求排队(延迟升高,堆积增长)
- 拒绝请求(快速失败,保护系统)
而”无限排队”最终会雪崩,所以正确选择是”有界队列 + 快速失败”。
第二幕:背压
Q3 背压是什么?TCP 的拥塞控制是怎么做背压的?
背压(backpressure):当”下游处理不过来”时,让”上游慢下来”。
形象理解:水管。
下游(排水口)堵了,上游(水龙头)如果不关小,
水就会溢出来(堆积、溢出)。
背压 = 把"下游堵了"的信息,反馈给上游,让上游"关小水龙头"。
TCP 的拥塞控制,是最经典的背压实现:
场景:发送方发得快,接收方(或网络)处理不过来。
TCP 的背压机制:
1. 接收方的接收窗口(rwnd):
接收方告诉发送方"我还能收多少",
接收方忙了,窗口缩小,发送方慢下来。
2. 拥塞窗口(cwnd):
发送方根据"丢包"判断网络拥塞,
丢包了 → 拥塞了 → 减小窗口 → 慢下来。
3. 慢启动 + 拥塞避免 + 快重传:
一套完整的"感知拥塞、调整速率"的算法。
本质:TCP 通过"窗口",把"下游/网络的拥塞"反馈给发送方,
让发送方"主动慢下来",而不是"无限发送导致丢包"。
TCP 拥塞控制的精妙:
1. 闭环反馈:发送方根据"反馈"(ACK、丢包、窗口)调整速率
2. 自适应:慢启动快速试探,拥塞避免缓慢逼近
3. 公平:多个 TCP 流共享带宽,各自退避
这是"分布式背压"的典范——没有中心协调,
每个发送方根据"局部反馈"调整,全局达到平衡。
背压的本质:把”下游过载”的信号,沿着”调用链”向上游传递,让上游”主动降速”。
没有背压,上游就”盲目发送”,下游堆积、溢出、崩溃。 有背压,上游”感知”下游过载,主动慢下来,系统保持稳定。
这和第 15 章的”配额”、第 8 章的”中断 vs 轮询”是呼应的: 让”资源的使用者”感知”资源的紧张”,从而”自我调节”。
Q4 你的服务里怎么实现背压?(有界队列、信号量、拒绝)
背压从”TCP 的概念”落地到”你的 Go 服务”,有三个层次的手段:
手段一:有界队列 + 快速失败。
// 有界 channel:缓冲区满了,发送就阻塞(背压)或失败
ch := make(chan Task, 100) // 有界,最多 100 个排队
// 生产者:满了就阻塞(背压,让上游慢下来)
ch <- task
// 或者:满了就快速失败
select {
case ch <- task:
// 入队成功
default:
// 队列满了,快速失败
return errors.New("queue full, try later")
}手段二:信号量限制并发(在途请求数)。
// 信号量:限制"同时处理"的请求数
var sem = make(chan struct{}, 1000) // 最多 1000 个并发
func handler(w http.ResponseWriter, r *http.Request) {
select {
case sem <- struct{}{}: // 拿到令牌
defer func() { <-sem }()
handle(w, r)
default:
// 并发已满,快速失败(503)
http.Error(w, "service overloaded", 503)
}
}手段三:限流器(令牌桶 / 滑动窗口)。
import "golang.org/x/time/rate"
// 令牌桶:每秒 1000 个请求
limiter := rate.NewLimiter(1000, 1000)
func handler(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "rate limited", 429)
return
}
handle(w, r)
}三者对比:
| 手段 | 限制什么 | 满载行为 | 适用 |
|---|---|---|---|
| 有界队列 | 排队长度 | 阻塞或失败 | 任务队列 |
| 信号量 | 在途并发数 | 快速失败 | 请求处理 |
| 限流器 | 请求速率 | 拒绝(429) | 入口限流 |
背压的”关键设计”:满载时怎么办?
满载时,三个选择:
1. 阻塞(让上游慢下来):生产者阻塞,直到有空位
→ 真正的"背压"(把压力传回上游)
2. 快速失败:立即返回错误
→ 让调用方降级、重试、告警
3. 丢弃(新请求 or 旧请求):
→ 丢弃新请求(保护已接受的)
→ 或丢弃旧请求(优先新请求,适用于"实时性"场景)
选择取决于:能不能"背压到上游"?
- 能(同一个调用链内)→ 阻塞(背压)
- 不能(跨网络、异步)→ 快速失败或丢弃
背压落地的核心:
- 队列必须有界(第 Q1 的”无限队列原罪”)
- 满载必须有行为(阻塞 / 快速失败 / 丢弃,不能”无限等”)
- 尽量把压力传回上游(背压的本质)
而”快速失败”的价值(第 14 章呼应):诚实告知”我忙不过来”,比”假装在处理”更好。 让调用方有机会降级、重试、或稍后再来。
第三幕:超时
Q5 超时时间怎么设?为什么有”三重矛盾”?
超时(timeout):给”等待”设一个上限,避免无限等待。
没有超时:调用下游,下游挂了,你永远等 → 请求堆积 → 雪崩
有超时:调用下游,超时了就放弃 → 快速失败 → 保护自己
但超时时间怎么设,是个”三重矛盾”:
矛盾一:太短 vs 太长。
太短(比如 10ms):
下游正常响应 50ms,你的超时 10ms → 大量"误判超时"
→ 正常的慢请求也被放弃 → 重试 → 下游压力更大
太长(比如 10s):
下游挂了,你要等 10s 才发现 → 10s 内请求堆积
→ 堆积的请求占满资源 → 雪崩
所以:超时时间要"匹配下游的真实延迟"。
矛盾二:P99 vs 平均值。
如果按"平均延迟"设超时:
平均 20ms,超时设 40ms
但 P99 是 200ms,那 1% 的请求会超时
→ 这 1% 的"慢请求"被误杀
如果按"P99"设超时:
P99 200ms,超时设 250ms
但下游偶尔更慢(P999 1s),那 0.1% 还是超时
→ 超时永远"杀"掉最慢的那批请求
所以:超时是"必然误杀一部分慢请求"的妥协。
矛盾三:串行调用叠加 vs 总超时。
一个请求要串行调 3 个下游,每个超时 100ms:
最坏情况:3 × 100ms = 300ms
但你的"总超时"(客户端能等多久)可能是 200ms:
→ 3 个下游各 100ms,加起来 300ms > 200ms
→ 总超时先触发,下游的超时白设了
所以:要协调"每个下游的超时"和"整个请求的总超时"。
怎么设超时?(实践建议)
1. 基于真实延迟分布:
- 测量下游的 P50、P99、P999
- 超时 = P99 的 2-3 倍(留缓冲)
- 但不要超过"总超时"的预算
2. 用"预算"而不是"固定值":
- 整个请求总超时 1s
- 分配给各下游:A 300ms + B 300ms + C 300ms = 900ms
- 留 100ms 缓冲
3. 动态调整(自适应超时):
- 根据下游的实时延迟,动态调整超时
- 复杂,但能避免"误杀"
4. 超时 + 重试要配合:
- 超时后重试,但重试也有超时
- 总时间 = 超时 × (1 + 重试次数)
超时的本质:用”可能误杀慢请求”的代价,换取”不被慢下游拖死”。
超时永远”杀”掉最慢的那批请求(它们是”拖垮系统”的根源)。 而”设多长”,是”误杀多少”和”拖多久”之间的权衡。
核心原则:超时要”有”,且要”合理”(基于真实延迟),且要”协调”(各下游超时之和 ≤ 总超时)。
Q6 Go 的 context 是怎么把超时串起来的?
Go 的 context 是”超时和取消”的标准机制,它优雅地解决了”串行调用超时叠加”的问题。
context 的核心:一个”取消信号”的传播链。
// 1. 创建一个带超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
// 2. 传给下游调用
result, err := callDownstream(ctx) // 下游感知 ctx 的超时
// 3. 下游内部,继续传给它的下游
func callDownstream(ctx context.Context) {
// 调数据库,传同一个 ctx
db.QueryContext(ctx, ...)
// 调另一个服务,也传 ctx
rpc.CallContext(ctx, ...)
}context 的精妙之处:
1. 一个超时,贯穿整个调用链:
最外层设 1s 超时,内层的所有调用都"继承"这个超时
→ 不用每个下游单独设超时
2. 超时/取消自动传播:
最外层超时了 → ctx 被取消 → 内层所有调用收到取消信号
→ 内层调用"及时"放弃,而不是继续傻等
3. 区分"超时"和"取消":
- 超时(WithTimeout/WithDeadline):到时间自动取消
- 取消(WithCancel):手动取消(比如客户端断开)
- 两者都是"取消信号",内层统一处理
context 的正确用法:
func handleRequest(w http.ResponseWriter, r *http.Request) {
// 1. 从请求派生 context(继承客户端的取消)
ctx := r.Context()
// 2. 加一个总超时
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// 3. 串行调用,都传 ctx
user, err := getUser(ctx, userID) // 内部有超时
if err != nil { return }
order, err := getOrder(ctx, orderID) // 内部有超时
if err != nil { return }
// ...
}
func getUser(ctx context.Context, id int) (*User, error) {
// 数据库查询,感知 ctx 取消
return db.QueryContext(ctx, "SELECT ...", id)
}context 的常见错误(要避免):
1. 忘了 defer cancel():
会导致 context 泄漏(timer 不释放)
2. 把 context 存到 struct 里长期持有:
context 应该"随请求传递",不应该"存起来"
(官方文档明确说不要存 struct)
3. 用 context.Background() 而不是 r.Context():
丢失了"客户端断开就取消"的能力
4. 在循环里不检查 ctx.Done():
长循环里要 select ctx.Done(),及时响应取消
context 的启示:
“超时/取消”不是”每个调用各自为政”,而是”一个信号,贯穿整个调用链”。
这样做的价值:
- 避免”超时叠加”(每个下游各自超时,加起来超总预算)
- 及时”止损”(上游超时了,下游也别浪费资源了)
- 优雅关闭(客户端断开,整个调用链及时停止)
这是”分布式超时”的典范——一个 context,串起所有下游。
第四幕:降级与熔断
Q7 熔断器为什么必须有”半开”状态?
熔断器(circuit breaker):下游故障时,“熔断”(直接快速失败),避免”反复调用失败的下游”。
灵感来自电路保险丝:
电流过大 → 保险丝熔断 → 电路断开(保护电器)
稍后 → 换保险丝(恢复)
软件熔断器:
下游错误率过高 → 熔断(直接失败,不再调用下游)
→ 让下游有机会恢复
→ 稍后 → 探测下游是否恢复
熔断器的三个状态:
1. 关闭(Closed):正常,请求正常转发
- 监控错误率
- 错误率超过阈值(比如 50%)→ 打开
2. 打开(Open):熔断,直接快速失败
- 不调用下游(让下游喘息)
- 经过一段时间(冷却期)→ 半开
3. 半开(Half-Open):探测,放少量请求试探
- 放一个(或几个)请求去试探下游
- 成功 → 关闭(恢复正常)
- 失败 → 打开(继续熔断)
为什么必须有”半开”状态?
问题:熔断后,怎么知道下游"恢复了"?
方案一:熔断后,永远不恢复(关死)
→ 下游恢复了,你也不知道,永远失败
→ 不可接受
方案二:熔断后,直接恢复正常(关闭)
→ 下游可能还没恢复,你又开始大量调用
→ 又被打垮,又熔断,反复震荡
方案三:半开状态(试探性恢复)
→ 放少量请求试探
→ 成功了,说明恢复,逐步放开
→ 失败了,说明没好,继续熔断
→ 优雅的"渐进式恢复"
“半开”的本质:
在”完全熔断”和”完全恢复”之间,加一个”试探”的过渡。
因为”恢复”是”不确定的”——你不能确定下游好了,也不能确定它还坏着。 半开状态 = “用少量流量试探”,根据结果决定”放开还是继续熔断”。
这和第 6 章”活锁”的”退避”、第 8 章 NAPI 的”中断/轮询切换”是同一个思想:不确定时,用”试探 + 反馈”来渐进调整。
熔断器的实现(Go 简化版):
type CircuitBreaker struct {
state int32 // closed / open / half-open
failureRate float64
// ...
}
func (cb *CircuitBreaker) Call(fn func() error) error {
switch cb.state {
case open:
return errors.New("circuit open") // 直接失败
case halfOpen:
// 放少量请求试探
if !cb.allowProbe() {
return errors.New("circuit half-open, probing")
}
}
err := fn()
cb.record(err) // 记录成功/失败
return err
}成熟的库:
- Go:
github.com/sony/gobreaker、github.com/afex/hystrix-go - Java:Hystrix、Resilience4j
- 服务网格:Istio、Envoy 内置熔断
Q8 重试风暴是怎么把故障放大的?怎么防?
重试风暴(retry storm):过载时的重试,反而放大故障。
重试风暴的形成:
1. 下游 B 变慢
2. 上游 A 调用 B 超时,A 开始重试
3. A 的重试让 B 的负载翻倍,B 更慢
4. A 的更多请求超时,A 更疯狂地重试
5. B 彻底被打垮
6. A 的所有重试都失败,A 也"假死"(都在重试)
结果:一次"下游变慢",被"重试"放大成"全链路雪崩"。
重试风暴的放大效应:
假设:下游 B 能承受 1000 QPS,上游 A 正常发 800 QPS。
B 变慢 → A 的请求超时率 10% → A 重试这 10%
→ A 实际发出 800 + 80 = 880 QPS(还没事)
B 更慢 → A 超时率 50% → A 重试这 50%
→ A 实际发出 800 + 400 = 1200 QPS(超过 B 的承受!)
B 被打垮 → A 超时率 100% → A 重试全部
→ A 发出 1600 QPS(B 已经死了,还在被打)
怎么防重试风暴?
1. 重试要有"退避"(backoff):
- 指数退避:1ms, 2ms, 4ms, 8ms...
- 加随机抖动(jitter):避免"同时重试"
- 避免"重试风暴"的"同步性"
2. 重试要有"上限":
- 最多重试 3 次
- 超过上限就放弃(别无限重试)
3. 重试要"幂等"(第 14 章):
- 重试的前提是"操作幂等"
- 否则重试 = 重复扣款
4. 重试要配合"熔断":
- 错误率过高,先熔断,别再重试了
- 熔断 > 重试(重试是"再试一次",熔断是"先别试了")
5. 重试要"区分错误":
- 幂等错误(超时、网络抖动)→ 可以重试
- 非幂等错误(参数错误、业务失败)→ 别重试
Go 里的退避重试:
func callWithRetry(fn func() error, maxRetries int) error {
for i := 0; i < maxRetries; i++ {
err := fn()
if err == nil {
return nil
}
if !isRetryable(err) {
return err // 不可重试的错误,直接返回
}
// 指数退避 + 随机抖动
backoff := time.Duration(1<<uint(i)) * time.Millisecond
jitter := time.Duration(rand.Intn(1000)) * time.Microsecond
time.Sleep(backoff + jitter)
}
return errors.New("retry exhausted")
}重试是一把双刃剑:
- 正确用:提高成功率(网络抖动时重试)
- 错误用:放大故障(过载时重试风暴)
关键区别:重试是”偶尔的重试”,还是”过载时的疯狂重试”?
防重试风暴的核心:退避 + 上限 + 幂等 + 熔断。 有了这四样,重试才是”可靠的补偿”,而不是”雪崩的放大器”。
Q9 降级的层次和原则是什么?
降级(degradation):放弃不重要的,保住核心的。
降级的层次:
1. 功能降级:关闭非核心功能
例:活动高峰,关闭"推荐系统"(非核心),保住"下单"(核心)
2. 质量降级:降低服务质量
例:缓存失效时,返回"旧数据"(弱一致)而不是"报错"
3. 范围降级:缩小服务范围
例:只服务"VIP 用户",普通用户排队或拒绝
4. 完全降级:只保留"保命"功能
例:只返回"系统繁忙",其他全停
降级的原则:
1. 保住核心:
先搞清楚"什么是核心"(下单?支付?登录?)
降级时,优先保核心,牺牲边缘
2. 优雅降级:
- 返回"友好"的错误(而不是崩溃)
- 返回"降级"的结果(旧数据、默认值、部分数据)
3. 可恢复:
降级是"临时的",系统恢复后要能"升回来"
(降级不是"永久关闭")
4. 有优先级:
核心功能 > 重要功能 > 一般功能 > 可有可无
按优先级降级
一个经典的降级案例(秒杀系统):
秒杀高峰期,系统过载:
1. 关闭"浏览历史"(可有可无)
2. 关闭"推荐"(一般功能)
3. 关闭"购物车"(重要功能,但可以稍后)
4. 只保留"下单 + 支付"(核心)
甚至:
5. 下单也降级:返回"排队中",异步处理
6. 支付降级:先收单,稍后结算
降级的本质:
降级是”承认自己扛不住,主动放弃一部分,保住最重要的”。
它和”背压”、“超时”是配合的:
- 背压:让上游慢下来(限制速率)
- 超时:让等待有上限(限制时间)
- 降级:放弃不重要的(限制范围)
三者的共同目标:在过载时,“优雅地失败”,而不是”雪崩式崩溃”。
思考题
思考题 1
面试官问:“什么是背压?为什么’无限队列’是危险的?有界队列满载时应该怎么处理?“请回答,并结合 TCP 拥塞控制说明。
参考答案
核心答案:背压是把”下游过载”的信号反馈给上游,让上游慢下来。无限队列危险,是因为它把”立刻的失败”变成”延迟的失败”,导致雪崩。有界队列满载时应”快速失败”或”阻塞背压”。
什么是背压:
下游处理不过来时,让上游"慢下来",而不是"盲目发送"。
形象理解:水管
下游堵了,上游不关小,水就溢出来
背压 = 把"下游堵了"反馈给上游,让上游关小水龙头
TCP 拥塞控制是怎么做背压的:
1. 接收窗口(rwnd):接收方告诉发送方"我还能收多少"
→ 接收方忙了,窗口缩小,发送方慢下来
2. 拥塞窗口(cwnd):发送方根据"丢包"判断网络拥塞
→ 丢包 = 拥塞 → 减小窗口 → 慢下来
3. 本质:闭环反馈,让发送方"感知"下游/网络的拥塞,
主动降速,而不是盲目发送导致丢包
为什么无限队列危险:
1. 把"立刻的失败"变成"延迟的失败":
有界队列满了 → 立刻失败 → 调用方知道过载了
无限队列 → 一直排队 → 调用方以为在处理 → 最后超时
2. 放大雪崩:
请求堆积在无限队列 → 延迟越来越高 → 更多超时
→ 更多重试 → 更多堆积 → 正反馈 → 崩溃
3. 消耗内存:
每个排队请求占内存,无限队列 = 无限内存 = 迟早 OOM
有界队列满载时怎么办:
三个选择:
1. 快速失败(最常用):
满了就返回错误(503 / 429)
→ 让调用方降级、重试、告警
→ 保护系统
2. 阻塞(真正的背压):
满了就阻塞生产者,直到有空位
→ 把压力传回上游
→ 适合"同一个调用链内"(能背压到上游)
3. 丢弃:
- 丢新请求(保护已接受的)
- 或丢旧请求(优先新请求,实时场景)
选择依据:
- 能背压到上游吗?能 → 阻塞;不能 → 快速失败或丢弃
- 实时性要求?高 → 丢旧的;低 → 丢新的
关键原则:
队列必须有界(防止无限堆积)
满载必须有行为(快速失败 / 阻塞 / 丢弃)
不能"无限等"(无限等 = 无限堆积 = 雪崩)
这道题考察什么:
- 知不知道背压的本质(下游过载反馈给上游)
- 知不知道 TCP 拥塞控制是背压的典范
- 知不知道无限队列的危险(延迟失败、放大雪崩)
- 知不知道满载的三种处理方式
思考题 2
你的服务调用下游,下游 P50 = 10ms,P99 = 100ms,P999 = 1s。你会把超时设成多少?如果这个调用在”下单”的关键路径上,超时设错了会有什么后果?
参考答案
核心答案:超时设为 P99 的 2-3 倍,即 200-300ms。但要结合”关键路径”和”总预算”综合考虑。
先分析延迟分布:
P50 = 10ms(一半请求 10ms 内完成)
P99 = 100ms(99% 请求 100ms 内完成)
P999 = 1s(0.1% 请求会到 1s)
关键:P99 是 100ms,说明 99% 的请求很快,
但 0.1% 的请求(P999)会慢到 1s。
超时设多少?
选项一:设 50ms(接近 P90)
→ 会误杀 10% 的正常慢请求
→ 太激进,误杀率高
选项二:设 200-300ms(P99 的 2-3 倍)
→ 误杀 1% 的请求(P99 到 P999 之间的)
→ 合理的折中
选项三:设 1s 以上
→ 几乎不误杀,但下游挂了你 1s 才发现
→ 太慢,请求堆积
推荐:200-300ms(P99 的 2-3 倍)
- 保住 99% 的请求
- 超时了能及时止损(不拖太久)
如果在下单关键路径上,超时设错有什么后果?
设太短的后果(比如 30ms):
1. 大量误杀:
下游 P99 是 100ms,你 30ms 超时,
那 50% 的请求(> 30ms 的)都被误杀
→ 下单成功率暴跌
2. 误杀导致重试风暴:
被"误杀"的请求,客户端重试
→ 重试流量翻倍 → 下游更慢 → 更多误杀
→ 恶性循环
3. 用户体验极差:
本来能成功的下单,因为超时误杀而失败
→ 用户看到"下单失败",其实下游是好的
设太长的后果(比如 5s):
1. 请求堆积:
下游挂了,你要 5s 才发现
5s 内,请求堆积(利特尔法则:延迟 × 到达率)
→ 堆积的请求占满线程/goroutine/连接
2. 雪崩:
堆积 → 资源耗尽 → 更多请求堆积 → 雪崩
3. 数据库/下游被打垮:
下游慢,但你没超时,一直"压着"下游
→ 下游被你的"慢请求"拖垮
关键路径的特殊考虑:
下单是"核心 + 关键"路径,要求:
1. 不能误杀(用户下单失败很严重)
2. 不能拖太久(堆积会雪崩)
所以:
1. 超时要"合理"(P99 的 2-3 倍,200-300ms)
2. 配合重试(超时了重试一次,但重试也要有退避和上限)
3. 配合熔断(下游持续超时,熔断,快速失败)
4. 配合降级(下单失败,给用户"请稍后重试"的友好提示)
而且下单的"幂等"很重要:
超时重试,可能导致"重复下单"
→ 用"幂等键"(订单号)保证不重复(第 14 章)
这道题考察什么:
- 知不知道”超时 = P99 的 2-3 倍”这个经验
- 知不知道超时太短/太长的后果
- 知不知道”关键路径”的特殊要求
- 知不知道超时要配合”重试、熔断、幂等”
思考题 3
你的服务 A 调用服务 B,B 调用服务 C。现在 C 挂了,导致 B 变慢,进而 A 变慢。请问:A、B、C 三层分别应该怎么做(超时、重试、熔断、降级),才能避免全链路雪崩?
参考答案
核心思路:每一层都用”超时 + 熔断 + 降级”保护自己,同时”背压”到上游,形成”层层设防”。
C 层(故障源头):
C 挂了,C 自己"无能为力"(已经挂了),
但如果 C 只是"过载"而不是"崩溃":
1. C 要"限流"(保护自己不被流量打死):
- 有界队列 + 快速失败
- 过载时拒绝新请求(背压到 B)
2. C 要"快速失败"(而不是"慢慢等死"):
- 过载时立刻返回错误,让 B 及时知道"C 不行了"
- 而不是让 B 等超时
B 层(中间层,最关键):
B 调用 C,C 挂了,B 要:
1. 超时(限制等待):
B 调 C 的超时不能太长(否则请求堆积)
- 比如 C 正常 20ms,超时设 50ms
- C 挂了,B 50ms 就放弃,不无限等
2. 熔断(快速失败):
C 持续失败,B 熔断
- 不再调用 C,直接返回失败
- 让 C 有机会恢复(不被 B 的调用压垮)
- 半开状态试探恢复
3. 降级(保住 B 的核心):
B 调 C 失败,B 降级:
- 返回缓存/默认值(如果 C 是"非关键"依赖)
- 或返回"部分失败"(如果 C 是"可选"依赖)
- 或快速失败(如果 C 是"关键"依赖,无法降级)
4. 背压(让 A 慢下来):
B 变慢/失败,要把"压力"传给 A:
- B 快速失败(而不是慢响应)
- 让 A 及时知道 B 不行了
A 层(最上游):
A 调用 B,B 变慢,A 要:
1. 超时(限制等待 B):
A 调 B 的超时要合理(B 正常 100ms,超时设 300ms)
2. 熔断(B 持续失败,A 熔断 B):
不再调用 B,快速失败
3. 降级(保住 A 的用户体验):
A 的请求失败,给用户"友好提示"
- "系统繁忙,请稍后重试"
- 而不是"500 Internal Error"
4. 限流(保护 A 自己):
A 也被流量打,要限流(有界队列、信号量)
全链路防雪崩的关键:
1. 每层都要"超时"(不让等待无限)
2. 每层都要"熔断"(下游持续失败,就断开)
3. 每层都要"降级"(失败时保住核心)
4. 每层都要"快速失败"(背压到上游,让上游及时知道)
核心:让"故障"快速传播(快速失败),而不是"慢"传播(雪崩)。
一个对比:
没有防护(雪崩):
C 挂 → B 无限等 C → B 堆积 → A 无限等 B → A 堆积
→ 全链路假死
有防护(快速失败):
C 挂 → B 超时/熔断,快速失败 → B 背压 A
→ A 快速失败/降级,给用户"稍后重试"
→ 每层都"活着",只是"降级"了
→ C 恢复后,各层半开探测,逐步恢复
这道题考察什么:
- 知不知道”层层设防”(每层都要超时/熔断/降级)
- 知不知道”快速失败”比”慢响应”更好(背压)
- 知不知道熔断的”半开”状态
- 知不知道降级(返回缓存/默认值 vs 快速失败)
思考题 4(开放题)
“宁可慢,不要死;宁可失败,不要雪崩。“这句话你怎么理解?结合”背压、超时、降级、熔断、重试”这些机制,说说”优雅的失败”和”粗暴的崩溃”的区别,以及如何在系统设计里贯彻”优雅失败”的原则。
参考答案
开放题,考察你对”可靠性设计”的整体理解。
先理解这句话:
"宁可慢":过载时,让请求排队/降速(背压),
而不是"硬扛"到崩溃。
"不要死":系统的第一目标是"活着",
只要活着,就能恢复;死了,就全完了。
"宁可失败":个别请求失败(快速失败、降级),
是可以接受的代价。
"不要雪崩":最坏的不是"个别失败",是"全体崩溃"。
“优雅的失败” vs “粗暴的崩溃”:
粗暴的崩溃:
- 无限等待(没有超时)
- 无限堆积(无限队列)
- 疯狂重试(没有退避)
- 硬扛到 OOM / 全链路假死
- 用户看到"白屏"、"超时"、"500"
优雅的失败:
- 有界等待(超时)
- 有界堆积(有界队列 + 快速失败)
- 有节制的重试(退避 + 上限 + 幂等)
- 主动降级(放弃非核心,保核心)
- 用户看到"友好的提示"(稍后重试、降级内容)
各机制的”优雅失败”角色:
| 机制 | 优雅失败的角色 |
|---|---|
| 背压 | 让上游慢下来(限制速率,不盲目发送) |
| 超时 | 让等待有上限(不无限等) |
| 降级 | 放弃不重要的(保住核心) |
| 熔断 | 断开故障下游(不再反复调用失败者) |
| 重试 | 有节制地补偿(退避+上限+幂等) |
如何贯彻”优雅失败”的原则:
1. 设计阶段:问自己"如果这个依赖挂了,我的系统怎么办?"
- 每个外部依赖,都要想好"降级方案"
- 每个同步调用,都要有超时
- 每个重试,都要有退避和上限
2. 编码阶段:
- 队列要有界(拒绝无限队列)
- 调用要传 context(超时/取消贯穿)
- 错误要区分(可重试 vs 不可重试)
3. 测试阶段:
- 故障注入(chaos engineering):
模拟下游挂、网络慢、数据库抖
- 验证"降级"、"熔断"是否生效
4. 运行阶段:
- 监控"延迟分布"(P99/P999),不是平均
- 监控"错误率"、"队列长度"、"熔断状态"
- 告警:延迟飙升、错误率飙升
一个统一的框架(判断”优雅失败”):
问三个问题:
1. 失败"及时"吗?
- 快速失败(立刻报错)> 慢失败(等超时)
- 及时失败,让调用方及时降级
2. 失败"有界"吗?
- 有界队列、有界重试、有界等待
- 无限 = 雪崩
3. 失败"可恢复"吗?
- 熔断能半开恢复、降级能升回、重试能成功
- 不是"永久失败"
这道题的深层价值:
“优雅失败”不是”避免失败”(失败无法避免),而是”设计失败时的行为”。
因为(第 14 章的主题)“承认失败是常态”——系统一定会出问题(下游挂、网络抖、过载), 关键不是”不让它失败”,而是”失败时,系统还能优雅地工作”。
而”优雅”的核心,就是这三个词:
- 及时(快速失败,不拖)
- 有界(不无限堆积、不无限重试)
- 可恢复(熔断能恢复、降级能升回)
能做到这三点,你的系统就是”宁可慢不要死、宁可失败不要雪崩”的。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 16 章 · 背压、超时与降级:宁可慢,不要死 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【无限队列的原罪】 │
│ 把"立刻的失败"变成"延迟的失败",放大雪崩、耗尽内存 │
│ 利特尔法则:L = λ × W(延迟涨,堆积就涨) │
│ 有界队列 + 满载快速失败 = 正确解 │
│ │
│ 【背压】 │
│ 把"下游过载"反馈给上游,让上游慢下来 │
│ TCP 拥塞控制 = 最经典的背压(窗口 + 丢包反馈) │
│ 落地:有界队列、信号量、限流器 │
│ │
│ 【超时】 │
│ 给等待设上限,避免无限等 │
│ 三重矛盾:太短误杀 vs 太长堆积;P99 vs 平均;叠加 vs 总预算 │
│ 经验:P99 的 2-3 倍;用 context 串起整个调用链 │
│ │
│ 【熔断】 │
│ 下游故障,直接快速失败(不再反复调用失败者) │
│ 三状态:关闭 → 打开 → 半开(试探恢复) │
│ 半开 = 不确定时,用少量流量试探 │
│ │
│ 【重试风暴】 │
│ 过载时重试,放大故障(正反馈) │
│ 防:退避 + 上限 + 幂等 + 熔断 │
│ │
│ 【降级】 │
│ 放弃不重要的,保住核心的 │
│ 层次:功能降级 → 质量降级 → 范围降级 → 完全降级 │
│ │
│ 【优雅失败三原则】 │
│ 及时(快速失败)、有界(不无限堆积)、可恢复(熔断能恢复) │
│ │
│ 【一句话】 │
│ 过载时正确的应对是"优雅地慢下来或快速失败", │
│ 宁可慢不要死,宁可失败不要雪崩。 │
│ │
│ 【第四部完 · 第五部预告】 │
│ 单机的可靠性讲完了,但现代系统是分布式的—— │
│ 下一部讲:锁、共识、虚拟化、可观测性, │
│ 单机思想在分布式环境下的投射与变形。 │
└──────────────────────────────────────────────────────────────┘