前言

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协议之上的,微信的退款是双向认证,不同的商户证书是不一样的。太复杂,至今不敢相信我们竟然在没有现场的情况下发现这个缺陷;
 
其他故障总结案例:
 
更多最新案例分析,请关注猿界汪汪队

做支付遇到的HttpClient大坑的更多相关文章

  1. 使用 ping++做支付的流程

    获取支付凭据 /// <summary> /// 获取支付凭据 /// </summary> /// <param name="model">& ...

  2. 干货 | 高耦合场景下,Trip.com如何做支付设计与落地

    干货 | 高耦合场景下,Trip.com如何做支付设计与落地 https://mp.weixin.qq.com/s/VR9NTR3RpKVfmUPcwgMABg 原创 Ryann Liu 携程技术 2 ...

  3. HttpClient学习(四)—— 关于Http

    一.Http状态码 状态码分类 100 ~ 199 信息提示 200 ~ 299 成功 300 ~ 399 重定向 400 ~ 499 客户端错误 500 ~ 599 服务端错误 常见状态码 200 ...

  4. 微信支付开发h5调用

    这两天做微信支付开发.碰到大坑.纠结死我了.好不容做完. 后台java:直接上代码:注意区分前后端的变量大小写... @RequestMapping(value = "/index" ...

  5. APP支付报错ALI40247处理方案!

    简直日狗!这里要吐槽支付宝: 1.支付宝文档太复杂,分类虽然详细,但是我找不到app支付 对应服务端的demo 2.提供下载的sdk都是全整合的 用下来都是一条龙服务,还有一些客户端(app)的请求也 ...

  6. Phonegap 之 iOS银联在线支付(js调用ios端银联支付控件)

    Phonegap项目,做支付的时候,当把网站打包到ios或android端成app后,在app上通过wap调用银联在线存在一个问题: 就是当从银联支付成功后,再从服务器返回到app客户端就很难实现. ...

  7. JAVA微信支付接口开发——支付

    微信支付接口开发--支付 这几天在做支付服务,系统接入了支付宝.微信.银联三方支付接口.个人感觉支付宝的接口开发较为简单,并且易于测试. 关于数据传输,微信是用xml,所以需要对xml进行解析. 1. ...

  8. 微信 JS API 支付教程

    最近一个项目中用到了微信开发,之前没有做过支付相关的东西,算是拿这个来练练手,刚开始接触支付时候很懵逼,加上微信支付开发文档本来就讲得不清楚,我是彻底蒙圈了,参考了很多代码之后,算是有一点思路了. 用 ...

  9. 【SSH网上商城项目实战26】完成订单支付后的短信发送功能

     转自: https://blog.csdn.net/eson_15/article/details/51475431 上一节我们使用了Java mail完成了给买家发送邮件的功能,还遗留一个功能,就 ...

随机推荐

  1. 【bzoj1001】【狼抓兔子】

    1001: [BeiJing2006]狼抓兔子 Time Limit: 15 Sec Memory Limit: 162 MB Submit: 12719 Solved: 3017 [Submit][ ...

  2. Cocos2d-x 3.0final 终结者系列教程13-贪食蛇游戏案例(全)

    快过节了.谢谢了屈原,我们爱你. 应该多几个向屈大人一样跳江的,这样我们就能够放假纪念啦. ---------------------------------快过节了.弄个案例,大家最好还是假期做做, ...

  3. 基础树形DP小结

    HDU 4044 Geodefense http://blog.csdn.net/zmx354/article/details/25109897 树形DP暂且先告一段落了. HDU 3586 Info ...

  4. scanf,printf函数细节

    今天笔试的时候遇到一个考察C语言scanf函数的题目 int x; float y; scanf("%3d%f",&x,&y); // input 123456 6 ...

  5. VIM中括号的自动补全与删除

    先放来源 http://oldj.net/article/vim-parenthesis/ 很多现代 IDE 都有自动补全配对括号的功能,比如输入了左括号“(”,IDE 就自动在后面添加一个对应的右括 ...

  6. Controller methods and views

    https://docs.asp.net/en/latest/tutorials/first-mvc-app/controller-methods-views.html We have a good ...

  7. raspberry-常用命令

    安全关闭raspberry:sudo shutdown -h now 一次升级系统中的所有内容:sudo apt-get update 升级单个软件包:sudo apt-get install *** ...

  8. [CF1139 E] Maximize Mex 解题报告 (二分图匹配)

    interlinkage: https://codeforces.com/contest/1139/problem/E description: 有$n$个学生,$m$个社团,每个学生有一个能力值,属 ...

  9. python基本数据类型之列表list

    list的基本功能 结果类型  中括号括起来 逗号(,)分割每一个元素 列表中的元素可以是数字,字符串,列表,布尔值所有的都能放 索引,切片 ? 1 2 3 li = [1, 3, 5, " ...

  10. B - Mike and Cellphone(map)

    Problem description While swimming at the beach, Mike has accidentally dropped his cellphone into th ...