最近帮一个朋友看他们的订单系统,接口响应慢得像蜗牛爬树。排查了一圈,发现问题居然不在数据库、不在Redis、也不在那些他天天卷的JVM参数上——而是他用的那个HTTP客户端,默认配置跟裸奔差不多。
这不是个案。我敢说90%的后端项目,HTTP客户端的配置都是随缘的。今天咱们就来说说这个事儿。
先说个鬼故事
你知道吗?当你用默认配置创建一个HTTP客户端的时候,每次发请求都可能是这样的:
// 每次请求都这样
Connection: close
// 服务器:好的,那我关闭连接了
// 下次请求:啊?又要重新建连接?
TCP三次握手了解一下?TLS握手了解一下?对于一个简单的GET请求,可能 handshake 的时间比实际数据传输时间还多几倍。你以为你在调接口,实际上你在给网络做热身运动。
Keep-Alive:这玩意儿是给你的
HTTP/1.1 默认是开启 Keep-Alive 的,但问题在于——很多客户端库的默认行为并不是你想象的那样。以Java生态举例:
// 默认情况:连接池可能只有5个连接
// 每个host最多5个
// 等待时间可能只有1分钟
// 你的QPS稍微高一点,就开始排队了
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setDefaultMaxPerRoute(50); // 每个路由50个连接
connectionManager.setMaxTotal(200); // 总共200个连接
connectionManager.setValidateAfterInactivity(30000); // 30秒后验证连接
很多人用到系统挂都不知道是这个原因。我见过一个服务,机器配置不错,但HTTP调外部接口的时候总是超时。后来一看——连接池大小是1。是的,你没看错,maxTotal=1。所有请求串行执行,排队排到姥姥家去了。
超时配置:要么等死要么急死
超时配置是个玄学。设短了,服务偶发抖动就失败;设长了,一个慢请求卡住半天不动。我见过的奇葩配置包括但不限于:
- connectTimeout = 0(永不超时,等到你怀疑人生)
- socketTimeout = 0(同上)
- connectionRequestTimeout = 0(从连接池拿连接也永不超时)
- 三个超时全设置为同一个值,不管什么场景
正确的做法是分场景配置:
RequestConfig defaultConfig = RequestConfig.custom()
.setConnectTimeout(3000) // 建连3秒,给老子快点
.setSocketTimeout(10000) // 读数据10秒,接口再慢也别超过这个
.setConnectionRequestTimeout(5000) // 从池里拿连接5秒
.setCircularRedirectsAllowed(false)
.setRedirectsEnabled(true)
.setMaxRedirects(5)
.build();
而且,如果你调用的服务是分级别的,比如核心服务和边缘服务,超时应该不一样。核心服务可以多等一会儿,边缘服务失败就失败了,别影响主流程。
重试:retry的艺术与灾难
HTTP请求失败要不要重试?这个问题看起来简单,但90%的人处理得都有问题。首先是哪些情况可以重试:
- 网络抖动导致的连接失败 ✓
- 服务器端处理超时(你不知道服务器到底有没有收到) ✓
- 500错误(服务器内部问题,可能重试就成功了) ✓
哪些情况绝对不能重试:
- POST请求到达了服务器,服务器返回了错误,但你没收到响应 —— 重复提交!
- 400错误 —— 你错了,重试一万次还是错
- 任何涉及金钱/订单状态的操作 —— 除非你做好了幂等
一个好的重试策略应该是这样的:
HttpRequestRetryHandler retryHandler = (exception, executionCount, context) -> {
if (executionCount > 3) {
return false; // 最多重试3次
}
if (exception instanceof InterruptedIOException) {
return false; // 超时中断,不重试
}
if (exception instanceof UnknownHostException) {
return false; // DNS问题,重试也没用
}
if (exception instanceof SSLException) {
return false; // SSL问题,别重试
}
if (context.getAttribute(ExecutionContext.HTTP_REQUEST) instanceof HttpRequestWrapper) {
String method = ((HttpRequestWrapper) context.getAttribute(
ExecutionContext.HTTP_REQUEST)).getOriginal().getRequestLine().getMethod();
return "GET".equalsIgnoreCase(method);
}
return true;
};
什么?你说你们的POST接口也重试?那你最好祈祷上游也做了幂等,不然就会出现重复下单、重复通知这种离谱问题。
连接泄漏:最难发现的定时炸弹
连接池最怕什么?泄漏。当你从连接池拿到一个连接,用完之后没有正确归还,这个连接就丢 了。丢个一两条不明显,等你发现的时候,可能已经丢了几百个连接,连接池满了,所有请求都在等待,CPU 100%,接口全挂。常见泄漏场景:
// 场景1:异常情况下忘记释放
CloseableHttpResponse response = httpClient.execute(httpGet);
try {
// 业务逻辑
if (somethingWrong) {
return; // 这里response没关!
}
} finally {
response.close(); // 一定要放finally里
}
// 场景2:response关闭了但连接没回到池里
// 某些情况下需要保证连接真正归还
// 可以用EntityUtils.consume(response.getEntity())来彻底消费
更好的方式是用try-with-resources,让语言层面帮你搞定:
try (CloseableHttpResponse response = httpClient.execute(httpGet)) {
// 业务逻辑
} // 自动关闭,自动归还连接
DNS缓存:这个坑踩过一次就够了
DNS缓存是个容易被忽视的问题。默认情况下,JVM会缓存DNS查询结果,而且缓存时间可能很长(通常是永久,直到进程重启)。这意味着什么?假设你调用的服务做了负载均衡,更换了IP地址,但你的JVM还在用旧的。一部分流量打到新IP,一部分打到旧IP,可能旧IP已经下线了,于是你开始间歇性怀疑人生。正确的做法:
// 设置DNS缓存时间为可接受的范围
java.security.Security.setProperty("networkaddress.cache.ttl", "60");
// 或者彻底禁用缓存(测试环境)
java.security.Security.setProperty("networkaddress.cache.negative.ttl", "0");
另一个方案是使用DnsResolver自己管理DNS查询,每次请求前动态解析。但这样性能会差一些,要看场景。
最后说个王炸:连接池预热
你以为配好了连接池就完事了?图样。连接池里的连接是懒加载的。就是说,你配置了200个连接,但一开始池里可能是0个。前200个请求,每个都要新建连接,等于前200个请求都是慢请求。正确的做法是预热:
// 启动时预热连接池
public void warmUp(HttpClient httpClient, String... urls) {
for (String url : urls) {
try {
HttpGet request = new HttpGet(url);
try (CloseableHttpResponse response = httpClient.execute(request)) {
EntityUtils.consumeQuietly(response.getEntity());
}
} catch (Exception e) {
log.warn("预热失败: {}", url, e);
}
}
}
这样服务启动后,连接池已经是Ready状态,第一笔请求就能用上池里的连接。
总结
HTTP客户端配置这件事,说大不大说小不小。小团队可能觉得无所谓,流量上来了自然就出问题;大团队可能早就踩过坑,积累了一堆配置规范。但不管怎样,希望这篇文章能让你少走点弯路。下次你发现接口响应慢、线程池打满、CPU莫名奇妙高,先别急着加机器、扩容、或者怀疑JVM。看看你的HTTP客户端配置,说不定问题就在那儿。记住:魔鬼在细节里,性能在连接池中。