《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 cnblogs.com/xrq730/p/10963689.html 「五月的仓颉」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

起因

6.1大促值班发现的一个问题,一个rpc接口在0~2点用户下单高峰的时候表现rt高(超过1s,实际上针对性优化过的接口rt超过这个值也是有问题的,通常rpc接口里面即使逻辑复杂,300ms应该也搞定了),可以理解,但是在4~5点的时候接口的tps已经不高了,耗时依然在600ms~700ms之间就不能理解了。

查了一下,里面有段调用支付宝http接口的逻辑,但是每次都new一个HttpClient出来发起调用,调用时长大概在300ms+,所以导致即使在非高峰期接口耗时依然非常高。

问题不难,写篇文章系统性地对这块进行一下总结。

用不用线程池的差别

本文主要写的是“池”对于系统性能的影响,因此开始连接池之前,可以以线程池的例子作为一个引子开始本文,简单看下使不使用池的一个效果差别,代码如下:

/**
* 线程池测试
*
* @author 五月的仓颉https://www.cnblogs.com/xrq730/p/10963689.html
*/
public class ThreadPoolTest {

private static final AtomicInteger FINISH_COUNT = new AtomicInteger(0);

private static final AtomicLong COST = new AtomicLong(0);

private static final Integer INCREASE_COUNT = 1000000;

private static final Integer TASK_COUNT = 1000;

@Test
public void testRunWithoutThreadPool() {
List<Thread> tList = new ArrayList<Thread>(TASK_COUNT);

for (int i = 0; i < TASK_COUNT; i++) {
tList.add(new Thread(new IncreaseThread()));
}

for (Thread t : tList) {
t.start();
}

for (;;);
}

@Test
public void testRunWithThreadPool() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 100, 0, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>());

for (int i = 0; i < TASK_COUNT; i++) {
executor.submit(new IncreaseThread());
}

for (;;);
}

private class IncreaseThread implements Runnable {

@Override
public void run() {
long startTime = System.currentTimeMillis();

AtomicInteger counter = new AtomicInteger(0);
for (int i = 0; i < INCREASE_COUNT; i++) {
counter.incrementAndGet();
}
// 累加执行时间
COST.addAndGet(System.currentTimeMillis() - startTime);
if (FINISH_COUNT.incrementAndGet() == TASK_COUNT) {
System.out.println("cost: " + COST.get() + "ms");
}
}

}

}

逻辑比较简单:1000个任务,每个任务做的事情都是使用AtomicInteger从0累加到100W。

每个Test方法运行12次,排除一个最低的和一个最高的,对中间的10次取一个平均数,当不使用线程池的时候,任务总耗时为16693s;而当使用线程池的时候,任务平均执行时间为1073s,超过15倍,差别是非常明显的。

究其原因比较简单,相信大家都知道,主要是两点:

  • 减少线程创建、销毁的开销
  • 控制线程的数量,避免来一个任务创建一个线程,最终内存的暴增甚至耗尽

当然,前面也说了,这只是一个引子引出本文,当我们使用HTTP连接池的时候,任务处理效率提升的原因不止于此。

用哪个httpclient

容易搞错的一个点,大家特别注意一下。HttpClient可以搜到两个类似的工具包,一个是commons-httpclient:

<dependency>
<groupId>commons-httpclient</groupId>
<artifactId>commons-httpclient</artifactId>
<version>3.1</version>
</dependency>

一个是httpclient:

<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.8</version>
</dependency>

选第二个用,不要搞错了,他们的区别在stackoverflow上有解答:

即commons-httpclient是一个HttpClient老版本的项目,到3.1版本为止,此后项目被废弃不再更新(3.1版本,07年8.21发布),它已经被归入了一个更大的Apache HttpComponents项目中,这个项目版本号是HttpClient 4.x(4.5.8最新版本,19年5.30发布)。

随着不断更新,HttpClient底层针对代码细节、性能上都有持续的优化,因此切记选择org.apache.httpcomponents这个groupId。

不使用连接池的运行效果

有了工具类,就可以写代码来验证一下了。首先定义一个测试基类,等下使用连接池的代码演示的时候可以共用:

/**
* 连接池基类
*
* @author 五月的仓颉https://www.cnblogs.com/xrq730/p/10963689.html
*/
public class BaseHttpClientTest {

protected static final int REQUEST_COUNT = 5;

protected static final String SEPERATOR = " ";

protected static final AtomicInteger NOW_COUNT = new AtomicInteger(0);

protected static final StringBuilder EVERY_REQ_COST = new StringBuilder(200);

/**
* 获取待运行的线程
*/
protected List<Thread> getRunThreads(Runnable runnable) {
List<Thread> tList = new ArrayList<Thread>(REQUEST_COUNT);

for (int i = 0; i < REQUEST_COUNT; i++) {
tList.add(new Thread(runnable));
}

return tList;
}

/**
* 启动所有线程
*/
protected void startUpAllThreads(List<Thread> tList) {
for (Thread t : tList) {
t.start();
// 这里需要加一点延迟,保证请求按顺序发出去
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

protected synchronized void addCost(long cost) {
EVERY_REQ_COST.append(cost);
EVERY_REQ_COST.append("ms");
EVERY_REQ_COST.append(SEPERATOR);
}

}

接着看一下测试代码:

/**
* 不使用连接池测试
*
* @author 五月的仓颉https://www.cnblogs.com/xrq730/p/10963689.html
*/
public class HttpClientWithoutPoolTest extends BaseHttpClientTest {

@Test
public void test() throws Exception {
startUpAllThreads(getRunThreads(new HttpThread()));
// 等待线程运行
for (;;);
}

private class HttpThread implements Runnable {

@Override
public void run() {
/**
* HttpClient是线程安全的,因此HttpClient正常使用应当做成全局变量,但是一旦全局共用一个,HttpClient内部构建的时候会new一个连接池
* 出来,这样就体现不出使用连接池的效果,因此这里每次new一个HttpClient,保证每次都不通过连接池请求对端
*/
CloseableHttpClient httpClient = HttpClients.custom().build();
HttpGet httpGet = new HttpGet("https://www.baidu.com/");

long startTime = System.currentTimeMillis();
try {
CloseableHttpResponse response = httpClient.execute(httpGet);
if (response != null) {
response.close();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
addCost(System.currentTimeMillis() - startTime);

if (NOW_COUNT.incrementAndGet() == REQUEST_COUNT) {
System.out.println(EVERY_REQ_COST.toString());
}
}
}

}

}

注意这里如注释所说,HttpClient是线程安全的,但是一旦做成全局的就失去了测试效果,因为HttpClient在初始化的时候默认会new一个连接池出来。

看一下代码运行效果:

324ms   324ms   220ms   324ms   324ms

每个请求几乎都是独立的,所以执行时间都在200ms以上,接着我们看一下使用连接池的效果。

使用连接池的运行结果

BaseHttpClientTest这个类保持不变,写一个使用连接池的测试类:

/**
* 使用连接池测试
*
* @author 五月的仓颉https://www.cnblogs.com/xrq730/p/10963689.html
*/
public class HttpclientWithPoolTest extends BaseHttpClientTest {

private CloseableHttpClient httpClient = null;

@Before
public void before() {
initHttpClient();
}

@Test
public void test() throws Exception {
startUpAllThreads(getRunThreads(new HttpThread()));
// 等待线程运行
for (;;);
}

private class HttpThread implements Runnable {

@Override
public void run() {
HttpGet httpGet = new HttpGet("https://www.baidu.com/");
// 长连接标识,不加也没事,HTTP1.1默认都是Connection: keep-alive的
httpGet.addHeader("Connection", "keep-alive");

long startTime = System.currentTimeMillis();
try {
CloseableHttpResponse response = httpClient.execute(httpGet);
if (response != null) {
response.close();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
addCost(System.currentTimeMillis() - startTime);

if (NOW_COUNT.incrementAndGet() == REQUEST_COUNT) {
System.out.println(EVERY_REQ_COST.toString());
}
}
}

}

private void initHttpClient() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
// 总连接池数量
connectionManager.setMaxTotal(1);
// 可为每个域名设置单独的连接池数量
connectionManager.setMaxPerRoute(new HttpRoute(new HttpHost("www.baidu.com")), 1);
// setConnectTimeout表示设置建立连接的超时时间
// setConnectionRequestTimeout表示从连接池中拿连接的等待超时时间
// setSocketTimeout表示发出请求后等待对端应答的超时时间
RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(1000).setConnectionRequestTimeout(2000)
.setSocketTimeout(3000).build();
// 重试处理器,StandardHttpRequestRetryHandler这个是官方提供的,看了下感觉比较挫,很多错误不能重试,可自己实现HttpRequestRetryHandler接口去做
HttpRequestRetryHandler retryHandler = new StandardHttpRequestRetryHandler();

httpClient = HttpClients.custom().setConnectionManager(connectionManager).setDefaultRequestConfig(requestConfig)
.setRetryHandler(retryHandler).build();

// 服务端假设关闭了连接,对客户端是不透明的,HttpClient为了缓解这一问题,在某个连接使用前会检测这个连接是否过时,如果过时则连接失效,但是这种做法会为每个请求
// 增加一定额外开销,因此有一个定时任务专门回收长时间不活动而被判定为失效的连接,可以某种程度上解决这个问题
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
try {
// 关闭失效连接并从连接池中移除
connectionManager.closeExpiredConnections();
// 关闭30秒钟内不活动的连接并从连接池中移除,空闲时间从交还给连接管理器时开始
connectionManager.closeIdleConnections(20, TimeUnit.SECONDS);
} catch (Throwable t) {
t.printStackTrace();
}
}
}, 0 , 1000 * 5);
}

}

这个类详细地演示了HttpClient的用法,相关注意点都写了注释,就不讲了。

和上面一样,看一下代码执行效果:

309ms   83ms   57ms   53ms   46ms

看到除开第一次调用的309ms以外,后续四次调用整体执行时间大大提升,这就是使用了连接池的好处,接着,就探究一下使用连接池提升整体性能的原因。

绕不开的长短连接

说起HTTP,必然绕不开的一个话题就是长短连接,这个话题之前的文章已经写了好多次了,这里再写一次。

我们知道,从客户端发起一个HTTP请求到服务端响应HTTP请求之间,大致有以下几个步骤:

HTTP1.0最早在网页中使用是1996年,那个时候只是使用一些较为简单的网页和网络的请求,每次请求都需要建立一个单独的连接,上一次和下一次请求完全分离。这种做法,即使每次的请求量都很小,但是客户端和服务端每次建立TCP连接和关闭TCP连接都是相对比较费时的过程,严重影响客户端和服务端的性能。

基于以上的问题,HTTP1.1在1999年广泛应用于现在的各大浏览器网络请求中,同时HTTP1.1也是当前使用最为广泛的HTTP协议(2015年诞生了HTTP2,但是还未大规模应用),这里不详细对比HTTP1.1针对HTTP1.0改进了什么,只是在连接这块,HTTP1.1支持在一个TCP连接上传送多个HTTP请求和响应,减少了建立和关闭连接的消耗延迟,一定程度上弥补了HTTP1.0每次请求都要创建连接的缺点,这就是长连接,HTTP1.1默认使用长连接。

那么,长连接是如何工作的呢?首先,我们要明确一下,长短连接是通信层(TCP)的概念,HTTP是应用层协议,它只能说告诉通信层我打算一段时间内复用TCP通道而没有自己去建立、释放TCP通道的能力。那么HTTP是如何告诉通信层复用TCP通道的呢?看下图:

分为以下几个步骤:

  • 客户端发送一个Connection: keep-alive的header,表示需要保持连接
  • 客户端可以顺带Keep-Alive: timeout=5,max=100这个header给服务端,表示tcp连接最多保持5秒,长连接接受100次请求就断开,不过浏览器看了一些请求貌似没看到带这个参数的
  • 服务端必须能识别Connection: keep-alive这个header,并且通过Response Header带同样的Connection: keep-alive,告诉客户端我可以保持连接
  • 客户端和服务端之间通过保持的通道收发数据
  • 最后一次请求数据,客户端带Connection:close这个header,表示连接关闭

至此在一个通道上交换数据的过程结束,在默认的情况下:

  • 长连接的请求数量限定是最多连续发送100个请求,超过限制即关闭这条连接
  • 长连接连续两个请求之间的超时时间是15秒(存在1~2秒误差),超时后会关闭TCP连接,因此使用长连接应当尽量保持在13秒之内发送一个请求
  • 这些的限制都是在重用长连接与长连接过多之间做的一个折衷,因为长连接虽好,但是长时间的TCP连接容易导致系统资源无效占用,浪费系统资源。

最后这个地方多说一句http的keep-alive和tcp的keep-alive的区别,一个经常讲的问题,顺便记录一下:

  • http的keep-alive是为了复用已有连接
  • tcp的keep-alive是为了保活,即保证对端还存活,不然对端已经不在了我这边还占着和对端的这个连接,浪费服务器资源,做法是隔一段时间发送一个心跳包到对端服务器,一旦长时间没有接收到应答,就主动关闭连接

性能提升的原因

通过前面的分析,很显而易见的,使用HTTP连接池提升性能最重要的原因就是省去了大量连接建立与释放的时间,除此之外还想说一点。

TCP建立连接的时候有如下流程:

如图所示,这里面有两个队列,分别为syns queue(半连接队列)与accept queue(全连接队列)。

一旦不使用长连接而每次连接都重新握手的话,队列一满服务端将会发送一个ECONNREFUSED错误信息给到客户端,相当于这次请求就失效了,即使不失效,后来的请求需要等待前面的请求处理,排队也会增加响应的时间。

By the way,基于上面的分析,不仅仅是HTTP,所有应用层协议,例如数据库有数据库连接池、hsf提供了hsf接口连接池,使用连接池的方式对于接口性能都是有非常大的提升的,都是同一个道理。

TLS层的优化

上面讲的都是针对应用层协议使用连接池提升性能的原因,但是对于HTTP请求,我们知道目前大多数网站都运行在HTTPS协议之上,即在通信层和应用层之间多了一层TLS:

通过TLS层对报文进行了加密,保证数据安全,其实在HTTPS这个层面上,使用连接池对性能有提升,TLS层的优化也是一个非常重要的原因。

HTTPS原理不细讲了,反正大致上就是一个证书交换–>服务端加密–>客户端解密的过程,整个过程中反复地客户端+服务端交换数据是一个耗时的过程,且数据的加解密是一个计算密集型的操作消耗CPU资源,因此如果相同的请求能省去加解密这一套就能在HTTPS协议下对整个性能有很大提升了,实际上这种优化是有的,这里用到了一种会话复用的技术。

TLS的握手由客户端发送Client Hello消息开始,服务端返回Server Hello结束,整个流程中提供了2种不同的会话复用机制,这个地方就简单看一下,知道有这么一回事:

  • session id会话复用—-对于已建立的TLS会话,使用session id为key(来自第一次请求的Server Hello中的session id),主密钥为value组成一对键值对保存在服务端和客户端的本地。当第二次握手时,客户端如果想复用会话,则发起的Client Hello中带上session id,服务端收到这个session id检查本地是否存在,有则允许会话复用,进行后续操作
  • session ticket会话复用—-一个session ticket是一个加密的数据blob,其中包含需要重用的TLS连接信息如session key等,它一般使用ticket key加密,因为ticket key服务端也知道,在初始化握手中服务端发送一个session ticket到客户端并存储到客户端本地,当会话重用时,客户端发送session ticket到服务端,服务端解密成功即可复用会话

session id的方式缺点是比较明显的,主要原因是负载均衡中,多机之间不同步session,如果两次请求不落在同一台机器上就无法找到匹配信息,另外服务端存储大量的session id又需要消耗很多资源,而session ticket是比较好解决这个问题的,但是最终使用的是哪种方式还是有浏览器决定。关于session ticket,在网上找了一张图,展示的是客户端第二次发起请求,携带session ticket的过程:

一个session ticket超时时间默认为300s,TLS层的证书交换+非对称加密作为性能消耗大户,通过会话复用技术可以大大提升性能。

使用连接池的注意点

使用连接池,切记每个任务的执行时间不要太长。

因为HTTP请求也好、数据库请求也好、hsf请求也好都是有超时时间的,比如连接池中有10个线程,并发来了100个请求,一旦任务执行时间非常长,连接都被先来的10个任务占着,后面90个请求迟迟得不到连接去处理,就会导致这次的请求响应慢甚至超时。

当然每个任务的业务不一样,但是按照我的经验,尽量把任务的执行时间控制在50ms最多100ms之内,如果超出的,可以考虑以下三种方案:

  • 优化任务执行逻辑,比如引入缓存
  • 适当增大连接池中的连接数量
  • 任务拆分,将任务拆分为若干小任务

连接池中的连接数量如何设置

有些朋友可能会问,我知道需要使用连接池,那么一般连接池数量设置为多少比较合适?有没有经验值呢?首先我们需要明确一个点,连接池中的连接数量太多不好、太少也不好:

  • 比如qps=100,因为上游请求速率不可能是恒定不变的100个请求/秒,可能前1秒900个请求,后9秒100个请求,平均下来qps=100,当连接数太多的时候,可能出现的场景是高流量下建立连接—>低流量下释放部分连接—>高流量下重新建立连接的情况,相当于虽然使用了连接池,但是因为流量不均匀反复建立连接、释放链接
  • 线程数太少当然也是不好的,任务多而连接少,导致很多任务一直在排队等待前面的执行完才可以拿到连接去处理,降低了处理速度

那针对连接池中的连接数量如何设置的这个问题,答案是没有固定的,但是可以通过估算得到一个预估值。

首先开发同学对于一个任务每天的调用量心中需要有数,假设一天1000W次好了,线上有10台服务器,那么平均到每台服务器每天的调用量在100W,100W平均到1天的86400秒,每秒的调用量1000000 / 86400 ≈ 11.574次,根据接口的一个平均响应时长适当加一点余量,差不多设置在15~30比较合适,根据线上运行的实际情况再做调整。

文章目录
  1. 1. 起因
  2. 2. 用不用线程池的差别
  3. 3. 用哪个httpclient
  4. 4. 不使用连接池的运行效果
  5. 5. 使用连接池的运行结果
  6. 6. 绕不开的长短连接
  7. 7. 性能提升的原因
  8. 8. TLS层的优化
  9. 9. 使用连接池的注意点
  10. 10. 连接池中的连接数量如何设置