依赖治理、雪崩防护与团队门禁:可靠性怎样成为持续工程
订单接口调用支付服务,报表接口调用分析服务。业务调用图上,两条路径互不依赖;如果它们共用一个执行池,两个长时间等待的报表请求就可能让支付请求连发送机会都没有。分析故障传播时,服务之间的调用关系和进程内部的资源共享关系都要画出来。
画出调用路径与共同占用的资源
业务依赖只是第一层关系
业务调用
├─ 下单 → 库存 → 支付
└─ 报表 → 分析服务
进程内共享
├─ HTTP 入口线程或事件循环
├─ 下游执行池、连接池、并发许可
├─ CPU、堆内存、文件描述符
└─ DNS、认证、配置、日志出口
部署共享
└─ 节点、可用区、数据库集群、网络出口、云账号业务上可选的依赖,也可能通过共享连接池拖住必要路径。异步调用减少等待线程占用后,连接、内存、事件循环和下游并发量仍然存在。线程池换成虚拟线程也不会让数据库容量增加。
还应标出控制面和数据面。配置中心暂时不可达时,已经加载合法配置的实例是否仍可服务;身份服务中断时,已取得的令牌和公钥能否在允许的期限内继续使用;服务发现更新失败时,旧地址能否安全保留。这些选择直接影响一个管理依赖的故障是否传播到全部业务请求。
慢调用怎样逐步耗尽整个服务
这个反馈过程会让原本局部的容量不足扩散。正常时候每秒完成的请求数,不能直接用来估计故障中的可用容量;资源竞争、重试和实例退出都可能降低实际成功吞吐。Google SRE Cascading Failures对这种正反馈及恢复困难有系统说明。
排队长度之外,还应看最老任务已经等待多久。如果大量任务在开始执行前就已超过客户端 deadline,继续处理它们会消耗容量,却无法产生对调用方有用的结果。清理过期任务、及时减载和保护必要请求,才能让完成的工作重新占据主要资源。
算清每条路径的放大次数
沿同一串行路径,各层最大尝试次数会相乘;不同扇出分支则应分别计算再按访问关系求和。
客户端最多 3 次
→ SDK 每次最多 3 次
→ 数据服务:最多 9 次访问
一次聚合请求
→ 库存路径最多 2 次
→ 运费路径最多 3 次
→ 两条路径合计最多 5 次访问(若每次都走到两者)这些是上界推导,提前成功、熔断拒绝、过期和分支条件都会减少实际次数。容量规划既要保留这种最坏情况分析,也要用调用计数核对运行时是否还有 SDK、代理或消费者层的隐藏重试。
用共享执行池重现不相关请求相互阻塞
运行独立的池对照
下载 HTTP 保护与依赖实验,解压到 reliability-protection。它与熔断、隔离、限流与降级使用同一个完整工程;shared、separated、amplify 是这里的三种模式。
环境为 Linux Bash、Docker、Java 17/25、Maven 3.9.12 和 Resilience4j 2.3.0。当前用户需要 Docker 使用权限;构建目录和缓存由映射 UID/GID 写入。所有 HTTP 调用都在实验容器回环接口中完成。
mkdir -p .m2-lab
docker run --rm --user "$(id -u):$(id -g)" \
--entrypoint mvn -e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2-lab:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-B -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify
docker run --rm --network none --read-only --user "$(id -u):$(id -g)" \
--tmpfs /tmp -v "$PWD:/work:ro" -w /work \
eclipse-temurin:17.0.20_8-jdk \
java -cp 'target/classes:target/dependency/*' dev.example.ProtectionLab shared构建应报告 11 个测试通过,包含保护机制及本篇的三种模式。shared 输出:
shared reportActive=2 payment=queueTimeout paymentHttpCalls=0
nextBatch payment=42 report=42 queuesEmpty=true两个报表任务已经通过真实 HTTP 到达下游,由服务端同步器暂时阻塞。调用方只有两个工作线程,支付任务只能进入容量为 1 的等待队列。在 200ms 等待额度内,它没有执行,所以服务端支付调用数为 0。
等待到期后,实验取消仍在队列中的支付任务并清理已取消项;随后释放两个报表请求。下一批报表与支付都取得真实响应,队列为空。这样可以区分“拒绝了请求”和“资源已经恢复、后续工作能继续”。
把必要路径分配到独立池
将模式换成 separated,报表仍使用两个线程,支付使用自己的一个线程,其他故障条件保持相同:
separated reportActive=2 payment=42
nextBatch payment=42 report=42 queuesEmpty=true支付请求在报表仍被阻塞时就能完成。隔离保证这项资源不会被报表独占,但二者仍共享 CPU、JVM 和实验 HTTP 服务器;如果共享 CPU 已饱和,独立线程池也会变慢。根据故障范围,可能还需要独立连接池、实例分组、租户配额或 cell 划分。
实验使用有界队列和显式拒绝策略:
new ThreadPoolExecutor(
2, 2, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.AbortPolicy()
);无界队列会持续接收任务,线程数上限无法限制排队对象占用的内存。CallerRunsPolicy 会让提交线程亲自执行任务,在 HTTP 入口上使用时可能把阻塞重新带回入口线程;选择拒绝策略时应考虑这条执行路径。ThreadPoolExecutor给出了队列、线程数和拒绝处理的配合关系。
Future.get(timeout) 限制调用方等待,队列中未开始的任务还需要取消或移除;已经开始的任务则应按实际取消能力释放资源。相关区别见超时与端到端预算。
直接数出嵌套重试的访问量
运行 amplify。下游真实返回 HTTP 503,代码将它映射为 DependencyFailure,外层和内层分别使用最多三次尝试。结果为:
nestedHttpAttempts=9 singleOwnerHttpAttempts=3 recovered=42数字来自 HTTP handler 的调用计数。移除内层重试后,同一故障只触发三次访问;故障解除后,普通请求仍得到 42。为了快速观察次数,实验把退避设为零,生产接入应同时考虑随机退避、总预算与重试流量配额。Resilience4j Retry说明了最大尝试数包含首次调用,以及异常、结果和等待策略。
若运行没有出现预期阻塞,先检查是否确实进入两个下游请求;若下一批任务失败,检查取消项是否仍占队列、工作线程是否退出,以及调用结果是否被包装异常遮住。不要把增加测试 sleep 当成修复资源释放逻辑。
让保护措施适应容量变化和共同故障
入口减载与故障余量
四个实例平均分担流量时,一个实例退出,原有流量由剩下三个分担,每个实例的负载约变为原来的 4/3。如果平时已接近极限,摘除一台就可能触发连锁过载。
容量评估应包括少一个实例、少一个可用区、下游变慢和冷缓存等条件,而不只是健康满员状态。入口可以按请求成本和业务优先级限制接入,让核心操作保留资源;后台扫描和可选聚合可以延后。丢弃已经没有价值的等待工作,也属于减载。相关策略见 Google SRE Handling Overload。
429、503、超时和业务拒绝要分别计数,同时观察成功吞吐。保护生效后,拒绝数可能增加而服务整体更稳定;如果成功吞吐也继续下降,则要检查拒绝是否足够早、是否还在执行昂贵的认证、解码或下游请求。
避免全体实例同时动作
固定周期缓存刷新、同时过期的令牌、统一的重试延迟和大批实例一起启动,都会制造同步请求峰值。可为周期和退避增加有界随机性,让缓存刷新在过期前分散进行;同一个热点键的刷新还应合并,避免每个请求各自穿透到数据库。
缓存返回旧值要有明确的最大年龄和适用业务。价格展示可能允许短暂旧值,库存扣减和权限判定不能随意沿用过期结果。若依赖恢复后一次性把所有过期键刷新,也可能再次把它压垮。
扩容和重启同样要控制批次。新实例往往需要加载数据、建立连接和编译热代码,刚启动时的吞吐未必达到稳态。让新实例先完成必要初始化,再逐渐承接流量;外部依赖故障不宜通过全体 liveness 失败来触发重启,详见健康与启停。
隔离到哪个层级
调用级:timeout、并发许可、错误分类、可选降级
资源级:线程池、连接池、缓存空间、租户配额
实例级:不同流量组、专用 worker、不同部署批次
故障域:节点、可用区、独立 cell、备份与恢复身份隔离越深,管理和冗余成本通常越高。为每个小依赖都创建大量线程池,会增加线程和闲置容量;只在应用中配置一个全局大池,又可能让关键业务被低优先级任务拖住。根据调用成本、重要性和共同故障范围选择隔离粒度。
Kubernetes PodDisruptionBudget 可限制部分自愿中断中的同时不可用数量,不能阻止节点意外失效或所有来源的中断,也不能替代应用容量和重试设计,见 Kubernetes Disruptions。
将依赖参数落实到代码、变更和恢复操作
每项依赖保留可执行的约定
维护依赖清单时,记录会影响调用行为的具体信息:
| 信息 | 示例 | 需要与谁确认 |
|---|---|---|
| 调用用途与负责人 | 支付授权,支付团队 | 业务语义、故障联系与查询方式 |
| 等待预算 | 连接上限、完整调用上限、继承入口剩余量 | 调用方、SDK 和服务端 |
| 重试承担层 | SDK 一次,业务客户端最多三次尝试 | 避免策略叠加 |
| 资源配额 | 报表池两个执行槽、一个等待位置 | 单实例成本、扩容后的总下游压力 |
| 错误分类 | 哪些 5xx 可重试,哪些业务拒绝终止 | HTTP 与业务协议 |
| 降级行为 | 允许缓存值的最大年龄 | 产品语义与数据敏感性 |
| 恢复操作 | 半开探针、逐步放量、原动作查询 | 值班人员与依赖负责人 |
这些值应落到配置绑定和实际调用代码中。实验的 DependencyPolicy 在构造时拒绝零线程、非正等待时间和超出允许范围的尝试数,并用有效配置创建真正的有界池。这种校验只能阻止非法配置;两个合法配置组合起来仍可能超出下游容量,需要负载和故障对照。
变更检查围绕受影响行为展开
增加重试次数时,重新计算最坏访问量并跑 amplify 类型的计数测试;改变线程池或异步 API 时,重跑在途占用、取消和下一批请求;缩短超时时,检查慢响应体及提交后失联;修改 readiness 组时,检查依赖故障会不会导致全部实例同时摘流。
新的业务类型可能需要不同超时、异步受理或专用资源,变更时应说明请求成本、允许等待时间和下游容量。自动检查可以拒绝非法配置,并复跑共享资源与恢复测试;对一项合法但更宽松的配额,还需要结合业务优先级和容量测试决定是否启用。
临时放宽配额或关闭保护时,应保留生效范围、负责人和恢复条件。跨实例发布先从小批次开始,比较成功吞吐、尾延迟和依赖压力;异常时能撤回当前配置,而不必等待整批发布结束。
恢复时先降低压力,再增加能力
一次连锁过载已经发生后,恢复容量可能低于故障前。先限制无效重试和低优先级流量,恢复关键依赖,清理过期排队任务,再让少量正常请求完成。观察连接、执行槽和队列是否释放后,再逐步增加流量。
熔断的半开探针、负载均衡重新接入和消息积压重放需要相互配合。若每一层都在同一时刻“全量恢复”,原故障可能刚解除就再次进入过载。恢复阈值可以比触发阈值保守,留下稳定观察时间,避免状态在健康与故障之间快速反复切换。
恢复后的写入要能查询到确定结果,重复消息要保持原业务效果;执行池则应清掉旧任务,正常完成下一批必要请求。这些检查分别落在数据和执行资源上。跨节点或数据丢失场景还需要备份恢复与 RTO/RPO 演练,局部熔断和重启只处理其中一部分故障。
权威资料与规范地址
- Google SRE Cascading Failures:资源耗尽、正反馈和故障扩散。https://sre.google/sre-book/addressing-cascading-failures/
- Google SRE Handling Overload:减载、优先级和过载控制。https://sre.google/sre-book/handling-overload/
- Java 17 ThreadPoolExecutor:线程、队列、拒绝策略和取消项回收。https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html
- Resilience4j Retry:尝试次数、异常分类和退避策略。https://resilience4j.readme.io/docs/retry
- Kubernetes Disruptions:自愿与非自愿中断及 PDB 的作用范围。https://kubernetes.io/docs/concepts/workloads/pods/disruptions/
