【HttpClient最差实践(bad-practice)】开发者在 http 工具方法中直接实例化 HttpClient,然后……
一、问题现场:一段看似“正常”的工具代码
在很多项目的 HTTP 工具类里,你可能会看到这样的写法:
public static String doPost(Map<String, String> map, String url) throws HttpTransportException {
log_.info("连接超时时长为:" + timeout);
HttpPost method = new HttpPost();
method.setHeader("Content-Encoding", sendEncode);
method.setHeader("User-Agent", "Rich Powered/1.0");
method.setHeader("Content-Type", "application/x-www-form-urlencoded");
method.setConfig(requestConfig);
CloseableHttpClient httpClient = HttpClients.custom()
.setRetryHandler(new DefaultHttpRequestRetryHandler(retryConnTimes, false))
.build();
method.setURI(getUri(url));
// ... 执行请求并返回结果
}
这段代码看起来“能用”——每次调用都创建了一个 CloseableHttpClient,执行完请求后似乎也没关闭它。但正是这种“能用”的写法,埋下了严重的性能和资源隐患。
二、隐藏的四大问题
1. 每次调用都创建全新连接池,连接无法复用
HttpClients.custom().build() 内部会创建一个 PoolingHttpClientConnectionManager,默认配置为:
- 最大总连接数:20
- 每个路由(host:port)最大连接数:2
问题:每次请求都新建一个连接池,意味着 TCP 连接无法复用。频繁的 connect() 和 disconnect() 不仅增加了延迟,还大量消耗服务器端的 TIME_WAIT 资源。在高并发场景下,系统很快会耗尽端口或达到文件描述符上限。
2. 资源泄漏风险
CloseableHttpClient 实现了 Closeable 接口,持有连接池、后台线程(如空闲连接清理线程)等资源。
如果不在使用完毕后调用 close(),这些资源永远不会被释放。而上述代码中,httpClient 是局部变量,方法结束后便失去引用,但连接池仍在后台运行,造成内存泄漏和线程泄漏。
3. 频繁 GC 压力
每次调用都创建新的 HttpClient 实例,内部会创建数十个对象(连接管理器、拦截器链、处理器等)。在 QPS 较高的系统中,这会导致 Young GC 频繁触发,甚至晋升到 Old Gen 引发 Full GC。
4. 无法统一控制连接池参数
每个 HttpClient 实例使用独立的连接池,无法全局配置最大连接数、超时时间、空闲回收策略等。当需要调优时,只能逐个修改所有调用处的代码,极易遗漏。
三、根本原因:误解了 HttpClient 的生命周期
许多开发者将 HttpClient 视为“一次性的请求对象”,但实际上它是重量级的、线程安全的资源。官方文档明确指出:
HttpClient is thread-safe. It is recommended that the same instance of this class be reused for multiple request executions.
正确的做法是:整个应用生命周期内只创建一次 HttpClient,所有请求共用同一个实例。
四、修复方案:提取为静态单例
4.1 基础改造:将 HttpClient 提升为静态成员
public class HttpUtil {
private static final CloseableHttpClient HTTP_CLIENT;
static {
// 从配置文件读取超时等参数
int connectTimeout = 5000;
int socketTimeout = 5000;
int maxTotal = 200;
int maxPerRoute = 50;
int retryCount = 3;
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(connectTimeout)
.setSocketTimeout(socketTimeout)
.setConnectionRequestTimeout(connectTimeout)
.build();
HTTP_CLIENT = HttpClients.custom()
.setDefaultRequestConfig(config)
.setRetryHandler(new DefaultHttpRequestRetryHandler(retryCount, false))
.setMaxConnTotal(maxTotal)
.setMaxConnPerRoute(maxPerRoute)
.setConnectionTimeToLive(60, TimeUnit.SECONDS)
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
// 注册 JVM 关闭钩子,优雅释放连接池
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
HTTP_CLIENT.close();
} catch (IOException e) {
log.error("关闭 HttpClient 失败", e);
}
}));
}
private HttpUtil() {}
public static String doPost(Map<String, String> map, String url) throws HttpTransportException {
HttpPost method = new HttpPost();
method.setHeader("Content-Encoding", sendEncode);
method.setHeader("User-Agent", "Rich Powered/1.0");
method.setHeader("Content-Type", "application/x-www-form-urlencoded");
method.setConfig(requestConfig);
method.setURI(getUri(url));
// 设置请求体
List<NameValuePair> params = new ArrayList<>();
for (Map.Entry<String, String> entry : map.entrySet()) {
params.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
}
try {
method.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new HttpTransportException("编码不支持", e);
}
try (CloseableHttpResponse response = HTTP_CLIENT.execute(method)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode >= 200 && statusCode < 300) {
String body = EntityUtils.toString(response.getEntity(), "UTF-8");
return body;
} else {
throw new HttpTransportException("HTTP 错误: " + statusCode + ", body: " + body);
}
} catch (IOException e) {
throw new HttpTransportException("请求失败", e);
}
}
}
4.2 关键改进点
| 改进项 | 说明 |
|---|---|
| 单例化 | HTTP_CLIENT 为 static final,整个进程只创建一次 |
| 连接池复用 | 所有请求共享同一个连接池,支持 Keep-Alive,减少 TCP 握手 |
| 资源清理 | JVM 关闭钩子确保应用退出时释放连接池 |
| 参数集中配置 | 连接数、超时、重试等均可统一调整 |
| try-with-resources | 自动关闭 CloseableHttpResponse,防止响应体泄漏 |
4.3 连接池参数调优建议
maxTotal:建议设置为(目标服务并发数 × 2),一般 200~1000 之间。maxPerRoute:针对单一目标服务的最大并发连接数,建议 50~200。connectionTimeToLive:连接最大存活时间,建议 30~60 秒,避免长时间占用。evictIdleConnections:定期回收空闲连接,建议 10~30 秒执行一次。
五、澄清:HttpPost 与 HttpClient 的角色区分
有读者可能会问:你说 HttpClient 应该全局单例,那为什么 doPost 方法里每次都要 new HttpPost?难道 HttpPost 不应该也复用吗?
答案是:不能复用,也不需要复用。两者的角色完全不同。
5.1 职责不同
| 对象 | 角色 | 特点 |
|---|---|---|
HttpClient |
执行引擎 | 线程安全、重量级、包含连接池、重试策略、拦截器等基础设施 |
HttpPost |
请求消息 | 非线程安全、轻量级、只描述一次请求的方法、URL、头、实体 |
HttpClient 负责:管理连接、发送请求、处理重定向、重试、解码响应等。
HttpPost 负责:封装本次请求的目标地址、HTTP 方法、头部、请求体。
5.2 线程安全性
CloseableHttpClient是线程安全的。官方文档明确说:“It is safe to share a single instance across threads.” 因此可以全局单例。HttpPost不是线程安全的。它的setHeader()、setEntity()、setConfig()等方法修改内部状态,如果被多个线程同时使用,会出现数据竞争。所以每个请求必须新建一个HttpPost实例。
5.3 资源消耗
HttpClient创建时需要初始化连接池、注册 Socket Factory、构建拦截器链等,开销较大,适合复用。HttpPost只是一个简单的 POJO,内部持有几个集合和配置对象,创建成本极低(微秒级),完全可以每次请求新建。
5.4 类比理解
| 现实类比 | HttpClient | HttpPost |
|---|---|---|
| 快递公司 | 顺丰总部(车辆、分拣中心、人员) | 一个包裹(收件人、地址、物品) |
| 餐厅 | 厨房(炉灶、厨师、食材储备) | 一张点菜单(菜名、口味要求) |
你不会每次点餐都重建一个厨房,但你会每次点餐都写一张新菜单。同样,你不会每次请求都重建一个 HttpClient,但你会每次请求都新建一个 HttpPost。
5.5 误区澄清
有些开发者会担心:既然 HttpClient 是复用的,那多个请求同时使用时,HttpPost 上的配置会不会互相污染?
答案是不会。因为 HttpClient.execute(HttpPost) 方法内部会深拷贝请求配置,不会修改传入的 HttpPost 对象。每个 HttpPost 实例是独立的,不会共享状态。
六、进阶优化:支持动态超时
如果某些请求需要不同的超时时间,可以为 doPost 方法增加 RequestConfig 参数,但注意不要每次都新建 HttpClient,只需在 HttpPost 上设置独立的 config:
public static String doPost(Map<String, String> map, String url, int customTimeout) throws HttpTransportException {
HttpPost method = new HttpPost();
method.setConfig(RequestConfig.custom()
.setConnectTimeout(customTimeout)
.setSocketTimeout(customTimeout)
.build());
// ... 其余相同
}
CloseableHttpClient 的 execute() 方法会合并默认配置和请求级别配置,互不干扰。
七、总结
| 错误做法 | 正确做法 |
|---|---|
每次请求都 new HttpClient() |
整个应用只创建一个 HttpClient 实例 |
| 不关心连接池参数 | 根据并发量合理配置连接池 |
不关闭 HttpClient |
注册关闭钩子或使用容器管理生命周期 |
将 HttpClient 当作轻量对象 |
视作重量级资源,线程安全可复用 |
误以为 HttpPost 也需要复用 |
每次请求新建 HttpPost,轻量且线程安全 |
一句话记住:HttpClient 是线程安全的重量级执行引擎,应当全局单例、复用连接池、统一配置、妥善关闭;而 HttpPost 只是轻量的请求消息,每次请求新建即可。
下次你在工具方法里看到 HttpClients.custom().build() 时,不妨停下来想一想——这个“方便”的写法,会不会成为线上事故的导火索?
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/21305231
浙公网安备 33010602011771号