连接池 · 租借 · connectionRequestTimeout · connectTimeout · SO_TIMEOUT · responseTimeout · 总 deadline · Little 定律 · keep-alive · 空闲回收 · 重试 · 超时预算
HTTP 客户端与连接池:超时怎么配。一次调用分四段,每段一个超时,外面再套一个总的
一次 HTTP 调用的时间分成四段:排队拿连接、建连接、等响应、读响应,各有一个超时管着。精华在一个容易误会的地方:大家常说的“读超时”,底层是 socket 的 SO_TIMEOUT,管的是两次读之间最多隔多久,不是整次调用最多多久。对面每 400 ms 挤一个字节,读超时 1 s 的调用能跑 4 s(实测)。所以还要在外面套一个总 deadline,并且让整条链路的 deadline 一级比一级短。不懂这一点,就会只配一个 readTimeout=3s,以为调用最多 3 秒。
全景:订单服务调库存服务的一次 RPC · 第 3、10 站地图:J04 · HTTP 客户端与连接池:超时怎么配前置:TCP 握手 · 网络 IO 模型 · RPC 原理
①为什么学:库存服务卡了 3 秒,订单服务跟着挂了
全景第 3 站要拿一条连接,第 10 站要在 300 ms 内拿到结果或者放弃。全景用的是 gRPC;大多数 Spring 服务之间还是 HTTP(RestClient、RestTemplate、OpenFeign),底下是一个 HTTP 客户端加一个连接池。假设场景:库存服务一次 GC 卡顿,3 秒才回。
②老办法:JDK 自带的 HttpURLConnection
JDK 1.1 就有的 HttpURLConnection 能发请求,也悄悄复用连接。能用,但上面那个问题它几乎一个都没回答。
老办法:new 一个 URLConnection
HttpURLConnection c = (HttpURLConnection)
new URL("http://inventory:8080/reserve?order=9527")
.openConnection();
c.setRequestMethod("POST");
// 没写 setConnectTimeout / setReadTimeout:都是 0 = 无限等
try (InputStream in = c.getInputStream()) { ... }
// 复用藏在 KeepAliveCache 里:每个地址最多留 5 条空闲连接
// (系统属性 http.maxConnections)
疼在哪
- 两个超时默认都是 0,按
URLConnection的文档就是无限等。对面不回,调用线程就一直挂着 - 只能配“连接”和“读”两个:没有“整次调用最多多久”,读超时还是每次读单独算(④-1)
- 没有真正的池子:只缓存空闲连接,并发多少就开多少条,没有上限、没有排队超时,也看不到池子里的状态
- 每个调用点自己配,漏一处就是一颗雷
缺的那一块:把一次调用切成几段,每段有上限,整次也有上限;连接数有上限、排队有上限、看得见。这就是 HTTP 客户端加连接池要给的。典型误用:只配一个 readTimeout = 3s,以为“调用最多 3 秒”;池子用默认值,压测时 RT 莫名其妙翻倍。
③选谁:四个常用客户端,默认值差得很远
下面是各自不配任何参数时的样子。看得出来,没有一个默认值能直接上生产。
| JDK HttpClient | Apache HttpClient 5.6 | OkHttp | Reactor Netty(WebClient) | |
|---|---|---|---|---|
| 最低 JDK | 11 | 8 | 8 | 8 |
| IO 模型 | 非阻塞,自带选择器线程 | classic API 阻塞,一个线程一次调用(另有 async API) | 同步调用阻塞 | 非阻塞,Netty EventLoop |
| 连接池默认 | 不限条数,空闲 30 s 关(源码) | 每个地址 5 条、总共 25 条(源码) | 最多留 5 条空闲、5 分钟;同步调用不限并发(文档) | 每个地址 max(2×核数, 16) 条,排队上限是它的 2 倍(文档) |
| 默认超时 | 连接、请求都不设 | 连接 3 分钟、拿连接 3 分钟、读不设 | 连接、读、写各 10 s | 连接 30 s、拿连接 45 s、读不设 |
| 整次调用的上限 | HttpRequest.timeout(),但只管到收到响应头,读 body 不算(JDK 21 源码) | classic 没有,要自己加定时 cancel(⑤) | callTimeout,包含读 body;默认不设 | Reactor 的 .timeout() 算子 |
Spring Boot 3.5 的 RestClient | 第 4 顺位 | 第 1 顺位:classpath 上有就用它 | 没有内置支持 | 第 3 顺位(Jetty 第 2) |
选择规则:同步的 Spring MVC 服务,用 RestClient + Apache HttpClient 5(Boot 默认就选它);不想加依赖,JDK 11+ 用 JDK HttpClient;WebFlux 用 Reactor Netty;Android、Kotlin 项目常用 OkHttp。本页学 Apache HttpClient 5:它是 Boot 的首选,参数分得最细,每一段超时都能单独看清;JDK 8 也能跑。四个客户端分段的思路是一样的,换哪个都用得上。
④原理:一次调用的四段时间,各归谁管
先看一次调用花的时间分成哪几段,再看三件最容易想错的事:读超时到底在量什么,池子满了会怎样,超时到了之后发生了什么。最后讲为什么同步客户端没有“总超时”。
- 建连接要一次往返:TCP 三次握手,客户端发 SYN 等 SYN-ACK;对面机器挂了,SYN 没人回,内核按 1、2、4…秒的间隔重发,Linux 默认重发 6 次,约 127 秒后才放弃。TCP 那页讲过握手。
- 阻塞读:线程调
socket.read(),没有数据就停在内核里等。socket 上可以设一个SO_TIMEOUT:这一次 read 最多等多久,到点抛SocketTimeoutException。 - HTTP/1.1 一条连接同一时刻只跑一个请求:发完请求要等响应读完,这条连接才能给下一个请求用。所以要并发,就得有多条连接,也就有了连接池。HTTP 那页讲过 keep-alive。
- Little 定律:同时在路上的请求数 = 到达速率 × 每个请求停留的时间。网络 IO 模型用它算过线程池。
④-0 一次调用的四段,和管它们的超时
④-1 读超时量的是“两次读之间”
responseTimeout 设成 socket 的 SO_TIMEOUT(源码 InternalExecRuntime:endpoint.setSocketTimeout(responseTimeout))。内核只认“这一次 read 最多阻塞多久”,读到任何一个字节,这次 read 就返回了,下一次重新计时。它的 Javadoc 也写明:response timeout 不是 deadline。慢慢挤的对面并不少见:大文件下载、流式接口、半死不活的下游、带宽被打满的网卡。④-2 池子满了,请求在客户端排队
池子要多大:同时借出去的连接数 = 调用 QPS × 每次占用时间(Little 定律)。每秒 400 次、每次 50 ms,平均 20 条在用;留五成余量,每个地址配 30 条。池子也是一道闸:下游慢的时候,压在它身上的请求最多就这么多,多出来的在客户端排队,排队再用 connectionRequestTimeout 截断,调用线程就能按时回来。
④-3 超时到了:客户端放手,对面不知道
为什么这样设计:同步客户端为什么没有“总超时”
HttpClient 5 classic:阻塞读 + 每次读的超时
- 一个线程从头到尾做完一次调用,栈很直,出错一眼能看出卡在哪
- 不用额外的线程和定时器:超时交给内核,
SO_TIMEOUT到点让 read 返回 - 代价:内核只能给“这一次 read”计时。想要整次调用的上限,就得另起一个定时器,到点从外面把 socket 关掉(⑤ 的
req::cancel就是这么做的)
JDK HttpClient、gRPC、Reactor Netty:非阻塞 + 定时器
- 线程不停在 read 里,事件循环本来就有定时器,deadline 就是一个到点取消整个调用的定时任务
- gRPC 的 deadline 还会写进请求头(
grpc-timeout),下游知道还剩多少时间 - 代价:要有事件循环线程,回调和异步栈难读;而且“总”到哪一步各家不同,JDK HttpClient 的
timeout()只管到响应头(JDK 21 源码:收到响应头就取消计时器)
决定胜负的约束:阻塞读的时候,线程停在内核里,它自己没法看表。能让它回来的只有两样:内核的 SO_TIMEOUT(只能按“这一次 read”算),或者别的线程把 socket 关掉。所以同步客户端天生只有分段超时,总 deadline 要用第二种办法自己加。
最少的配置
PoolingHttpClientConnectionManager pool = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnPerRoute(30).setMaxConnTotal(100) // 池子:默认 5 / 25
.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(100)) // ② 默认 3 分钟
.build())
.build();
CloseableHttpClient http = HttpClients.custom().setConnectionManager(pool)
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofMilliseconds(50)) // ① 默认 3 分钟
.setResponseTimeout(Timeout.ofMilliseconds(250)) // ③④ 默认不设
.build())
.build(); // 一个进程一个,别每次 new
spring.http.client:
connect-timeout: 100ms # ②
read-timeout: 250ms # ③④,同样是“每次读”
# 池子大小、拿连接超时、总 deadline 没有对应属性,要写代码配(⑥ 第二圈)
⑤跑起来:订单服务的库存客户端,四段 + 总 deadline
一个假的库存服务(JDK 自带 HttpServer)加一个配好的客户端。三次调用分别撞上:正常、对面卡住、对面慢慢挤。依赖只有 org.apache.httpcomponents.client5:httpclient5:5.6.5,按 JDK 8 写法。
pool = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnPerRoute(30) // 400 次/秒 × 0.05 秒 × 1.5
.setMaxConnTotal(100)
.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(100)) // ②
.setSocketTimeout(Timeout.ofMilliseconds(250)) // ③④ 的兜底
.setValidateAfterInactivity(TimeValue.ofSeconds(2)) // 空闲 2 秒以上,复用前先检查
.setTimeToLive(TimeValue.ofMinutes(5)) // 连接最多活 5 分钟
.build())
.build();
http = HttpClients.custom()
.setConnectionManager(pool)
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofMilliseconds(50)) // ①
.setResponseTimeout(Timeout.ofMilliseconds(250)) // ③④
.build())
.evictIdleConnections(TimeValue.ofSeconds(30)) // 比 Tomcat 的 60 秒空闲超时短
.evictExpiredConnections()
.disableAutomaticRetries() // 预占库存不能自动重试
.build();
HttpGet req = new HttpGet(base + pathAndQuery);
ScheduledFuture<?> killer = deadlineTimer.schedule(req::cancel, deadlineMs, MILLISECONDS);
try {
return http.execute(req, resp -> EntityUtils.toString(resp.getEntity()));
} catch (IOException e) {
if (req.isCancelled()) { // cancel 抛的是 SocketException: Socket closed
SocketTimeoutException te = new SocketTimeoutException("deadline " + deadlineMs + " ms exceeded");
te.initCause(e);
throw te;
}
throw e;
} finally {
killer.cancel(false); // 按时回来了,撤掉定时器
}
git clone https://github.com/archeu/study-labs.git && cd study-labs/java
mvn -q -pl network/http-client-pool-timeouts compile exec:java \
-Dexec.mainClass=labs.network.http_client_pool_timeouts.InventoryClient
JDK 17.0.20.1,deadline 300 ms,读超时 250 ms 正常:库存服务 5 ms 就回 94 ms 成功 ok order=9527 库存服务卡 1 秒才回 251 ms 失败 SocketTimeoutException: Read timed out 响应头很快,body 每 100 ms 挤一个字节,挤 10 次 300 ms 失败 SocketTimeoutException: deadline 300 ms exceeded 池子:[leased: 0; pending: 0; available: 0; max: 100] 服务端实际扣库存次数:2(9528 那次客户端已经放弃,服务端照样扣了)
实测 本机 2 核 ARM,跑了 2 次,耗时差 1~8 ms。第一次 94 ms 是冷启动(类加载、第一次建连接)。第三行:每 100 ms 就有数据,250 ms 的读超时永远不触发,是总 deadline 在 300 ms 把它掐掉的。池子里一条可用连接都没剩:超时和 cancel 的连接都被关了(④-3)。完整代码 →
⑥细节:读超时不是总超时,超时不等于没做
第一圈是不知道就会出事故的坑,第二圈是团队里该怎么配,第三圈是参数为什么这样定、版本之间差在哪。
注意事项
- 现象
- 配了
read-timeout: 1s,监控上却有 4 秒、10 秒的调用;上游的超时先到,报错链路很乱。实验 A:读超时 1000 ms,对面每 400 ms 挤 1 字节,4091 ms 才成功。 - 根源
responseTimeout落到 socket 上就是SO_TIMEOUT,读到任何数据都重新计时。HttpClient 5 的 Javadoc 原话:response timeout is not a deadline。- 做法
- 在外面再套一个总 deadline:classic API 用定时
cancel()(⑤),OkHttp 用callTimeout,WebClient 用.timeout(),gRPC 用withDeadlineAfter。
- 现象
- 下游自己的耗时稳定,调用方的 RT 随并发线性上涨;线程 dump 里一排线程停在
PoolingHttpClientConnectionManager.lease。实验 B:每地址 5 条,20 个并发,最后一批 2.2 s。 - 根源
- HttpClient 5 默认每个地址 5 条、总共 25 条(4.x 是 2 条、20 条);Boot 的
spring.http.client.*属性不改池子大小。 - 做法
- 每个地址按 QPS × p99 RT × 1.5 配,总数 ≥ 各下游之和;同时设拿连接超时。
- 现象
- ①的场景:库存卡 3 秒,订单服务 200 个 Tomcat 线程全部停在拿连接上,订单服务的健康检查也超时,被摘掉。
- 根源
connectionRequestTimeout默认 3 分钟(RequestConfig源码)。读超时管不到这一段:请求还没拿到连接,根本没开始读。- 做法
- 设成几十毫秒:拿不到连接说明下游已经饱和,快速失败比排队更好。实验 B:设成 300 ms 后,15 个请求 300 ms 就回来了。
- 现象
- 实验 C:读超时 300 ms,库存要 500 ms;业务代码超时后重试一次。客户端两次都算失败,库存扣了 2 次。
- 根源
- 请求已经发出去了,超时只是客户端不等了。HttpClient 5 默认的重试策略比较克制:只重试 1 次,只重试 GET、PUT、DELETE 这类幂等方法,读超时和连不上(
InterruptedIOException、ConnectException)不重试(DefaultHttpRequestRetryStrategy源码)。闯祸的通常是业务层自己写的重试、Feign 的Retryer、Spring Retry。 - 做法
- 写操作先幂等再重试:库存按
order_id去重,同一订单扣过就返回上次的结果。重试要算进总 deadline,并加退避。
- 现象
- 网关 1 s,订单调库存配 2 s,库存调数据库配 3 s。网关早就给用户返回 504 了,订单和库存还在跑,跑完的结果没人要,却占着线程和连接。
- 根源
- 每一级的超时只管自己这一跳。重试还会放大:读超时 250 ms、重试 2 次,最坏要 750 ms。
- 做法
- 从入口的预算往下分:下游的 (超时 × 尝试次数) 要小于上游剩下的时间。能传的就把剩余时间传下去(gRPC 自动传
grpc-timeout;HTTP 可以约定一个请求头)。
- 现象
- 夜里过后第一批请求报
NoHttpResponseException或Connection reset;更糟的是没有报错,一直挂着。 - 根源
- 池子里的空闲连接已经被对面关了(Tomcat 默认空闲 60 秒),或者被中间的防火墙、NAT 静默丢弃(不发 RST)。后一种情况客户端发出请求后收不到任何东西,读超时没配的话,要等内核重传放弃:Linux
tcp_retries2 = 15,内核文档给的是约 924.6 秒。 - 做法
- 空闲回收时间短于服务端和中间设备的空闲超时;空闲一段时间的连接复用前先校验(
validateAfterInactivity);读超时一定要设。细节见 网络 IO 页坑 3。
实验台:哪个超时先到
模型:一次调用按 ④-0 分段,按 HttpClient 5 的语义算每段谁先到点。几个固定假设:池子满时要排队 800 ms;对面宕机时 SYN 没人回,Linux 约 127 s 放弃;连接被防火墙静默丢弃时,没有读超时就要等约 924.6 s;超时的连接会被关掉,所以重试一定拿新连接(握手按 1 ms 算)。总 deadline 从第一次尝试开始算,包住重试。
先猜:库存服务响应头 20 ms 就回了,之后每 150 ms 挤 1 个字节,一共 20 个。客户端配了读超时 250 ms,没配总 deadline。这次调用多久结束?
使用规范
每次 new 客户端:每次握手、TIME_WAIT 堆满端口(网络 IO 页坑 4);池子太小,RT 翻倍(坑 2)
默认值是 3 分钟和无限,一个下游卡住就拖死上游(坑 1、3)
上游放弃了,下游还在白干(坑 5)
同一个订单扣两次(坑 4);重试风暴把下游打死
低峰后第一个请求报 reset 或挂住(坑 6)
HttpComponentsClientHttpRequestFactoryBuilder 的 withConnectionManagerCustomizer、withDefaultRequestConfigCustomizer 配spring.http.client.* 只有连接超时和读超时,池子还是 5 条(坑 2)
getTotalStats() 的 leased、pending、available;pending 持续大于 0 就告警RT 上涨时分不清是下游慢,还是自己在排队
优化和深处
读超时定多少:看 p99,不看平均
读超时是一道截断线:线以外的请求被判失败。定在平均值附近,正常的慢请求也被误杀;定得太长,卡住时线程回来得太晚。常用做法是取下游 p99(或 p99.9)的 1.5~2 倍,再检查它放不放得进上游给的预算:放不进,说明这一跳本身就慢,该改的是调用方式(批量、缓存、异步),而不是超时。
截断线以外的那一小撮,可以发“对冲请求”:等到 p95 还没回,就向另一台再发一份,谁先回用谁。它只适合幂等的读请求,代价是多出几个百分点的流量。
超时风暴会变成握手风暴
⑤ 的输出里,三次调用之后池子里一条可用连接都没有(available: 0,实测):超时和 cancel 的连接都被关掉了,下一个请求要重新握手(④-3)。下游一抖,超时一多,客户端就在不停地建新连接、关旧连接,主动关的一方还要背 60 秒的 TIME_WAIT。
所以下游抖动时,单靠超时不够,还要熔断:错误率过线就先不调,给下游喘口气,也保住自己的连接(J10 的 Sentinel)。HTTP/2 和 gRPC 可以只取消一个 stream,连接留着,这个问题小得多。
版本差异:同一件事,各家默认值
| 拿连接超时 | 连接超时 | 读超时 | 池子 | 出处 | |
|---|---|---|---|---|---|
HttpURLConnection(JDK 8 / 17 / 21) | 没有这一段 | 0 = 无限 | 0 = 无限 | 只缓存空闲连接,每地址 5 条 | JDK 21 源码 URLConnection、KeepAliveCache |
| JDK HttpClient(11+) | 没有这一段(池子不限) | 不设 = 无限 | timeout() 不设 = 无限,只管到响应头 | 不限条数,空闲 30 s 关 | JDK 21 源码 MultiExchange、ConnectionPool、HttpClientImpl |
| Apache HttpClient 4.5 | -1 = 无限 | -1 = 系统默认 | -1 = 系统默认(无限) | 每地址 2、总 20 | 按文档;池子数字源码核对过 |
| Apache HttpClient 5.6 | 3 分钟 | 3 分钟 | 不设 = 无限 | 每地址 5、总 25 | 5.6.5 源码 RequestConfig、ConnectionConfig、PoolingHttpClientConnectionManager |
Spring Boot 3.5 RestClient | 不改 | spring.http.client.connect-timeout | spring.http.client.read-timeout | 不改 | 3.5.16 配置元数据 |
| gRPC Java | —(每地址一条 HTTP/2 连接) | 不单独配,受 deadline 管 | 默认没有 deadline | 不用池子 | 按文档,见 全景第 10 站 |
这一页没展开的
- 熔断、限流、舱壁:超时只能让线程按时回来,挡不住下游一直坏(J10 的 Sentinel / Resilience4j)
- 自适应超时:按最近的 RT 分布动态调整截断线,以及对冲请求在 gRPC 里的实现(
hedgingPolicy) - HttpClient 5 的 async API 和 HTTP/2:一条连接多路复用后,池子和超时怎么变
- 虚拟线程下的 HTTP 客户端:线程不再是瓶颈,池子和超时变成唯一的闸
- OpenFeign 的超时和重试:
Request.Options、Retryer与底层客户端的超时谁说了算 - DNS 缓存:JVM 的
networkaddress.cache.ttl和连接的timeToLive,为什么扩容后新实例分不到流量
↺回到 ⑤:带着 ⑥ 改一遍
每个练习都先猜结果再运行。代码和练习都在 study-labs 里。
把 TimeoutLab.experimentB 的每地址连接数从 5 改成 20,两组结果各变成什么样?再改成 10,最后一批大约多少毫秒(坑 2)?
给 FakeInventoryServer.reserve 加按 order 去重:同一订单第二次直接返回上次的结果。再跑实验 C,扣库存次数应该是 1(坑 4)。
把 InventoryClient 的 deadline 改成 200 ms、读超时保持 250 ms。第二个调用在多少毫秒失败,抛的是哪个异常?说明读超时这时还有没有用(坑 1、坑 6)。
在 InventoryClient.get 里加一个请求头 X-Deadline-Ms,写上还剩多少毫秒;让 FakeInventoryServer 读到它,剩余时间不够处理就直接返回 503,不再扣库存(坑 5)。
1. 用一句话说出这一页的精华。
一次调用分四段(拿连接、建连接、等响应、读响应),每段一个超时;读超时只管两次读之间的间隔,所以外面还要套一个总 deadline,并且整条链路一级比一级短。
2. 只用 HttpURLConnection,痛点在哪?
两个超时默认都是 0(无限);没有整次调用的上限;没有真正的池子,并发多少就开多少条连接,没有排队上限,也看不到状态;每个调用点都要自己配。
3. “配了读超时 1 秒,调用却跑了 4 秒”,根源是哪条原理?
④-1:responseTimeout 落到 socket 上是 SO_TIMEOUT,内核只给“这一次 read”计时,读到任何数据就重新开始。对面每 400 ms 挤一点,每次都在 1 秒内,读超时永远不触发。
4. 预测:拿连接超时 50 ms、读超时 250 ms、总 deadline 不设、业务重试 2 次,库存服务这次卡住 1.2 s。订单线程多久回来?库存服务执行了几次?
约 752 ms(250 + 1 + 250 + 1 + 250),库存执行了 3 次。每次都是读超时(④-1),超时的连接被关掉,重试要重新握手(④-3);请求每次都送到了对面,对面不知道客户端放弃了,所以三次都执行。把总 deadline 设成 300 ms,就只有约 300 ms、执行 2 次。可以在实验台上验证。
5. 预测:库存服务所在的机器断电了(不是进程退出)。连接超时和读超时都用 HttpClient 5 的默认值,第一次调用多久才失败?
如果借到的是池子里的旧连接:请求发出去后没有任何回应(机器断电,不会回 RST),读超时默认无限,要等内核重传放弃,Linux 默认约 924.6 秒。如果要新建连接:SYN 没人回,连接超时默认 3 分钟,比内核放弃握手的约 127 秒还长,所以约 127 秒后报连接超时。两种都远超任何业务预算,所以连接超时和读超时都必须显式配(④-0、坑 6)。
6. 预测:把每地址连接数从 5 调到 500,坑 3 那种“库存卡 3 秒”的事故会变好还是变坏?
多半变坏。池子是一道闸(④-2):5 条时最多 5 个请求压在库存上,其余的被拿连接超时快速打回。500 条时 200 个 Tomcat 线程全部拿到连接,全部卡在读上,直到读超时;库存服务同时也多扛了 40 倍的请求,恢复得更慢。池子要按 QPS × RT 配,而不是越大越好。
7. 放回全景:第 3 站和第 10 站前后各发生了什么?这一页的东西配错了,整条链路会怎样?
第 2 站负载均衡挑好了一台库存实例;第 3 站从池子里借一条到它的连接(没有就握手);接着第 4~9 站编码、发帧、过 TCP、在库存那边被叫醒、扣库存;第 10 站等结果回来,或者 300 ms 到点放弃。拿连接和读超时用默认值,库存一卡,订单服务的线程全部卡在第 3 站或第 10 站,订单服务自己也不响应了,再往上把网关拖住;超时后无脑重试,第 9 站同一个订单扣两次。