超时与端到端预算:截止时间怎样沿调用链递减
一个接口需要在 800 毫秒内返回。它先查询库存,再计算运费,最后写入订单。如果三个客户端都配置 timeout=800ms,一次请求仍可能等待两秒以上;连接池排队和重试退避还会继续增加耗时。入口允许等待多久、每一步最多占用多少、超时后已经发生的操作怎样处理,需要一起设计。
把一次等待拆成有起止点的时间段
Timeout 和 deadline 分别表达什么
Timeout 是一段允许等待的时长,例如“建立连接最多等待 100ms”。它必须有明确的计时起点和结束事件。Deadline 是某项工作最晚应该完成的时刻;沿同一调用继续执行时,剩余时间会减少。
客户端的一次逻辑调用
├─ 本地调度:等待线程、信号量或连接池
├─ 建立连接:名称解析 → TCP → TLS(复用连接时可能跳过)
├─ 发送请求:请求头、请求体、流量控制等待
├─ 等待响应:服务端排队、执行、下游调用、提交
├─ 接收响应:响应头 → 第一个字节 → 剩余响应体
└─ 本地处理:解码、校验、聚合、构造返回值“读取超时”在不同客户端中可能指相邻两次读取之间的空闲时长,也可能覆盖整个响应阶段。上传一个大文件、读取持续发送数据的流、等待一个普通 JSON 响应,适合的计时方式也不同。配置前先找出参数控制的操作,不能从参数名字推断覆盖范围。
Java HttpClient.connectTimeout 用于新连接建立;已有连接可复用时,这个配置不会重新限制业务处理时间。HttpRequest.Builder.timeout 是请求级配置,未设置意味着没有该项超时。响应何时交给应用还受 BodyHandler 影响,尤其是返回流的处理器。具体定义见 Java 17 HttpClient 和 HttpRequest.Builder。
给串行步骤分配剩余量
800ms 的接口可以先给响应整理与安全余量留下 100ms,其余 700ms 供内部操作使用。假设入口调度已花掉 50ms,库存用掉 180ms,运费步骤开始时,就只能从余下的 470ms 中取得自己的额度。
入口总预算:800ms
保留返回开销:100ms
内部截止时间:入口开始 + 700ms
调度后 剩余 650ms
库存查询完成后 剩余 470ms
运费单次上限 min(运费配置上限, 470ms)
订单写入前 重新计算剩余量,过期则不再发起子步骤只能使用入口留下的时间,上限配置再长也无法延后入口截止时间。反过来,每一步的额度过短又会增加误超时和重试流量。这组分配需要结合实际请求延迟和业务目标调整。
并行扇出主要受最慢的必要分支影响,同时会消耗多个连接或执行槽。可选推荐数据可以在独立的小额度内返回,订单是否创建成功通常要等待必要写入。汇合点到期后,应取消不再需要的分支,并安排其资源释放。限时等待的代码需要与隔离和降级策略对应。
重试也使用同一个截止时间。下一次尝试要先扣除退避时间,再判断剩余时间是否足以完成一次有意义的请求。若还剩 30ms,而建立新连接通常就需要 50ms,继续重试只会占用资源。
单机使用单调时钟,跨机传播协议允许的值
同一 JVM 内可用 System.nanoTime() 计算经过时长。它适合求差,数值本身没有跨进程、跨机器的时间含义。
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(700);
long remaining = deadline - System.nanoTime();
if (remaining <= 0) {
throw new TimeoutException("deadline expired");
}跨服务通常传播剩余时长,接收方按协议规则重新建立本地截止时间,并考虑传输期间的消耗;也可使用约定好的绝对时间,但必须处理时钟偏差、精度和单位。不要把上面的 deadline 直接放进 HTTP 头交给另一台机器。
gRPC 具有自己的 deadline 传播机制,传播时会将已消耗时间扣除,以超时量避免直接比较两台机器的时钟。服务端得知调用取消后,应用启动的工作仍要主动停止或回收。各语言默认传播行为见 gRPC Deadlines。普通 HTTP 自定义一个 X-Timeout 头不会自动获得这些语义,还要定义可信来源、最大允许值、异常值处理和每一跳的扣减方式。
用真实 HTTP 比较局部超时和完整响应预算
运行环境与第一条成功请求
下载 HTTP 等待预算实验,解压进入 reliability-deadline。工程使用 JDK 自带 HTTP 服务端和客户端,在容器内随机回环端口通信,不依赖外部服务。Java 17、25 都可以编译运行,Maven 版本为 3.9.12。
以下命令在 Linux Bash 中执行。当前用户需要能够使用 Docker;构建容器映射当前 UID/GID,不使用容器 root。项目和 .m2-lab 缓存均应由当前用户可写。
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 dev.example.DeadlineLab normal构建应报告 5 个测试通过;独立命令输出 normal=done。离线运行容器中的两个端点都位于该容器的回环接口,因此 --network none 不影响实验。首次构建仍需要拉取镜像与 Maven 依赖。
若下载依赖失败,先处理镜像或仓库访问;若报告缓存权限问题,检查 .m2-lab 所有者和挂载路径。看到编译失败时不要跳到后续 Java 命令,否则运行的可能是旧的 target/classes。
响应头到了,响应体还没有完成
将最后的 normal 分别替换成 header 和 body。header 让服务端等待 350ms 才发送响应头,客户端请求超时设为 100ms,结果为 slowHeader=HttpTimeoutException。
body 则立刻发送响应头与字节 A,等待 350ms 后再发送 B。请求超时为 120ms。使用 Temurin 17.0.20 和 25.0.4 的这组回环实验,两种响应体处理器均读到了完整内容:
ofString=complete elapsedMs=...
ofInputStream=complete elapsedMs=...
wholeBodyBudget=TimeoutException前两项耗时约为 350ms,明显超过请求的 120ms。这个结果对应上述版本和 HTTP 路径,不能据此推断所有客户端都忽略响应体超时;它说明接入一个客户端后,需要把“头及时到、体持续慢”的情况单独测出来。Java 25 的请求超时说明见 HttpRequest.Builder。
ofInputStream() 尤其容易造成误判:取得 HttpResponse<InputStream> 后,后面的流读取仍由应用执行。读取结束、关闭流或取消订阅,才会释放相应资源;未消费的流还可能拖住客户端关闭过程。Java 25 HttpClient 对这一生命周期有明确说明。
对于体积受限的普通 JSON,可以让异步结果在完整响应体收集完成后才结束,再用剩余预算约束等待:
long remaining = budget.remaining();
var pending = client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
try {
return pending.get(Math.min(remaining, budget.remaining()),
TimeUnit.NANOSECONDS).body();
} catch (TimeoutException | InterruptedException failure) {
pending.cancel(true);
if (failure instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw failure;
}完整响应体收集结束后,JSON 反序列化和业务处理继续消耗同一个预算。ofString() 会把内容收集到内存,大下载应使用流式处理。
流式业务需要约束允许的空闲时间、最大字节数及业务生命周期,并在结束时关闭流。定时器到期还受线程调度影响,Java 应用无法承诺在某个纳秒精确中止所有工作。
两次各有额度,与共享额度的差别
运行 shared,两次 HTTP 请求分别在服务端等待 140ms。
resetBudget=complete elapsedMs=...
sharedBudget=TimeoutException
expiredNextStep=notSent第一组为每一步新建 220ms 预算,两步能完成,但总时间已超过 220ms。第二组共享 220ms 截止时间,第一步结束后,第二步只能使用剩余量,因此等待到期。最后再尝试发起一项工作,程序在发请求之前检查过期,并确认服务端调用计数没有增长。
工程先发送一条正常请求建立客户端和连接,然后再比较延迟。冷启动、TLS 握手和首次 DNS 解析需要另外测试;将它们和已复用连接的稳态结果混在一起,会掩盖超时发生在哪个阶段。
等待结束后,分别处理调用方和执行方
取消请求之后,远端仍可能完成操作
运行 late。客户端给完整调用 100ms;服务端已进入处理后等待 350ms,再增加内存计数。客户端到期后调用 cancel(true),随后从另一个 HTTP 请求读取服务端状态:
caller=TimeoutException remoteCompleted=1 storage=in-memory计数来自实际服务端执行,说明本地不再等待时,远端工作仍可能继续。这个实验使用内存计数,只验证执行与取消的关系;重试、幂等与补偿中的数据库实验进一步验证提交后响应丢失。
调用方:发送 ── 等待 ── 超时/取消 ── 按操作标识查询
执行方: 接收 ── 执行业务 ── 完成 ── 尝试写回响应取消 HTTP Future 可以请求客户端中止交换,但远端可能已经收到完整请求、提交事务,甚至只是响应在网络中延迟。处理超时写入时,需要保留操作标识,先查询已记录结果;支持幂等重放的接口可用同一个标识重发。生成一个新标识,通常会被视作新的业务动作。
中断、数据库取消与事务结束各有执行条件
Thread.interrupt() 设置中断状态。阻塞方法是否抛出 InterruptedException、计算循环是否检查标记、驱动是否尝试取消查询,取决于实际执行代码。捕获中断后继续无限重试,会抵消上游的取消意图。
数据库端也需要设置适当限制。PostgreSQL 的 statement_timeout 限制语句执行时间,lock_timeout 限制获取锁的等待;两者作用不同,客户端停止读取不会自动替代它们。超时错误发生在事务内时,还要按事务状态回滚或处理保存点,才能复用连接。参数细节和默认值见 PostgreSQL 客户端连接默认设置。
应用配置这些值时,应让数据库有机会在外层请求过期前返回错误,并给回滚和连接归还留下时间。例如某一步只剩 200ms,就不应给新语句设置数秒级额度。外部支付、邮件投递等已经发生的副作用则需要查询或业务补偿,数据库回滚无法撤销它们。
对于消息消费者和后台任务,HTTP 调用结束可能只是任务已持久受理。此时接口应返回任务标识和状态查询方式,执行期限由任务协议定义。不要把交互请求的短 deadline 原样套到持续数小时的导出任务,也不要让同步请求超时后暗中转成长任务。
根据耗时分布定位和调整超时
先量每一段,再决定改哪个值
下面的 curl 命令用于检查一个可直连的 HTTPS 地址。URL 是待诊断目标;需要代理才能访问的网络不适合这个直连对照,应另行保留并说明代理路径。
URL='https://example.com/'
curl -q --noproxy '*' --fail-with-body \
--connect-timeout 2 --max-time 5 -sS -o /dev/null \
-w 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} first=%{time_starttransfer} total=%{time_total} code=%{http_code}\n' \
"$URL"-q 放在第一个选项,防止用户 .curlrc 改变行为;--noproxy '*' 隔离环境代理。连接超时只管连接阶段,总时间还需要 --max-time。这些时间通常是从调用开始累计到某个事件:HTTPS 首字节耗时减去 TLS 完成耗时,包含请求发送、网络往返和服务端处理,无法单独视作服务端业务执行时长。重定向、多地址尝试与连接复用会改变分段含义,参数完整定义见 curl 官方手册。
成功请求应返回 HTTP 2xx 且进程退出码为 0。退出码 28 表示 curl 自己的操作超时;HTTP 504 是某个 HTTP 节点返回的响应,要继续定位是谁生成它。--fail-with-body 遇 HTTP 错误会保留响应体并非零退出,它和网络错误需要分别记录。
| 观测到的情况 | 先检查的位置 | 调整方向 |
|---|---|---|
| 建连前已排队很久 | 执行槽、连接池等待与队列年龄 | 限制进入量,缩短或移除无收益排队 |
| 冷连接明显慢,复用连接正常 | DNS、TCP/TLS、代理与地址选择 | 分开设置连接额度,检查预热和连接复用 |
| 响应头快、响应体慢 | BodyHandler、读取空闲限制、内容大小 | 补全读取生命周期的限制和关闭动作 |
| 每一跳都没超时,总调用却很长 | 子调用是否重置预算、隐藏重试 | 传播剩余量,确定重试承担层 |
| 调用方超时后出现成功写入 | 原操作记录、事务提交与响应发送时序 | 查询原结果,同键重放,避免新动作重复执行 |
| 超时率下降但内存、在途数上升 | 放宽超时后的资源占用 | 复查吞吐与容量,配合并发限制 |
阈值对应的是业务取舍
先按接口和依赖统计延迟分布,再选择可以接受的误超时比例。若依赖 p99.9 已非常接近正常中位数,轻微抖动就可能让紧贴分位值的阈值误杀大量正常调用;若尾部来自无界队列,单纯扩大超时会让队列保存更多过期工作。
日志和 Trace 至少应能关联操作标识、尝试序号、失败阶段、开始时剩余预算和实际耗时。对已经到期的请求,还应能查出下游是否已收到、是否已完成以及结果存放在哪里。敏感请求体无需为了诊断完整写入日志。
修改后同时观察成功吞吐、超时数量、重试次数、在途任务和连接占用。恢复正常延迟后,确认旧任务已经清理、连接能够复用、下一批请求能完成,再逐步增加流量。
权威资料与规范地址
- Java 17 HttpClient:连接、响应处理与取消接口。https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpClient.html
- Java 17 HttpRequest.Builder:请求超时参数。https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html
- Java 25 HttpRequest.Builder:对应版本的请求超时定义。https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpRequest.Builder.html
- Java 25 HttpClient:流式响应及客户端关闭生命周期。https://docs.oracle.com/en/java/javase/25/docs/api/java.net.http/java/net/http/HttpClient.html
- gRPC Deadlines:传播、时钟偏差和取消处理。https://grpc.io/docs/guides/deadlines/
- PostgreSQL 18:statement_timeout、lock_timeout 与客户端连接设置。https://www.postgresql.org/docs/18/runtime-config-client.html
- curl 手册:超时、代理、退出码与分阶段计时。https://curl.se/docs/manpage.html
