连接池 · 租借 · 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 秒才回。

订单服务(调用方) 下单接口:总预算 300 ms 紫色:拿到连接、在等库存 红色:连接都被占了,在池子外排队 HttpClient 连接池 每个地址 5 条(HttpClient 5 默认) 5 条全被借走,一条都还不回来 195 个线程在排队拿连接 connectionRequestTimeout 默认 3 分钟 库存服务 平时 p99 约 20 ms 今天 GC 卡顿,3 秒才回 5 个请求都在里面等 responseTimeout 默认不设 = 一直等 库存慢 → 5 条连接被占满 → 其余线程在池子外排队 → 200 个线程全卡住 → 订单服务也不响应了 要回答的问题:每一段最多等多久?等不到怎么办?
下游慢一次,上游被拖死,这叫级联故障。挡住它靠两样东西:池子的大小限住同时压在下游上的请求数,每一段的超时让线程按时回来。这一页学怎么把它们配对。

②老办法: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 HttpClientApache HttpClient 5.6OkHttpReactor Netty(WebClient)
最低 JDK11888
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 一次调用的四段,和管它们的超时

connectionRequestTimeout connectTimeout responseTimeout(底层是 SO_TIMEOUT:每次 read 单独计时) ① 排队拿连接 ② DNS + 握手(+TLS) 发 ③ 等首字节 ④ 读 body 池子有空位就是 0 借到的是旧连接就跳过 对面在算 每读到一点数据,计时清零 总 deadline:整次调用(含重试)最多多久。HttpClient 5 classic 没有这个参数,⑤ 用定时 cancel 补上 同机房、下游 p99 约 50 ms 时 ⑤ 的配法:① 50 ms · ② 100 ms · ③④ 250 ms · 总 300 ms ④ 之后连接还回池子,下一个请求借到它就跳过 ②;超时或出错的连接不还,直接关掉(④-3)
四个参数各管一段,互相不重叠。最常见的误会是把 responseTimeout(在 Spring 里叫 read-timeout)当成整次调用的上限;它其实只管紫红色那两段里“每两次读之间”。

④-1 读超时量的是“两次读之间”

总 deadline 1500 ms 对面不回 1000 ms:Read timed out 慢慢挤 每 400 ms 1 字节 每次 400 ms < 1000 ms,计时清零 4091 ms 成功,读超时一次没触发(实测) 加了 deadline:1500 ms 被 cancel(实测) 01 s2 s3 s4 s
HttpClient 5 把 responseTimeout 设成 socket 的 SO_TIMEOUT(源码 InternalExecRuntime:endpoint.setSocketTimeout(responseTimeout))。内核只认“这一次 read 最多阻塞多久”,读到任何一个字节,这次 read 就返回了,下一次重新计时。它的 Javadoc 也写明:response timeout 不是 deadline。慢慢挤的对面并不少见:大文件下载、流式接口、半死不活的下游、带宽被打满的网卡。

④-2 池子满了,请求在客户端排队

第 1 批 5 个 500 ms 第 2 批 5 个 约 1000 ms 第 3 批 5 个 约 1600 ms 第 4 批 5 个 约 2100~2200 ms 00.5 s1 s1.5 s2 s2.5 s 在客户端池子外排队 库存服务真正在处理(500 ms) 拿连接最多等 300 ms 5 个成功 15 个在 300 ms 报 ConnectionRequestTimeoutException(实测)
库存服务每次都只用 500 ms,调用方看到的 RT 却从 500 ms 一路涨到 2.2 s,多出来的全是在自己池子里排队。这就是 网络 IO 那页坑 2 说的“下游正常、调用方 RT 很高”。

池子要多大:同时借出去的连接数 = 调用 QPS × 每次占用时间(Little 定律)。每秒 400 次、每次 50 ms,平均 20 条在用;留五成余量,每个地址配 30 条。池子也是一道闸:下游慢的时候,压在它身上的请求最多就这么多,多出来的在客户端排队,排队再用 connectionRequestTimeout 截断,调用线程就能按时回来。

④-3 超时到了:客户端放手,对面不知道

订单服务 库存服务 0 ms:POST /reserve?order=9527 处理 500 ms 300 ms:读超时,抛异常,关掉这条连接 连接不还回池子:HTTP/1.1 没有请求编号,迟到的响应 会被下一个借到这条连接的请求读到 500 ms:响应写回去,没人收 库存已经扣了(实测)
超时是客户端单方面的决定。请求已经发出去了,库存服务照样扣;业务代码这时候再重试一次,同一个订单就扣两次(⑤ 和实验 C 都实测到了)。要重试,先让接口幂等。HTTP/2 和 gRPC 能只取消一个 stream(发 RST_STREAM)而不关整条连接,见 RPC 页坑 1。

为什么这样设计:同步客户端为什么没有“总超时”

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 要用第二种办法自己加。

最少的配置

Apache HttpClient 5.6:四段超时 + 池子参数细节放到 ⑥
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 Boot 3.5:application.yml只管 ② 和 ③④
spring.http.client:
  connect-timeout: 100ms     # ②
  read-timeout: 250ms        # ③④,同样是“每次读”
# 池子大小、拿连接超时、总 deadline 没有对应属性,要写代码配(⑥ 第二圈)

⑤跑起来:订单服务的库存客户端,四段 + 总 deadline

一个假的库存服务(JDK 自带 HttpServer)加一个配好的客户端。三次调用分别撞上:正常、对面卡住、对面慢慢挤。依赖只有 org.apache.httpcomponents.client5:httpclient5:5.6.5,按 JDK 8 写法。

InventoryClient:构造器里配池子和超时JDK 8+,HttpClient 5.6.5
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();
InventoryClient.get:总 deadline,到点 cancel④-0 的橙色那条
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);             // 按时回来了,撤掉定时器
}
怎么跑JDK 8+,Maven
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)。完整代码 →

到这里已经能交差了

四段都有上限,整次也有上限:库存服务怎么卡,订单线程最多 300 ms 就回来;池子按流量配了大小;客户端全局一个。

还没处理的细节

超时后要不要重试(坑 4);300 ms 是怎么来的,上游给了多少(坑 5);夜里第一个请求报 reset(坑 6);超时一多,连接被反复关掉重建(第三圈)。

⑥细节:读超时不是总超时,超时不等于没做

第一圈是不知道就会出事故的坑,第二圈是团队里该怎么配,第三圈是参数为什么这样定、版本之间差在哪。

第一圈

注意事项

坑 1只配了读超时,一次调用跑了好几倍← ④-1 每次 read 单独计时实测 + 按源码
现象
配了 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。
坑 2池子用默认值,压测时 RT 翻了几倍← ④-2 Little 定律实测 + 按源码
现象
下游自己的耗时稳定,调用方的 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没设拿连接超时,下游一慢,上游线程全卡在池子外← ④-2 排队没有上限按源码
现象
①的场景:库存卡 3 秒,订单服务 200 个 Tomcat 线程全部停在拿连接上,订单服务的健康检查也超时,被摘掉。
根源
connectionRequestTimeout 默认 3 分钟(RequestConfig 源码)。读超时管不到这一段:请求还没拿到连接,根本没开始读。
做法
设成几十毫秒:拿不到连接说明下游已经饱和,快速失败比排队更好。实验 B:设成 300 ms 后,15 个请求 300 ms 就回来了。
坑 4超时后重试,同一个订单扣了两次← ④-3 对面不知道你放弃了实测 + 按源码
现象
实验 C:读超时 300 ms,库存要 500 ms;业务代码超时后重试一次。客户端两次都算失败,库存扣了 2 次。
根源
请求已经发出去了,超时只是客户端不等了。HttpClient 5 默认的重试策略比较克制:只重试 1 次,只重试 GET、PUT、DELETE 这类幂等方法,读超时和连不上(InterruptedIOException、ConnectException)不重试(DefaultHttpRequestRetryStrategy 源码)。闯祸的通常是业务层自己写的重试、Feign 的 Retryer、Spring Retry。
做法
写操作先幂等再重试:库存按 order_id 去重,同一订单扣过就返回上次的结果。重试要算进总 deadline,并加退避。
坑 5超时没有一级比一级短,上游放弃了下游还在白干← ④-0 总 deadline推理
现象
网关 1 s,订单调库存配 2 s,库存调数据库配 3 s。网关早就给用户返回 504 了,订单和库存还在跑,跑完的结果没人要,却占着线程和连接。
根源
每一级的超时只管自己这一跳。重试还会放大:读超时 250 ms、重试 2 次,最坏要 750 ms。
做法
从入口的预算往下分:下游的 (超时 × 尝试次数) 要小于上游剩下的时间。能传的就把剩余时间传下去(gRPC 自动传 grpc-timeout;HTTP 可以约定一个请求头)。
坑 6低峰后的第一个请求报 reset,或者干脆挂住← ④-0 连接是复用的按文档
现象
夜里过后第一批请求报 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。这次调用多久结束?

下游这次怎么了
① 拿连接超时
② 连接超时
③④ 读超时
总 deadline
超时后业务重试几次
排队拿连接建连接等首字节读 body

第二圈

使用规范

一个进程一个客户端,按下游分池子;池子大小按 QPS × p99 RT × 1.5 算

每次 new 客户端:每次握手、TIME_WAIT 堆满端口(网络 IO 页坑 4);池子太小,RT 翻倍(坑 2)

四个都配,再套一个总 deadline:拿连接几十毫秒,连接按网络往返(同机房 100 ms 左右),读超时按下游 p99 的 1.5~2 倍

默认值是 3 分钟和无限,一个下游卡住就拖死上游(坑 1、3)

超时一级比一级短;下游的 (超时 × 尝试次数) 小于上游剩下的时间

上游放弃了,下游还在白干(坑 5)

写操作不自动重试;要重试先做幂等,重试算进总 deadline,并加退避

同一个订单扣两次(坑 4);重试风暴把下游打死

空闲回收 < 服务端 keep-alive 超时 < 负载均衡和防火墙的空闲超时

低峰后第一个请求报 reset 或挂住(坑 6)

在 Spring Boot 3.5 里,池子和拿连接超时用 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.63 分钟3 分钟不设 = 无限每地址 5、总 255.6.5 源码 RequestConfig、ConnectionConfig、PoolingHttpClientConnectionManager
Spring Boot 3.5 RestClient不改spring.http.client.connect-timeoutspring.http.client.read-timeout不改3.5.16 配置元数据
gRPC Java—(每地址一条 HTTP/2 连接)不单独配,受 deadline 管默认没有 deadline不用池子按文档,见 全景第 10 站
下一圈

这一页没展开的

↺回到 ⑤:带着 ⑥ 改一遍

每个练习都先猜结果再运行。代码和练习都在 study-labs 里。

1. 池子调大

把 TimeoutLab.experimentB 的每地址连接数从 5 改成 20,两组结果各变成什么样?再改成 10,最后一批大约多少毫秒(坑 2)?

2. 先幂等,再重试

给 FakeInventoryServer.reserve 加按 order 去重:同一订单第二次直接返回上次的结果。再跑实验 C,扣库存次数应该是 1(坑 4)。

3. deadline 比读超时还短

把 InventoryClient 的 deadline 改成 200 ms、读超时保持 250 ms。第二个调用在多少毫秒失败,抛的是哪个异常?说明读超时这时还有没有用(坑 1、坑 6)。

4. 把剩余时间传下去

在 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 站同一个订单扣两次。