HttpClient 在 vivo 内销浏览器的高并发实践优化
作者:vivo 互联网服务器团队- Zhi Guangquan
HttpClient 作为 Java 程序员最常用的 Http 工具,其对 Http 连接的管理能简化开发,并且提升连接重用效率;在正常情况下,HttpClient 能帮助我们高效管理连接,但在一些并发高,报文体较大的情况下,如果再遇到网络波动,如何保证连接被高效利用,有哪些优化空间。
一、问题现象
北京时间 X 月 X 日,浏览器信息流服务监控出现异常,主要表现在以下三个方面:
从某个时间点开始,云监控显示部分 Http 接口的熔断器被打开,而且从明细列表可以发现问题机器:
2. 从 PAAS 平台 Hystrix 熔断管理界面中可以进一步确认问题机器的所有 Http 接口调用均出现了熔断:
3. 日志中心有大量从 Http 连接池获取连接的异常:org.apache.http.impl.execchain.RequestAbortedException: Request aborted。
二、问题定位
综合以上三个现象,大概可以推测出问题机器的 TCP 连接管理出了问题,可能是虚拟机问题,也可能是物理机问题;与运维与系统侧沟通后,发现虚拟机与物理机均无明显异常,第一时间联系运维重启了问题机器,线上问题得到解决。
2.1 临时解决方案
几天以后,线上部分其他机器也陆续出现了上述现象,此时基本可以确认是服务本身有问题;既然问题与 TCP 连接相关,于是联系运维在问题机器上建立了一个作业查看 TCP 连接的状态分布:
结果如下:
如上图,问题机器的 CLOSE_WAIT 状态的连接数已经接近 200 左右(该服务 Http 连接池最大连接数设置的 250),那问题直接原因基本可以确认是 CLOSE_WAIT 状态的连接过多导致的;本着第一时间先解决线上问题的原则,先把连接池调整到 500,然后让运维重启了机器,线上问题暂时得到解决。
2.2 原因分析
调整连接池大小只是暂时解决了线上问题,但是具体原因还不确定,按照以往经验,出现连接无法正常释放基本都是开发者使用不当,在使用完成后没有及时关闭连接;但很快这个想法就被否定了,原因显而易见:当前的服务已经在线上运行了一周左右,中间没有经历过发版,以浏览器的业务量,如果是连接使用完没有及时关。
闭,250 的连接数连一分钟都撑不到就会被打爆。那么问题就只能是一些异常场景导致的连接没有释放;于是,重点排查了下近期上线的业务接口,尤其是那种数据包体较大,响应时间较长的接口,最终把目标锁定在了某个详情页优化接口上;先查看处于 CLOSE_WAIT 状态的 IP 与端口连接对,确认对方服务器 IP 地址。
经过与合作方确认,目标 IP 均来自该合作方,与我们的推测是相符的。
2.3 TCP 抓包
在定位问题的同时,也让运维同事帮忙抓取了 TCP 的数据包,结果表明确实是客户端(浏览器服务端)没返回 ACK 结束握手,导致挥手失败,客户端处于了 CLOSE_WAIT 状态,数据包的大小也与怀疑的问题接口相符。
为了方便大家理解,我从网上找了一张图,大家可以作为参考:
CLOSE_WAIT 是一种被动关闭状态,如果是 SERVER 主动断开的连接,那么就会在 CLIENT 出现 CLOSE_WAIT 的状态,反之同理;
通常情况下,如果客户端在一次 http 请求完成后没有及时关闭流(tcp 中的流套接字),那么超时后服务端就会主动发送关闭连接的 FIN,客户端没有主动关闭,所以就停留在了 CLOSE_WAIT 状态,如果是这种情况,很快连接池中的连接就会被耗尽。
所以,我们今天遇到的情况(处于 CLOSE_WAIT 状态的连接数每天都在缓慢增长),更像是某一种异常场景导致的连接没有关闭。
2.4 独立连接池
为了不影响其他业务场景,防止出现系统性风险,我们先把问题接口连接池进行了独立管理。
2.5 深入分析
带着 2.3 的疑问我们仔细查看一下业务调用代码:
这段代码存在一个明显的问题:既关闭了数据传输流( IOUtils.closeQuietly(is)),也关闭了整个连接(IOUtils.closeQuietly(httpResponse)),这样我们就没办法进行连接的复用了;但是却更让人疑惑了:既然每次都手动关闭了连接,为什么还会有大量 CLOSE_WAIT 状态的连接存在呢?
如果问题不在业务调用代码上,那么只能是这个业务接口具有的某种特殊性导致了问题的发生;通过抓包分析发现该接口有一个明显特征:接口返回报文较大,平均在 500KB 左右。那么问题就极有可能是报文过大导致了某种异常,造成了连接不能被复用也不能被释放。
2.6 源码分析
开始分析之前,我们需要了解一个基础知识:Http 的长连接和短连接。所谓长连接就是建立起连接之后,可以复用连接多次进行数据传输;而短连接则是每次都需要重新建立连接再进行数据传输。
而通过对接口的抓包我们发现,响应头里有 Connection:keep-live 字样,那我们就可以重点从 HttpClient 对长连接的管理入手来进行代码分析。
2.6.1 连接池初始化
初始化方法:
进入 PoolingHttpClientConnectionManager 这个类,有一个重载构造方法里包含连接存活时间参数:
顺着继续向下查看
manager 的构造方法到此结束,我们不难发现 validityDeadline 会被赋值给 expiry 变量,那我们接下来就要看下 HttpClient 是在哪里使用 expiry 这个参数的;
通常情况下,实例对象被构建出来的时候会初始化一些策略参数,此时我们需要查看构建 HttpClient 实例的方法来寻找答案:
此方法包含一系列的初始化操作,包括构建连接池,给连接池设置最大连接数,指定重用策略和长连接策略等,这里我们还注意到,HttpClient 创建了一个异步线程,去监听清理空闲连接。
当然,前提是你打开了自动清理空闲连接的配置,默认是关闭的。
接着我们就看到了 HttpClient 关闭空闲连接的具体实现,里面有我们想要看到的内容:
此时,我们可以得出第一个结论:可以在初始化连接池的时候,通过实现带参的 PoolingHttpClientConnectionManager 构造方法,修改 validityDeadline 的值,从而影响 HttpClient 对长连接的管理策略。
2.6.2 执行方法入口
先找到执行入口方法:org.apache.http.impl.execchain.MainClientExec.execute,看到了 keepalive 相关代码实现:
我们来看下默认的策略:
由于中间的调用逻辑比较简单,就不在这里一一把调用的链路贴出来了,这边直接给结论:HttpClient 对没有指定连接有效时间的长连接,有效期设置为永久(Long.MAX_VALUE)。
综合以上分析,我们可以得出最终结论:
HttpClient 通过控制 newExpiry 和 validityDeadline 来实现对长连接的有效期的管理,而且对没有指定连接有效时间的长连接,有效期设置为永久。
至此我们可以大胆给出一个猜测:长连接的有效期是永久,而因为某种异常导致长连接没有被及时关闭,而永久存活了下来,不能被复用也不能被释放。(只是根据现象的猜测,虽然最后被证实并不完全正确,但确实提高了我们解决问题的效率)。
基于此,我们也可以通过改变这两个参数来实现对长连接的管理:
这样简单修改上线后,处于 close_wait 状态的连接数没有再持续增长,这个线上问题也算是得到了彻底的解决。
但此时相信大家也都存在一个疑问:作为被广泛使用的开源框架,HttpClient 难道对长连接的管理这么粗糙吗?一个简单的异常调用就能导致整个调度机制彻底崩溃,而且不会自行恢复;
于是带着疑问,再一次详细查看了 HttpClient 的源码。
三、关于 HttpClient
3.1 前言
开始分析之前,先简单介绍下几个核心类:
【PoolingHttpClientConnectionManager】:连接池管理器类,主要作用是管理连接和连接池,封装连接的创建、状态流转以及连接池的相关操作,是操作连接和连接池的入口方法;
【CPool】:连接池的具体实现类,连接和连接池的具体实现均在 CPool 以及抽象类 AbstractConnPool 中实现,也是分析的重点;
【CPoolEntry】:具体的连接封装类,包含连接的一些基础属性和基础操作,比如连接 id,创建时间,有效期等;
【HttpClientBuilder】:HttpClient 的构造器,重点关注 build 方法;
【MainClientExec】:客户端请求的执行类,是执行的入口,重点关注 execute 方法;
【ConnectionHolder】:主要封装释放连接的方法,是在 PoolingHttpClientConnectionManager 的基础上进行了封装。
3.2 两个连接
最大连接数(maxTotal)
最大单路由连接数(maxPerRoute)
最大连接数,顾名思义,就是连接池允许创建的最大连接数量;
最大单路由连接数可以理解为同一个域名允许的最大连接数,且所有 maxPerRoute 的总和不能超过 maxTotal。
以浏览器为例,浏览器对接了头条和一点,为了做到业务隔离,不相互影响,可以把 maxTotal 设置成 500,而 defaultMaxPerRoute 设置成 400,主要是因为头条的业务接口量远大于一点,defaultMaxPerRoute 需要满足调用量较大的一方。
3.3 三个超时
connectionRequestTimout
connetionTimeout
socketTimeout
【connectionRequestTimout】:指从连接池获取连接的超时时间;
【connetionTimeout】:指客户端和服务器建立连接的超时时间,超时后会报
ConnectionTimeOutException 异常;
【socketTimeout】:指客户端和服务器建立连接后,数据传输过程中数据包之间间隔的最大时间,超出后会抛出 SocketTimeOutException。
一定要注意:这里的超时不是数据传输完成,而只是接收到两个数据包的间隔时间,这也是很多线上诡异问题发生的根本原因。
3.4 四个容器
free
leased
pending
available
【free】:空闲连接的容器,连接还没有建立,理论上 freeSize=maxTotal -leasedSize
- availableSize(其实 HttpClient 中并没有该容器,只是为了描述方便,特意引入的一个容器)。
【leased】:租赁连接的容器,连接创建后,会从 free 容器转移到 leased 容器;也可以直接从 available 容器租赁连接,租赁成功后连接被放在 leased 容器中,此种场景主要是连接的复用,也是连接池的一个很重要的能力。
【pending】:等待连接的容器,其实该容器只是在等待连接释放的时候用作阻塞线程,下文也不会再提到,感兴趣的可以参考具体实现代码,其与 connectionRequestTimout 相关。
【available】:可复用连接的容器,通常直接从 leased 容器转移过来,长连接的情况下完成通信后,会把连接放到 available 列表,一些对连接的管理和释放通常都是围绕该容器进行的。
注:由于存在 maxTotal 和 maxPerRoute 两个连接数限制,下文在提到这四种容器时,如果没有带前缀,都代表是总连接数,如果是 r.xxxx 则代表是路由连接里的某个容器大小。
maxTotal 的组成
3.5 连接的产生与管理
循环从 available 容器中获取连接,如果该连接未失效(根据上文提到的 expiry 字段判断),则把该连接从 available 容器中删除,并添加到 leased 容器,并返回该连接;
如果在第一步中没有获取到可用连接,则判断 r.available + r.leased 是否大于 maxPerRoute,其实就是判断是否还有 free 连接;如果不存在,则需要把多余分配的连接释放掉(r. available + r.leased - maxPerRoute),来保证真实的连接数受 maxPerRoute 控制(至于为什么会出现 r.leased+r.available>maxPerRoute 的情况其实也很好理解,虽然在整个状态流转过程都加了锁,但是状态的流转并不是原子操作,存在一些异常的场景都会导致状态短时间不正确);所以我们可以得出结论,maxPerRoute 只是一个理论上的最大数值,其实真实产生的连接数在短时间内是可能大于这个值的;
在真实的连接数(r .leased+ r .available)小于 maxPerRoute 且 maxTotal>leased 的情况下:如果 free>0,则重新创建一个连接;如果 free=0,则把 available 容器里的最早创建的一个连接关闭掉,然后再重新创建一个连接;看起来有点绕,其实就是优先使用 free 容器里的连接,获取不到再释放 available 容器里的连接;
如果经过上述过程仍然没有获取到可用连接,那就只能等待一个 connectionRequestTimout 时间,或者有其他线程的信号通知来结束整个获取连接的过程。
3.6 连接的释放
如果是长连接(reusable),则把该连接从 leased 容器中删除,然后添加到 available 容器的头部,设置有效期为 expiry;
如果是短连接(non-reusable),则直接关闭该连接,并且从 released 容器中删除,此时的连接被释放,处于 free 容器中;
最后,唤醒“连接的产生与管理“第四部中的等待线程。
整个过程分析完,了解了 httpclient 如何管理连接,再回头来看我们遇到的那个问题就比较清晰了:
正常情况下,虽然建立了长连接,但是我们会在 finally 代码块里去手动关闭,此场景其实是触发了“连接的释放”中的步骤 2,连接直接被关闭;所以正常情况下是没有问题的,长连接其实并没有发挥真正的作用;
那问题自然就只能出现在一些异常场景,导致了长连接没有被及时关闭,结合最初的分析,是服务端主动断开了连接,那大概率出现在一些超时导致连接断开的异常场景,我们再回到 org.apache.http.impl.execchain.MainClientExec 这个类,发现这样几行代码:
connHolder.releaseConnection()对应“连接的释放”中提到的步骤 1,此时连接只是被放入了 available 容器,并且有效期是永久;
return new HttpResponseProxy(response, null)返回的 ConnectionHolder 是 null,结合 IOUtils.closeQuietly(httpResponse)的具体实现,连接并没有及时关闭,而是永久的放在了 available 容器里,并且状态为 CLOSE_WAIT,无法被复用;
根据 “连接的产生与管理”的步骤 3 的描述,在 free 容器为空的时候 httpclient 是能够主动释放 available 里的连接的,即使连接永久的放在了 available 容器里,理论上也不会造成连接永远无法释放;
然而再结合“连接的产生与管理”的步骤 4,当 free 容器为空了以后,从连接池获取连接时需要等待 available 容器里的连接被释放掉,整个过程是单线程的,效率极低,势必会造成拥堵,最终导致大量等待获取连接超时报错,这也与我们线上看到的场景相吻合。
四、总结
连接池的主要功能有两个:连接的管理和连接的复用,在使用连接池的时候一定要注意只需关闭当前数据流,而不要每次都关闭连接,除非你的目标访问地址是完全随机的;
maxTotal 和 maxPerRoute 的设置一定要谨慎,合理的分配参数可以做到业务隔离,但如果无法准确做出评估,可以暂时设置成一样,或者用两个独立的 httpclient 实例;
一定记得要设置长连接的有效期,用
PoolingHttpClientConnectionManager(60, TimeUnit.SECONDS)构造函数,尤其是调用量较大的情况,防止发生不可预知的问题;
可以通过设置 evictIdleConnections(5, TimeUnit.SECONDS)定时清理空闲连接,尤其是 http 接口响应时间短,并发量大的情况下,及时清理空闲连接,避免从连接池获取连接的时候发现连接过期再去关闭连接,能在一定程度上提高接口性能。
五、写在最后
HttpClient 作为当前使用最广泛的基于 Java 语言的 Http 调用框架,在笔者看来其存在两点明显不足:
没有提供监控连接状态的入口,也没有提供能外部介入动态影响连接生命周期的扩展点,一旦线上出现问题可能就是致命的;
此外,其获取连接的方式是采用同步锁的方式,在并发较高的情况下存在一定的性能瓶颈,而且其对长连接的管理方式存在问题,稍不注意就会导致建立大量异常长连接而无法及时释放,造成系统性灾难。
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/30e3def1f8c32a146bb3189e9】。文章转载请联系作者。
评论