做支付遇到的HttpClient大坑

发表于:2019-6-04 10:55

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:猿界汪汪队    来源:博客园

  前言
  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个参数在使用了池管理的组件中都存在。如果这些组件,没有设置这几个参数,一样会存在类似的问题。
  PS:其实在我们的支付项目中,这个问题隐藏的更深,支付和退款的超时不一样并且公用了同一个httpclient,退款把所有httpclient的连接都占用完毕导致用户无法支付;我们访问微信使用的https协议,https协议是构建在http协议之上的,微信的退款是双向认证,不同的商户证书是不一样的。太复杂,至今不敢相信我们竟然在没有现场的情况下发现这个缺陷;

     上文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号