盒子
盒子
文章目录
  1. 问题
    1. 如何创建 HttpClient
    2. 为什么使用连接池?
    3. 什么是 Keep-Alive
    4. 通过 tcp 报文分析 Keep-Alive
      1. 命令
      2. 测试用例
      3. 过程 tcp 报文
  2. 参考

使用 httpclient 连接池及注意事项

问题

最近有个系统通过任务调度每天从别的系统同步数据,但是连续观察两天,查看日志发现调度任务第二天开始就停止执行,但是并没有任务错误信息。
之前调度任务部分代码如下

1
2
3
4
5
6
7
8
9
@Scheduled(cron = "0 0 0 * * ?")
@Override
public void syncPipScmFrozenboxes() {
try {
Map<String, String> params = Maps.newHashMap();
LocalDateTime date = LocalDateTime.now().minusDays(1).with(LocalTime.MIN);
String transDate = date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
params.put("transDate", transDate);
byte[] response = syncDataManager.doGet("bd3/frozenboxes", params);

问题重现:
我在本地调试的时候将 cron 表达式设置为 @Scheduled(cron = "0/30 * * * * ?") 每 30s 调用一次,果然该方法只调用了一次。设置断点时,发现在 doGet() 方法处阻塞了,查看该线程堆栈信息,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"pool-3-thread-4@10651" prio=5 tid=0x4f nid=NA waiting
java.lang.Thread.State: WAITING
at sun.misc.Unsafe.park(Unsafe.java:-1)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at org.apache.http.pool.AbstractConnPool.getPoolEntryBlocking(AbstractConnPool.java:377)
at org.apache.http.pool.AbstractConnPool.access$200(AbstractConnPool.java:67)
at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:243)
- locked <0x2d1c> (a org.apache.http.pool.AbstractConnPool$2)
at org.apache.http.pool.AbstractConnPool$2.get(AbstractConnPool.java:191)
at org.apache.http.impl.conn.PoolingHttpClientConnectionManager.leaseConnection(PoolingHttpClientConnectionManager.java:282)
at org.apache.http.impl.conn.PoolingHttpClientConnectionManager$1.get(PoolingHttpClientConnectionManager.java:269)
at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:191)
at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:185)
at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:89)
at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:111)
at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:185)
at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:83)
at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:108)
at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:56)
at com.jet.scm.thirdservice.datasync.config.impl.SyncDataManager4HttpClient.doGet(SyncDataManager4HttpClient.java:107)
at com.jet.scm.thirdservice.datasync.bdcor3.impl.SyncDataFromBdcor3ServiceImpl.syncPipScmFrozenboxes(SyncDataFromBdcor3ServiceImpl.java:82)
...

下面是部分之前的代码,对同一个 httpClient 多次调用如下方法之后,并没有释放连接,最终导致了上面的问题。

1
2
3
4
5
6
// 维持登录状态
CookieStore cookieStore = new BasicCookieStore();
HttpClient httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build();
// 多次调用
HttpGet get = new HttpGet("http://localhost:8912/hello");
HttpResponse response = httpClient.execute(get);

下面是修改之后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 创建 httpClient
public HttpClient createHttpclient() {
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(20); // 最大连接数
cm.setDefaultMaxPerRoute(cm.getMaxTotal());
CookieStore cookieStore = new BasicCookieStore();
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(3 * 1000) // 请求超时时间
.setSocketTimeout(60 * 1000) // 等待数据超时时间
.setConnectionRequestTimeout(500) // 连接超时时间
.build();
return HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(requestConfig)
.setDefaultCookieStore(cookieStore)
.build();
}
// 执行 get 请求
public byte[] doGet(String path, Map<String, String> params) {
URIBuilder builder = new URIBuilder(); builder.setScheme("http").setHost(hostAndPort).setPath(path);
params.forEach(builder::addParameter);
HttpResponse getResponse = null;
try {
HttpGet httpGet = new HttpGet(builder.build());
getResponse = httpClient.execute(httpGet);
InputStream input = getResponse.getEntity().getContent();
return IOUtils.toByteArray(input);
} catch (Exception e) {
logger.error("获取数据异常", e);
return null;
} finally {
if (getResponse != null) {
try {
// 释放连接
EntityUtils.consume(getResponse.getEntity());
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

使用 HttpClient 连接池 PoolingHttpClientConnectionManager,参数的意思

  • maxTotal 指的是连接池内连接最大数
  • perRoute 一定要设置,路由指的是请求的系统,如访问 localhost:8080localhost:8081 就是两个路由。perRote 指的是每个路由的连接数,因为我目前只连接一个系统的进行数据同步,所以 perRote 设置和最大连接数一样

请求参数 RequestConfig 含义是:

  • connectTimeout 请求超时时间
  • socketTimeout 等待数据超时时间
  • connectionRequestTimeout 连接不够用时等待超时时间,一定要设置,如果不设置的话,如果连接池连接不够用,就会线程阻塞。

综上,我调度不执行的原因主要是我没有释放连接,导致连接池内连接不够用,并且因为我没有设置连接不够用等待超时时间,一直等待导致线程阻塞。

如何创建 HttpClient

我查看了下 HttpClient 创建的源码,默认就使用了连接池,默认的最大连接数是 20,但是最大 perRoute 数量是2,也就是说访问同一个服务器连接池内最大只有两个连接可用,这个是天坑。
所以创建 HttpClient 有两种方式,两种方式都统一管理连接

  • 一种是单例创建 httpClient,通过 setConnectionManager() 注入连接池(PoolingHttpClientConnectionManager), httpClient 是线程安全取决于 connectionManager,而该 pool 是线程安全(源码注释),另外官方文档上也有如下一段话

    While HttpClient instances are thread safe and can be shared between multiple threads of execution, it is highly recommended that each thread maintains its own dedicated instance of HttpContext .

  • 另外是可以创建多个 httpClient 实例,但是必须得注入同一个连接池

为什么使用连接池?

使用连接池的好处主要有

  • 在 keep-alive 时间内,可以使用同一个 tcp 连接发起多次 http 请求。
  • 如果不使用连接池,在大并发的情况下,每次连接都会打开一个端口,使系统资源很快耗尽,无法建立新的连接,可以限定最多打开的端口数。

我的理解是连接池内的连接数其实就是可以同时创建多少个 tcp 连接,httpClient 维护着两个 Set,leased(被占用的连接集合) 和 avaliabled(可用的连接集合) 两个集合,释放连接就是将被占用连接放到可用连接里面。

什么是 Keep-Alive

HTTP1.1 默认启用 Keep-Alive,我们的 client(如浏览器)访问 http-server(比如 tomcat/nginx/apache)连接,其实就是发起一次 tcp 连接,要经历连接三次握手,关闭四次握手,在一次连接里面,可以发起多个 http 请求。如果没有 Keep-Alive,每次 http 请求都要发起一次 tcp 连接。
下图是 apache-server 一次 http 请求返回的响应头,可以看到连接方式就是 Keep-Alive,另外还有 Keep-Alive 头,其中

  • timeout=5 5s 之内如果没有发起新的 http 请求,服务器将断开这次 tcp 连接,如果发起新的请求,断开连接时间将继续刷新为 5s
  • max=100 的意思在这次 tcp 连接之内,最多允许发送 100 次 http 请求,100 次之后,即使在 timeout 时间之内发起新的请求,服务器依然会断开这次 tcp 连接

通过 tcp 报文分析 Keep-Alive

为了更直观的看到整个请求,我使用了 tcpdump 查看整个请求的报文

命令

1
sudo tcpdump -i lo0 -n port 8912

测试用例

模拟的是两次登录操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class HttpClientTests {
static PoolingHttpClientConnectionManager cm;
static {
cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(20); // 最大连接数
cm.setDefaultMaxPerRoute(cm.getMaxTotal());
}
HttpClient client;
@Test
public void testClient() throws Exception {
CookieStore cookieStore = new BasicCookieStore();
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(3 * 1000) // 请求超时时间
.setSocketTimeout(60 * 1000) // 等待数据超时时间
.setConnectionRequestTimeout(500). // 连接超时时间
build();
client = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(requestConfig)
.setDefaultCookieStore(cookieStore)
.setKeepAliveStrategy((response, 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")) {
try {
return Long.parseLong(value) * 1000;
} catch (NumberFormatException ignore) {
}
}
}
return 5 * 1000; // 设置 Keep-alive 时间为 5s
})
.build();
URIBuilder builder = new URIBuilder();
builder.setScheme("http").setHost("localhost:8912").setPath("login");
builder.addParameter("username", "dahuang");
builder.addParameter("password", "dahuang123");
HttpGet get = new HttpGet(builder.build());
HttpResponse response = client.execute(get);
Thread.sleep(120 * 1000); // 线程休眠时间
HttpResponse response1 = client.execute(get);
}

过程 tcp 报文

报文分析
横线分隔的是两次执行测试用例,分别设置线程休眠时间为 120s 和 60s,目的是约束 keep-alive 的时间,每次测试发起两次 http get 请求

  • 可以看到 1、2、3分别是 tcp 握手、数据传输(执行 http 请求)、挥手的过程。第一次请求完毕,接着发起第二次 tcp 连接,端口为 64599。
  • 可以看到整个过程只有一次 tcp 连接,在 keep-alive 时间内,可以发起多次 http 请求。

测试发现 setKeepAliveStrategy 没有起到作用,如果 60s 内没有新的请求发生,服务器就会主动断开连接。

参考

Connection management
使用httpclient必须知道的参数设置及代码写法、存在的风险

HttpClient4.5.2 连接池原理及注意事项

支持一下
扫一扫,支持dahuang