起因
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>
选第二个用, 不要搞错了, 他们的区别在 Stack Overflow 上有解答:
即 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(全连接队列), 这里面的流程就不细讲了, 之前我有文章 https://www.cnblogs.com/xrq730/p/6910719.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 比较合适, 根据线上运行的实际情况再做调整.
来源: https://www.cnblogs.com/xrq730/p/10963689.html