第 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,所有请求都超时
  - 相比之下,"有界队列 + 快速失败"反而更好:
    少数请求被拒绝,但大多数请求快速成功

核心原则:队列必须有界,满载必须”快速失败”。

因为:

  1. 无限队列把”立刻的失败”变成”延迟的失败”(更糟)
  2. 有界队列 + 快速失败,让系统”优雅地拒绝”,而不是”慢慢等死”

“快速失败”不是”不友好”,而是”诚实”——告诉调用方”我现在忙不过来”,让调用方有机会降级、重试、或稍后再来。

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 旧请求):
   → 丢弃新请求(保护已接受的)
   → 或丢弃旧请求(优先新请求,适用于"实时性"场景)

选择取决于:能不能"背压到上游"?
  - 能(同一个调用链内)→ 阻塞(背压)
  - 不能(跨网络、异步)→ 快速失败或丢弃

背压落地的核心:

  1. 队列必须有界(第 Q1 的”无限队列原罪”)
  2. 满载必须有行为(阻塞 / 快速失败 / 丢弃,不能”无限等”)
  3. 尽量把压力传回上游(背压的本质)

而”快速失败”的价值(第 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 的启示:

“超时/取消”不是”每个调用各自为政”,而是”一个信号,贯穿整个调用链”。

这样做的价值:

  1. 避免”超时叠加”(每个下游各自超时,加起来超总预算)
  2. 及时”止损”(上游超时了,下游也别浪费资源了)
  3. 优雅关闭(客户端断开,整个调用链及时停止)

这是”分布式超时”的典范——一个 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. 丢弃:
   - 丢新请求(保护已接受的)
   - 或丢旧请求(优先新请求,实时场景)

选择依据:
  - 能背压到上游吗?能 → 阻塞;不能 → 快速失败或丢弃
  - 实时性要求?高 → 丢旧的;低 → 丢新的

关键原则:

队列必须有界(防止无限堆积)
满载必须有行为(快速失败 / 阻塞 / 丢弃)
不能"无限等"(无限等 = 无限堆积 = 雪崩)

这道题考察什么:

  1. 知不知道背压的本质(下游过载反馈给上游)
  2. 知不知道 TCP 拥塞控制是背压的典范
  3. 知不知道无限队列的危险(延迟失败、放大雪崩)
  4. 知不知道满载的三种处理方式

思考题 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 章)

这道题考察什么:

  1. 知不知道”超时 = P99 的 2-3 倍”这个经验
  2. 知不知道超时太短/太长的后果
  3. 知不知道”关键路径”的特殊要求
  4. 知不知道超时要配合”重试、熔断、幂等”

思考题 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 恢复后,各层半开探测,逐步恢复

这道题考察什么:

  1. 知不知道”层层设防”(每层都要超时/熔断/降级)
  2. 知不知道”快速失败”比”慢响应”更好(背压)
  3. 知不知道熔断的”半开”状态
  4. 知不知道降级(返回缓存/默认值 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 串起整个调用链                 │
│                                                              │
│  【熔断】                                                      │
│    下游故障,直接快速失败(不再反复调用失败者)                    │
│    三状态:关闭 → 打开 → 半开(试探恢复)                         │
│    半开 = 不确定时,用少量流量试探                                │
│                                                              │
│  【重试风暴】                                                   │
│    过载时重试,放大故障(正反馈)                                 │
│    防:退避 + 上限 + 幂等 + 熔断                                 │
│                                                              │
│  【降级】                                                      │
│    放弃不重要的,保住核心的                                      │
│    层次:功能降级 → 质量降级 → 范围降级 → 完全降级                 │
│                                                              │
│  【优雅失败三原则】                                              │
│    及时(快速失败)、有界(不无限堆积)、可恢复(熔断能恢复)        │
│                                                              │
│  【一句话】                                                     │
│    过载时正确的应对是"优雅地慢下来或快速失败",                      │
│    宁可慢不要死,宁可失败不要雪崩。                               │
│                                                              │
│  【第四部完 · 第五部预告】                                        │
│    单机的可靠性讲完了,但现代系统是分布式的——                      │
│    下一部讲:锁、共识、虚拟化、可观测性,                           │
│    单机思想在分布式环境下的投射与变形。                            │
└──────────────────────────────────────────────────────────────┘