Skip to content

微服务稳定性治理:超时、重试、隔离、熔断、过载保护与故障演练

微服务雪崩通常不是“一个服务宕机”这么简单,而是某个依赖变慢后,上游请求持续进入,线程、连接和队列逐层堆积;超时配置过长,重试又放大流量,最终原本健康的服务也被拖垮。稳定性治理的目标不是保证永不失败,而是让故障有边界、失败可预期、恢复不产生二次冲击。

本页不重复限流算法专栏中的令牌桶和滑动窗口,而是回答:一次慢调用怎样演变为雪崩,各保护器分别在哪个阶段发挥作用,为什么配置错误会反向放大事故,以及怎样用故障注入证明系统真正可恢复。

如果要从业务依赖角度先梳理“谁是强依赖、谁能降级、每个依赖最多占多少线程和连接”,先看:微服务依赖治理:依赖分级、故障域、资源隔离、降级预案与容量闭环

如果要先统一“哪些异常算失败、哪些错误可以重试、超时为什么是结果未知、降级能不能返回默认成功”,先看:微服务错误语义:HTTP状态、业务错误码、异常分类、可重试与结果未知

如果要把超时、重试、熔断、降级、幂等和恢复能力做成可验证闭环,继续阅读:故障演练与混沌工程

一、学习目标

学完后应能:

  1. 画出慢依赖引发线程池、连接池和队列耗尽的完整链路。
  2. 区分连接超时、读取超时、单次尝试超时和端到端Deadline。
  3. 计算多层重试放大倍数,并设计全链路重试预算。
  4. 区分线程池隔离、信号量隔离、连接池隔离和资源配额。
  5. 解释Circuit Breaker的CLOSED、OPEN、HALF_OPEN状态机。
  6. 区分限流、并发限制、负载卸载、熔断和降级。
  7. 理解Little定律、排队论直觉和尾延迟放大。
  8. 设计固定并发与自适应并发保护。
  9. 处理缓存、配置和依赖故障下的有界降级。
  10. 建立SLO、错误预算、故障注入、混沌演练和恢复Runbook。

二、一次慢依赖怎样演变为雪崩

mermaid
flowchart TD
    A["数据库锁等待让库存接口从50ms变成5s"] --> B["订单服务在途请求快速增加"]
    B --> C["HTTP连接池与业务线程被占用"]
    C --> D["线程池队列持续增长"]
    D --> E["请求排队时间超过调用超时"]
    E --> F["调用方、网关和代理开始重试"]
    F --> G["实际到达库存的尝试流量进一步增大"]
    G --> H["订单服务自身也无法处理健康检查"]
    H --> I["实例摘除与流量重分配冲击剩余实例"]
    I --> J["局部慢故障扩散为系统雪崩"]

这里最危险的不是5秒本身,而是“到达率仍高于完成率”。只要请求进入速度持续大于系统排出速度,内存、队列、线程或连接总有一个先耗尽。

三、每种保护器解决哪个阶段

能力决策问题典型位置不解决什么
超时/Deadline最多等多久调用端、代理、数据库不证明下游未执行
重试预算是否值得再试最了解错误语义的一层不替代幂等
速率限制单位时间允许多少Gateway、服务入口慢调用导致的低QPS高并发
并发限制同时允许多少在途服务入口、依赖调用前请求是否业务正确
Bulkhead隔离某依赖最多占多少资源独立线程池、信号量、连接池下游数据一致性
熔断下游持续异常时还要不要调用依赖调用前业务降级内容
Load Shedding系统过载时丢弃哪些工作尽量靠前已经提交的副作用
降级核心能力如何继续服务业务层自动恢复事实校验

稳定性不是把所有注解都加上。保护器需要按调用链顺序协调,否则可能出现入口已经超时,内部任务还在排队;熔断已经打开,重试层仍疯狂换实例;服务返回降级数据,却被缓存成长期事实。

四、容量与排队:为什么QPS不高也会崩

Little定律的稳定状态近似:

text
平均在途数量 = 到达率 × 平均停留时间

20 QPS、平均5秒,就约有100个在途请求。若每个请求占一个线程和一个数据库连接,80线程、50连接的系统即使QPS不高也会耗尽。

4.1 利用率接近100%为什么尾延迟陡升

资源利用率较低时,请求大多直接获得服务;接近饱和后,微小流量波动、GC或慢SQL都会让队列迅速增长。平均耗时可能看起来只涨一点,P99却会先失控。

因此容量阈值不应设在压测极限点,而应在延迟曲线拐点之前保留安全余量,并考虑:

  • GC和JIT波动。
  • 数据库Checkpoint和磁盘抖动。
  • 单机下线后的剩余容量。
  • 热点租户与热点Key。
  • 发布期间新实例预热。

五、端到端Deadline与超时预算

假设用户接口SLO为1200ms,不能给网关、订单、库存、优惠和数据库每层都配置1200ms。串行调用会把总耗时叠加,上游放弃后下游仍继续做无效工作。

mermaid
flowchart TD
    A["入口建立1200ms Deadline"] --> B["认证和排队消耗100ms"]
    B --> C["订单本地处理预算150ms"]
    C --> D["并行调用库存与优惠最多600ms"]
    D --> E["数据库提交预算200ms"]
    E --> F["预留序列化与响应返回150ms"]

5.1 常见超时

超时约束范围常见错误
DNS超时名称解析被隐藏在总连接失败中
Connect TimeoutTCP/TLS建立配得与读取超时一样长
Pool Acquire Timeout等连接池许可无限等待导致线程堆积
Read/Response Timeout等响应数据误认为超时就是未执行
Per-try Timeout单次物理尝试大于总Deadline
Queue Timeout在线程池等待没有配置,过期请求仍执行
DB Query TimeoutSQL执行只超时Java Future,不取消SQL

5.2 Deadline传播

入口保存绝对截止时间或可安全传播的剩余预算。调用下游前扣除已消耗时间和返回预留;剩余预算不足时不再发起请求。跨机器直接传播墙上时钟时间要考虑时钟偏差,gRPC等通常按剩余时长传播。

超时只终止等待,不天然回滚远端事务。支付和库存必须通过幂等键、状态查询和补偿解决结果未知。

5.3 分层超时到底怎样设计

线上最常见的错误是每一层都配同一个超时值。例如入口、Gateway、Feign、数据库和下游都配置5秒。这样看似统一,实际会造成上游早已放弃,内部任务还在排队或执行。

mermaid
flowchart TD
    A["用户请求总预算1500ms"] --> B["Gateway路由和鉴权最多150ms"]
    B --> C["订单服务本地逻辑最多200ms"]
    C --> D["库存Feign总预算最多500ms"]
    D --> E["单次Attempt最多300ms"]
    E --> F["数据库SQL最多200ms"]
    F --> G["预留响应返回和序列化150ms"]

推荐原则:

层级预算原则错误做法
用户入口Deadline小于用户或上游可接受等待时间没有总Deadline,只靠各组件超时
Gateway总超时小于客户端超时,并预留写回时间比客户端超时还长
服务本地预算包含排队、参数校验、事务和下游调用只考虑业务代码执行时间
下游逻辑调用预算小于当前剩余Deadline每个下游都重新给完整5秒
单次Attempt超时小于下游逻辑预算,给重试留空间per-try timeout大于总预算
连接池等待必须很短且有上限无限等连接,线程全部堆住
数据库查询超时小于业务剩余预算Java Future超时但SQL继续跑很久

一个可落地的设计方式:

text
入口SLO:1500ms
客户端超时:1700ms
Gateway响应超时:1400ms
订单服务总Deadline:1200ms
库存调用逻辑预算:500ms
库存单次Attempt:300ms
连接池等待:50ms
数据库SQL:200ms
响应返回预留:100ms

这里不是说所有系统都照抄这些数字,而是说明层级关系:越往里越要扣减预算,不能越调用越“重获新生”。

5.4 超时、取消和远端执行不是一回事

很多线上事故来自一个误解:

调用方超时了,就等于下游没有执行。

这是错误的。

mermaid
flowchart TD
    A["订单服务调用库存服务"] --> B["请求已经到达库存服务"]
    B --> C["库存服务扣减库存并提交事务"]
    C --> D["响应返回途中变慢或丢失"]
    D --> E["订单服务Read Timeout"]
    E --> F["订单服务不知道库存是否成功"]

不同超时和取消的语义:

现象是否说明下游没执行正确处理
连接池等待超时通常请求还没发出查本地连接池、并发和下游连接配额
Connect Timeout通常TCP未建立查网络、端口、实例和防火墙
TLS握手超时通常业务未执行查证书、协议、CPU和网络
Write Timeout请求体可能只发送一部分大请求要查服务端是否收到
Read Timeout很可能已经执行写操作按幂等键查状态
TimeLimiter超时只是上层不等了底层Future、Socket、SQL可能继续
线程中断只是协作信号代码或驱动不响应中断就仍会跑
Gateway 504网关等待超时下游可能已提交事实

因此所有写接口都要准备“结果未知”状态。正确模型不是简单的成功/失败二元,而是:

text
SUCCESS:确认成功
FAILED:确认失败
UNKNOWN:调用方超时、连接断开或响应丢失,业务事实未知

UNKNOWN必须通过幂等查询、对账、补偿或人工处理收敛,不能直接用新请求号重做。

5.5 超时排查要先判断卡在哪一层

遇到“接口超时”,不要先改大超时。先定位等待发生在哪里。

mermaid
flowchart TD
    A["接口超时"] --> B["拿traceId和请求时间"]
    B --> C["看请求是否到达Gateway"]
    C --> D["看是否到达应用服务"]
    D --> E["看是否进入Feign或RPC客户端"]
    E --> F["看是否选到目标实例"]
    F --> G["看是否等连接池"]
    G --> H["看是否已经发到下游"]
    H --> I["看下游线程、DB、锁和GC"]

证据优先级:

证据能回答什么
Trace时间线慢在哪一段,是否有多次Attempt
调用方线程栈是等连接、socket read、锁还是线程池
HTTP Client连接池指标active、idle、pending、acquire time
LoadBalancer选择结果请求最终打到哪台实例
下游访问日志请求是否到达下游
下游业务日志是否执行到关键事务点
DB慢日志和锁等待SQL是否慢或被锁
GC日志和容器CPU是否实例停顿或CPU throttle
熔断和限流指标请求是否在本地被拦截

如果调用方线程栈停在连接池acquire,说明请求可能还没发出;如果停在socketRead,说明请求大概率已发出并在等响应;如果下游日志已有业务成功记录,则不能把调用方超时当成业务失败。

六、重试预算:重试不是免费的可靠性

若网关2次、Mesh 2次、Feign 3次,最坏物理尝试为:

text
2 × 2 × 3 = 12次
mermaid
flowchart TD
    A["一次逻辑请求失败"] --> B{"错误是否明确可重试"}
    B -- "否" --> C["返回失败、查询事实或补偿"]
    B -- "是" --> D{"操作是否幂等且仍有Deadline"}
    D -- "否" --> C
    D -- "是" --> E{"全局重试预算是否充足"}
    E -- "否" --> F["快速失败保护下游"]
    E -- "是" --> G["指数退避加抖动后重试"]

6.1 哪些情况可能重试

  • 连接建立前明确失败。
  • 只读、幂等请求遇到短暂UNAVAILABLE。
  • 乐观锁冲突且业务允许重新读取后合并。
  • 明确未提交的数据库死锁回滚。

不能盲目重试:参数错误、权限拒绝、库存不足、确定的业务失败,以及结果未知但没有幂等保护的写操作。

6.2 Retry Budget

可用“额外Attempt不超过正常请求一定比例”控制重试。例如最近窗口1000个原始请求,预算允许额外5%,则最多约50次重试。故障扩大时预算迅速耗尽,系统转为快速失败,避免所有请求都重复攻击下游。

除了比例,还要限制:

  • 每个逻辑请求最大Attempt。
  • 每个目标的并发重试数。
  • 总Deadline和Per-try Timeout。
  • 退避、抖动和服务端Retry-After
  • 不同业务优先级的独立预算。

6.3 重试所有权:只能有一个主要重试层

现代微服务里Gateway、Service Mesh、OpenFeign、LoadBalancer、Resilience4j、HTTP Client和业务代码都可能重试。必须明确谁拥有重试权。

mermaid
flowchart TD
    A["一次逻辑请求"] --> B["Gateway可能重试"]
    B --> C["Mesh可能重试"]
    C --> D["Feign或RPC可能重试"]
    D --> E["业务catch后可能重试"]
    E --> F["物理Attempt乘法放大"]

推荐做法:

场景推荐重试Owner原因
外部GET查询Gateway或客户端一层入口最容易统一控制总次数
服务内部查询Feign/RPC客户端一层最了解下游错误类型和实例选择
写操作业务层受控重试或不自动重试需要幂等键和状态查询
Mesh统一治理Mesh一层多语言统一,但业务语义弱
数据库死锁DAO/Service层有限重试需要重新开启事务

其他层应关闭自动重试,或只保留不会造成副作用的连接前失败重试,并受同一个Deadline和Retry Budget约束。

6.4 为什么退避和抖动不可省

固定间隔重试会让大量调用方在同一时刻再次冲击下游。

text
10万客户端都在1秒后重试
-> 下游在第1秒再次收到10万请求尖峰
-> 继续超时
-> 第2秒再来一轮

指数退避让重试间隔逐步拉长,抖动让不同客户端分散在不同时间点。

mermaid
flowchart TD
    A["首次失败"] --> B["等待100ms到300ms随机区间"]
    B --> C["第二次失败"]
    C --> D["等待300ms到900ms随机区间"]
    D --> E["仍失败则停止或进入补偿"]

退避不是为了“慢慢等一定成功”,而是为了避免所有调用方同时重试。必须同时设置最大Attempt、总Deadline和预算上限。

6.5 重试和幂等必须绑定

没有幂等的重试,本质是在赌下游没有执行。商业系统不能赌。

场景是否可自动重试必要保护
查询字典可以缓存、短超时、有限重试
查询订单可以只读语义、Deadline
创建订单谨慎orderNo唯一约束、状态查询
锁库存谨慎requestNo幂等表、库存流水
支付扣款极谨慎支付单号、查单、对账
发短信谨慎messageId去重、防重复通知

下游服务必须把幂等键和业务修改放在同一个事务里。只在调用方缓存一个“已请求”标记不可靠,因为调用方崩溃、缓存过期或重试换实例都会失效。

七、Bulkhead舱壁隔离

舱壁来自船舱分隔:一个区域进水不应淹没整艘船。微服务中让不同依赖、租户或业务使用独立资源上限。

7.1 线程池隔离

为第三方医院接口、短信和核心数据库调用配置不同线程池。短信慢不会占满订单核心线程。成本是线程切换、额外队列和上下文传播;线程池过多也会浪费内存和增加调度。

7.2 信号量隔离

请求仍在原线程执行,但进入依赖前获取有限许可。开销小,适合本身异步或快速调用;若依赖阻塞,原请求线程仍会被占用。

7.3 连接池隔离

即使线程池独立,共用一个HTTP或数据库连接池仍可能互相拖累。关键依赖要有连接池配额、Acquire Timeout和最大Pending;但连接池总和不能超过下游真正容量。

7.4 队列为什么必须有界

无界队列把“立即拒绝”变成“长时间排队后超时”。请求在队列中已经过期,出队后仍访问数据库,形成幽灵流量。队列应有长度和等待时间双上限,过期任务在执行前再次检查Deadline。

八、熔断器状态机

mermaid
flowchart TD
    A["CLOSED正常统计调用"] --> B{"最小样本满足且失败或慢调用超阈值"}
    B -- "否" --> A
    B -- "是" --> C["OPEN快速拒绝"]
    C --> D{"等待窗口到期"}
    D -- "否" --> C
    D -- "是" --> E["HALF_OPEN仅放少量探测"]
    E --> F{"探测是否达到恢复条件"}
    F -- "是" --> A
    F -- "否" --> C

8.1 CLOSED

正常放行并在滑动窗口中统计成功、失败、慢调用。必须有最小调用数,否则2次请求失败1次就达到50%,会频繁误开。

8.2 OPEN

不再访问下游,立即返回可识别的拒绝或业务降级,保护线程和连接。OPEN不是“服务已修好”,只是暂停施压。

8.3 HALF_OPEN

只放少量探测调用。若一瞬间放开全部积压流量,会在下游刚恢复时再次打垮,形成恢复风暴。

8.4 慢调用熔断

只统计异常率会漏掉“全部请求最终成功但耗时10秒”的慢故障。应同时观察慢调用比例,并让阈值基于业务SLO与正常分布。

8.5 本地熔断的边界

Resilience4j等本地熔断器通常每个实例独立统计,不保证同一时刻全部OPEN。流量分布不同会使状态不同;这不是错误,而是本地观测模型。若需要全局治理,应结合网关、控制面或集中信号,但也要考虑集中依赖故障。

8.6 熔断滑动窗口到底统计什么

熔断器不是“失败一次就打开”,也不是“平均RT高就一定打开”。成熟实现通常会在最近一段窗口内统计调用结果,再结合最小样本数、失败率、慢调用比例和等待窗口决定状态。

常见统计口径:

指标计算方式解决的问题
总调用数最近窗口内完成的调用数量样本太少时不做比例判断
失败数异常、超时或被定义为失败的结果判断依赖是否持续失败
失败率失败数 / 总调用数避免少量偶发错误误开
慢调用数RT超过慢调用阈值的成功或失败调用发现“都成功但很慢”的故障
慢调用比例慢调用数 / 总调用数在错误率升高前保护上游
半开探测结果HALF_OPEN期间有限样本的成功/失败判断是否可以恢复
mermaid
flowchart TD
    A["一次调用结束"] --> B["记录耗时和结果"]
    B --> C["写入当前时间窗口桶"]
    C --> D["聚合最近N个桶"]
    D --> E{"是否达到最小样本数"}
    E -- "否" --> F["继续CLOSED观察"]
    E -- "是" --> G{"失败率或慢调用比例超阈值"}
    G -- "否" --> F
    G -- "是" --> H["切换OPEN并快速拒绝"]

时间窗口和计数窗口要分清:

窗口类型怎么移动适合场景风险
计数窗口最近固定N次调用流量稳定、样本数量明确低QPS接口可能覆盖很长时间
时间窗口最近固定秒数,切成多个桶更贴近实时故障低QPS时样本不足,必须配最小调用数

阈值不能拍脑袋。比如第三方医院接口正常P99为800ms,慢调用阈值设100ms会长期误判;如果核心库存接口SLO为300ms,慢调用阈值设5秒又太迟。推荐从接口SLO、历史P95/P99、下游容量和业务可接受等待时间共同确定。

8.7 HALF_OPEN探测为什么必须限并发

OPEN一段时间后,熔断器会尝试让少量请求进入下游,观察是否恢复。这个阶段最危险:下游可能只是“刚能响应”,还没有恢复峰值容量;如果所有积压请求同时放开,会再次打崩。

mermaid
flowchart TD
    A["OPEN等待窗口到期"] --> B["进入HALF_OPEN"]
    B --> C["只允许少量探测请求"]
    C --> D{"探测是否成功且RT健康"}
    D -- "是" --> E["切回CLOSED并逐步恢复流量"]
    D -- "否" --> F["重新OPEN并延长或继续等待"]

HALF_OPEN设计要回答:

问题建议
放多少探测少量并发,例如1到几十,取决于依赖容量
谁可以探测低风险只读请求优先,核心写请求谨慎
探测失败怎么办立即回到OPEN,避免继续施压
探测成功是否全开不建议一键全量,最好配合Warmup和限速恢复
多个调用方同时半开怎么办加抖动、分批恢复,避免同步探测洪峰

注意:HALF_OPEN成功只能证明当前少量请求成功,不证明依赖已经能承接全部历史峰值。恢复阶段仍要观察P99、错误率、队列、连接池等待和下游CPU。

8.8 JDK 8 Demo:滑动窗口慢调用熔断

下面 Demo 用固定大小计数窗口演示核心思想:最近10次调用中,至少达到5个样本后,如果慢调用比例超过50%,就打开熔断。真实Resilience4j或Sentinel会有更完整的并发安全、时间窗口、事件发布和动态配置。

java
public class SlidingWindowCircuitDemo {
    enum State { CLOSED, OPEN, HALF_OPEN }

    private final long slowThresholdMs;
    private final int minimumCalls;
    private final double slowRateThreshold;
    private final boolean[] slowRing;
    private int cursor;
    private int count;
    private State state = State.CLOSED;

    public SlidingWindowCircuitDemo(int windowSize,
                                    int minimumCalls,
                                    long slowThresholdMs,
                                    double slowRateThreshold) {
        this.slowRing = new boolean[windowSize];
        this.minimumCalls = minimumCalls;
        this.slowThresholdMs = slowThresholdMs;
        this.slowRateThreshold = slowRateThreshold;
    }

    public boolean allowRequest() {
        return state != State.OPEN;
    }

    public void record(long costMs, boolean success) {
        if (!success) {
            open();
            return;
        }

        boolean slow = costMs >= slowThresholdMs;
        slowRing[cursor] = slow;
        cursor = (cursor + 1) % slowRing.length;
        if (count < slowRing.length) {
            count++;
        }

        if (count >= minimumCalls && slowRate() >= slowRateThreshold) {
            open();
        }
    }

    private double slowRate() {
        int slowCount = 0;
        for (int i = 0; i < count; i++) {
            if (slowRing[i]) {
                slowCount++;
            }
        }
        return count == 0 ? 0.0 : (double) slowCount / count;
    }

    private void open() {
        state = State.OPEN;
    }

    public State state() {
        return state;
    }

    public static void main(String[] args) {
        SlidingWindowCircuitDemo cb =
                new SlidingWindowCircuitDemo(10, 5, 1000L, 0.5);

        long[] costs = {80, 90, 1200, 1500, 1300, 70};
        for (long cost : costs) {
            if (!cb.allowRequest()) {
                System.out.println("blocked, state=" + cb.state());
                continue;
            }
            cb.record(cost, true);
            System.out.println("cost=" + cost + ", state=" + cb.state());
        }
    }
}

这个 Demo 故意很小,只保留原理。生产实现不能遗漏:

  1. OPEN等待时间和HALF_OPEN探测。
  2. 成功率、异常类型和慢调用同时统计。
  3. 时间窗口桶的滚动和并发安全。
  4. 配置热更新与实例间规则版本一致性。
  5. 熔断事件指标、日志和告警。
  6. 降级语义,尤其是写请求不能返回假成功。

九、过载保护与Load Shedding

过载时继续接收全部请求,只会让所有请求都变慢并最终失败。Load Shedding是在昂贵工作前主动丢弃部分负载,让核心请求保持可用。

决策信号包括:

  • 当前in-flight与自适应并发上限。
  • 线程池活跃数、队列等待时间。
  • 事件循环延迟。
  • 数据库连接等待和锁等待。
  • CPU、GC和内存压力。
  • 请求剩余Deadline。

丢弃顺序应体现业务优先级:健康检查和支付确认高于推荐刷新,大客户核心交易与离线报表使用不同预算。不能只按“谁先到谁先服务”,否则低价值洪峰会饿死核心链路。

9.1 Admission Control

在真正占用昂贵资源前判断是否接纳。请求已进入数据库事务后再拒绝,保护价值很低,还可能制造回滚成本。

9.2 CoDel直觉

只限制队列长度不能识别“队列不长但每个请求等很久”。受控延迟队列关注最小排队时间,在持续超过目标时主动丢弃,避免Bufferbloat。业务实现不必照搬网络算法,但要同时看队列长度和等待年龄。

9.3 基于Deadline的丢弃:过期请求不要再执行

很多系统“看起来没有拒绝请求”,其实只是把请求放进队列,等用户或上游已经超时后才开始执行。这样的请求叫幽灵流量:它不再给用户带来价值,却继续占用线程、连接、数据库锁和下游配额。

mermaid
flowchart TD
    A["请求进入队列"] --> B["等待800ms"]
    B --> C["入口Deadline只剩50ms"]
    C --> D{"执行前检查剩余时间"}
    D -- "不足" --> E["直接丢弃或返回超时"]
    D -- "足够" --> F["进入昂贵下游调用"]

执行前检查Deadline的意义是:把资源留给仍有机会成功返回的请求。否则系统会出现一种很反直觉的现象:入口QPS已经下降,但数据库和下游仍然很忙,因为它们在处理一批早已过期的旧任务。

丢弃决策一般发生在这些位置:

位置适合判断什么不适合做什么
Gateway入口用户、租户、路径、全局并发、剩余Deadline深入业务数据库判断
服务入口Filter登录用户、租户、接口成本、本实例压力发起远程依赖后再拒绝
线程池入队前队列长度、最老等待、任务优先级无界排队
出队执行前Deadline是否已经过期过期后仍访问数据库
下游调用前剩余预算是否足够完成一次Attempt剩几十毫秒还调用5秒超时接口

9.4 优先级和公平性

Load Shedding不能只看“谁先到”。如果低价值报表、推荐刷新、批量导出和核心支付确认共用一个入口队列,洪峰时先到先服务会让核心请求排在低价值任务后面。

商业系统常见优先级:

优先级示例处理策略
P0支付确认、库存释放、幂等结果查询保留专属并发和连接预算
P1下单、核心查询、医疗采集关键回传正常限流,必要时短队列
P2推荐、营销、非核心聚合信息过载时快速降级或不展示
P3导出、报表、批处理、重建缓存排队、限速、可暂停

公平性也要按租户和用户做。一个大租户的批处理不应该耗尽全局队列,让其他租户登录和查询都失败。可以把全局预算拆成“全局上限 + 租户上限 + 接口上限 + 高优先级保底”。

9.5 JDK 8 Demo:带Deadline和优先级的接纳控制

下面Demo模拟一个最小接纳控制器。它不会真的执行HTTP请求,只演示进入昂贵操作前怎样判断:系统是否过载、请求是否已过期、低优先级是否应该被丢弃。

java
import java.util.concurrent.atomic.AtomicInteger;

public class AdmissionControlDemo {

    static class Request {
        final String id;
        final int priority; // 数字越小优先级越高
        final long deadlineMillis;

        Request(String id, int priority, long deadlineMillis) {
            this.id = id;
            this.priority = priority;
            this.deadlineMillis = deadlineMillis;
        }
    }

    static class AdmissionController {
        private final int maxInFlight;
        private final int lowPriorityStartRejectAt;
        private final AtomicInteger inFlight = new AtomicInteger();

        AdmissionController(int maxInFlight, int lowPriorityStartRejectAt) {
            this.maxInFlight = maxInFlight;
            this.lowPriorityStartRejectAt = lowPriorityStartRejectAt;
        }

        boolean tryAcquire(Request request, long nowMillis) {
            long remain = request.deadlineMillis - nowMillis;
            if (remain < 100) {
                System.out.println(request.id + " rejected: deadline almost exhausted");
                return false;
            }

            int current = inFlight.get();
            if (request.priority >= 3 && current >= lowPriorityStartRejectAt) {
                System.out.println(request.id + " rejected: low priority shedding");
                return false;
            }

            for (;;) {
                current = inFlight.get();
                if (current >= maxInFlight) {
                    System.out.println(request.id + " rejected: instance overloaded");
                    return false;
                }
                if (inFlight.compareAndSet(current, current + 1)) {
                    System.out.println(request.id + " accepted, inFlight=" + (current + 1));
                    return true;
                }
            }
        }

        void release() {
            inFlight.decrementAndGet();
        }
    }

    public static void main(String[] args) {
        AdmissionController controller = new AdmissionController(3, 2);
        long now = System.currentTimeMillis();

        Request pay = new Request("pay-confirm", 0, now + 1000);
        Request query = new Request("order-query", 1, now + 500);
        Request report = new Request("big-report", 3, now + 2000);
        Request expired = new Request("expired-query", 1, now + 50);

        controller.tryAcquire(pay, now);
        controller.tryAcquire(query, now);
        controller.tryAcquire(report, now);
        controller.tryAcquire(expired, now);
    }
}

这段代码说明:

  1. 过期或快过期请求不应该进入昂贵调用。
  2. 低优先级任务应更早被丢弃,为核心请求保留容量。
  3. inFlight比单纯QPS更能反映慢调用风险。
  4. 生产还要按接口、租户、依赖和实例维度拆预算,不能全站只有一个计数器。

9.6 过载保护Runbook

发现系统过载时,不要第一反应扩线程池。按顺序取证:

  1. 看入口QPS、in-flight、队列长度、最老队列年龄和拒绝率。
  2. 看请求剩余Deadline,确认是否有大量过期请求仍在执行。
  3. 按接口、租户、版本拆P95/P99和资源占用。
  4. 看下游连接池、DB连接池、Redis、MQ Lag和第三方配额。
  5. 看是否存在多层重试放大,使物理Attempt远大于逻辑请求。
  6. 临时动作优先降低低价值接口并发、暂停报表和批处理、开启降级。
  7. 恢复时限速放开,不要把积压一次性冲给下游。

判断是否有效,看这些指标是否一起好转:核心接口P99下降、in-flight回落、队列年龄下降、数据库连接等待下降、重试Attempt比例下降、业务成功率恢复。只看CPU下降不够,因为CPU低也可能是线程都在等连接或锁。

十、自适应并发限制

固定并发阈值在机器性能、实例数量、下游状态变化时不够灵活。自适应算法通过观察低延迟基线、当前RTT或排队增长,动态调整允许的in-flight。

基本控制思想:

  1. 记录无明显排队时的最小延迟基线。
  2. 当当前延迟显著高于基线,推断排队增加,降低并发上限。
  3. 稳定低延迟一段时间后,小步增加上限探索容量。
  4. 使用平滑、最小/最大边界和冷却窗口,避免阈值振荡。

自适应限制不是无需压测。若延迟上涨来自合法大请求、GC或网络变化,算法可能误判;需要按接口或成本分类,而不是让1KB查询和2GB导出共享同一个控制器。

十一、降级:失败后返回什么

降级是业务决策,不是简单catch (Exception) return null

场景可接受降级不可接受降级
商品推荐返回热门列表或不展示阻断下单
短信通知写Outbox稍后发送把支付回滚
库存查询标记“暂不可确认”返回虚假充足库存
支付结果返回处理中并提供查询超时直接宣告失败并再次扣款
配置中心使用有版本本地快照使用未知来源无限期旧配置

降级数据必须带来源、版本和新鲜度;缓存兜底要限制最大陈旧时间。恢复后还需清除降级状态、逐步放量并核对期间积压或遗漏的任务。

十二、恢复为什么也会造成事故

依赖恢复时,所有熔断器、客户端、定时任务和积压队列同时重试,会形成Thundering Herd。恢复策略需要:

  • HALF_OPEN限制探测并发。
  • 指数退避和随机抖动。
  • 按租户、分区或实例分批恢复。
  • 新实例Warmup和权重渐增。
  • 积压任务按下游容量匀速回放。
  • 证实业务状态后再解除人工降级。

健康检查只证明“现在能返回”,不证明下游已经具备峰值容量。恢复速度应由成功率、P99、队列和资源共同控制。

十三、SLO、SLI与错误预算

  • SLI:实际测量指标,例如成功请求比例、P99延迟。
  • SLO:目标,例如30天内99.9%的有效订单查询成功且低于500ms。
  • SLA:对外承诺及可能的商业责任,不等于内部所有监控阈值。
  • Error Budget:允许不满足SLO的预算,例如99.9%意味着约0.1%的允许失败空间。

只看平均成功率会掩盖大客户、某机房或某版本故障。SLI要定义有效请求、排除项、统计窗口和维度。错误预算消耗过快时,应暂停高风险发布并优先修复可靠性,而不是继续堆功能。

十四、故障注入与混沌演练

正常功能测试无法证明保护器有效。至少验证:

  1. 下游延迟从50ms升到5s。
  2. 连接拒绝、连接Reset和响应丢失。
  3. 单个Endpoint慢、整个Cluster慢。
  4. 数据库连接池耗尽和锁等待。
  5. 注册中心短暂不可用和地址陈旧。
  6. Pod滚动下线与长连接残留。
  7. MQ消费变慢和重试风暴。
  8. 证书过期或时钟偏差。

演练前必须明确爆炸半径、停止条件、值班人、观测面和回滚方案。先在测试环境和小流量单元演练,不以“混沌”为名直接冲击全部生产。

mermaid
flowchart TD
    A["提出可证伪假设"] --> B["定义稳态指标与停止条件"]
    B --> C["选择最小爆炸半径"]
    C --> D["注入延迟、错误或资源故障"]
    D --> E["观察限流、隔离、熔断和降级"]
    E --> F["停止注入并验证有序恢复"]
    F --> G["记录缺口并修复后复演"]

十五、JDK 8 Demo:最小可理解熔断状态机

java
public class CircuitBreakerDemo {
    enum State { CLOSED, OPEN, HALF_OPEN }

    private State state = State.CLOSED;
    private int calls;
    private int failures;
    private long openedAt;
    private final long openMillis = 1000L;

    synchronized boolean allowRequest(long now) {
        if (state == State.OPEN && now - openedAt >= openMillis) {
            state = State.HALF_OPEN;
            return true; // Demo只允许第一个探测;生产要有探测并发控制
        }
        return state != State.OPEN;
    }

    synchronized void record(boolean success, long now) {
        if (state == State.HALF_OPEN) {
            if (success) {
                state = State.CLOSED;
                calls = failures = 0;
            } else {
                open(now);
            }
            return;
        }

        calls++;
        if (!success) failures++;
        if (calls >= 4 && failures * 100 / calls >= 50) {
            open(now);
        }
    }

    private void open(long now) {
        state = State.OPEN;
        openedAt = now;
    }

    public static void main(String[] args) {
        CircuitBreakerDemo cb = new CircuitBreakerDemo();
        long now = 1000L;
        boolean[] results = {false, false, true, false};
        for (boolean result : results) {
            if (cb.allowRequest(now)) cb.record(result, now);
        }
        System.out.println("after failures=" + cb.state);
        System.out.println("before wait allowed=" + cb.allowRequest(1500L));
        System.out.println("probe allowed=" + cb.allowRequest(2100L));
        cb.record(true, 2100L);
        System.out.println("after probe=" + cb.state);
    }
}

这是教学模型,不应替代Resilience4j、Sentinel等成熟实现。生产还需要滑动窗口、慢调用统计、最小样本、并发安全、事件指标、配置动态化和探测并发限制。

十六、Spring技术基线

基线常见方案注意点
JDK 8 / Boot 2.7Resilience4j兼容版本、Sentinel、历史Hystrix存量Hystrix已停止活跃演进,迁移不能只换注解
Java 17+ / Boot 3.x对应Boot 3兼容的Resilience4j、Sentinel适配、Micrometer Observation使用Jakarta体系与匹配的Spring Cloud发行列

Boot 3不会改变熔断状态机和排队原理,但会改变依赖、自动配置、指标接入和上下文传播。新系统应优先选择仍在维护的组件;存量Hystrix文档用于理解线程池隔离和迁移,不代表新项目首选。

十七、商业场景:医疗接口聚合

患者资产页并行调用主数据、采集状态、报告和第三方医院接口:

  1. 入口总Deadline为1500ms。
  2. 主数据预算500ms,第三方医院接口预算700ms,保留响应组装时间。
  3. 第三方接口使用独立线程池、连接池和并发许可。
  4. 只对连接前失败做一次退避重试,查询请求携带稳定请求标识。
  5. 慢调用比例持续超阈值后熔断第三方接口。
  6. 熔断时返回“外部报告暂不可用”和最近成功时间,不伪造空报告。
  7. HALF_OPEN只放少量内部或低风险请求。
  8. 恢复后逐步放量并核对期间待拉取任务。

主数据故障与推荐故障不能使用同一降级等级;核心患者身份无法确认时应拒绝高风险操作,而不是拼接可能错误的数据。

十八、生产Runbook

18.1 线程池耗尽

  1. 获取活跃线程、队列长度、最老等待时间和拒绝数。
  2. 线程Dump确认阻塞在HTTP、数据库锁、连接池还是同步锁。
  3. 对齐下游P99、连接Acquire、SQL和GC时间线。
  4. 先限流或降级降低进入量,不盲目扩大线程。
  5. 检查请求Deadline是否在队列中已经过期。

18.2 熔断器频繁OPEN/HALF_OPEN

  1. 检查最小样本是否过小、窗口是否太短。
  2. 区分业务4xx、系统5xx和慢调用。
  3. 按实例查看是否单个坏节点污染统计。
  4. 检查HALF_OPEN探测是否瞬间过多。
  5. 校准阈值后灰度,不直接全网关闭保护。

18.3 明明熔断仍然雪崩

  1. 熔断点可能在昂贵操作之后。
  2. 其他实例、网关或异步任务仍在调用。
  3. 重试发生在熔断器外层并不断创建新请求。
  4. 调用被隔离,但共享数据库连接池仍耗尽。
  5. 降级逻辑自身访问同一个故障依赖。

18.4 故障恢复后再次打崩

  1. 查看熔断器是否同时HALF_OPEN。
  2. 检查客户端退避是否没有抖动。
  3. 检查积压任务是否无速率回放。
  4. 检查新Pod是否没有Warmup。
  5. 按下游实时容量逐步恢复,而不是一键全开。

十九、常见误区

误区后果
超时越长成功率越高线程和连接占用更久,雪崩更快
超时等于远端失败重试造成重复扣款或扣库存
线程池越大吞吐越高下游容量不变,只增加排队和切换
队列无界更少拒绝过期请求和OOM
熔断只看异常率全部慢成功仍能拖垮上游
OPEN后直接全量恢复恢复风暴再次打崩下游
降级返回null即可调用方无法区分无数据与故障
自适应限流无需压测误判和振荡造成误伤
混沌工程就是随机断服务没有假设和停止条件会制造事故

二十、面试回答主线

先从慢依赖导致in-flight和排队增加讲起;再用Deadline限制等待、Bulkhead限制故障域、并发限制和Load Shedding保护容量、Circuit Breaker阻断持续故障、业务降级保持核心能力;强调重试必须有幂等和全局预算;最后讲HALF_OPEN、抖动、Warmup、SLO和故障演练证明恢复能力。

标准回答见微服务稳定性治理面试题

二十一、关联知识点

本章小结

稳定性治理是一套闭环,不是几个注解:Deadline让等待有界,重试预算防止流量放大,Bulkhead限制故障域,并发限制和Load Shedding保护容量,熔断阻断持续失败,降级维持核心业务,HALF_OPEN和Warmup控制恢复速度,SLO与故障演练验证这些机制是否真实有效。