一:Http请求的过程

image.png

http请求的生命周期如图所示,其建立连接,关闭连接环节,占据整个生命周期的不小比例。那么频繁的建立连接、释放连接势必造成不小的网络IO浪费。

那么如何尽可能地减少http连接的重复创建释放,是我们讨论的重点。

二:连接池

连接的创建和释放,很容易让人联想到线程创建和终止。跟线程一样,线程有线程池的概念,连接也有连接池。Apache的httpclient是我们服务端最常使用的http工具。

Apache官方的简单使用示例

  1. CloseableHttpClient httpclient = HttpClients.createDefault();
  2. HttpGet httpget = new HttpGet("http://localhost/");
  3. CloseableHttpResponse response = httpclient.execute(httpget);
  4. try {
  5. HttpEntity entity = response.getEntity();
  6. if (entity != null) {
  7. long len = entity.getContentLength();
  8. if (len != -1 && len < 2048) {
  9. System.out.println(EntityUtils.toString(entity));
  10. } else {
  11. // Stream content out
  12. }
  13. }
  14. } finally {
  15. response.close();
  16. }

示例中创建client使用的是createDefault方法,此外还提供的了例外的HttpClients.custom()方法,可以实现自定义httpClient。_custom方法返回的是一个_HttpClientBuilder类,这个类有setConnectionManager �方法可以实现定制httpclient连接池。饶了一圈,终于来到定制连接池这一步啦~。

PoolingHttpClientConnectionManager

官方doc

ClientConnectionPoolManager维护一个HttpClientConnection池,并且能够为来自多个执行线程的连接请求提供服务。连接按每条路由汇集。通过从池中租用连接而不是创建全新的连接,对管理器已经在池中可用的持久连接的路由的请求将成为服务。 ClientConnectionPoolManager维护每个路由的最大连接限制和总数。默认情况下,此实现将为每个给定路由创建不超过 2 个并发连接,并且总共不超过 20 个连接。对于许多现实世界的应用程序来说,这些限制可能过于局限,尤其是当他们使用 HTTP 作为其服务的传输协议时。但是,可以使用ConnPoolControl方法调整连接限制。 在构建时设置的总生存时间 (TTL) 定义了持久连接的最大生命周期,无论其过期设置如何。超过其 TTL 值后,不会再使用持久连接。 在 4.4 版中更改了旧连接的处理。以前,代码会在重新使用之前默认检查每个连接。代码现在仅在自上次使用连接后经过的时间超过已设置的超时时才检查连接。默认超时设置为 2000 毫秒

  1. 从官方的doc中,可以看出ClientConnectionPoolManager可以支持我们需要的减少创建释放连接的需求。<br />what给定路由?给定路由就是一个明确的域名or ip:port,那么使用默认的ClientConnectionPoolManagerhttpclient访问同一个网址的时候,就只能支持两个并发的连接。<br />**ps:使用HttpClients.createDefault()去调用接口将严重限制我们系统的吞吐量!**

支持的参数

既然了解到默认的ClientConnectionPoolManager会导致吞吐量的严重下降,那么我们可以通过哪些参数改变路由数和总连接数?

setMaxTotal():该方法支持设置连接池最大连接数。
setDefaultMaxPerRoute():该方法支持设置连接池中每个路由的最大连接数。
连接池最大连接数和路由最大连接数有什么关系?

连接池的结构

image.png
从上图连接池的结构中,可以看出连接池其实包含很多小的路由池,每个路由的连接数组成了连接池的连接数。那么当我们系统比较依赖于某几个特定的域名的话,我们就需要把该路由的最大连接数调高。

获取连接

image.png

获取连接的流程主要是判断池子中是否有可用的连接,没有则创建,创建时根据池子的最大连接数、每个路由的的最连接数进行创建。有则直接返回连接。

关闭超时的连接

如果连接“长期”没有使用,那么势必占用到我们连接池的数量了,ClientConnectionPoolManager还提供了closeExpiredConnections方法,支持关闭所有过期的空闲连接。closeIdleConnections方法关闭空闲的连接,它支持两个参数第一个空闲时间,第二个是时间的单位。

  1. /**
  2. * Closes all expired connections in the pool.
  3. * <p>
  4. * Open connections in the pool that have not been used for
  5. * the timespan defined when the connection was released will be closed.
  6. * Currently allocated connections are not subject to this method.
  7. * Times will be checked with milliseconds precision.
  8. * </p>
  9. */
  10. void closeExpiredConnections();
  1. void closeIdleConnections(long idletime, TimeUnit timeUnit);

我们可以用一条线程,定时的关闭连接池中空闲的、过期的连接。

  1. ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1);
  2. // 回收过期的http连接,30秒空闲即回收
  3. scheduledExecutorService.scheduleWithFixedDelay(() -> {
  4. connectionManager.closeExpiredConnections();
  5. connectionManager.closeIdleConnections(30, TimeUnit.SECONDS);
  6. }, 30, 60, TimeUnit.SECONDS);

创建了单线程的延迟任务线程池,每60s执行一次回收连接的任务。

三:请求配置

HttpClient还支持定制http请求配置。
HttpClientBuilder的setDefaultRequestConfig方法支持我们设置默认的http请求配置。其接受的参数是一个RequestConfig对象。
可以通过以下的代码定义一个配置对象,连接超时2s,获取连接请求超时1s,读取超时时间5s。

  1. RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(2000).setConnectionRequestTimeout(1000).setSocketTimeout(5000).build();

ConnectTimeout:建立连接的超时时间
ConnectionRequestTimeout:从连接池里取出连接的超时时间
SocketTimeout:从服务端读取读取等待超时时间

四:重试次数

HttpClient支持设置请求的重试次数。
Apacha提供了HttpRequestRetryHandler接口,及其两个实现类DefaultHttpRequestRetryHandler和StandardHttpRequestRetryHandler。
StandardHttpRequestRetryHandler是httpClient默认的重试策略,默认允许三次重试。
我们可以实现HttpRequestRetryHandler接口重写retryRequest方法实现自己的重试策略。也可以简单的使用DefaultHttpRequestRetryHandler,它的构造方法支持自定义重试次数。
当我们不希望httpClient重试时,可以如下操作

  1. // 默认不可重试,重试交由调用方实现
  2. HttpRequestRetryHandler retryHandler = new DefaultHttpRequestRetryHandler(0, false);
  3. HttpClients.custom().setRetryHandler(retryHandler)

五:关闭流

HttpClient执行http请求后返回response对象,而响应的内容是封装在entity里的(其实是io流),如果没有读取entity关闭流的话,流会一直保持,连接没法回到连接池中或释放掉。这将导致后续从池子中取不到连接。

  1. CloseableHttpResponse response = httpclient.execute(httpget);
  2. HttpEntity entity = response.getEntity();
  1. 使用如下的方式,可以在读取entity的内容时关闭流。consumetoString方法底层会默认在读取的同时帮我们关闭io流。
  1. EntityUtils.consume(execute.getEntity());
  2. EntityUtils.toString(execute.getEntity(), "UTF-8")

六:关闭httpclient

我们希望程序关闭的时候,关闭httpClient。避免出现内存泄露。

每个java程序都可以通过Runtime.getRuntime()获取其应用的Runtime实例,通过实例addShutdownHook方法可以向jvm注册一个钩子函数,该函数将在程序关闭的时候被执行。(kill -9 不会)

下面的代码将展示如何注册钩子函数关闭httpClient。

  1. Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  2. log.info("close httpClient");
  3. try {
  4. if (HTTP_CLIENT != null) {
  5. HTTP_CLIENT.close();
  6. }
  7. } catch (IOException e) {
  8. log.error("close httpClient error", e);
  9. }
  10. }));

�七:基于连接池改造SoaClient

java版的soaClient是基于htppClient实现的。

我们希望对SoaClient进行改造得到一个具有以下特性的soaClient。

  1. 池化的http连接池,同时具备连接回收,超时关闭等特性。
  2. 仅对网络问题进行重试,业务报错不重试。减少不必要的网络io
  3. 支持透传参数,可以自定义连接池最大连接数和每个路由的最大连接数。

key code

SoaClient的请求是基于httpclient发出的。

  1. public SoaResponse callService(String service, String method, Object[] form, SoaBaseParams soaBasic, String distinctRequestId, String env) {
  2. ......httprequest 参数拼接....
  3. //尝试次数
  4. int tryCount = 0;
  5. //最多重试tryLimit次
  6. while (tryCount <= tryLimit) {
  7. try {
  8. ......正常的处理..
  9. break;
  10. } catch (SocketException | SocketTimeoutException socketException) {
  11. tryCount++;
  12. if (tryCount >3){
  13. LOGGER.error("soa第"+ (tryCount -1) +"次重试失败",socketException);
  14. }else {
  15. LOGGER.warn("soa第"+ (tryCount -1) +"次重试失败",socketException);
  16. }
  17. } catch (JsonProcessingException jse) {
  18. response.setCode("soa_response_error");
  19. response.setMsg(MessageFormat.format("invalid json, {0}", responseStr));
  20. break;
  21. } catch (Exception e) {
  22. response.setCode("soa_call_failed");
  23. response.setMsg(MessageFormat.format("soa请求失败. soa地址:{0}, 异常描述:{1}", reqUrl.toString(), e.getMessage()));
  24. LOGGER.error("soa请求失败", e);
  25. break;
  26. }
  27. }
  28. return response;
  29. }

HttpclientUtils

  1. /**
  2. * http工具
  3. *
  4. * @author linzhe
  5. * @date 2022/04/07
  6. */
  7. @Slf4j
  8. public class HttpClientUtil {
  9. /**
  10. * 编码
  11. */
  12. private static final String ENCODING = "UTF-8";
  13. /**
  14. * 连接管理器
  15. */
  16. private static PoolingHttpClientConnectionManager connectionManager = null;
  17. /**
  18. * http客户端
  19. */
  20. private static final CloseableHttpClient HTTP_CLIENT;
  21. /**
  22. * 默认最大总连接数
  23. */
  24. private static final Integer DEFAULT_MAX_TOTAL = 120;
  25. /**
  26. * 默认每个路由最大连接数
  27. */
  28. private static final Integer DEFAULT_MAX_PER_ROUTE = 60;
  29. static {
  30. try {
  31. // 配置同时支持 HTTP 和 HTTPS
  32. SSLContextBuilder builder = new SSLContextBuilder();
  33. builder.loadTrustMaterial(null, new TrustSelfSignedStrategy());
  34. SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(builder.build());
  35. Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create().register("http", PlainConnectionSocketFactory.getSocketFactory()).register("https", sslConnectionSocketFactory).build();
  36. connectionManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
  37. } catch (Exception e) {
  38. log.error("init http connection exception", e);
  39. connectionManager = new PoolingHttpClientConnectionManager();
  40. }
  41. /*
  42. maxTotal:总连接数
  43. maxPerRoute:每个路由最大连接数
  44. 计算公式,系统的qps * 1.5~2
  45. eg:j24线上单日被请求数5800w,qps 680左右,12台机器,单台qps56,故最大连接数保持在 90-120比较合适
  46. */
  47. String maxTotal = EnvironmentUtil.getEnvironment().getProperty("taqu.httpClient.maxTotal");
  48. String maxPerRoute = EnvironmentUtil.getEnvironment().getProperty("taqu.httpClient.maxPerRoute");
  49. connectionManager.setMaxTotal(StringUtils.isBlank(maxTotal) ? DEFAULT_MAX_TOTAL : Integer.parseInt(maxTotal));
  50. connectionManager.setDefaultMaxPerRoute(StringUtils.isBlank(maxPerRoute) ? DEFAULT_MAX_PER_ROUTE : Integer.parseInt(maxPerRoute));
  51. /*
  52. setConnectTimeout表示设置建立连接的超时时间
  53. setSocketTimeout表示发出请求后等待对端应答的超时时间
  54. setConnectionRequestTimeout表示从连接池中拿连接的等待超时时间
  55. 连接超时2s(开发环境网络较差,1s容易超时),socket超时5s,从池子中获取连接超时1s
  56. */
  57. RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(2000).setConnectionRequestTimeout(1000).setSocketTimeout(5000).build();
  58. // 默认不可重试,重试交由调用方实现
  59. HttpRequestRetryHandler retryHandler = new DefaultHttpRequestRetryHandler(0, false);
  60. HTTP_CLIENT = HttpClients.custom().setConnectionManager(connectionManager).setDefaultRequestConfig(requestConfig).setRetryHandler(retryHandler).build();
  61. ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1);
  62. // 回收过期的http连接,30秒空闲即回收
  63. scheduledExecutorService.scheduleWithFixedDelay(() -> {
  64. connectionManager.closeExpiredConnections();
  65. connectionManager.closeIdleConnections(30, TimeUnit.SECONDS);
  66. }, 30, 60, TimeUnit.SECONDS);
  67. // 关闭的钩子
  68. Runtime.getRuntime().addShutdownHook(new Thread(() -> {
  69. log.info("close httpClient");
  70. try {
  71. if (HTTP_CLIENT != null) {
  72. HTTP_CLIENT.close();
  73. }
  74. } catch (IOException e) {
  75. log.error("close httpClient error", e);
  76. }
  77. }));
  78. }
  79. /**
  80. * post
  81. *
  82. * @param url url
  83. * @param postBody json
  84. * @return {@link String}
  85. * @throws IOException ioexception
  86. */
  87. public static String post(String url, String postBody) throws IOException {
  88. HttpPost httpPost = new HttpPost(url);
  89. StringEntity entity = new StringEntity(postBody, "utf-8");
  90. entity.setContentEncoding(ENCODING);
  91. entity.setContentType("application/json");
  92. httpPost.setEntity(entity);
  93. HttpResponse resp = HTTP_CLIENT.execute(httpPost);
  94. String respContent = "";
  95. if (resp.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
  96. HttpEntity he = resp.getEntity();
  97. // 读取完自动关闭
  98. respContent = EntityUtils.toString(he, ENCODING);
  99. } else {
  100. EntityUtils.consume(resp.getEntity());
  101. throw new ApiException(resp.getStatusLine().toString());
  102. }
  103. return respContent;
  104. }
  105. /**
  106. * httpPost soa使用
  107. *
  108. * @param url url
  109. * @param args
  110. * @return {@link String}
  111. * @throws IOException ioexception
  112. */
  113. public static String postBySoa(String url, Object... args) throws Exception {
  114. RequestBuilder rb = RequestBuilder.post();
  115. rb.setHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
  116. HashMap<String, Object> asMap = convertToMap(args);
  117. for (Map.Entry<String, ? extends Object> entry : asMap.entrySet()) {
  118. String key = entry.getKey();
  119. Object value = entry.getValue();
  120. rb.addParameter(key, String.valueOf(value));
  121. }
  122. rb.setUri(url);
  123. HttpUriRequest request = rb.build();
  124. CloseableHttpResponse execute = HTTP_CLIENT.execute(request);
  125. StatusLine statusLine = execute.getStatusLine();
  126. if (HttpStatus.SC_OK != statusLine.getStatusCode()) {
  127. EntityUtils.consume(execute.getEntity());
  128. throw new ApiException(statusLine.toString());
  129. }
  130. return EntityUtils.toString(execute.getEntity(), "UTF-8");
  131. }
  132. /**
  133. * 转换为map
  134. *
  135. * @param args
  136. * @return {@link HashMap}<{@link String}, {@link Object}>
  137. */
  138. private static HashMap<String, Object> convertToMap(Object... args) {
  139. validateDataArgs(args);
  140. HashMap<String, Object> asMap = Maps.newHashMap();
  141. for (int i = 0; i < args.length; i += 2) {
  142. Object key = args[i];
  143. Object value = args[i + 1];
  144. if ((key != null) && (value != null)) {
  145. asMap.put(String.valueOf(key), value);
  146. }
  147. }
  148. return asMap;
  149. }
  150. /**
  151. * 校验soa参数
  152. *
  153. * @param args
  154. */
  155. public static void validateDataArgs(Object... args) {
  156. if (args.length > 0) {
  157. if ((args.length % 2) != 0) {
  158. throw new ApiException("args must be odd (key+value in order.)");
  159. }
  160. }
  161. }
  162. }

透传参数的获取

  1. String maxTotal = EnvironmentUtil.getEnvironment().getProperty("taqu.httpClient.maxTotal");
  2. String maxPerRoute = EnvironmentUtil.getEnvironment().getProperty("taqu.httpClient.maxPerRoute");
  3. connectionManager.setMaxTotal(StringUtils.isBlank(maxTotal) ? DEFAULT_MAX_TOTAL : Integer.parseInt(maxTotal));
  4. connectionManager.setDefaultMaxPerRoute(StringUtils.isBlank(maxPerRoute) ? DEFAULT_MAX_PER_ROUTE : Integer.parseInt(maxPerRoute));