微服务为什么会发生级联故障?从 Timeout、Retry 到熔断、限流与 Backpressure

前面我们已经学习了微服务之间如何通过 RPC 通信,以及 Kafka 如何让不同服务异步协作。

在正常情况下,这些机制都比较容易理解。

例如用户提交订单:

text 复制代码
Client
   |
   v
Gateway
   |
   v
Order Service
   |
   v
Inventory Service
   |
   v
MySQL

Order Service 调用 Inventory Service,检查库存并预留商品。

Inventory Service 返回成功以后,Order Service 继续创建订单。

但假设某一天,Inventory Service 的 MySQL 查询突然变慢了。

原本只需要几十毫秒的库存请求,现在需要几秒才能返回。

这时候 Order Service 应该怎么办?

最直接的做法是设置超时时间。如果请求超时,就重新调用一次。

但问题是:如果下游服务本来就处理不过来,继续重试会不会让它变得更慢?

更麻烦的是,Order Service 在等待 Inventory Service 时,也会消耗自身的线程、连接或者并发执行资源。

如果大量请求都在等待,Order Service 也可能逐渐无法处理新的订单。

最后,故障会从 MySQL 扩散到 Inventory Service,再扩散到 Order Service,甚至影响 Gateway。

这种现象就是微服务中的级联故障(Cascading Failure)。

它通常不是因为所有服务同时发生了故障,而是一个局部问题通过调用链、资源等待和流量放大不断向上传播。

这一篇我们就围绕这条故障链,依次理解 Timeout、Retry、Circuit Breaker、Rate Limiting、Bulkhead 和 Backpressure。


一、为什么一个服务变慢,会影响整条调用链?

假设订单系统有三个服务:

text 复制代码
Order Service
      |
      v
Inventory Service
      |
      v
MySQL

正常情况下:

text 复制代码
Inventory Service:

平均响应时间 = 20ms

Order Service 发起库存预留请求,很快就能得到结果。

但现在 MySQL 出现慢查询。

库存服务的响应时间变成:

text 复制代码
Inventory Service:

响应时间 = 3s

从 Inventory Service 自身来看,它可能只是处理速度下降了。

但对于 Order Service 来说,影响可能更大。

1. 等待本身也会消耗资源

假设 Order Service 使用同步 RPC 调用:

text 复制代码
请求进入 Order Service
          |
          v
调用 Inventory Service
          |
          v
等待响应
          |
          v
继续处理订单

在等待期间,执行该请求的线程或者其他并发资源可能一直被占用。

假设 Order Service 每秒接收 200 个请求。

正常情况下,库存调用平均只需要 20ms。

但如果库存调用持续需要 3 秒,那么同时等待库存响应的请求数量就可能显著增加。

即使 Order Service 自身的业务逻辑没有变慢,它也会因为大量请求等待下游而积累并发压力。

这里可以联系一个常见的性能关系:

当请求到达速率保持不变,而请求在系统中的停留时间增加时,系统内同时存在的请求数量通常也会增加。

这就是为什么下游延迟上升,会影响上游的线程池、连接池和请求队列。

2. 等待资源耗尽后会发生什么?

例如 Order Service 只有一定数量的工作线程。

text 复制代码
Order Service

Worker 1 → 等待库存
Worker 2 → 等待库存
Worker 3 → 等待库存
Worker 4 → 等待库存
...

当大量线程都处于等待状态时,新请求可能只能进入队列。

text 复制代码
新订单请求
     |
     v
等待队列
     |
     v
可用 Worker 不足

随着队列积压:

text 复制代码
请求等待时间上升
       |
       v
客户端超时
       |
       v
部分客户端重试
       |
       v
更多请求进入系统

最终 Order Service 也开始变慢。

此时即使 Inventory Service 恢复了一部分能力,上游已经积累的请求、超时和重试仍然可能继续造成压力。

所以级联故障的核心是:

一个服务的延迟问题,通过资源占用和请求积压,逐渐转变成多个服务的容量问题。

3. 为什么增加实例不一定立即解决?

我们之前学习 Kubernetes HPA 时,讨论过根据 CPU 等指标增加 Pod 数量。

假设 Order Service 出现大量等待。

HPA 将其从三个 Pod 扩容到六个 Pod。

这可能提高 Order Service 的并发处理能力。

但如果真正的瓶颈在 Inventory Service 或 MySQL,那么新增的 Order Service 实例也可能向下游发送更多请求。

于是:

text 复制代码
Order Service 扩容
        |
        v
上游并发能力增加
        |
        v
更多请求进入 Inventory Service
        |
        v
MySQL 压力进一步增加

所以 HPA 解决的是工作负载容量扩展问题,不能代替下游流量控制。

理解了故障传播以后,我们再讨论最基本的保护机制:Timeout。


二、Timeout:为什么所有远程调用都应该有时间边界?

假设 Order Service 调用 Inventory Service:

http 复制代码
POST /inventory/reserve

请求内容:

json 复制代码
{
  "request_id": "R10001",
  "order_id": 10001,
  "product_id": 1001,
  "quantity": 1
}

如果 Inventory Service 一直没有响应,Order Service 不能无限等待。

否则线程、连接或者其他请求资源可能长期被占用。

因此,需要为远程调用设置超时。

例如:

text 复制代码
Inventory RPC Timeout = 2 秒

如果超过相应时间边界,调用方就结束等待,并进入超时处理流程。

1. Timeout 的作用是什么?

Timeout 主要解决:

一次远程调用最多允许占用多少等待时间。

它能够限制单次调用的等待范围,防止请求无限期挂起。

但需要注意,实际工程中可能有不同类型的超时:

类型 主要限制
Connect Timeout 建立网络连接的等待时间
Read Timeout 等待读取响应数据的时间
Overall Timeout / Deadline 整次调用或请求链路允许的总时间

其中,Read Timeout 和 Overall Timeout 不能简单混为一谈。

例如某些读取超时配置可能限制的是两次网络读取之间的等待时间,而不是整次业务调用的总耗时。

因此,重要的业务请求通常还需要一个明确的整体时间预算。

2. Timeout 不等于操作失败

这是我们之前学习 Kafka、Outbox 和分布式锁时,多次讨论过的一个结论。

假设库存预留请求已经到达 Inventory Service。

text 复制代码
Order Service              Inventory Service
      |                            |
      |----- ReserveStock -------->|
      |                            |
      |                        MySQL 扣减成功
      |                            |
      |                        COMMIT 成功
      |                            |
      |<------ Timeout -----------X

Order Service 看到的是:

text 复制代码
ReserveStock Timeout

但 Inventory Service 实际上已经完成了库存预留。

因此:

调用超时只能说明调用方没有在规定时间内获得确定结果,不能证明远端操作没有执行。

如果 Order Service 直接重新调用一次库存扣减:

text 复制代码
第一次:库存已经扣减

第二次:重新扣减

就可能产生重复业务副作用。

所以对于修改状态的远程操作,尤其需要小心处理超时。

3. 超时以后应该怎么做?

首先要判断操作属于哪一类。

例如:

http 复制代码
GET /products/1001

这是普通查询。

超时以后,如果业务允许,可以安全地尝试有限重试。

但对于:

http 复制代码
POST /inventory/reserve

这种可能修改业务状态的操作,不能简单假设它失败了。

可以使用稳定的业务请求标识:

text 复制代码
request_id = R10001

让库存服务根据该标识识别重复操作。

同时提供状态查询能力:

http 复制代码
GET /inventory/reservations/R10001

超时后,Order Service 可以:

text 复制代码
调用库存预留超时
        |
        v
查询 R10001 的执行状态
        |
   +----+----+-----------+
   |         |           |
   v         v           v
已成功    明确失败     状态未知
   |         |           |
   v         v           v
继续流程   业务处理    保留待确认状态

如果状态仍然未知,就需要根据幂等与恢复策略决定是否重新发起操作。

这里的关键不是"超时必须先查询一次",而是:

不能把结果未知直接解释为操作未执行。


三、Retry:为什么重试有时能恢复故障,有时却会放大故障?

重试(Retry)是微服务中非常常见的恢复机制。

例如:

text 复制代码
Order Service
      |
      v
Inventory Service
      |
      X
临时网络错误

如果只是一次短暂网络抖动,稍后重新发送请求可能就会成功。

所以重试本身不是错误设计。

问题在于:不是所有失败都值得重试,也不是任何时刻都应该立即重试。

1. 先区分临时性错误和确定性错误

例如:

text 复制代码
网络短暂中断

可能适合重试。

text 复制代码
服务暂时返回 503

在具备恢复条件且受到流量控制时,可以考虑稍后重试。

但:

text 复制代码
库存不足

通常是明确的业务失败。

重复请求并不会凭空增加库存。

又例如:

text 复制代码
参数格式错误

如果不修改参数,重试一百次仍然可能失败。

因此,重试前首先应该识别失败类型。

失败情况 是否适合直接重试
短暂连接失败 可考虑有限重试
下游暂时不可用 结合负载与重试策略
请求超时且执行状态未知 先考虑幂等与状态确认
参数错误 通常不应重试
业务条件不满足 通常不应盲目重试
明确的资源冲突 根据业务设计决定

2. 立即重试为什么危险?

假设 Inventory Service 正常每秒能够处理约 1000 个请求。

但某个时刻数据库变慢,有效处理能力下降。

此时上游仍然持续发送请求。

如果所有失败请求都立即重试两次,那么一次原始业务请求最多可能产生:

text 复制代码
第一次调用

第一次重试

第二次重试

共三次下游调用。

当大量请求同时重试时,原本已经过载的服务会收到更多请求。

例如:

text 复制代码
原始请求量:1000 次/秒

每个请求最多调用 3 次

在极端情况下,短时间内产生的调用需求可能接近原始请求量的三倍。

这并不代表实际观测到的 QPS 一定精确变成三倍,因为超时、并发限制和请求到达时间会影响结果。

但机制很清楚:

重试会增加下游需要处理的调用次数。

而此时下游最缺少的恰恰是处理能力。

3. 为什么多层重试更加危险?

假设:

text 复制代码
Gateway
   |
   v
Order Service
   |
   v
Inventory Service

Gateway 允许一次请求最多尝试三次。

Order Service 调用 Inventory Service 时,也允许最多三次尝试。

那么一条原始业务请求,在某些故障路径下,可能引发最多九次库存服务调用。

text 复制代码
Gateway 第一次尝试
    |
    +-- Inventory 调用 3 次

Gateway 第二次尝试
    |
    +-- Inventory 调用 3 次

Gateway 第三次尝试
    |
    +-- Inventory 调用 3 次

因此:

text 复制代码
3 × 3 = 9

如果每一层都独立添加重试,而没有统一预算,很容易造成请求放大。

所以重试策略不应该只是每个服务随手配置一个:

text 复制代码
retry = 3

而应该明确:

哪一层负责重试、哪些错误允许重试、允许增加多少额外调用,以及是否还在整体请求时间预算内。


四、指数退避与随机抖动:为什么重试不能一起发生?

假设 Inventory Service 暂时不可用。

有一千个 Order Service 请求同时失败。

如果所有请求都:

text 复制代码
失败后等待 1 秒
然后重试

那么一秒以后,可能有大量请求再次同时进入 Inventory Service。

text 复制代码
T = 0s:

大量请求失败

T = 1s:

大量请求同时重试

这会形成新的流量峰值。

即使服务本来已经开始恢复,也可能再次被打满。

1. 指数退避(Exponential Backoff)

一种常见思路是随着重试次数增加,逐步拉长等待时间。

例如:

text 复制代码
第一次重试前:等待 200ms

第二次重试前:等待 400ms

第三次重试前:等待 800ms

这样可以避免客户端持续以极高频率重试。

但指数退避还有一个问题:

如果所有请求使用完全相同的等待时间,它们仍然可能在相同时间一起重试。

2. 随机抖动(Jitter)

因此,可以给等待时间增加随机性。

例如:

text 复制代码
请求 A:等待 230ms

请求 B:等待 370ms

请求 C:等待 190ms

请求 D:等待 410ms

使请求分散到不同时间点。

这样能够减轻同步重试造成的瞬时压力。

不过,退避与抖动并不意味着可以无限重试。

实际设计中通常还需要限制:

text 复制代码
最大重试次数

最大重试等待时间

整体请求 Deadline

重试并发量

例如,如果客户端只愿意等待两秒,那么在剩余时间不足时继续安排一个很长的重试,就没有太大意义。

重试的目标是恢复短暂故障,而不是让一个已经过载的系统承受更多无意义工作。


五、熔断:为什么有时候应该暂时停止调用下游?

前面讨论的 Timeout 和 Retry,仍然主要从单次调用角度处理故障。

但假设 Inventory Service 已经持续出现大量超时。

这时 Order Service 每次都要:

text 复制代码
发起请求
    |
    v
等待两秒
    |
    v
超时

即使我们限制了重试次数,仍然会有大量请求浪费时间等待一个明显不可用的服务。

所以我们需要一种机制:

当下游持续表现异常时,暂时停止向它发送部分请求。

这就是 Circuit Breaker(熔断器)的核心思想。

1. 熔断器的三个状态

熔断器常见的状态包括:

text 复制代码
Closed

Open

Half-Open

它们可以理解为:

Closed:闭合状态。

正常允许请求访问下游,同时统计错误率、超时等指标。

Open:打开状态。

熔断条件触发后,暂时不再把普通请求转发到对应下游,而是快速返回失败或进入允许的降级路径。

Half-Open:半开状态。

经过一定恢复等待后,允许少量探测请求访问下游,判断服务是否已经恢复。

状态转换:

text 复制代码
            正常调用
                |
                v
             CLOSED
                |
       错误达到触发条件
                |
                v
              OPEN
                |
         暂停正常调用
                |
         恢复等待结束
                |
                v
            HALF-OPEN
             /      \
            /        \
        探测成功    探测失败
           |            |
           v            v
         CLOSED        OPEN

2. 熔断有什么价值?

假设 Inventory Service 已经连续超时。

如果没有熔断:

text 复制代码
每个订单请求
     |
     v
等待库存服务超时
     |
     v
占用上游资源

如果熔断器打开:

text 复制代码
订单请求
    |
    v
检查熔断状态
    |
    v
发现下游不可用
    |
    v
快速失败或安全降级

这样能够减少对下游的无效调用,也避免上游每次都付出完整的等待成本。

3. 熔断不是修复下游

这里需要注意:

熔断器不会让 MySQL 自动恢复,也不会修复 Inventory Service 的程序错误。

它主要用于限制故障传播。

同时,如果熔断条件设置不合理,也可能出现误触发。

例如短时间内只有极少数请求,但其中一次失败,就直接打开熔断器,可能过于敏感。

因此,实际设计通常需要结合足够的请求样本、错误比例、慢调用比例和恢复窗口,而不是简单地看到一次错误就熔断。

4. 熔断以后应该返回什么?

对于商品推荐接口,可以允许降级:

text 复制代码
推荐服务不可用
      |
      v
返回普通商品列表

但对于库存预留:

text 复制代码
Inventory Service 不可用

不能随意返回:

text 复制代码
库存预留成功

因为这会造成虚假的业务成功。

正确做法可能是:

text 复制代码
返回暂时无法下单

或者将订单保留在可恢复的待处理状态,前提是业务明确支持这种异步流程。

所以:

熔断之后能否降级,以及如何降级,取决于业务正确性要求。

不能为了接口成功率,把无法确认的业务操作当成已经成功。


六、限流:为什么必须控制进入系统的请求量?

熔断主要关注下游已经出现明显异常时,是否继续调用。

但如果请求量本身已经超过系统处理能力,还需要在更早的位置进行保护。

例如:

text 复制代码
Client
   |
   v
Gateway
   |
   v
Order Service
   |
   v
Inventory Service

假设订单系统稳定处理能力是每秒 1000 个请求。

但促销活动开始后,突然有每秒 10000 个请求进入。

如果所有请求都放行,就可能导致:

text 复制代码
线程池耗尽

数据库连接池等待

库存服务过载

请求队列不断增长

这时候可以使用 Rate Limiting(限流)。

1. 限流主要解决什么?

限流用于控制某个时间范围内允许通过的请求数量或速率。

例如:

text 复制代码
最多允许 1000 QPS

超过限制的请求可以根据业务策略:

text 复制代码
快速拒绝

返回 429

进入有界等待队列

而不是全部进入下游执行。

需要注意,1000 QPS 只是示例。实际阈值需要通过压测、监控以及服务容量评估确定。

2. 为什么 Gateway 是常见的限流位置?

因为 Gateway 是很多外部请求进入微服务系统的统一入口。

text 复制代码
Client
   |
   v
Gateway
   |
   +-- Order Service
   |
   +-- Product Service
   |
   +-- User Service

在 Gateway 进行限流,可以在请求进入内部服务之前拦截部分流量。

例如:

text 复制代码
某用户每秒最多请求 10 次

某接口每秒最多接收 1000 次请求

这样可以减少不必要的内部处理开销。

但 Gateway 限流也存在边界。

它主要控制经过该 Gateway 的流量。

如果还有 Kafka Consumer、内部 RPC、后台任务等其他入口,仅靠外部 Gateway 限流无法保护所有下游资源。

因此,重要的下游服务有时还需要在内部增加自己的并发限制和容量保护。

3. Gateway 限流和 HPA 是不是一回事?

不是。

这是我们之前讨论 Kubernetes 时专门区分过的。

Gateway 限流:控制允许多少请求进入。

Kubernetes HPA:根据指标调整工作负载的副本数量。

例如:

text 复制代码
Gateway
   |
   | 控制进入 Order Service 的请求
   v
Order Service Pods
   |
   | HPA 根据指标调整 Pod 数量
   v
更多或更少的 Pod

两者解决的问题不同。

机制 核心问题
Gateway 限流 当前是否应该继续接收这些请求?
HPA 当前工作负载是否需要更多实例?
熔断 某个下游持续异常时,是否暂停调用?
超时 单次调用最多允许等待多久?

而且 HPA 扩容需要时间,也受到节点容量和下游处理能力的限制。

不能认为:

text 复制代码
请求量突然增加
       |
       v
HPA 马上扩容
       |
       v
所有请求都能成功

这不是可靠的容量模型。


七、隔离:为什么不能让一个故障服务占满所有资源?

除了 Timeout、Retry 和限流,还有一个常见问题:

假设 Order Service 需要调用两个下游。

text 复制代码
Order Service
      |
      +-- Inventory Service
      |
      +-- Coupon Service

库存服务正常,但优惠券服务突然变慢。

如果所有调用共用同一个有限线程池:

text 复制代码
Shared Thread Pool
       |
       +-- Inventory 请求
       |
       +-- Coupon 请求

那么大量等待 Coupon Service 的调用可能占满线程池。

结果是:

库存服务虽然正常,但订单服务已经没有足够资源继续调用它。

这种情况下,故障就从一个下游传播到了其他业务能力。

Bulkhead:隔离不同资源池

Bulkhead(舱壁隔离)的思想,就是不要让某一类操作无限消耗所有共享资源。

例如:

text 复制代码
Order Service
      |
      +-- Inventory Thread Pool
      |
      +-- Coupon Thread Pool

或者对于异步调用,使用不同的并发限制:

text 复制代码
Inventory:最多 100 个并发请求

Coupon:最多 20 个并发请求

假设 Coupon Service 变慢:

text 复制代码
Coupon 的 20 个并发名额被占满

后续 Coupon 调用可以被拒绝、降级或有限排队。

但 Inventory 的并发资源不会因此全部被占用。

这里不一定要为每个下游创建独立线程池。

对于异步框架,也可以使用 Semaphore(信号量)等方式限制特定调用的同时执行数量。

真正重要的是:

让某一个下游的异常,只能消耗它被分配的资源预算,而不是无限占用整个上游服务的执行能力。

当然,资源隔离也会引入容量配置问题。

如果隔离得过细,可能导致某些资源池空闲,而另一些资源池一直排队。

因此,应当根据调用量、延迟和业务优先级进行设计。


八、Backpressure:为什么处理不过来时,应该让上游减速?

前面讲到的限流主要强调控制进入系统的请求速率。

Backpressure(背压)则更关注:

当下游消费或处理能力不足时,如何把这种容量限制向上游传递,避免请求或消息无限积压。

例如:

text 复制代码
Producer
    |
    v
Queue
    |
    v
Worker

假设:

text 复制代码
Producer 每秒产生 5000 个任务

Worker 每秒只能处理 2000 个任务

如果一直维持这个状态:

text 复制代码
队列每秒新增积压约 3000 个任务

队列会越来越长。

Kafka 可以缓冲任务和事件,但 Kafka 不能自动让 Worker 拥有无限处理能力。

如果积压长期超过可管理范围,就会带来:

text 复制代码
业务处理延迟增加

消息保存压力增加

故障恢复时间变长

消费者长时间追不上生产速度

1. 为什么不能让队列无限增长?

假设应用使用内存队列处理请求。

text 复制代码
Request Queue
      |
      v
Worker Pool

如果队列没有边界,在下游持续变慢时,大量等待任务就可能占用越来越多内存。

最终造成应用内存压力、GC 停顿甚至进程崩溃。

所以背压的一个常见做法是使用:

有界队列(Bounded Queue)。

例如:

text 复制代码
最多允许 1000 个任务排队

当队列满了以后,系统不再无条件接受新任务。

而是根据业务选择:

text 复制代码
拒绝

等待

通知上游降低发送速率

将任务可靠转移到其他可承载的位置

需要注意,对于必须处理的业务任务,不能简单把队列满了的请求直接丢弃,而不留下任何可靠记录。

如果业务无法接受任务丢失,就需要设计持久化任务记录、可靠消息或其他恢复机制。

2. Backpressure 和 Retry 有什么关系?

它们的方向可以这样区分。

Retry:

text 复制代码
失败后尝试再次执行

Backpressure:

text 复制代码
处理能力不足时,减少新的输入压力

如果系统已经持续过载,继续重试只会增加输入压力。

此时更需要的是背压、限流和容量隔离。

所以:

重试解决的是部分暂时性失败,背压解决的是输入速度与处理能力不匹配。

3. Backpressure 和 Kafka 有什么关系?

我们之前学习 Kafka 时,讨论过 Consumer Lag。

text 复制代码
Kafka Topic
      |
      v
Consumer Group
      |
      v
MySQL

如果 MySQL 变慢,Consumer 处理消息的速度就会下降。

Kafka 中的消费积压随之增加。

此时不能只依靠增加 Kafka Consumer 数量。

如果真正的瓶颈是 MySQL,增加 Consumer 并发可能把更多请求打到数据库。

更合理的处理方向可能包括:

text 复制代码
限制数据库写入并发

控制每批消息的处理量

优化 MySQL SQL 与索引

使用有界执行队列

在容量允许时增加消费并行度

但必须注意:暂停消费或者降低读取速度,只是防止 Consumer 继续加重下游压力,并不能自动消除 Kafka 中已经积累的消息。

如果生产速度长期超过消费速度,还需要从生产端、业务处理能力和系统整体容量上解决问题。


九、把这些保护机制放在一起,应该怎么理解?

到这里,我们已经讨论了六类机制:

text 复制代码
Timeout

Retry

Circuit Breaker

Rate Limiting

Bulkhead

Backpressure

它们并不是六种互相竞争的方案。

它们分别处理故障传播的不同阶段。

机制 主要保护什么 典型问题
Timeout 单次调用的等待时间 下游迟迟不返回
Retry 短暂失败后的恢复机会 一次网络抖动导致请求失败
Circuit Breaker 上游免受持续异常下游拖累 大量调用持续超时
Rate Limiting 系统入口与下游容量 请求量突然超过处理能力
Bulkhead 不同操作的资源隔离 一个故障下游占满所有线程
Backpressure 输入速率与处理能力匹配 队列无限积压

可以用一个完整请求链来理解它们的位置。

text 复制代码
                    Client
                       |
                       v
                 Gateway 限流
                       |
                       v
                 Order Service
                       |
                 并发资源隔离
                       |
                       v
                    熔断判断
                       |
                       v
                 Inventory RPC
                       |
                  Timeout
                       |
                 有条件 Retry
                       |
                       v
               Inventory Service
                       |
                 并发 / 队列控制
                       |
                       v
                     MySQL

这里并不代表所有系统都必须严格按这个顺序实现每个机制。

例如重试和熔断可以由同一客户端调用框架协调,具体执行顺序也需要结合调用策略设计。

真正的重点是:

每个服务应该知道自己能够承受多少工作,以及在下游已经无法处理时,应该怎样及时减少额外压力。


十、一个完整案例:库存服务变慢以后,系统如何避免级联故障?

现在把前面的机制放回订单系统。

假设正常情况下:

text 复制代码
Client
   |
   v
Gateway
   |
   v
Order Service
   |
   v
Inventory Service
   |
   v
MySQL

Inventory Service 响应稳定。

突然,MySQL 因为某条慢 SQL 导致查询延迟明显增加。

第一阶段:下游延迟开始上升

text 复制代码
MySQL 查询变慢
       |
       v
Inventory Service 响应变慢

Order Service 开始观察到 RPC 延迟上升。

这时合理的 Timeout 可以避免某个请求无限等待。

第二阶段:限制重试造成的额外压力

部分库存请求开始超时。

如果调用方立即大量重试,Inventory Service 会收到更多请求。

所以需要:

text 复制代码
只对允许重试的错误重试

保持稳定的 request_id

限制最大尝试次数

设置退避与 Jitter

遵守整体 Deadline

对于库存预留这种有状态操作,还需要根据幂等标识和执行状态避免重复扣减。

第三阶段:触发熔断

如果库存服务持续异常,熔断器可以暂停大量无效调用。

text 复制代码
Order Service
      |
      v
Circuit Breaker
      |
      v
OPEN
      |
      v
快速返回不可用

这样 Order Service 不必让每个请求都等待库存 RPC 超时。

但不能把库存服务不可用伪装成库存预留成功。

第四阶段:限制入口请求和内部并发

Gateway 可以对下单接口进行限流。

Order Service 还可以控制自己同时向 Inventory Service 发起多少请求。

例如:

text 复制代码
Gateway:

限制下单请求进入速率
text 复制代码
Order Service:

限制库存 RPC 并发数量

这样可以防止库存服务恢复期间再次被瞬间打满。

第五阶段:保护其他业务功能

假设订单服务还需要调用优惠券服务。

通过 Bulkhead 或按下游设置 Semaphore,使库存故障不会占满所有下游调用资源。

这样至少可以减少库存故障对其他请求路径的连带影响。

第六阶段:恢复时逐步放开

当 Inventory Service 开始恢复:

text 复制代码
Circuit Breaker
      |
      v
HALF-OPEN
      |
      v
允许少量探测请求

如果探测稳定,再逐步恢复正常调用。

同时观察:

text 复制代码
错误率

P99 延迟

线程池使用率

数据库连接池等待

请求积压

避免刚恢复就把大量积压请求和重试一起释放给下游。

这里需要强调:系统恢复不是简单地把熔断器从 OPEN 改回 CLOSED,而是要确认真实的下游处理能力已经恢复。


十一、为什么快速失败有时候比一直等待更可靠?

前面讲到熔断和限流时,多次出现了一个词:

text 复制代码
快速失败

对于刚接触分布式系统的人来说,这可能有些反直觉。

既然我们的目标是让请求成功,为什么要主动拒绝请求?

原因在于:

当系统已经无法处理全部请求时,继续接受所有请求,可能让原本可以成功的请求也一起失败。

例如:

text 复制代码
Inventory Service 每秒只能稳定处理 1000 次请求

但上游持续发送:

text 复制代码
每秒 5000 次请求

如果全部放进队列,系统可能出现:

text 复制代码
等待时间越来越长

客户端纷纷超时

客户端继续重试

请求进一步积压

最后,大部分请求都无法在可接受时间内完成。

但如果系统在入口就限制部分流量:

text 复制代码
允许处理可承载的请求

对超出容量的请求快速拒绝

那么被允许的请求反而有更高机会及时完成。

这就是过载保护的一个基本思想。

但快速失败并不是所有业务的最终处理方式。

例如:

普通商品推荐查询可以直接返回稍后重试提示,或者降级返回默认推荐。

订单提交如果没有创建任何业务状态,可以明确拒绝请求并让用户重新提交。

已经开始执行、但状态未知的支付请求则不能直接当成没有发生过。它需要通过业务 ID、状态查询和恢复机制确认最终结果。

因此,真正需要区分的是:

text 复制代码
尚未开始执行的请求

和:

text 复制代码
可能已经产生副作用的请求

前者可以根据容量快速拒绝。

后者需要按照相应业务协议确认状态,避免快速失败掩盖已经发生的业务操作。


十二、实际排查级联故障时,应该先看什么?

假设某一天,订单接口的错误率突然升高。

我们可以按照调用链逐步排查。

1. 先看错误从哪里开始出现

例如:

text 复制代码
Order Service:

P99 从 200ms 上升到 5s

但 Order Service 自身 CPU 并不高。

这时应该检查它是否正在等待下游。

如果发现:

text 复制代码
Inventory RPC:

超时比例明显增加

再继续排查 Inventory Service。

如果 Inventory Service 又大量等待 MySQL,就可能找到真正的初始瓶颈。

2. 看重试是否放大了调用量

例如正常情况下:

text 复制代码
Order Service 每秒接收 1000 个请求

但 Inventory Service 突然收到:

text 复制代码
每秒 2500 个 RPC 调用

这时就应该检查是否有大量自动重试。

还需要区分:

text 复制代码
原始请求 QPS

和:

text 复制代码
实际下游调用 QPS

两者不一定相同。

3. 看资源是否被等待占满

例如:

text 复制代码
线程池活跃线程数

线程池排队数量

HTTP 连接池等待

数据库连接池等待

CPU 使用率

内存与 GC

这些指标能帮助判断问题是 CPU 计算能力不足,还是大量请求正在等待外部资源。

4. 看熔断、限流与拒绝是否正常触发

例如:

text 复制代码
Circuit Breaker Open 次数

429 返回比例

并发限制拒绝数量

降级次数

半开探测成功率

这些指标不能单独代表系统质量好坏。

例如限流拒绝数量增加,可能说明系统正在主动保护下游,而不一定是限流器出现故障。

需要结合下游延迟和真实成功率判断。

5. 看故障恢复后是否仍然存在积压

如果系统使用 Kafka 或后台任务队列,还应该关注:

text 复制代码
Consumer Lag

任务队列长度

最旧任务等待时间

重试队列数量

即使 MySQL 已经恢复,消息积压也可能需要一段时间才能处理完成。

所以排查级联故障不能只看到:

text 复制代码
服务进程重新启动成功

就认为问题已经完全结束。

还需要确认:

调用延迟、业务错误、积压任务和下游负载是否真正恢复到可接受水平。


十三、总结:微服务可靠性不是靠无限重试实现的

这一篇从库存服务变慢开始,逐步解释了级联故障如何发生,以及不同故障治理机制分别解决什么问题。

整个过程可以归纳为:

text 复制代码
下游服务变慢
       |
       v
上游请求等待
       |
       v
并发资源被占用
       |
       v
请求开始超时
       |
       v
错误重试增加下游压力
       |
       v
故障向更多服务传播

针对这条故障链,我们学习了几种保护机制。

Timeout: 限制单次调用的等待时间,避免资源长期占用。

Retry: 在合适的条件下恢复短暂故障,但需要控制次数、时间和额外流量。

Circuit Breaker: 当下游持续异常时,暂时减少无效调用,防止故障继续向上游传播。

Rate Limiting: 控制进入系统的请求速率,避免流量超过处理能力。

Bulkhead: 隔离不同业务路径或下游调用的资源,避免一个故障占满全部执行能力。

Backpressure: 当处理能力不足时,减少新的输入压力或进行有界缓冲,避免无限积压。

其中最重要的是以下三个结论。

第一,Timeout 不等于失败

如果调用涉及数据库修改、支付或库存预留,超时以后必须考虑操作已经执行成功但响应丢失的可能性。

不能简单地用重复调用替代状态确认和业务幂等。

第二,重试不是免费的

每一次重试都会占用额外的网络、线程、连接或者下游计算资源。

当下游已经过载时,不受限制的重试只会进一步放大故障。

第三,扩容与流量治理必须一起考虑

HPA 可以增加工作负载实例,但不能自动解决数据库瓶颈,也不能阻止客户端重试风暴。

Gateway 限流、服务内部并发限制、熔断和背压仍然具有独立价值。

最后,可以把这一篇的核心观点总结为:

一个可靠的微服务系统,不是保证每个请求在任何情况下都必须完成,而是在资源有限、下游故障和网络不确定时,仍然能够控制工作量、明确业务状态,并阻止局部故障扩散到整个系统。

下一篇我们继续学习微服务性能与可观测性,重点介绍 QPS、P99、Trace,以及如何从一条跨服务调用链中定位真正的性能瓶颈。

相关推荐
无心水2 小时前
15、云原生架构设计理论与实践 核心考点与学习笔记
笔记·学习·云原生·架构师·软考·系统架构设计师·软考系统架构设计师
ESDWAN2 小时前
SD-WAN 是什么?工作原理、适用场景与选型要点
开发语言·网络·数据库·架构·php
闲云野鹤在人间2 小时前
头秃修炼手册:StorageClass+StatefulSet+ETCD 全套渡劫笔记
linux·数据库·笔记·云原生·kubernetes·etcd
Figo_Cheung2 小时前
Figo基于结构空间算法假设(SSAH)的生成式大模型优化范式与工程实现边界研究——提出适应AGI架构演化的自适应拓扑感知表征机制
算法·架构·agi
FPGA小迷弟2 小时前
FPGA 与无线电:从软件定义无线电到 5G 基带的硬件实现之路
5g·fpga开发·架构·dsp开发·fpga·无线电
你看我名字2 小时前
Kafka 架构、消息可靠性与生产运维:从分区模型到故障治理
运维·架构·kafka
Decay__3 小时前
03_MySQL 存储引擎与存储管理 —— InnoDB 深入、表空间/ibd、binlog/redo/undo 与双写缓冲
运维·数据库·sql·mysql·架构
艾伦_耶格宇3 小时前
【kubernetes - 1.28版本】-7 K8S命令
云原生·容器·kubernetes
Z21-X3 小时前
云原生运维笔记:打通OpenStack的任督二脉——从Neutron网络踩坑到Swift与Heat硬核实战
运维·笔记·学习·云原生