业务系统性能改造手册 · 稳定性治理
用超时与隔离封住慢下游
从端到端响应目标拆分下游调用的时间预算,再用独立且有上限的线程、连接和并发资源舱限制慢依赖的故障半径。
用超时与隔离封住慢下游
重试已经受控,不代表订单服务就安全了。
一个下游请求超过用户能接受的等待时间后,外层接口可能已经返回超时,但执行请求的线程、连接和下游任务仍然活着。如果这些“已经没人等、却还在干活”的调用不断积累,慢下游最终会拖满订单服务的公共线程池和连接池。那时,原本不依赖它的请求也会一起变慢。
这里要同时解决两个问题:超时决定一次等待何时止损,隔离决定一次故障最多占用多少资源。 只有超时,没有隔离,慢调用仍可能占满公共资源;只有隔离,没有超时,隔离舱会长期被少量僵死任务占住。
本文延续 04-03 的重试约束,只处理一个可控下游的时间预算和资源隔离。入口限流、背压和业务优先级留到 05-02。
先从用户响应目标倒推,不要从默认参数开始
超时不是一个孤立的 3s。它是端到端响应目标被逐段切开后的结果。
一次订单查询可能依次经过入口排队、本地校验、隔离舱排队、获取连接、建连与 TLS、等待响应、解析结果、必要的重试,最后还要给返回响应留下余量。各段预算相加,不能超过用户响应目标;重试也不能在这个总预算之外另开一只表。
可以先把预算写成配置模板,再用真实链路数据填值:
1order-query:
2 response-target: ${ORDER_QUERY_RESPONSE_TARGET}
3 ingress-reserve: ${ORDER_QUERY_INGRESS_RESERVE}
4 local-processing-budget: ${ORDER_QUERY_LOCAL_BUDGET}
5 dependency:
6 isolation-queue-wait: ${INVENTORY_QUEUE_WAIT}
7 connection-acquire: ${INVENTORY_CONNECTION_ACQUIRE_TIMEOUT}
8 connect: ${INVENTORY_CONNECT_TIMEOUT}
9 response: ${INVENTORY_RESPONSE_TIMEOUT}
10 retry-backoff: ${INVENTORY_RETRY_BACKOFF_BUDGET}
11 response-margin: ${ORDER_QUERY_RESPONSE_MARGIN}不要急着把这些占位符替换成“行业常用值”。先从生产监控中取得正常状态下各阶段的 p95、p99 和尾部抖动,再结合业务响应目标分配。一个基本约束是:
1入口余量 + 本地处理 + 隔离排队 + 首次调用 + 重试退避 + 重试调用 + 返回余量
2<= 当前请求剩余时间四类时间限制不是一回事
| 限制位置 | 它约束什么 | 常见误判 |
|---|---|---|
| 隔离舱排队时间 | 任务等待独立工作线程的时间 | 只限制执行时间,队列里已经耗掉大半预算 |
| 获取连接时间 | 从连接池取得可用连接的时间 | 线程隔离了,但仍与其他依赖争抢同一个连接池 |
| 建连超时 | 新建 TCP/TLS 连接的时间 | 以为它能限制复用连接上的响应等待 |
| 请求/响应超时 | 从发出请求到收到所需响应的时间 | 只在外层 Future.get 设置超时,以为底层 I/O 会同步停止 |
JDK HttpClient 的 connectTimeout 只在建立新连接时生效;请求复用已有连接时,它不会限制后续等待。单个 HttpRequest 的 timeout 才约束该请求等待响应的时长。实际项目如果使用 Apache HttpClient、OkHttp 或 Spring Boot 默认客户端,要逐项核对当前版本的“连接池获取、建连、读取/响应、整体调用”语义,不能直接照搬参数名。
预算在调用链上还应表现为截止时间,而不是每层重新领取一份完整时长。进入下游调用前计算 remaining = deadline - now;排队后再计算一次;准备重试前继续计算。剩余时间不足以覆盖一次有意义的尝试和返回余量时,直接停止,不再发起一个注定来不及完成的请求。
外层返回超时,不等于底层工作已经停止
下面这种写法只能证明调用方不再等待:
1future.get(remaining.toMillis(), TimeUnit.MILLISECONDS);发生 TimeoutException 后调用 future.cancel(true) 是必要动作,但它仍然只是“请求取消”。Java 的 Future 契约说明,取消运行中任务时可以尝试中断执行线程;返回值并不证明底层任务、套接字或远端操作已经停止。ThreadPoolExecutor 的停止行为同样依赖任务响应中断,不响应中断的任务可能无法终止。
因此,时间控制至少要落到两层:
- 外层用剩余截止时间限制排队加执行,避免调用线程无限等待。
- HTTP、RPC 或数据库客户端自身配置不大于剩余预算的网络超时,让底层 I/O 有明确退出条件。
任务代码还应在开始调用、准备重试和进行昂贵解析前检查剩余时间,并正确关闭响应体等资源。不能用“外层已经超时”替代这些动作。
对于写操作,超时还有一个额外风险:客户端不知道远端究竟没执行、正在执行,还是已经成功但响应丢失。此时不能把“未知”记录成“失败”,更不能立即换一个新业务编号重试。应沿用 04-02、04-03 的稳定操作标识,通过幂等查询或对账确认最终状态。
给慢下游一个独立且有上限的资源舱
资源隔离的目标不是让慢调用更快,而是让它最多占用一小块事先分配的资源。
对一个外部库存服务,至少检查三层是否真正独立:
- 独立的有界执行器,限制同时执行和排队的任务数。
- 独立的客户端或连接池,避免隔离线程最后仍争抢公共连接。
- 独立的并发与超时指标,能够看见资源舱何时饱和、何时恢复。
只新建线程池但继续共用一个已经耗尽的 HTTP 连接池,不算完整隔离。反过来,每次请求都创建新的 JDK HttpClient 也不是办法:客户端自身管理连接池,反复创建会放弃连接复用。更合适的做法是按下游生命周期复用独立客户端;如果所选客户端支持连接池上限,再把上限与隔离舱并发数配套设置。
下面是一段只依赖 JDK 17 的最小实现。它用固定工作线程和小型有界队列限制故障半径,并把“排队 + 执行”纳入同一份剩余预算:
1import java.time.Duration;
2import java.util.concurrent.*;
3import java.util.concurrent.atomic.AtomicInteger;
4
5public final class IsolatedDependencyExecutor implements AutoCloseable {
6 private final ThreadPoolExecutor executor;
7
8 public IsolatedDependencyExecutor(String dependency, int workers, int queueCapacity) {
9 if (workers < 1 || queueCapacity < 0) {
10 throw new IllegalArgumentException("workers must be positive and queueCapacity non-negative");
11 }
12 AtomicInteger sequence = new AtomicInteger();
13 ThreadFactory factory = task -> {
14 Thread thread = new Thread(task);
15 thread.setName("dependency-" + dependency + "-" + sequence.incrementAndGet());
16 thread.setDaemon(true);
17 return thread;
18 };
19 BlockingQueue<Runnable> queue = queueCapacity == 0
20 ? new SynchronousQueue<>()
21 : new ArrayBlockingQueue<>(queueCapacity);
22 this.executor = new ThreadPoolExecutor(
23 workers, workers, 0L, TimeUnit.MILLISECONDS,
24 queue, factory, new ThreadPoolExecutor.AbortPolicy());
25 }
26
27 public <T> T call(Callable<T> task, Duration remaining) throws Exception {
28 if (remaining.isZero() || remaining.isNegative()) {
29 throw new DependencyTimeoutException("no remaining deadline", false);
30 }
31
32 final Future<T> future;
33 try {
34 future = executor.submit(task);
35 } catch (RejectedExecutionException saturated) {
36 throw new DependencySaturatedException("dependency isolation compartment is full", saturated);
37 }
38
39 try {
40 return future.get(remaining.toNanos(), TimeUnit.NANOSECONDS);
41 } catch (TimeoutException timeout) {
42 boolean cancellationAccepted = future.cancel(true);
43 throw new DependencyTimeoutException("dependency deadline exceeded", cancellationAccepted);
44 } catch (InterruptedException interrupted) {
45 future.cancel(true);
46 Thread.currentThread().interrupt();
47 throw interrupted;
48 } catch (ExecutionException failed) {
49 Throwable cause = failed.getCause();
50 if (cause instanceof Exception exception) throw exception;
51 if (cause instanceof Error error) throw error;
52 throw new IllegalStateException(cause);
53 }
54 }
55
56 public int activeTasks() {
57 return executor.getActiveCount();
58 }
59
60 public int queuedTasks() {
61 return executor.getQueue().size();
62 }
63
64 @Override
65 public void close() {
66 executor.shutdown();
67 }
68
69 public static final class DependencyTimeoutException extends Exception {
70 private final boolean cancellationAccepted;
71
72 DependencyTimeoutException(String message, boolean cancellationAccepted) {
73 super(message);
74 this.cancellationAccepted = cancellationAccepted;
75 }
76
77 public boolean cancellationAccepted() {
78 return cancellationAccepted;
79 }
80 }
81
82 public static final class DependencySaturatedException extends Exception {
83 DependencySaturatedException(String message, Throwable cause) {
84 super(message, cause);
85 }
86 }
87}这段代码有意没有把 cancel(true) 描述成“已经取消”。cancellationAccepted 只能用于记录取消请求是否被接受,不能作为 I/O 已释放的证据。真正接入业务时,Callable 内部的 HTTP 请求仍需设置自己的响应超时,并在 finally 中释放响应资源。
工作线程数、队列长度和连接池上限也不能拍脑袋。线程数要受下游允许并发、订单服务实例数和连接池能力共同约束;队列只用于吸收短暂抖动,不能变成隐藏等待室。若队列等待的 p99 已经吃掉大部分预算,扩大队列通常只会让更多请求晚一点失败。
超时后的返回必须保持业务诚实
隔离舱满或调用超时后,应用需要一个明确结果,而不是把所有异常都包装成“系统繁忙”。
对订单查询这类读请求,可以在契约允许时采用有限降级,例如返回带有生成时间和数据版本的旧缓存,或省略不影响决策的附加信息。响应必须让调用方知道数据不完整或已经过期。
对下单、扣减库存等改变状态的操作,不能伪造成功。若远端结果未知,应返回可识别的处理中或未知状态,保留稳定操作标识,并进入查询、补偿或人工对账流程。降级的边界应由业务契约决定,而不是由线程池拒绝策略决定。
每次超时至少记录这些字段:依赖名称、操作标识、超时阶段、已耗时、剩余预算、是否请求取消、任务最终何时结束、隔离舱活动数与排队数、实际尝试次数。这样才能回答一个关键问题:接口返回后,慢任务还占了资源多久?
用故障实验验证故障半径,而不是只测成功率
测试目标不是证明异常能被捕获,而是证明慢下游不会继续扩散。
建议使用同一批请求,先在共享资源版本建立基线,再切换为相同网络超时、独立资源舱的版本。除隔离方式外保持流量模型和依赖延迟一致,分别注入:建连缓慢、响应头缓慢、响应体中途停顿、写操作已受理但响应丢失,以及故障恢复。
实验报告至少保留以下结果:
- 订单接口吞吐、p95、p99 和超时比例。
- 隔离舱活动线程、排队任务、拒绝次数和恢复到零的时间。
- 调用方返回超时后,底层任务与连接继续存活的数量和时长。
- 下游实际请求数、重试数,以及是否仍满足 04-03 的总重试预算。
- 不依赖该下游的核心请求是否保持在既定延迟范围内。
- 写操作出现未知结果时,是否保留同一操作标识并能完成对账。
代码层还应补两类确定性测试。第一类把工作线程和队列都占满,断言后续任务立即得到 DependencySaturatedException,而不是进入无界等待。第二类让任务响应中断,断言调用方超时后发出取消请求且活动任务最终归零;随后再用一个不响应中断的假任务验证监控仍能发现残留占用。后一个测试尤其重要,它能防止团队把 cancel(true) 的返回值误当成资源已经释放。
上线时先对单个可控下游启用独立资源舱,从少量实例观察尾延迟、拒绝和残留任务。回滚可以恢复原执行路径,但不要删掉超时阶段和资源占用指标;它们正是判断参数错误还是下游容量不足的证据。
验收时不要数“配置了几个 timeout”,要看三个可观察结果:超过截止时间的调用能及时结束等待,慢依赖占用的线程和连接有硬上限,故障解除后资源舱能在预期时间内恢复。做到这三点,慢下游才真正被封在自己的边界里。