业务系统性能改造手册 · 稳定性治理

用超时与隔离封住慢下游

2026-08-053 min read稳定性治理
摘要

从端到端响应目标拆分下游调用的时间预算,再用独立且有上限的线程、连接和并发资源舱限制慢依赖的故障半径。

用超时与隔离封住慢下游

重试已经受控,不代表订单服务就安全了。

一个下游请求超过用户能接受的等待时间后,外层接口可能已经返回超时,但执行请求的线程、连接和下游任务仍然活着。如果这些“已经没人等、却还在干活”的调用不断积累,慢下游最终会拖满订单服务的公共线程池和连接池。那时,原本不依赖它的请求也会一起变慢。

这里要同时解决两个问题:超时决定一次等待何时止损,隔离决定一次故障最多占用多少资源。 只有超时,没有隔离,慢调用仍可能占满公共资源;只有隔离,没有超时,隔离舱会长期被少量僵死任务占住。

本文延续 04-03 的重试约束,只处理一个可控下游的时间预算和资源隔离。入口限流、背压和业务优先级留到 05-02。

先从用户响应目标倒推,不要从默认参数开始

超时预算应从用户响应目标向内分配连接读取外层等待和总截止时间各自约束不同 超时不是一个孤立的 3s。它是端到端响应目标被逐段切开后的结果。

一次订单查询可能依次经过入口排队、本地校验、隔离舱排队、获取连接、建连与 TLS、等待响应、解析结果、必要的重试,最后还要给返回响应留下余量。各段预算相加,不能超过用户响应目标;重试也不能在这个总预算之外另开一只表。

可以先把预算写成配置模板,再用真实链路数据填值:

yaml
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 和尾部抖动,再结合业务响应目标分配。一个基本约束是:

text
1入口余量 + 本地处理 + 隔离排队 + 首次调用 + 重试退避 + 重试调用 + 返回余量 2<= 当前请求剩余时间

四类时间限制不是一回事

限制位置它约束什么常见误判
隔离舱排队时间任务等待独立工作线程的时间只限制执行时间,队列里已经耗掉大半预算
获取连接时间从连接池取得可用连接的时间线程隔离了,但仍与其他依赖争抢同一个连接池
建连超时新建 TCP/TLS 连接的时间以为它能限制复用连接上的响应等待
请求/响应超时从发出请求到收到所需响应的时间只在外层 Future.get 设置超时,以为底层 I/O 会同步停止

JDK HttpClientconnectTimeout 只在建立新连接时生效;请求复用已有连接时,它不会限制后续等待。单个 HttpRequesttimeout 才约束该请求等待响应的时长。实际项目如果使用 Apache HttpClient、OkHttp 或 Spring Boot 默认客户端,要逐项核对当前版本的“连接池获取、建连、读取/响应、整体调用”语义,不能直接照搬参数名。

预算在调用链上还应表现为截止时间,而不是每层重新领取一份完整时长。进入下游调用前计算 remaining = deadline - now;排队后再计算一次;准备重试前继续计算。剩余时间不足以覆盖一次有意义的尝试和返回余量时,直接停止,不再发起一个注定来不及完成的请求。

外层返回超时,不等于底层工作已经停止

下面这种写法只能证明调用方不再等待:

java
1future.get(remaining.toMillis(), TimeUnit.MILLISECONDS);

发生 TimeoutException 后调用 future.cancel(true) 是必要动作,但它仍然只是“请求取消”。Java 的 Future 契约说明,取消运行中任务时可以尝试中断执行线程;返回值并不证明底层任务、套接字或远端操作已经停止。ThreadPoolExecutor 的停止行为同样依赖任务响应中断,不响应中断的任务可能无法终止。

因此,时间控制至少要落到两层:

  1. 外层用剩余截止时间限制排队加执行,避免调用线程无限等待。
  2. HTTP、RPC 或数据库客户端自身配置不大于剩余预算的网络超时,让底层 I/O 有明确退出条件。

任务代码还应在开始调用、准备重试和进行昂贵解析前检查剩余时间,并正确关闭响应体等资源。不能用“外层已经超时”替代这些动作。

对于写操作,超时还有一个额外风险:客户端不知道远端究竟没执行、正在执行,还是已经成功但响应丢失。此时不能把“未知”记录成“失败”,更不能立即换一个新业务编号重试。应沿用 04-02、04-03 的稳定操作标识,通过幂等查询或对账确认最终状态。

给慢下游一个独立且有上限的资源舱

慢下游必须使用独立且有上限的资源舱外层超时还要配合底层取消与容量回收 资源隔离的目标不是让慢调用更快,而是让它最多占用一小块事先分配的资源。

对一个外部库存服务,至少检查三层是否真正独立:

  • 独立的有界执行器,限制同时执行和排队的任务数。
  • 独立的客户端或连接池,避免隔离线程最后仍争抢公共连接。
  • 独立的并发与超时指标,能够看见资源舱何时饱和、何时恢复。

只新建线程池但继续共用一个已经耗尽的 HTTP 连接池,不算完整隔离。反过来,每次请求都创建新的 JDK HttpClient 也不是办法:客户端自身管理连接池,反复创建会放弃连接复用。更合适的做法是按下游生命周期复用独立客户端;如果所选客户端支持连接池上限,再把上限与隔离舱并发数配套设置。

下面是一段只依赖 JDK 17 的最小实现。它用固定工作线程和小型有界队列限制故障半径,并把“排队 + 执行”纳入同一份剩余预算:

java
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”,要看三个可观察结果:超过截止时间的调用能及时结束等待,慢依赖占用的线程和连接有硬上限,故障解除后资源舱能在预期时间内恢复。做到这三点,慢下游才真正被封在自己的边界里。

参考资料