前面我们已经学习了微服务之间如何通过 RPC 通信,以及 Kafka 如何让不同服务异步协作。
在正常情况下,这些机制都比较容易理解。
例如用户提交订单:
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。
一、为什么一个服务变慢,会影响整条调用链?
假设订单系统有三个服务:
Order Service
|
v
Inventory Service
|
v
MySQL
正常情况下:
Inventory Service:
平均响应时间 = 20ms
Order Service 发起库存预留请求,很快就能得到结果。
但现在 MySQL 出现慢查询。
库存服务的响应时间变成:
Inventory Service:
响应时间 = 3s
从 Inventory Service 自身来看,它可能只是处理速度下降了。
但对于 Order Service 来说,影响可能更大。
1. 等待本身也会消耗资源
假设 Order Service 使用同步 RPC 调用:
请求进入 Order Service
|
v
调用 Inventory Service
|
v
等待响应
|
v
继续处理订单
在等待期间,执行该请求的线程或者其他并发资源可能一直被占用。
假设 Order Service 每秒接收 200 个请求。
正常情况下,库存调用平均只需要 20ms。
但如果库存调用持续需要 3 秒,那么同时等待库存响应的请求数量就可能显著增加。
即使 Order Service 自身的业务逻辑没有变慢,它也会因为大量请求等待下游而积累并发压力。
这里可以联系一个常见的性能关系:
当请求到达速率保持不变,而请求在系统中的停留时间增加时,系统内同时存在的请求数量通常也会增加。
这就是为什么下游延迟上升,会影响上游的线程池、连接池和请求队列。
2. 等待资源耗尽后会发生什么?
例如 Order Service 只有一定数量的工作线程。
Order Service
Worker 1 → 等待库存
Worker 2 → 等待库存
Worker 3 → 等待库存
Worker 4 → 等待库存
...
当大量线程都处于等待状态时,新请求可能只能进入队列。
新订单请求
|
v
等待队列
|
v
可用 Worker 不足
随着队列积压:
请求等待时间上升
|
v
客户端超时
|
v
部分客户端重试
|
v
更多请求进入系统
最终 Order Service 也开始变慢。
此时即使 Inventory Service 恢复了一部分能力,上游已经积累的请求、超时和重试仍然可能继续造成压力。
所以级联故障的核心是:
一个服务的延迟问题,通过资源占用和请求积压,逐渐转变成多个服务的容量问题。
3. 为什么增加实例不一定立即解决?
我们之前学习 Kubernetes HPA 时,讨论过根据 CPU 等指标增加 Pod 数量。
假设 Order Service 出现大量等待。
HPA 将其从三个 Pod 扩容到六个 Pod。
这可能提高 Order Service 的并发处理能力。
但如果真正的瓶颈在 Inventory Service 或 MySQL,那么新增的 Order Service 实例也可能向下游发送更多请求。
于是:
Order Service 扩容
|
v
上游并发能力增加
|
v
更多请求进入 Inventory Service
|
v
MySQL 压力进一步增加
所以 HPA 解决的是工作负载容量扩展问题,不能代替下游流量控制。
理解了故障传播以后,我们再讨论最基本的保护机制:Timeout。
二、Timeout:为什么所有远程调用都应该有时间边界?
假设 Order Service 调用 Inventory Service:
POST /inventory/reserve
请求内容:
{
"request_id": "R10001",
"order_id": 10001,
"product_id": 1001,
"quantity": 1
}
如果 Inventory Service 一直没有响应,Order Service 不能无限等待。
否则线程、连接或者其他请求资源可能长期被占用。
因此,需要为远程调用设置超时。
例如:
Inventory RPC Timeout = 2 秒
如果超过相应时间边界,调用方就结束等待,并进入超时处理流程。
1. Timeout 的作用是什么?
Timeout 主要解决:
一次远程调用最多允许占用多少等待时间。
它能够限制单次调用的等待范围,防止请求无限期挂起。
但需要注意,实际工程中可能有不同类型的超时:
| 类型 | 主要限制 |
|---|---|
| Connect Timeout | 建立网络连接的等待时间 |
| Read Timeout | 等待读取响应数据的时间 |
| Overall Timeout / Deadline | 整次调用或请求链路允许的总时间 |
其中,Read Timeout 和 Overall Timeout 不能简单混为一谈。
例如某些读取超时配置可能限制的是两次网络读取之间的等待时间,而不是整次业务调用的总耗时。
因此,重要的业务请求通常还需要一个明确的整体时间预算。
2. Timeout 不等于操作失败
这是我们之前学习 Kafka、Outbox 和分布式锁时,多次讨论过的一个结论。
假设库存预留请求已经到达 Inventory Service。
Order Service Inventory Service
| |
|----- ReserveStock -------->|
| |
| MySQL 扣减成功
| |
| COMMIT 成功
| |
|<------ Timeout -----------X
Order Service 看到的是:
ReserveStock Timeout
但 Inventory Service 实际上已经完成了库存预留。
因此:
调用超时只能说明调用方没有在规定时间内获得确定结果,不能证明远端操作没有执行。
如果 Order Service 直接重新调用一次库存扣减:
第一次:库存已经扣减
第二次:重新扣减
就可能产生重复业务副作用。
所以对于修改状态的远程操作,尤其需要小心处理超时。
3. 超时以后应该怎么做?
首先要判断操作属于哪一类。
例如:
GET /products/1001
这是普通查询。
超时以后,如果业务允许,可以安全地尝试有限重试。
但对于:
POST /inventory/reserve
这种可能修改业务状态的操作,不能简单假设它失败了。
可以使用稳定的业务请求标识:
request_id = R10001
让库存服务根据该标识识别重复操作。
同时提供状态查询能力:
GET /inventory/reservations/R10001
超时后,Order Service 可以:
调用库存预留超时
|
v
查询 R10001 的执行状态
|
+----+----+-----------+
| | |
v v v
已成功 明确失败 状态未知
| | |
v v v
继续流程 业务处理 保留待确认状态
如果状态仍然未知,就需要根据幂等与恢复策略决定是否重新发起操作。
这里的关键不是“超时必须先查询一次”,而是:
不能把结果未知直接解释为操作未执行。
三、Retry:为什么重试有时能恢复故障,有时却会放大故障?
重试(Retry)是微服务中非常常见的恢复机制。
例如:
Order Service
|
v
Inventory Service
|
X
临时网络错误
如果只是一次短暂网络抖动,稍后重新发送请求可能就会成功。
所以重试本身不是错误设计。
问题在于:不是所有失败都值得重试,也不是任何时刻都应该立即重试。
1. 先区分临时性错误和确定性错误
例如:
网络短暂中断
可能适合重试。
服务暂时返回 503
在具备恢复条件且受到流量控制时,可以考虑稍后重试。
但:
库存不足
通常是明确的业务失败。
重复请求并不会凭空增加库存。
又例如:
参数格式错误
如果不修改参数,重试一百次仍然可能失败。
因此,重试前首先应该识别失败类型。
| 失败情况 | 是否适合直接重试 |
|---|---|
| 短暂连接失败 | 可考虑有限重试 |
| 下游暂时不可用 | 结合负载与重试策略 |
| 请求超时且执行状态未知 | 先考虑幂等与状态确认 |
| 参数错误 | 通常不应重试 |
| 业务条件不满足 | 通常不应盲目重试 |
| 明确的资源冲突 | 根据业务设计决定 |
2. 立即重试为什么危险?
假设 Inventory Service 正常每秒能够处理约 1000 个请求。
但某个时刻数据库变慢,有效处理能力下降。
此时上游仍然持续发送请求。
如果所有失败请求都立即重试两次,那么一次原始业务请求最多可能产生:
第一次调用
第一次重试
第二次重试
共三次下游调用。
当大量请求同时重试时,原本已经过载的服务会收到更多请求。
例如:
原始请求量:1000 次/秒
每个请求最多调用 3 次
在极端情况下,短时间内产生的调用需求可能接近原始请求量的三倍。
这并不代表实际观测到的 QPS 一定精确变成三倍,因为超时、并发限制和请求到达时间会影响结果。
但机制很清楚:
重试会增加下游需要处理的调用次数。
而此时下游最缺少的恰恰是处理能力。
3. 为什么多层重试更加危险?
假设:
Gateway
|
v
Order Service
|
v
Inventory Service
Gateway 允许一次请求最多尝试三次。
Order Service 调用 Inventory Service 时,也允许最多三次尝试。
那么一条原始业务请求,在某些故障路径下,可能引发最多九次库存服务调用。
Gateway 第一次尝试
|
+-- Inventory 调用 3 次
Gateway 第二次尝试
|
+-- Inventory 调用 3 次
Gateway 第三次尝试
|
+-- Inventory 调用 3 次
因此:
3 × 3 = 9
如果每一层都独立添加重试,而没有统一预算,很容易造成请求放大。
所以重试策略不应该只是每个服务随手配置一个:
retry = 3
而应该明确:
哪一层负责重试、哪些错误允许重试、允许增加多少额外调用,以及是否还在整体请求时间预算内。
四、指数退避与随机抖动:为什么重试不能一起发生?
假设 Inventory Service 暂时不可用。
有一千个 Order Service 请求同时失败。
如果所有请求都:
失败后等待 1 秒
然后重试
那么一秒以后,可能有大量请求再次同时进入 Inventory Service。
T = 0s:
大量请求失败
T = 1s:
大量请求同时重试
这会形成新的流量峰值。
即使服务本来已经开始恢复,也可能再次被打满。
1. 指数退避(Exponential Backoff)
一种常见思路是随着重试次数增加,逐步拉长等待时间。
例如:
第一次重试前:等待 200ms
第二次重试前:等待 400ms
第三次重试前:等待 800ms
这样可以避免客户端持续以极高频率重试。
但指数退避还有一个问题:
如果所有请求使用完全相同的等待时间,它们仍然可能在相同时间一起重试。
2. 随机抖动(Jitter)
因此,可以给等待时间增加随机性。
例如:
请求 A:等待 230ms
请求 B:等待 370ms
请求 C:等待 190ms
请求 D:等待 410ms
使请求分散到不同时间点。
这样能够减轻同步重试造成的瞬时压力。
不过,退避与抖动并不意味着可以无限重试。
实际设计中通常还需要限制:
最大重试次数
最大重试等待时间
整体请求 Deadline
重试并发量
例如,如果客户端只愿意等待两秒,那么在剩余时间不足时继续安排一个很长的重试,就没有太大意义。
重试的目标是恢复短暂故障,而不是让一个已经过载的系统承受更多无意义工作。

五、熔断:为什么有时候应该暂时停止调用下游?
前面讨论的 Timeout 和 Retry,仍然主要从单次调用角度处理故障。
但假设 Inventory Service 已经持续出现大量超时。
这时 Order Service 每次都要:
发起请求
|
v
等待两秒
|
v
超时
即使我们限制了重试次数,仍然会有大量请求浪费时间等待一个明显不可用的服务。
所以我们需要一种机制:
当下游持续表现异常时,暂时停止向它发送部分请求。
这就是 Circuit Breaker(熔断器)的核心思想。
1. 熔断器的三个状态
熔断器常见的状态包括:
Closed
Open
Half-Open
它们可以理解为:
Closed:闭合状态。
正常允许请求访问下游,同时统计错误率、超时等指标。
Open:打开状态。
熔断条件触发后,暂时不再把普通请求转发到对应下游,而是快速返回失败或进入允许的降级路径。
Half-Open:半开状态。
经过一定恢复等待后,允许少量探测请求访问下游,判断服务是否已经恢复。
状态转换:
正常调用
|
v
CLOSED
|
错误达到触发条件
|
v
OPEN
|
暂停正常调用
|
恢复等待结束
|
v
HALF-OPEN
/ \
/ \
探测成功 探测失败
| |
v v
CLOSED OPEN
2. 熔断有什么价值?
假设 Inventory Service 已经连续超时。
如果没有熔断:
每个订单请求
|
v
等待库存服务超时
|
v
占用上游资源
如果熔断器打开:
订单请求
|
v
检查熔断状态
|
v
发现下游不可用
|
v
快速失败或安全降级
这样能够减少对下游的无效调用,也避免上游每次都付出完整的等待成本。
3. 熔断不是修复下游
这里需要注意:
熔断器不会让 MySQL 自动恢复,也不会修复 Inventory Service 的程序错误。
它主要用于限制故障传播。
同时,如果熔断条件设置不合理,也可能出现误触发。
例如短时间内只有极少数请求,但其中一次失败,就直接打开熔断器,可能过于敏感。
因此,实际设计通常需要结合足够的请求样本、错误比例、慢调用比例和恢复窗口,而不是简单地看到一次错误就熔断。
4. 熔断以后应该返回什么?
对于商品推荐接口,可以允许降级:
推荐服务不可用
|
v
返回普通商品列表
但对于库存预留:
Inventory Service 不可用
不能随意返回:
库存预留成功
因为这会造成虚假的业务成功。
正确做法可能是:
返回暂时无法下单
或者将订单保留在可恢复的待处理状态,前提是业务明确支持这种异步流程。
所以:
熔断之后能否降级,以及如何降级,取决于业务正确性要求。
不能为了接口成功率,把无法确认的业务操作当成已经成功。
六、限流:为什么必须控制进入系统的请求量?
熔断主要关注下游已经出现明显异常时,是否继续调用。
但如果请求量本身已经超过系统处理能力,还需要在更早的位置进行保护。
例如:
Client
|
v
Gateway
|
v
Order Service
|
v
Inventory Service
假设订单系统稳定处理能力是每秒 1000 个请求。
但促销活动开始后,突然有每秒 10000 个请求进入。
如果所有请求都放行,就可能导致:
线程池耗尽
数据库连接池等待
库存服务过载
请求队列不断增长
这时候可以使用 Rate Limiting(限流)。
1. 限流主要解决什么?
限流用于控制某个时间范围内允许通过的请求数量或速率。
例如:
最多允许 1000 QPS
超过限制的请求可以根据业务策略:
快速拒绝
返回 429
进入有界等待队列
而不是全部进入下游执行。
需要注意,1000 QPS 只是示例。实际阈值需要通过压测、监控以及服务容量评估确定。
2. 为什么 Gateway 是常见的限流位置?
因为 Gateway 是很多外部请求进入微服务系统的统一入口。
Client
|
v
Gateway
|
+-- Order Service
|
+-- Product Service
|
+-- User Service
在 Gateway 进行限流,可以在请求进入内部服务之前拦截部分流量。
例如:
某用户每秒最多请求 10 次
某接口每秒最多接收 1000 次请求
这样可以减少不必要的内部处理开销。
但 Gateway 限流也存在边界。
它主要控制经过该 Gateway 的流量。
如果还有 Kafka Consumer、内部 RPC、后台任务等其他入口,仅靠外部 Gateway 限流无法保护所有下游资源。
因此,重要的下游服务有时还需要在内部增加自己的并发限制和容量保护。
3. Gateway 限流和 HPA 是不是一回事?
不是。
这是我们之前讨论 Kubernetes 时专门区分过的。
Gateway 限流:控制允许多少请求进入。
Kubernetes HPA:根据指标调整工作负载的副本数量。
例如:
Gateway
|
| 控制进入 Order Service 的请求
v
Order Service Pods
|
| HPA 根据指标调整 Pod 数量
v
更多或更少的 Pod
两者解决的问题不同。
| 机制 | 核心问题 |
|---|---|
| Gateway 限流 | 当前是否应该继续接收这些请求? |
| HPA | 当前工作负载是否需要更多实例? |
| 熔断 | 某个下游持续异常时,是否暂停调用? |
| 超时 | 单次调用最多允许等待多久? |
而且 HPA 扩容需要时间,也受到节点容量和下游处理能力的限制。
不能认为:
请求量突然增加
|
v
HPA 马上扩容
|
v
所有请求都能成功
这不是可靠的容量模型。

七、隔离:为什么不能让一个故障服务占满所有资源?
除了 Timeout、Retry 和限流,还有一个常见问题:
假设 Order Service 需要调用两个下游。
Order Service
|
+-- Inventory Service
|
+-- Coupon Service
库存服务正常,但优惠券服务突然变慢。
如果所有调用共用同一个有限线程池:
Shared Thread Pool
|
+-- Inventory 请求
|
+-- Coupon 请求
那么大量等待 Coupon Service 的调用可能占满线程池。
结果是:
库存服务虽然正常,但订单服务已经没有足够资源继续调用它。
这种情况下,故障就从一个下游传播到了其他业务能力。
Bulkhead:隔离不同资源池
Bulkhead(舱壁隔离)的思想,就是不要让某一类操作无限消耗所有共享资源。
例如:
Order Service
|
+-- Inventory Thread Pool
|
+-- Coupon Thread Pool
或者对于异步调用,使用不同的并发限制:
Inventory:最多 100 个并发请求
Coupon:最多 20 个并发请求
假设 Coupon Service 变慢:
Coupon 的 20 个并发名额被占满
后续 Coupon 调用可以被拒绝、降级或有限排队。
但 Inventory 的并发资源不会因此全部被占用。
这里不一定要为每个下游创建独立线程池。
对于异步框架,也可以使用 Semaphore(信号量)等方式限制特定调用的同时执行数量。
真正重要的是:
让某一个下游的异常,只能消耗它被分配的资源预算,而不是无限占用整个上游服务的执行能力。
当然,资源隔离也会引入容量配置问题。
如果隔离得过细,可能导致某些资源池空闲,而另一些资源池一直排队。
因此,应当根据调用量、延迟和业务优先级进行设计。
八、Backpressure:为什么处理不过来时,应该让上游减速?
前面讲到的限流主要强调控制进入系统的请求速率。
Backpressure(背压)则更关注:
当下游消费或处理能力不足时,如何把这种容量限制向上游传递,避免请求或消息无限积压。
例如:
Producer
|
v
Queue
|
v
Worker
假设:
Producer 每秒产生 5000 个任务
Worker 每秒只能处理 2000 个任务
如果一直维持这个状态:
队列每秒新增积压约 3000 个任务
队列会越来越长。
Kafka 可以缓冲任务和事件,但 Kafka 不能自动让 Worker 拥有无限处理能力。
如果积压长期超过可管理范围,就会带来:
业务处理延迟增加
消息保存压力增加
故障恢复时间变长
消费者长时间追不上生产速度
1. 为什么不能让队列无限增长?
假设应用使用内存队列处理请求。
Request Queue
|
v
Worker Pool
如果队列没有边界,在下游持续变慢时,大量等待任务就可能占用越来越多内存。
最终造成应用内存压力、GC 停顿甚至进程崩溃。
所以背压的一个常见做法是使用:
有界队列(Bounded Queue)。
例如:
最多允许 1000 个任务排队
当队列满了以后,系统不再无条件接受新任务。
而是根据业务选择:
拒绝
等待
通知上游降低发送速率
将任务可靠转移到其他可承载的位置
需要注意,对于必须处理的业务任务,不能简单把队列满了的请求直接丢弃,而不留下任何可靠记录。
如果业务无法接受任务丢失,就需要设计持久化任务记录、可靠消息或其他恢复机制。
2. Backpressure 和 Retry 有什么关系?
它们的方向可以这样区分。
Retry:
失败后尝试再次执行
Backpressure:
处理能力不足时,减少新的输入压力
如果系统已经持续过载,继续重试只会增加输入压力。
此时更需要的是背压、限流和容量隔离。
所以:
重试解决的是部分暂时性失败,背压解决的是输入速度与处理能力不匹配。
3. Backpressure 和 Kafka 有什么关系?
我们之前学习 Kafka 时,讨论过 Consumer Lag。
Kafka Topic
|
v
Consumer Group
|
v
MySQL
如果 MySQL 变慢,Consumer 处理消息的速度就会下降。
Kafka 中的消费积压随之增加。
此时不能只依靠增加 Kafka Consumer 数量。
如果真正的瓶颈是 MySQL,增加 Consumer 并发可能把更多请求打到数据库。
更合理的处理方向可能包括:
限制数据库写入并发
控制每批消息的处理量
优化 MySQL SQL 与索引
使用有界执行队列
在容量允许时增加消费并行度
但必须注意:暂停消费或者降低读取速度,只是防止 Consumer 继续加重下游压力,并不能自动消除 Kafka 中已经积累的消息。
如果生产速度长期超过消费速度,还需要从生产端、业务处理能力和系统整体容量上解决问题。
九、把这些保护机制放在一起,应该怎么理解?
到这里,我们已经讨论了六类机制:
Timeout
Retry
Circuit Breaker
Rate Limiting
Bulkhead
Backpressure
它们并不是六种互相竞争的方案。
它们分别处理故障传播的不同阶段。
| 机制 | 主要保护什么 | 典型问题 |
|---|---|---|
| Timeout | 单次调用的等待时间 | 下游迟迟不返回 |
| Retry | 短暂失败后的恢复机会 | 一次网络抖动导致请求失败 |
| Circuit Breaker | 上游免受持续异常下游拖累 | 大量调用持续超时 |
| Rate Limiting | 系统入口与下游容量 | 请求量突然超过处理能力 |
| Bulkhead | 不同操作的资源隔离 | 一个故障下游占满所有线程 |
| Backpressure | 输入速率与处理能力匹配 | 队列无限积压 |
可以用一个完整请求链来理解它们的位置。
Client
|
v
Gateway 限流
|
v
Order Service
|
并发资源隔离
|
v
熔断判断
|
v
Inventory RPC
|
Timeout
|
有条件 Retry
|
v
Inventory Service
|
并发 / 队列控制
|
v
MySQL
这里并不代表所有系统都必须严格按这个顺序实现每个机制。
例如重试和熔断可以由同一客户端调用框架协调,具体执行顺序也需要结合调用策略设计。
真正的重点是:
每个服务应该知道自己能够承受多少工作,以及在下游已经无法处理时,应该怎样及时减少额外压力。
十、一个完整案例:库存服务变慢以后,系统如何避免级联故障?
现在把前面的机制放回订单系统。
假设正常情况下:
Client
|
v
Gateway
|
v
Order Service
|
v
Inventory Service
|
v
MySQL
Inventory Service 响应稳定。
突然,MySQL 因为某条慢 SQL 导致查询延迟明显增加。
第一阶段:下游延迟开始上升
MySQL 查询变慢
|
v
Inventory Service 响应变慢
Order Service 开始观察到 RPC 延迟上升。
这时合理的 Timeout 可以避免某个请求无限等待。
第二阶段:限制重试造成的额外压力
部分库存请求开始超时。
如果调用方立即大量重试,Inventory Service 会收到更多请求。
所以需要:
只对允许重试的错误重试
保持稳定的 request_id
限制最大尝试次数
设置退避与 Jitter
遵守整体 Deadline
对于库存预留这种有状态操作,还需要根据幂等标识和执行状态避免重复扣减。
第三阶段:触发熔断
如果库存服务持续异常,熔断器可以暂停大量无效调用。
Order Service
|
v
Circuit Breaker
|
v
OPEN
|
v
快速返回不可用
这样 Order Service 不必让每个请求都等待库存 RPC 超时。
但不能把库存服务不可用伪装成库存预留成功。
第四阶段:限制入口请求和内部并发
Gateway 可以对下单接口进行限流。
Order Service 还可以控制自己同时向 Inventory Service 发起多少请求。
例如:
Gateway:
限制下单请求进入速率
Order Service:
限制库存 RPC 并发数量
这样可以防止库存服务恢复期间再次被瞬间打满。
第五阶段:保护其他业务功能
假设订单服务还需要调用优惠券服务。
通过 Bulkhead 或按下游设置 Semaphore,使库存故障不会占满所有下游调用资源。
这样至少可以减少库存故障对其他请求路径的连带影响。
第六阶段:恢复时逐步放开
当 Inventory Service 开始恢复:
Circuit Breaker
|
v
HALF-OPEN
|
v
允许少量探测请求
如果探测稳定,再逐步恢复正常调用。
同时观察:
错误率
P99 延迟
线程池使用率
数据库连接池等待
请求积压
避免刚恢复就把大量积压请求和重试一起释放给下游。
这里需要强调:系统恢复不是简单地把熔断器从 OPEN 改回 CLOSED,而是要确认真实的下游处理能力已经恢复。

十一、为什么快速失败有时候比一直等待更可靠?
前面讲到熔断和限流时,多次出现了一个词:
快速失败
对于刚接触分布式系统的人来说,这可能有些反直觉。
既然我们的目标是让请求成功,为什么要主动拒绝请求?
原因在于:
当系统已经无法处理全部请求时,继续接受所有请求,可能让原本可以成功的请求也一起失败。
例如:
Inventory Service 每秒只能稳定处理 1000 次请求
但上游持续发送:
每秒 5000 次请求
如果全部放进队列,系统可能出现:
等待时间越来越长
客户端纷纷超时
客户端继续重试
请求进一步积压
最后,大部分请求都无法在可接受时间内完成。
但如果系统在入口就限制部分流量:
允许处理可承载的请求
对超出容量的请求快速拒绝
那么被允许的请求反而有更高机会及时完成。
这就是过载保护的一个基本思想。
但快速失败并不是所有业务的最终处理方式。
例如:
普通商品推荐查询可以直接返回稍后重试提示,或者降级返回默认推荐。
订单提交如果没有创建任何业务状态,可以明确拒绝请求并让用户重新提交。
已经开始执行、但状态未知的支付请求则不能直接当成没有发生过。它需要通过业务 ID、状态查询和恢复机制确认最终结果。
因此,真正需要区分的是:
尚未开始执行的请求
和:
可能已经产生副作用的请求
前者可以根据容量快速拒绝。
后者需要按照相应业务协议确认状态,避免快速失败掩盖已经发生的业务操作。
十二、实际排查级联故障时,应该先看什么?
假设某一天,订单接口的错误率突然升高。
我们可以按照调用链逐步排查。
1. 先看错误从哪里开始出现
例如:
Order Service:
P99 从 200ms 上升到 5s
但 Order Service 自身 CPU 并不高。
这时应该检查它是否正在等待下游。
如果发现:
Inventory RPC:
超时比例明显增加
再继续排查 Inventory Service。
如果 Inventory Service 又大量等待 MySQL,就可能找到真正的初始瓶颈。
2. 看重试是否放大了调用量
例如正常情况下:
Order Service 每秒接收 1000 个请求
但 Inventory Service 突然收到:
每秒 2500 个 RPC 调用
这时就应该检查是否有大量自动重试。
还需要区分:
原始请求 QPS
和:
实际下游调用 QPS
两者不一定相同。
3. 看资源是否被等待占满
例如:
线程池活跃线程数
线程池排队数量
HTTP 连接池等待
数据库连接池等待
CPU 使用率
内存与 GC
这些指标能帮助判断问题是 CPU 计算能力不足,还是大量请求正在等待外部资源。
4. 看熔断、限流与拒绝是否正常触发
例如:
Circuit Breaker Open 次数
429 返回比例
并发限制拒绝数量
降级次数
半开探测成功率
这些指标不能单独代表系统质量好坏。
例如限流拒绝数量增加,可能说明系统正在主动保护下游,而不一定是限流器出现故障。
需要结合下游延迟和真实成功率判断。
5. 看故障恢复后是否仍然存在积压
如果系统使用 Kafka 或后台任务队列,还应该关注:
Consumer Lag
任务队列长度
最旧任务等待时间
重试队列数量
即使 MySQL 已经恢复,消息积压也可能需要一段时间才能处理完成。
所以排查级联故障不能只看到:
服务进程重新启动成功
就认为问题已经完全结束。
还需要确认:
调用延迟、业务错误、积压任务和下游负载是否真正恢复到可接受水平。
十三、总结:微服务可靠性不是靠无限重试实现的
这一篇从库存服务变慢开始,逐步解释了级联故障如何发生,以及不同故障治理机制分别解决什么问题。
整个过程可以归纳为:
下游服务变慢
|
v
上游请求等待
|
v
并发资源被占用
|
v
请求开始超时
|
v
错误重试增加下游压力
|
v
故障向更多服务传播
针对这条故障链,我们学习了几种保护机制。
Timeout: 限制单次调用的等待时间,避免资源长期占用。
Retry: 在合适的条件下恢复短暂故障,但需要控制次数、时间和额外流量。
Circuit Breaker: 当下游持续异常时,暂时减少无效调用,防止故障继续向上游传播。
Rate Limiting: 控制进入系统的请求速率,避免流量超过处理能力。
Bulkhead: 隔离不同业务路径或下游调用的资源,避免一个故障占满全部执行能力。
Backpressure: 当处理能力不足时,减少新的输入压力或进行有界缓冲,避免无限积压。
其中最重要的是以下三个结论。
第一,Timeout 不等于失败
如果调用涉及数据库修改、支付或库存预留,超时以后必须考虑操作已经执行成功但响应丢失的可能性。
不能简单地用重复调用替代状态确认和业务幂等。
第二,重试不是免费的
每一次重试都会占用额外的网络、线程、连接或者下游计算资源。
当下游已经过载时,不受限制的重试只会进一步放大故障。
第三,扩容与流量治理必须一起考虑
HPA 可以增加工作负载实例,但不能自动解决数据库瓶颈,也不能阻止客户端重试风暴。
Gateway 限流、服务内部并发限制、熔断和背压仍然具有独立价值。
最后,可以把这一篇的核心观点总结为:
一个可靠的微服务系统,不是保证每个请求在任何情况下都必须完成,而是在资源有限、下游故障和网络不确定时,仍然能够控制工作量、明确业务状态,并阻止局部故障扩散到整个系统。
下一篇我们继续学习微服务性能与可观测性,重点介绍 QPS、P99、Trace,以及如何从一条跨服务调用链中定位真正的性能瓶颈。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_51711289/article/details/169460832




