先问个问题:你现在写的代码里,HTTP客户端是怎么初始化的?
全局复用同一个实例?每次请求 new 一个新实例?还是扔进 Spring 里当 Bean 一发了之?
如果你觉得"都差不多,反正能跑",那今天的文章就是为你准备的。我会用一次真实排查经历告诉你,这个看似无伤大雅的选择,是怎么在生产环境里把我们整得死去活来的。
一、一个诡异的周二下午
那天上线完一个接口网关重构,功能测试全绿,美滋滋等着下班。然后监控开始报警——接口延迟 P99 从 50ms 飙升到 8 秒。
第一时间看日志,异常不多,重试也不算频繁。数据库正常,Redis 正常,CPU 和内存都没问题。但就是慢,无缘无故地慢。
我们开始疯狂 GC 查看、线程 Dump、火焰图……折腾了两个小时一无所获。最后是谁发现了问题?DBA。他说他发现应用到 MySQL 的连接数突然飙到了上限,但我们的连接池配置明明很"合理"——最大 20 个连接。
等等,HTTP 请求跟 MySQL 连接有什么关系?
二、顺着网线摸到的那只手
我们重构的网关大量调用下游 HTTP 服务,用的是 OkHttp。代码里这样写的:
OkHttpClient client = new OkHttpClient(); // 每次调用都 new 一个
Response response = client.newCall(request).execute();
每次请求 new 一个 OkHttpClient。听起来也没毛病?毕竟 OkHttp 官方文档说它是轻量级的,new 一下很快。
快个锤子。
每个 OkHttpClient 内部都有一个连接池(ConnectionPool)、线程池(Dispatcher)和缓存组件。当你没有显式复用它时,以下几件事正在悄悄发生:
- 连接无法复用:每次请求都是新建 TCP 连接,三次握手、TLS 握手全要来一遍。对于调用下游服务频繁的场景,这直接导致大量短连接。
- 连接池泄漏:OkHttp 的连接池默认不会立即清理空闲连接。如果你不断创建新的 Client 但从不关闭它们,连接就会慢慢积累。
- 文件描述符耗尽:每个 TCP 连接对应一个 fd,而服务器的文件描述符上限是有限的。当我们调用量上来后,fd 迅速被耗尽,新请求开始排队等待,表现为诡异的全局延迟。
等等,MySQL 连接数飙升是怎么回事?因为 fd 耗尽后,数据库连接也无法建立,而 HikariCP 的行为是等待连接超时不报错——实际上在等待期间,这些等待中的线程占用了连接池的配额,导致数据库觉得"并发连接很多"。
三、连接池才是那个隐藏Boss
我们后来查了大量资料,发现这个问题远比表面复杂。HTTP 客户端的连接池管理不善,会通过以下几个路径影响整个系统:
1. TIME_WAIT堆积
大量短连接关闭后,会进入 TCP TIME_WAIT 状态,默认持续 60 秒(Linux 可通过 sysctl -w net.ipv4.tcp_fin_timeout=30 调整)。在高频调用场景下,TIME_WAIT 状态的连接会迅速占满本地端口(默认 32768-60999),导致"Address already in use"错误。
2. 连接池大小与线程数的错配
很多团队会配置连接池大小为"经验值"比如 50、100,但根本不知道这个值应该怎么算。正确的公式应该是:
连接池大小 = 线程数 × 单线程连接数 × (1 + 等待时间/处理时间)
对于 IO 密集型服务,理论上连接数可以非常大(因为大部分时间在等网络),但很多人还是沿用数据库连接池的经验,设个很小的值,结果连接不够用,请求排队。
3. Keep-Alive 陷阱
HTTP Keep-Alive 说的是连接复用,但很多客户端库的默认值非常保守。OkHttp 默认 Keep-Alive 是 5 分钟,Apache HttpClient 默认甚至是 1 分钟。如果你的下游服务响应较慢,连接可能在复用前就被回收了,等于没复用到。
四、正确的打开方式
说清楚问题后,解决方案其实很简单:全局复用 OkHttpClient 实例。
public class OkHttpClientHolder {
private static final OkHttpClient CLIENT;
static {
ConnectionPool connectionPool = new ConnectionPool(
32, // 最大空闲连接数
5, // 空闲连接存活时间(分钟)
TimeUnit.MINUTES
);
CLIENT = new OkHttpClient.Builder()
.connectionPool(connectionPool)
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.dispatcher(new Dispatcher(
new ExecutorService(64) // 加大并发
))
.build();
}
public static OkHttpClient getClient() {
return CLIENT;
}
}
但光复用还不够,你还需要监控它。给连接池加指标:
EventListener listener = new EventListener() {
@Override
public void connectionAcquired(Call call, Connection connection) {
metrics.increment("http.connection.acquired");
}
@Override
public void connectionReleased(Call call, Connection connection, Protocol protocol) {
metrics.increment("http.connection.released");
}
};
这样你就能看到连接获取和释放的比率,如果释放远少于获取,说明连接泄漏了。
五、其他常见作死行为
顺便说几个我们看到的其他"惨案":
1.每次请求 new 一个 HttpClient(我们这次踩的坑)
2.忽略连接超时,只配读写超时
很多人只配 readTimeout 和 writeTimeout,完全忽略 connectTimeout。三次握手如果碰上网络抖动,最长可以等多久?答案是永远,因为根本没配超时。这种请求会永远挂在那里。
3.用完不关闭 Response Body
OkHttp 的 Response Body 是流式加载的,如果你不调用 response.close() 或者不用 try-with-resources,底层连接不会被释放回连接池,轻则内存泄漏,重则连接池慢慢被耗光。
// 错误写法
Response response = client.newCall(request).execute();
String body = response.body().string();
// 连接没有回池
// 正确写法
try (Response response = client.newCall(request).execute()) {
String body = response.body().string();
} // 自动释放
4.不同域名单独建池
有些框架默认按域名隔离连接池。如果你的服务调用了 10 个不同的下游,每个下游各自维护一个连接池,那整体连接数可能是 N×10。这种时候最好统一管理。
六、总结一下
HTTP 客户端是基础设施,但基础设施最容易被人忽略。大家都在关心业务逻辑、算法优化,却忘了你的每一次 HTTP 调用背后,都可能藏着一个悄悄吃掉资源的连接池。
记住这几个原则:
- 全局复用 HTTP 客户端实例,不要每次请求 new 一个
- 合理配置连接池大小,根据实际调用量来算
- 务必配置所有三种超时:连接超时、读取超时、写入超时
- 永远用 try-with-resources 包裹 Response
- 加监控,看连接池的健康度
最后一句话:代码能跑不代表代码没问题。生产环境里那些"诡异的"、"无缘无故的"延迟,十有八九是你自己埋的雷。
下次写 HTTP 调用之前,先问自己一句:这个客户端,谁来管理它的生命周期?