最近学习了Http链接池

原由html

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

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

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

 

用不用线程池的差异缓存

本文主要写的是“池”对于系统性能的影响,所以开始链接池以前,能够以线程池的例子做为一个引子开始本文,简单看下使不使用池的一个效果差异,代码以下:安全

/**
 * 线程池测试
 * 
 * @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倍,差异是很是明显的。网络

究其缘由比较简单,相信你们都知道,主要是两点:session

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

固然,前面也说了,这只是一个引子引出本文,当咱们使用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(全链接队列),这里面的流程就不细讲了,以前我有文章http://www.javashuo.com/article/p-altbpfqa-dy.html专门写过这个话题。

一旦不使用长链接而每次链接都从新握手的话,队列一满服务端将会发送一个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比较合适,根据线上运行的实际状况再作调整。

相关文章
相关标签/搜索