HTTP 连接管理技术梳理:JDK KeepAliveCache 机制与池化选型
一个 Spring Boot 工程用 RestTemplate 调下游 HTTP 接口,串行批处理跑得好好的,
需要优化连接池吗?
http.maxConnections现在就调大岂不是更好?为什么 JDK 能"无限制"建连接,大家却还要换 Apache HttpClient?
本文从一次真实的架构评审问答出发,把 HTTP 连接管理的底层机制讲透:
RestTemplate 背后是什么、JDK KeepAliveCache 如何管理连接生命周期、
"缓存"和"池"的本质区别,以及什么时候该做什么。
一、从一个真实问题出发
场景:定时任务每 5 分钟串行下发 1000 条任务到下游网关(单条 100~200ms,HTTPS),
使用 RestTemplate + SimpleClientHttpRequestFactory。评审时被问到三个问题:
- 底层用什么发请求的?有没有连接数量配置?
- 听说 JDK 默认 5 连接/host,要不要调大?
- JDK 可以无限制创建连接,为啥还需要池化换 Apache HttpClient?
这三个问题的答案串起来,就是一条完整的 HTTP 连接管理知识线。
二、RestTemplate 底层:JDK 内置 HttpURLConnection
2.1 完整调用链
RestTemplate 本身只是个门面,真正的 HTTP 能力来自注入的 ClientHttpRequestFactory。
默认的 SimpleClientHttpRequestFactory 直接桥接到 JDK:
RestTemplate.postForEntity()
└─ SimpleClientHttpRequestFactory.createRequest() ← Spring 层到此为止
└─ URL.openConnection() ← 交棒给 JDK
└─ sun.net.www.protocol.https.HttpsURLConnectionImpl(https 时)
└─ sun.net.www.protocol.http.HttpURLConnection
└─ sun.net.www.http.HttpClient ← 真正持有 socket 的类
└─ 阻塞式 Socket / SSLSocket ← 同步 I/O,无 NIO
关键事实:
- 没有任何独立 HTTP 客户端库 ,HTTP/1.1 编解码是 JDK 手写的(
sun.net.www.*包); - socket 是阻塞式的;
SimpleClientHttpRequestFactory上可配置的只有三个参数 :
connectTimeout、readTimeout、bufferSize------没有任何连接数量参数。
这也是 Spring 官方的定位:需要连接池化时,换 HttpComponentsClientHttpRequestFactory
(桥接 Apache HttpClient),而不是给 Simple 工厂加参数。
2.2 自定义 TLS 策略的挂载点
测试环境常见需求:下游用自签名证书,需要降级信任。做法是继承
SimpleClientHttpRequestFactory 覆写 prepareConnection,对 HttpsURLConnection
调 setSSLSocketFactory / setHostnameVerifier 注入自定义 TrustManager------
能这么干,正是因为操作的就是裸的 JDK 对象。
提醒:
trust-all-cert类开关只应存在于测试环境。生产必须走标准校验。
三、JDK KeepAliveCache:连接复用的底层机制
3.1 它是什么
JVM 全局单例,类声明非常特殊:
java
class KeepAliveCache extends Thread
implements Map<KeepAliveKey, KeepAliveEntry>
JDK 里罕见的"集合即线程"设计:既是缓存本体,又自带后台清扫线程。
- Key =
KeepAliveKey(协议 + host + port + 代理信息),即"目的地" - Value = 该目的地下的空闲连接(HttpClient 实例 + 过期时刻)
3.2 连接生命周期全景
在飞 ──响应体读完──> 归还判定 ──容量未满──> 缓存(空闲) ──新请求取用──> 在飞 ...
│ │
└─容量已满→当场关闭 └─到期被清扫 / 对端已关→惰性死亡
① 归还判定(put) ------触发点是响应体被完全消费后。三个前置全过才有资格进缓存:
- 响应体读完(Content-Length 读满 / chunk 流读到终止符;
没读完就废弃,防止残留字节污染下一请求) - 响应头不是
Connection: close - HTTP/1.1(或 1.0 且显式带 Keep-Alive)
然后做容量判定 :该目的地缓存数已达 http.maxConnections(默认 5)→
这条连接当场 close。注意:不是挤掉最老的(非 LRU),是拒绝新来的。
② 空闲保活(缓存中)------过期时长优先级:
服务端 Keep-Alive: timeout=N 头
> 系统属性 http.keepAlive.time.server
> 实现默认值 5 秒
清扫线程每 5 秒醒一次,遍历全部 entry,过期的关 socket、移除。
③ 取用(get) ------新请求到达时先查缓存:命中 → entry 从缓存中移除后交给调用方
(关键语义:取用即独占);未命中 → 直接 new Socket().connect() 新建,
无任何排队、无拒绝、无背压。
④ 死亡的四条路径
| 路径 | 时机 | 发现方式 |
|---|---|---|
| 容量超限 | put 时 | 立即关闭 |
| 空闲超时 | 清扫线程周期 | 主动回收 |
| 服务端单方面 FIN | 缓存中任意时刻 | 惰性------下次取用、写请求时才 IOException |
| JVM 退出 | 进程结束 | 守护线程陪葬 |
3.3 相关系统属性速查
均为 JVM 级全局属性(-D 启动参数),影响进程内所有走 URLConnection 的代码:
| 属性 | 默认 | 含义 |
|---|---|---|
http.keepAlive |
true | 连接复用总开关 |
http.maxConnections |
5 | 每个目的地 最多同时保活的空闲连接数 |
http.keepAlive.time.server |
未设 | 空闲连接回收秒数(服务端头优先) |
sun.net.http.retryPost |
true | 连接失败时是否静默重发 POST(坑,见 5.2) |
四、最容易误解的一点:maxConnections 不是并发上限
http.maxConnections 管的是空闲缓存上限,不是并发连接上限。
HttpURLConnection 对在飞连接数完全没有限制------缓存里没有可用连接时直接新建 socket,
不排队、不拒绝、不报错。数量限制只发生在"请求完成后归还缓存"这一步:
每个目的地最多缓存 5 条,第 6 条立即关闭。
两个场景的精确推演:
| 场景 | 实际行为 |
|---|---|
| 串行(同一时刻在飞 ≤1) | 归还后缓存里最多就 1 条;maxConnections=5 是 5 倍冗余 |
| 8 线程并发 | 8 条 live 连接同时存在(无限制,正常);各自完成后缓存只收 5 条、关 3 条 → 下一轮 8 个请求里 5 个复用、3 个重新握手(TLS session 复用可走 abbreviated handshake,开销比全量握手小) |
推论:串行模型下"调大 maxConnections"零收益 ------缓存里永远最多 1 条空闲,
把上限从 5 调到 16,缓存里还是 1 条,配置不改变任何行为,还要承担全局属性
影响进程内其他 URLConnection 调用方的副作用。
五、为什么"无限制"反而是风险:池化买的是什么
5.1 无限制在故障场景下的连锁反应
假设 8 线程,下游突然变慢(响应从 200ms 涨到 10s),或触发一轮重试风暴:
100 个请求同时发起
→ 缓存里只有 ≤5 条空闲连接可复用
→ 95 个直接新建 socket(无排队、无拒绝、无背压)
→ 95 次完整 TLS 握手瞬间打到下游
故障链四环:
- 打挂对端 :TLS 握手对服务端也是真实开销(ECDHE 计算),连接风暴本质上是对
下游的半个 DDOS------下游本来只是"变慢",被补一刀后直接雪崩; - 本端 fd 耗尽 :每条 socket 占一个文件描述符,容器内 ulimit 是硬顶,
fd 打满后进程里所有 I/O(含日志、DB 连接)一起遭殃; - TIME_WAIT 端口耗尽 :缓存溢出的连接用完即被客户端关闭,主动关闭方进
TIME_WAIT(60s)。突发流量下几千个 TIME_WAIT 堆着,临时端口耗尽后
新建连接报Cannot assign requested address; - 无背压 :池化客户端在连接耗尽时会让请求排队等连接 (lease 超时报错)------
系统以"变慢"的方式优雅降级;无限制模型里请求全部硬闯,系统以"雪崩"的方式崩溃。
5.2 JDK 的隐式重试坑
HttpURLConnection 对部分失败有静默重试 :请求发出后连接断了,它可能自动重发
(GET 默认会;POST 受 sun.net.http.retryPost 控制,默认也是 true)。
对幂等接口没事,对非幂等接口就是重复提交事故。池化客户端的重试语义是显式可配的。
5.3 缓存 vs 池:能力对比
| 能力 | JDK KeepAliveCache | Apache HttpClient 连接池 |
|---|---|---|
| 本质 | 缓存(用完归还,多了扔掉) | 池(借出-归还,借不到排队) |
| 在飞总量上限 | ❌ 无限制 | ✅ maxTotal / perRoute |
| 排队等待 | ❌ 全部硬闯新建 | ✅ 连接租借 + 超时 |
| 空闲连接健康检查 | ❌ 服务端已关的连接可能被发出去(首写失败才暴露) | ✅ 借出前 validate、TTL 驱逐 |
| 可观测性 | ❌ 无任何指标 | ✅ 池利用率/等待数/租借时长指标 |
| 重试语义 | 隐式(有坑) | 显式可配 |
一个类比:缓存像衣帽间 ------最多寄存 5 件,多出来的直接扔地上(关掉);
连接池像出租车车队 ------8 辆车全派出去了,第 9 单让乘客排队等车回来,
而不是现场再造一辆车。
一句话:串行模型下"无限制"等于"用不到限制";并发模型下"无限制"等于"没有熔断"。
池化买的是治理能力,不是连接数。
六、为什么 JDK 缓存天生给不了排队和背压
背压需要记两本账:供给(在飞连接数)和需求(排队请求数)。
KeepAliveCache 两本都没有------它只在空闲窗口内有视野。
6.1 架构位置:池在路径上,缓存在路径旁
池化模型(Apache HttpClient 连接管理器):
请求 → lease(maxTotal) ──有空闲──> 复用
└──满了──> 排队等待(带超时)──超时──> 抛异常
↑ 所有连接必经此门:数得清在飞数、看得见等待队列 → 才有资格限流
JDK 缓存模型:
请求 → 查缓存 ──命中──> 复用
└──未命中──> new Socket().connect()(无条件放行)
↑ 缓存只是顺路看一眼:在飞数不知道、排队数没人排
池是水闸 (所有水过我这儿,满了关闸逼人排队);
缓存是路边蓄水池(路过顺手存一瓢,满了就倒掉,不管主干道流量)。
6.2 三个技术不可能点
-
API 契约不允许阻塞 。
URLConnection.connect()的语义就是"建立连接"。要背压就得在
new Socket()前插信号量------意味着 connect() 可能无限期等待,且这是全局行为变更:JVM 内所有走 URLConnection 的代码都被一个全局闸门卡住。
JDK 永远不会冒这个险。
-
没有"借出"概念就没法计数 。HttpURLConnection 是单请求对象:调
getInputStream()时底层连接是新建还是复用,对调用者完全透明。没有公开的"借"操作 → 没有可挂账的对象 → 取用时是"从缓存移除"(账面消失),在飞期间
对缓存不可见。池化客户端对每个借出的连接持有代理对象和状态,
所以才说得出"现在借出 17 条、上限 20、第 21 个请求在等"。
-
单例 + 系统属性的表达能力不够 。背压策略天然要"按调用方、按路由"配置
(下游 A 慢就限 A)。KeepAliveCache 是 JVM 单例、配置只有全局系统属性------
连区分进程里两个不同 RestTemplate 都做不到,遑论按路由限流。
6.3 历史背景
这套设计诞生于 1996 年 HTTP/1.0→1.1 转型期,目标场景是桌面浏览器/Applet:
单用户低并发,"复用省握手"就够了。服务端高并发的连接治理需求(限流、排队、
驱逐、监控)是 2000 年代 Apache HttpClient 这类组件补上的课------JDK 无法回头给
URLConnection 加全局闸门,只能保持现状。
七、实践建议与选型决策
7.1 决策速查
| 场景 | 结论 |
|---|---|
| 串行 / 低并发(定时批处理、后台同步) | JDK HttpURLConnection 完全够用,什么都不用配 |
| 并发调用(线程池、响应式) | 换 HttpComponentsClientHttpRequestFactory,配置 maxTotal/perRoute |
| 过渡期(不想引依赖先顶一下) | -Dhttp.maxConnections=N 扩缓存,但依然没有总量上限,只是缓解 |
| 非幂等 POST 接口 | 无论用哪个客户端,显式确认重试语义(JDK 记得关 sun.net.http.retryPost) |
7.2 装配层的单点设计(迁移成本只有一个类)
让 RestTemplate 的 factory 装配收敛在一个 @Configuration 里(语义化 bean 名 +
@Qualifier 注入),切换池化客户端时只改这一个类:
java
@Bean(UNICOM_REST_TEMPLATE_BEAN_NAME)
public RestTemplate unicomRestTemplate(UnicomOpenApiProperties properties) {
// 现状:SimpleClientHttpRequestFactory(串行够用)
// 池化演进:换 HttpComponentsClientHttpRequestFactory,
// 超时/TLS 策略全部保留,只增加连接池治理
...
}
超时参数建议放配置中心(nacos)按环境下发,联调期收紧免发版。
7.3 批处理场景的 read-timeout 提醒
从用户态接口拷来的宽松 read-timeout(如 15s)对批处理是隐患:调度预算有限时,
少量慢响应就能吃光整轮时间窗(如 24 条 × 15s = 360s)。批处理客户端的
read-timeout 应按下游 P99 + 余量收紧(典型 3~5s),让慢请求快速失败进重试队列,
而不是拖着整轮超时。
7.4 验证连接复用是否生效
串行场景下观察单条平均耗时(日志 avgMs):稳定接近下游正常 RT 说明复用生效;
若普遍偏高且波动大(每次都付握手成本),抓包确认下游是否回了
Connection: close(若回了,复用天然失效,任何客户端配置都救不了)。
八、总结
- 底层链路 :RestTemplate → SimpleClientHttpRequestFactory → JDK
HttpURLConnection → 阻塞式 socket;Simple 工厂只有超时参数,无连接数参数 - KeepAliveCache 语义 :全局单例"缓存",只管空闲窗口;容量满即关(非 LRU)、
取用即独占、过期靠 5s 周期清扫 - maxConnections 语义 :空闲缓存上限(默认 5/目的地),不是 并发上限;
在飞连接无限制 - 池化的价值 :不是"能建更多连接",而是限制、排队、背压、驱逐、监控
这些治理能力;无限制在故障场景下 = fd 耗尽 + TIME_WAIT 打满 + 对端雪崩 + 无背压 - 架构差异 :池在请求路径上(所有连接必经,可计数可限流),
缓存在路径旁(只顺路存取,无视野无账本) - 选型一句话 :串行模型落在 JDK 实现的舒适区,什么都不用配;
模型并发化的那天,需要的是 maxTotal 这个闸门,而不只是缓存扩容
参考资料
- Java Networking Properties(Oracle 官方)
- OpenJDK 源码:
sun.net.www.http.KeepAliveCache/sun.net.www.http.HttpClient - Apache HttpClient 连接池管理