来源:blog.csdn.net/u010285974/article/details/85696239
HttpClient 优化思路:
- 池化
- 长连接
- HttpClient 和 HttpGet 复用
- 合理的配置参数(最大并发请求数、各种超时时间、重试次数)
- 异步
- 多读源码
1. 背景
我们有个业务,会调用其他部门提供的一个基于 HTTP 的服务,日调用量在千万级别。之前使用 HttpClient 完成业务,但 QPS 一直上不去。于是翻了一遍业务代码,做了一些优化,记录在这里。
先对比优化前后:
优化之前,平均执行时间是 250ms;
优化之后,平均执行时间是 80ms,降低了三分之二的耗时,容器也不再动不动就告警线程耗尽了,清爽多了。
2. 分析
项目原来的实现比较粗略:每次请求时初始化一个 HttpClient,生成一个 HttpPost 对象,执行,然后从返回结果里取出 entity,保存成字符串,最后显式关闭 response 和 client。
我们一点点分析和优化。
2.1 HttpClient 反复创建开销
HttpClient 本身是一个线程安全的类,没有必要让每个线程每次使用时都创建,全局保留一个即可。
2.2 反复创建 TCP 连接的开销
tcp的三次握手与四次挥手 这两大裹脚布过程,对于高频次请求来说,开销实在太大。可以算一笔账:如果每次请求需要花 5ms 做握手和挥手,对于 QPS 为 100 的单系统来说,1 秒就要花 500ms 在连接协商上。总不能让每次请求都举行一次“握手 + 挥手”的全套礼仪吧?改成 keep-alive 方式复用连接,就能省下这笔消耗。
2.3 重复缓存 entity 的开销
原来的逻辑里使用了如下代码:
HttpEntity entity = httpResponse.getEntity();
String response = EntityUtils.toString(entity);
这里相当于额外把 content 复制了一份到字符串里,而原来的 httpResponse 还保留了一份 content 等待被消费。在高并发且 content 很大时,会消耗大量内存;并且原来的写法还需要显式关闭连接,比较麻烦。
3. 实现
按照上面的分析,需要做三件事:
- 单例的 client;
- 可缓存的保活连接;
- 更好地处理返回结果。
单例 client 比较简单,不再展开,重点看连接缓存。
提到连接缓存,很容易联想到数据库连接池。HttpClient 4 提供了 PoolingHttpClientConnectionManager 作为连接池。下面按步骤完成优化。
3.1 定义一个 keep-alive strategy
关于 keep-alive 这里不展开,只提一点:是否使用 keep-alive 要结合业务判断,它不是银弹。另外 keep-alive 和 time_wait/close_wait 之间也有不少故事。
在当前业务场景里,相当于少数固定客户端长时间、极高频次地访问服务端,启用 keep-alive 非常合适。
再多说一句,HTTP 的 keep-alive 和 TCP 的 KEEPALIVE 不是同一个东西。回到正文,定义一个 strategy 如下:
ConnectionKeepAliveStrategy myStrategy = new ConnectionKeepAliveStrategy() {
@Override
public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
HeaderElementIterator it = new BasicHeaderElementIterator(
response.headerIterator(HTTP.CONN_KEEP_ALIVE));
while (it.hasNext()) {
HeaderElement he = it.nextElement();
String param = he.getName();
String value = he.getValue();
if (value != null && param.equalsIgnoreCase("timeout")) {
return Long.parseLong(value) * 1000;
}
}
return 60 * 1000; // 如果没有约定,则默认定义时长为 60s
}
};
3.2 配置一个 PoolingHttpClientConnectionManager
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(500);
connectionManager.setDefaultMaxPerRoute(50); // 例如默认每路由最高 50 并发,具体依据业务来定
也可以针对每个路由单独设置并发数。
3.3 生成 HttpClient
httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setKeepAliveStrategy(kaStrategy)
.setDefaultRequestConfig(RequestConfig.custom().setStaleConnectionCheckEnabled(true).build())
.build();
注意:使用 setStaleConnectionCheckEnabled 来逐出已被关闭的连接并不推荐。更好的方式是手动启用一个线程,定时运行 closeExpiredConnections 和 closeIdleConnections 方法,如下所示。
public static class IdleConnectionMonitorThread extends Thread {
private final HttpClientConnectionManager connMgr;
private volatile boolean shutdown;
public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) {
super();
this.connMgr = connMgr;
}
@Override
public void run() {
try {
while (!shutdown) {
synchronized (this) {
wait(5000);
// Close expired connections
connMgr.closeExpiredConnections();
// Optionally, close connections that have been idle longer than 30 sec
connMgr.closeIdleConnections(30, TimeUnit.SECONDS);
}
}
} catch (InterruptedException ex) {
// terminate
}
}
public void shutdown() {
shutdown = true;
synchronized (this) {
notifyAll();
}
}
}
3.4 执行 method 时降低开销
这里要注意:不要手动关闭 connection。
一种可行的获取内容方式是把 entity 复制一份:
res = EntityUtils.toString(response.getEntity(), "UTF-8");
EntityUtils.consume(response1.getEntity());
但更推荐定义一个 ResponseHandler,这样就不需要自己 catch 异常和关闭流。可以看一下相关源码:
public <T> T execute(final HttpHost target, final HttpRequest request,
final ResponseHandler<? extends T> responseHandler, final HttpContext context)
throws IOException, ClientProtocolException {
Args.notNull(responseHandler, "Response handler");
final HttpResponse response = execute(target, request, context);
final T result;
try {
result = responseHandler.handleResponse(response);
} catch (final Exception t) {
final HttpEntity entity = response.getEntity();
try {
EntityUtils.consume(entity);
} catch (final Exception t2) {
// Log this exception. The original exception is more
// important and will be thrown to the caller.
this.log.warn("Error consuming content after an exception.", t2);
}
if (t instanceof RuntimeException) {
throw (RuntimeException) t;
}
if (t instanceof IOException) {
throw (IOException) t;
}
throw new UndeclaredThrowableException(t);
}
// Handling the response was successful. Ensure that the content has
// been fully consumed.
final HttpEntity entity = response.getEntity();
EntityUtils.consume(entity); // 看这里看这里
return result;
}
可以看到,如果通过 ResponseHandler 执行 execute 方法,最终会自动调用 consume。这个 consume 方法如下:
public static void consume(final HttpEntity entity) throws IOException {
if (entity == null) {
return;
}
if (entity.isStreaming()) {
final InputStream instream = entity.getContent();
if (instream != null) {
instream.close();
}
}
}
最终它关闭了输入流。
4. 其他
通过以上步骤,基本完成了一个支持高并发的 HttpClient 写法。下面再补充一些配置和提醒。
4.1 HttpClient 的一些超时配置
CONNECTION_TIMEOUT 是连接超时时间,SO_TIMEOUT 是 socket 超时时间,两者不同:连接超时是发起请求前的等待时间,socket 超时是等待数据的超时时间。
HttpParams params = new BasicHttpParams();
// 设置连接超时时间
Integer CONNECTION_TIMEOUT = 2 * 1000; // 设置请求超时 2 秒,根据业务调整
Integer SO_TIMEOUT = 2 * 1000; // 设置等待数据超时时间 2 秒,根据业务调整
// 定义了当从 ClientConnectionManager 中检索 ManagedClientConnection 实例时使用的毫秒级超时时间
// 这个参数期望得到一个 java.lang.Long 类型的值。如果没设置,默认等于 CONNECTION_TIMEOUT,因此一定要设置
Long CONN_MANAGER_TIMEOUT = 500L; // 在 httpclient 4.2.3 中它曾被改成一个对象,直接传 long 会报错,后来又改回来了
params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, CONNECTION_TIMEOUT);
params.setIntParameter(CoreConnectionPNames.SO_TIMEOUT, SO_TIMEOUT);
params.setLongParameter(ClientPNames.CONN_MANAGER_TIMEOUT, CONN_MANAGER_TIMEOUT);
// 在提交请求之前 测试连接是否可用
params.setBooleanParameter(CoreConnectionPNames.STALE_CONNECTION_CHECK, true);
// 另外设置 http client 的重试次数,默认是 3 次;当前是禁用掉(如果项目量不到,这个默认即可)
httpClient.setHttpRequestRetryHandler(new DefaultHttpRequestRetryHandler(0, false));
4.2 如果配置了 Nginx,Nginx 也要设置面向两端的 keep-alive
现在的业务几乎没有不用 Nginx 的情况。Nginx 默认和 client 端打开长连接,而和 server 端使用短连接。
注意 client 端的 keepalive_timeout 和 keepalive_requests 参数,以及 upstream 端的 keepalive 参数设置,这三个参数的意义这里不再赘述。
以上就是我的全部设置。通过这些调整,成功把单次请求耗时从 250ms 降到了 80ms 左右,效果显著。
JAR 包如下:
<!-- httpclient -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.6</version>
</dependency>
完整代码如下:
// Basic认证
private static final CredentialsProvider credsProvider = new BasicCredentialsProvider();
// httpClient
private static final CloseableHttpClient httpclient;
// httpGet方法
private static final HttpGet httpget;
//
private static final RequestConfig reqestConfig;
// 响应处理器
private static final ResponseHandler<String> responseHandler;
// jackson解析工具
private static final ObjectMapper mapper = new ObjectMapper();
static {
System.setProperty("http.maxConnections", "50");
System.setProperty("http.keepAlive", "true");
// 设置basic校验
credsProvider.setCredentials(
new AuthScope(AuthScope.ANY_HOST, AuthScope.ANY_PORT, AuthScope.ANY_REALM),
new UsernamePasswordCredentials("", ""));
// 创建http客户端
httpclient = HttpClients.custom()
.useSystemProperties()
.setRetryHandler(new DefaultHttpRequestRetryHandler(3, true))
.setDefaultCredentialsProvider(credsProvider)
.build();
// 初始化httpGet
httpget = new HttpGet();
// 初始化HTTP请求配置
reqestConfig = RequestConfig.custom()
.setContentCompressionEnabled(true)
.setSocketTimeout(100)
.setAuthenticationEnabled(true)
.setConnectionRequestTimeout(100)
.setConnectTimeout(100).build();
httpget.setConfig(reqestConfig);
// 初始化response解析器
responseHandler = new BasicResponseHandler();
}
/*
* 功能:返回响应
* @param [url]
* @return org.apache.http.client.methods.CloseableHttpResponse
*/
public static String getResponse(String url) throws IOException {
HttpGet get = new HttpGet(url);
String response = httpclient.execute(get, responseHandler);
return response;
}
/*
* 功能:发送http请求,并用net.sf.json工具解析
* @param [url]
* @return org.json.JSONObject
*/
public static JSONObject getUrl(String url) throws Exception {
try {
httpget.setURI(URI.create(url));
String response = httpclient.execute(httpget, responseHandler);
JSONObject json = JSONObject.fromObject(response);
return json;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/*
* 功能:发送http请求,并用jackson工具解析
* @param [url]
* @return com.fasterxml.jackson.databind.JsonNode
*/
public static JsonNode getUrl2(String url) {
try {
httpget.setURI(URI.create(url));
String response = httpclient.execute(httpget, responseHandler);
JsonNode node = mapper.readTree(response);
return node;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
/*
* 功能:发送http请求,并用fastjson工具解析
* @param [url]
* @return com.fasterxml.jackson.databind.JsonNode
*/
public static com.alibaba.fastjson.JSONObject getUrl3(String url) {
try {
httpget.setURI(URI.create(url));
String response = httpclient.execute(httpget, responseHandler);
com.alibaba.fastjson.JSONObject jsonObject = com.alibaba.fastjson.JSONObject.parseObject(response);
return jsonObject;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}