微服务稳定性治理:超时、重试、隔离、熔断、过载保护与故障演练
微服务雪崩通常不是“一个服务宕机”这么简单,而是某个依赖变慢后,上游请求持续进入,线程、连接和队列逐层堆积;超时配置过长,重试又放大流量,最终原本健康的服务也被拖垮。稳定性治理的目标不是保证永不失败,而是让故障有边界、失败可预期、恢复不产生二次冲击。
本页不重复限流算法专栏中的令牌桶和滑动窗口,而是回答:一次慢调用怎样演变为雪崩,各保护器分别在哪个阶段发挥作用,为什么配置错误会反向放大事故,以及怎样用故障注入证明系统真正可恢复。
如果要从业务依赖角度先梳理“谁是强依赖、谁能降级、每个依赖最多占多少线程和连接”,先看:微服务依赖治理:依赖分级、故障域、资源隔离、降级预案与容量闭环。
如果要先统一“哪些异常算失败、哪些错误可以重试、超时为什么是结果未知、降级能不能返回默认成功”,先看:微服务错误语义:HTTP状态、业务错误码、异常分类、可重试与结果未知。
如果要把超时、重试、熔断、降级、幂等和恢复能力做成可验证闭环,继续阅读:故障演练与混沌工程。
一、学习目标
学完后应能:
- 画出慢依赖引发线程池、连接池和队列耗尽的完整链路。
- 区分连接超时、读取超时、单次尝试超时和端到端Deadline。
- 计算多层重试放大倍数,并设计全链路重试预算。
- 区分线程池隔离、信号量隔离、连接池隔离和资源配额。
- 解释Circuit Breaker的CLOSED、OPEN、HALF_OPEN状态机。
- 区分限流、并发限制、负载卸载、熔断和降级。
- 理解Little定律、排队论直觉和尾延迟放大。
- 设计固定并发与自适应并发保护。
- 处理缓存、配置和依赖故障下的有界降级。
- 建立SLO、错误预算、故障注入、混沌演练和恢复Runbook。
二、一次慢依赖怎样演变为雪崩
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定律的稳定状态近似:
平均在途数量 = 到达率 × 平均停留时间20 QPS、平均5秒,就约有100个在途请求。若每个请求占一个线程和一个数据库连接,80线程、50连接的系统即使QPS不高也会耗尽。
4.1 利用率接近100%为什么尾延迟陡升
资源利用率较低时,请求大多直接获得服务;接近饱和后,微小流量波动、GC或慢SQL都会让队列迅速增长。平均耗时可能看起来只涨一点,P99却会先失控。
因此容量阈值不应设在压测极限点,而应在延迟曲线拐点之前保留安全余量,并考虑:
- GC和JIT波动。
- 数据库Checkpoint和磁盘抖动。
- 单机下线后的剩余容量。
- 热点租户与热点Key。
- 发布期间新实例预热。
五、端到端Deadline与超时预算
假设用户接口SLO为1200ms,不能给网关、订单、库存、优惠和数据库每层都配置1200ms。串行调用会把总耗时叠加,上游放弃后下游仍继续做无效工作。
flowchart TD
A["入口建立1200ms Deadline"] --> B["认证和排队消耗100ms"]
B --> C["订单本地处理预算150ms"]
C --> D["并行调用库存与优惠最多600ms"]
D --> E["数据库提交预算200ms"]
E --> F["预留序列化与响应返回150ms"]5.1 常见超时
| 超时 | 约束范围 | 常见错误 |
|---|---|---|
| DNS超时 | 名称解析 | 被隐藏在总连接失败中 |
| Connect Timeout | TCP/TLS建立 | 配得与读取超时一样长 |
| Pool Acquire Timeout | 等连接池许可 | 无限等待导致线程堆积 |
| Read/Response Timeout | 等响应数据 | 误认为超时就是未执行 |
| Per-try Timeout | 单次物理尝试 | 大于总Deadline |
| Queue Timeout | 在线程池等待 | 没有配置,过期请求仍执行 |
| DB Query Timeout | SQL执行 | 只超时Java Future,不取消SQL |
5.2 Deadline传播
入口保存绝对截止时间或可安全传播的剩余预算。调用下游前扣除已消耗时间和返回预留;剩余预算不足时不再发起请求。跨机器直接传播墙上时钟时间要考虑时钟偏差,gRPC等通常按剩余时长传播。
超时只终止等待,不天然回滚远端事务。支付和库存必须通过幂等键、状态查询和补偿解决结果未知。
5.3 分层超时到底怎样设计
线上最常见的错误是每一层都配同一个超时值。例如入口、Gateway、Feign、数据库和下游都配置5秒。这样看似统一,实际会造成上游早已放弃,内部任务还在排队或执行。
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继续跑很久 |
一个可落地的设计方式:
入口SLO:1500ms
客户端超时:1700ms
Gateway响应超时:1400ms
订单服务总Deadline:1200ms
库存调用逻辑预算:500ms
库存单次Attempt:300ms
连接池等待:50ms
数据库SQL:200ms
响应返回预留:100ms这里不是说所有系统都照抄这些数字,而是说明层级关系:越往里越要扣减预算,不能越调用越“重获新生”。
5.4 超时、取消和远端执行不是一回事
很多线上事故来自一个误解:
调用方超时了,就等于下游没有执行。
这是错误的。
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 | 网关等待超时 | 下游可能已提交事实 |
因此所有写接口都要准备“结果未知”状态。正确模型不是简单的成功/失败二元,而是:
SUCCESS:确认成功
FAILED:确认失败
UNKNOWN:调用方超时、连接断开或响应丢失,业务事实未知UNKNOWN必须通过幂等查询、对账、补偿或人工处理收敛,不能直接用新请求号重做。
5.5 超时排查要先判断卡在哪一层
遇到“接口超时”,不要先改大超时。先定位等待发生在哪里。
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次,最坏物理尝试为:
2 × 2 × 3 = 12次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和业务代码都可能重试。必须明确谁拥有重试权。
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 为什么退避和抖动不可省
固定间隔重试会让大量调用方在同一时刻再次冲击下游。
10万客户端都在1秒后重试
-> 下游在第1秒再次收到10万请求尖峰
-> 继续超时
-> 第2秒再来一轮指数退避让重试间隔逐步拉长,抖动让不同客户端分散在不同时间点。
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。
八、熔断器状态机
flowchart TD
A["CLOSED正常统计调用"] --> B{"最小样本满足且失败或慢调用超阈值"}
B -- "否" --> A
B -- "是" --> C["OPEN快速拒绝"]
C --> D{"等待窗口到期"}
D -- "否" --> C
D -- "是" --> E["HALF_OPEN仅放少量探测"]
E --> F{"探测是否达到恢复条件"}
F -- "是" --> A
F -- "否" --> C8.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期间有限样本的成功/失败 | 判断是否可以恢复 |
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一段时间后,熔断器会尝试让少量请求进入下游,观察是否恢复。这个阶段最危险:下游可能只是“刚能响应”,还没有恢复峰值容量;如果所有积压请求同时放开,会再次打崩。
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会有更完整的并发安全、时间窗口、事件发布和动态配置。
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 故意很小,只保留原理。生产实现不能遗漏:
- OPEN等待时间和HALF_OPEN探测。
- 成功率、异常类型和慢调用同时统计。
- 时间窗口桶的滚动和并发安全。
- 配置热更新与实例间规则版本一致性。
- 熔断事件指标、日志和告警。
- 降级语义,尤其是写请求不能返回假成功。
九、过载保护与Load Shedding
过载时继续接收全部请求,只会让所有请求都变慢并最终失败。Load Shedding是在昂贵工作前主动丢弃部分负载,让核心请求保持可用。
决策信号包括:
- 当前in-flight与自适应并发上限。
- 线程池活跃数、队列等待时间。
- 事件循环延迟。
- 数据库连接等待和锁等待。
- CPU、GC和内存压力。
- 请求剩余Deadline。
丢弃顺序应体现业务优先级:健康检查和支付确认高于推荐刷新,大客户核心交易与离线报表使用不同预算。不能只按“谁先到谁先服务”,否则低价值洪峰会饿死核心链路。
9.1 Admission Control
在真正占用昂贵资源前判断是否接纳。请求已进入数据库事务后再拒绝,保护价值很低,还可能制造回滚成本。
9.2 CoDel直觉
只限制队列长度不能识别“队列不长但每个请求等很久”。受控延迟队列关注最小排队时间,在持续超过目标时主动丢弃,避免Bufferbloat。业务实现不必照搬网络算法,但要同时看队列长度和等待年龄。
9.3 基于Deadline的丢弃:过期请求不要再执行
很多系统“看起来没有拒绝请求”,其实只是把请求放进队列,等用户或上游已经超时后才开始执行。这样的请求叫幽灵流量:它不再给用户带来价值,却继续占用线程、连接、数据库锁和下游配额。
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请求,只演示进入昂贵操作前怎样判断:系统是否过载、请求是否已过期、低优先级是否应该被丢弃。
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);
}
}这段代码说明:
- 过期或快过期请求不应该进入昂贵调用。
- 低优先级任务应更早被丢弃,为核心请求保留容量。
inFlight比单纯QPS更能反映慢调用风险。- 生产还要按接口、租户、依赖和实例维度拆预算,不能全站只有一个计数器。
9.6 过载保护Runbook
发现系统过载时,不要第一反应扩线程池。按顺序取证:
- 看入口QPS、in-flight、队列长度、最老队列年龄和拒绝率。
- 看请求剩余Deadline,确认是否有大量过期请求仍在执行。
- 按接口、租户、版本拆P95/P99和资源占用。
- 看下游连接池、DB连接池、Redis、MQ Lag和第三方配额。
- 看是否存在多层重试放大,使物理Attempt远大于逻辑请求。
- 临时动作优先降低低价值接口并发、暂停报表和批处理、开启降级。
- 恢复时限速放开,不要把积压一次性冲给下游。
判断是否有效,看这些指标是否一起好转:核心接口P99下降、in-flight回落、队列年龄下降、数据库连接等待下降、重试Attempt比例下降、业务成功率恢复。只看CPU下降不够,因为CPU低也可能是线程都在等连接或锁。
十、自适应并发限制
固定并发阈值在机器性能、实例数量、下游状态变化时不够灵活。自适应算法通过观察低延迟基线、当前RTT或排队增长,动态调整允许的in-flight。
基本控制思想:
- 记录无明显排队时的最小延迟基线。
- 当当前延迟显著高于基线,推断排队增加,降低并发上限。
- 稳定低延迟一段时间后,小步增加上限探索容量。
- 使用平滑、最小/最大边界和冷却窗口,避免阈值振荡。
自适应限制不是无需压测。若延迟上涨来自合法大请求、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要定义有效请求、排除项、统计窗口和维度。错误预算消耗过快时,应暂停高风险发布并优先修复可靠性,而不是继续堆功能。
十四、故障注入与混沌演练
正常功能测试无法证明保护器有效。至少验证:
- 下游延迟从50ms升到5s。
- 连接拒绝、连接Reset和响应丢失。
- 单个Endpoint慢、整个Cluster慢。
- 数据库连接池耗尽和锁等待。
- 注册中心短暂不可用和地址陈旧。
- Pod滚动下线与长连接残留。
- MQ消费变慢和重试风暴。
- 证书过期或时钟偏差。
演练前必须明确爆炸半径、停止条件、值班人、观测面和回滚方案。先在测试环境和小流量单元演练,不以“混沌”为名直接冲击全部生产。
flowchart TD
A["提出可证伪假设"] --> B["定义稳态指标与停止条件"]
B --> C["选择最小爆炸半径"]
C --> D["注入延迟、错误或资源故障"]
D --> E["观察限流、隔离、熔断和降级"]
E --> F["停止注入并验证有序恢复"]
F --> G["记录缺口并修复后复演"]十五、JDK 8 Demo:最小可理解熔断状态机
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.7 | Resilience4j兼容版本、Sentinel、历史Hystrix存量 | Hystrix已停止活跃演进,迁移不能只换注解 |
| Java 17+ / Boot 3.x | 对应Boot 3兼容的Resilience4j、Sentinel适配、Micrometer Observation | 使用Jakarta体系与匹配的Spring Cloud发行列 |
Boot 3不会改变熔断状态机和排队原理,但会改变依赖、自动配置、指标接入和上下文传播。新系统应优先选择仍在维护的组件;存量Hystrix文档用于理解线程池隔离和迁移,不代表新项目首选。
十七、商业场景:医疗接口聚合
患者资产页并行调用主数据、采集状态、报告和第三方医院接口:
- 入口总Deadline为1500ms。
- 主数据预算500ms,第三方医院接口预算700ms,保留响应组装时间。
- 第三方接口使用独立线程池、连接池和并发许可。
- 只对连接前失败做一次退避重试,查询请求携带稳定请求标识。
- 慢调用比例持续超阈值后熔断第三方接口。
- 熔断时返回“外部报告暂不可用”和最近成功时间,不伪造空报告。
- HALF_OPEN只放少量内部或低风险请求。
- 恢复后逐步放量并核对期间待拉取任务。
主数据故障与推荐故障不能使用同一降级等级;核心患者身份无法确认时应拒绝高风险操作,而不是拼接可能错误的数据。
十八、生产Runbook
18.1 线程池耗尽
- 获取活跃线程、队列长度、最老等待时间和拒绝数。
- 线程Dump确认阻塞在HTTP、数据库锁、连接池还是同步锁。
- 对齐下游P99、连接Acquire、SQL和GC时间线。
- 先限流或降级降低进入量,不盲目扩大线程。
- 检查请求Deadline是否在队列中已经过期。
18.2 熔断器频繁OPEN/HALF_OPEN
- 检查最小样本是否过小、窗口是否太短。
- 区分业务4xx、系统5xx和慢调用。
- 按实例查看是否单个坏节点污染统计。
- 检查HALF_OPEN探测是否瞬间过多。
- 校准阈值后灰度,不直接全网关闭保护。
18.3 明明熔断仍然雪崩
- 熔断点可能在昂贵操作之后。
- 其他实例、网关或异步任务仍在调用。
- 重试发生在熔断器外层并不断创建新请求。
- 调用被隔离,但共享数据库连接池仍耗尽。
- 降级逻辑自身访问同一个故障依赖。
18.4 故障恢复后再次打崩
- 查看熔断器是否同时HALF_OPEN。
- 检查客户端退避是否没有抖动。
- 检查积压任务是否无速率回放。
- 检查新Pod是否没有Warmup。
- 按下游实时容量逐步恢复,而不是一键全开。
十九、常见误区
| 误区 | 后果 |
|---|---|
| 超时越长成功率越高 | 线程和连接占用更久,雪崩更快 |
| 超时等于远端失败 | 重试造成重复扣款或扣库存 |
| 线程池越大吞吐越高 | 下游容量不变,只增加排队和切换 |
| 队列无界更少拒绝 | 过期请求和OOM |
| 熔断只看异常率 | 全部慢成功仍能拖垮上游 |
| OPEN后直接全量恢复 | 恢复风暴再次打崩下游 |
| 降级返回null即可 | 调用方无法区分无数据与故障 |
| 自适应限流无需压测 | 误判和振荡造成误伤 |
| 混沌工程就是随机断服务 | 没有假设和停止条件会制造事故 |
二十、面试回答主线
先从慢依赖导致in-flight和排队增加讲起;再用Deadline限制等待、Bulkhead限制故障域、并发限制和Load Shedding保护容量、Circuit Breaker阻断持续故障、业务降级保持核心能力;强调重试必须有幂等和全局预算;最后讲HALF_OPEN、抖动、Warmup、SLO和故障演练证明恢复能力。
标准回答见微服务稳定性治理面试题。
二十一、关联知识点
- 分布式限流
- Service Mesh重试与熔断
- gRPC Deadline与重试
- Dubbo治理与重试预算
- Spring Cloud Sentinel
- Resilience4j从零到生产级掌握
- Resilience4j内部原理与生产治理
- Spring Cloud Hystrix与迁移
- 线程池原理
- 分布式幂等
本章小结
稳定性治理是一套闭环,不是几个注解:Deadline让等待有界,重试预算防止流量放大,Bulkhead限制故障域,并发限制和Load Shedding保护容量,熔断阻断持续失败,降级维持核心业务,HALF_OPEN和Warmup控制恢复速度,SLO与故障演练验证这些机制是否真实有效。
