前言
HTTPClient大家应该都很熟悉,一个很好的抓网页,刷投票或者刷浏览量的工具。但是还有一项非常重要的功能就是外部接口调用,比如说发起微信支付,支付宝退款接口调用等;最近我们在这个工具上栽了一个大跟头,不怕大家笑话,拿出来跟大家分享一下;
过程描述
项目代码比较复杂,我为了直达问题,单独写了程序来说明;
我这里先重复一下导致问题的过程:程序源自于从.NET到Java的重构,开发使用了httpclient来调用微信支付的接口,设置了Httpclient的超时参数,为了提高性能,还遵循httpclient的推荐做法,将httpclient做成了单例;httpclient其他的参数都没有调整,使用的是默认参数;最终这种配置没能扛住网络的抖动,服务发生了雪崩。本篇博客也是“一个隐藏在支付系统很长时间的雷”的续篇;
缺陷复现
相信你对这个过程有很多疑点,下面我简化代码说一下这个问题;
我们现在要做的实验(demo)是这样的一个架构(先有架构才能显示出你是一名高级工程师,但是请原谅我简化的有点太简单)。
使用httpclient做客户端,然后使用多线程发起HTTP接口调用。为了模拟故障(包括网络故障和服务器服务故障),我们在服务器的接口sleep一段时间,然后观察服务器日志,如果客户端是多并发访问,httpclient是正常的。但如果客户端是一个一个请求过来的,那就说明使用httpclient的方式有问题。
好了,思路就是这样,我们开始通过代码来说明情况;
step1 服务器端程序
为了避免配置tomcat,我直接使用embed jetty,来启动一个8888端口的服务,这个服务什么都不做,就打印一下日志,然后sleep一下,出去时,再打印一次日志;一共两个类(如何引入maven依赖我就不写了);
public class JettyServerMain { public static void main(String[] args) throws Exception { Server server = new Server(8888); server.setHandler(new HelloHandler()); server.start(); server.join(); } } class HelloHandler extends AbstractHandler { /** * 作为测试,在这个方法故意sleep 3秒,然后返回hello; */ @Override public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { long threadId = Thread.currentThread().getId(); Log.getLogger(this.getClass()).info("threadId="+threadId+" come in"); try { Thread.sleep(3000); } catch(Exception e) { e.printStackTrace(); } response.setStatus(HttpServletResponse.SC_OK); PrintWriter out = response.getWriter(); out.println("hello+"+threadId); baseRequest.setHandled(true); Log.getLogger(this.getClass()).info("threadId="+threadId+" finish"); } }
step2 简化版httpclient(V1)
我们先写第一版的httpclient,即先通过httpclient调用一下刚才的程序,看是否好用;代码如下:
public class HTTPClientV1 { public static void main(String argvs[]){ CloseableHttpClient httpClient = HttpClientBuilder.create().build(); // 创建Get请求 HttpGet httpGet = new HttpGet("http://localhost:8888"); // 响应模型 CloseableHttpResponse response = null; try { // 由客户端执行(发送)Get请求 response = httpClient.execute(httpGet); // 从响应模型中获取响应实体 HttpEntity responseEntity = response.getEntity(); if (responseEntity != null) { System.out.println("响应内容为:" + EntityUtils.toString(responseEntity)); } } catch (Exception e) { e.printStackTrace(); } finally { try { // 释放资源 if (httpClient != null) { httpClient.close(); } if (response != null) { response.close(); } } catch (IOException e) { e.printStackTrace(); } } } }
step3 复用httpclient(V2)
我们从httpclient官方看到,推荐多线程复用httpclient;
因此,多线程复用httpclient单例,模拟同时发起10个请求;
public static void main(String argvs[]){ CloseableHttpClient httpClient = HttpClientBuilder.create().build(); for(int i=0;i<10;i++) { new Thread(new Runnable() { @Override public void run() { GetRequest(httpClient); } }).start(); } }
此时,应该允许一下看看效果;首选启动jetty,运行JettyServerMain
22:48:46.618 INFO log: Logging initialized @897ms 22:48:46.655 INFO Server: jetty-9.2.14.v20151106 22:48:47.051 INFO ServerConnector: Started ServerConnector@5136ac92{HTTP/1.1}{0.0.0.0:8888} 22:48:47.052 INFO Server: Started @1346ms
运行多线程请求HTTPClientV2,服务器端打印日志如下:
22:49:59.056 INFO HelloHandler: threadId=15 come in 22:49:59.057 INFO HelloHandler: threadId=14 come in 22:50:02.080 INFO HelloHandler: threadId=14 finish 22:50:02.080 INFO HelloHandler: threadId=15 finish 22:50:02.144 INFO HelloHandler: threadId=15 come in 22:50:02.144 INFO HelloHandler: threadId=19 come in 22:50:05.144 INFO HelloHandler: threadId=19 finish 22:50:05.144 INFO HelloHandler: threadId=15 finish 22:50:05.148 INFO HelloHandler: threadId=19 come in 22:50:05.148 INFO HelloHandler: threadId=14 come in 22:50:08.149 INFO HelloHandler: threadId=19 finish 22:50:08.149 INFO HelloHandler: threadId=14 finish 22:50:08.153 INFO HelloHandler: threadId=15 come in 22:50:08.153 INFO HelloHandler: threadId=19 come in 22:50:11.153 INFO HelloHandler: threadId=19 finish 22:50:11.153 INFO HelloHandler: threadId=15 finish 22:50:11.158 INFO HelloHandler: threadId=14 come in 22:50:11.158 INFO HelloHandler: threadId=19 come in 22:50:14.158 INFO HelloHandler: threadId=19 finish 22:50:14.158 INFO HelloHandler: threadId=14 finish
是不是感觉到有点惊奇?但从服务器端看,客户端在同一时间,只有2个请求过来,这两个请求完事之后,才会发下面的两个请求;如果服务器端sleep的不是3秒,而是10秒或者好几分钟,客户端会怎样?
step4 增加超时设置(V3)
能够想到超时,说明你一定是有一定技术储备的程序员了。核心代码如下:
// 创建Get请求 HttpGet httpGet = new HttpGet("http://localhost:8888"); RequestConfig requestConfig = RequestConfig.custom() .setSocketTimeout(2000) .setConnectTimeout(2000) .build(); httpGet.setConfig(requestConfig);
再跑一次,看看服务器端的输出
22:55:32.751 INFO HelloHandler: threadId=15 come in 22:55:32.751 INFO HelloHandler: threadId=14 come in 22:55:34.758 INFO HelloHandler: threadId=19 come in 22:55:34.759 INFO HelloHandler: threadId=21 come in 22:55:35.751 INFO HelloHandler: threadId=15 finish 22:55:35.751 INFO HelloHandler: threadId=14 finish 22:55:36.761 INFO HelloHandler: threadId=23 come in 22:55:36.767 INFO HelloHandler: threadId=14 come in 22:55:37.760 INFO HelloHandler: threadId=19 finish 22:55:37.761 INFO HelloHandler: threadId=21 finish 22:55:38.764 INFO HelloHandler: threadId=15 come in 22:55:38.769 INFO HelloHandler: threadId=19 come in 22:55:39.761 INFO HelloHandler: threadId=23 finish 22:55:39.767 INFO HelloHandler: threadId=14 finish 22:55:40.766 INFO HelloHandler: threadId=21 come in 22:55:40.771 INFO HelloHandler: threadId=23 come in 22:55:41.764 INFO HelloHandler: threadId=15 finish 22:55:41.770 INFO HelloHandler: threadId=19 finish 22:55:43.766 INFO HelloHandler: threadId=21 finish 22:55:43.771 INFO HelloHandler: threadId=23 finish
可以看到,因为有2秒的超时,所以在发起请求2秒后,服务器接收到后来的2个请求,此时服务器同时处理的请求有4个;为什么同时发起的有10个请求,服务器却做多同时只接收到4个请求呢?V3完整代码如下:
import java.io.IOException; import org.apache.http.HttpEntity; import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.util.EntityUtils; /** * Date: 2019/5/22 * TIME: 21:25 * HTTPClient * 1、共享httpclient * 2、增加超时时间 * @author donlianli */ public class HTTPClientV3 { public static void main(String argvs[]){ // 获得Http客户端(可以理解为:你得先有一个浏览器;注意:实际上HttpClient与浏览器是不一样的) CloseableHttpClient httpClient = HttpClientBuilder.create().build(); for(int i=0;i<10;i++) { new Thread(new Runnable() { @Override public void run() { GetRequest(httpClient); } }).start(); } } private static void GetRequest(CloseableHttpClient httpClient) { // 创建Get请求 HttpGet httpGet = new HttpGet("http://localhost:8888"); RequestConfig requestConfig = RequestConfig.custom() .setSocketTimeout(2000) .setConnectTimeout(2000) .build(); httpGet.setConfig(requestConfig); // 响应模型 CloseableHttpResponse response = null; try { // 由客户端执行(发送)Get请求 response = httpClient.execute(httpGet); // 从响应模型中获取响应实体 HttpEntity responseEntity = response.getEntity(); if (responseEntity != null) { System.out.println("响应内容为:" + EntityUtils.toString(responseEntity)); } } catch (Exception e) { e.printStackTrace(); } finally { try { if (response != null) { response.close(); } } catch (IOException e) { e.printStackTrace(); } } } }
这就是httpclient没有设置默认线程池的后果,赶快看看你们的代码是不是也有这个问题;
说到这边,有人说是因为连接池没有更改大小导致,其实是错误的,这个单独更改MaxTotal是不管用的,必须同时更改DefaultMaxPerRoute这个默认配置;
我们可以这样理解这两个参数,如果你访问的是一个域名,比如访问的是微信支付域名api.mch.weixin.qq.com,那么此时可以同时发起的请求受这两个参数影响。httpclient首先会从检查请求数是否超过DefaultMaxPerRoute,如果没有,则会再检查连接池中总连接数是否会超过MaxTotal大小。这两项都没有超过,才会新建立一个连接,反之则会等待连接池中其他线程释放。因此,同一时间向同一域名发起的总请求数<=DefaultMaxPerRoute<=MaxTotal;如果你使用httpclient不止向一个域名发起连接请求,那maxTotal会作为一个总的开关,来控制所有已经建立的网络连接数量;
还是上面的代码,如果想同时发起超过10个请求,就应该设置DefaultMaxPerRoute>10。代码(V5)如下:
public static void main(String argvs[]){ PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); // 总连接数 cm.setMaxTotal(200); // 这个至少要大于10 cm.setDefaultMaxPerRoute(20); CloseableHttpClient httpClient = HttpClientBuilder.create() .setConnectionManager(cm).build(); for(int i=0;i<10;i++) { new Thread(new Runnable() { @Override public void run() { GetRequest(httpClient); } }).start(); } }
扩展延伸
一、httpclient默认采用了连接池来管理连接,所以,如果采用这种策略,那么connect_timeout参数一般没什么用,因为本身连接是之前已经建立好的,如果你本身没有设置等待从连接池中获取连接的超时时间(RequestConfig.ConnectionRequestTimeout),那么你设置的超时时间是根本不管用的,因为那个SocketTimeout是获取网络连接之后请求发出之后才会生效的参数;
二、其实httpclient是使用了池管理技术,连接数据库使用的dbcp,c3p0,阿里的druid,连接redis使用的jedis都采用了池技术,这3个参数在使用了池管理的组件中都存在。如果这些组件,没有设置这几个参数,一样会存在类似的问题;关于池管理技术,如果有空,我会再单独写一篇文章;
好了,整个过程已经复现完毕,三个重要参数也都解释的应该清楚;更多的参数设置及其含义,其实还能讲好几篇,我这里就不再细讲了,大家可以参考:https://blog.csdn.net/lovomap151/article/details/78879904;
如果仍然有疑问,可以公众号(猿界汪汪队)私信我;所有用到的代码,可以在https://github.com/donlianli/easydig/tree/master/src/main/java/com/donlian/httpclient/defaultRoute 找到;
PS:其实在我们的支付项目中,这个问题隐藏的更深,支付和退款的超时不一样并且公用了同一个httpclient,退款把所有httpclient的连接都占用完毕导致用户无法支付;我们访问微信使用的https协议,https协议是构建在http协议之上的,微信的退款是双向认证,不同的商户证书是不一样的。太复杂,至今不敢相信我们竟然在没有现场的情况下发现这个缺陷;
其他故障总结案例:
更多最新案例分析,请关注猿界汪汪队