当前位置: 首页 > 科技观察

HttpClient针对vivo国产浏览器高并发实践进行了优化_0

时间:2023-03-14 23:55:19 科技观察

作者|vivo互联网服务器团队-志广权HttpClient是Java程序员最常用的Http工具。其对Http连接的管理可以简化开发,提高连接重用效率;一般情况下,HttpClient可以帮我们高效管理连接,但是在一些高并发、消息体较大的情况下,如果再次出现网络波动,如何保证连接被高效利用,还有哪些优化空间。1.症状北京时间X日,浏览器信息流服务监控异常,主要表现在以下三个方面:云监控某时间点,部分Http接口熔断打开,从details列表中可以查到问题机器:2.从PaaS平台Hystrix断路器管理界面可以进一步确认问题机器的所有Http接口调用都有断路器:3.有从http连接池org.apache获取连接的日志中心出现大量异常。http.impl.execchain.RequestAbortedException:请求中止。2、问题定位根据以上三种现象,可以推断是问题机的TCP连接管理出现了问题,可能是虚拟机的问题,也可能是物理机的问题;与运维和系统端沟通后,发现虚拟机与物理机没有明显异常,立即联系运维重启问题机器,线上问题为解决了。2.1临时解决方案过了几天,网上的一些其他机器上陆续出现了以上现象。这时候基本可以确定是服务本身有问题;由于问题与TCP连接有关,于是联系运维在问题机器上创建作业查看TCP连接状态分布:netstat-ant|awk'/^tcp/{++S[$NF]}END{for(ainS)print(a,S[a])}'结果如下:如上图所示,问题机器处于CLOSE_WAIT状态的连接数接近200(服务Http连接池最大连接数设置为250),问题的直接原因基本可以确认是CLOSE_WAIT状态的连接过多导致的;本着第一时间先解决线上问题的原则,先把连接池调到500,然后要求运维重启机器,线上问题就暂时解决了。2.2原因分析调整连接池大小只是暂时解决了线上问题,具体原因还不清楚。根据以往经验,连接无法正常释放是因为开发者使用不当,使用后没有及时关闭连接;但这个想法很快就被否决了,原因很明显:目前的服务已经上线一周左右,中间一直没有发布。由于浏览器的业务量,如果连接用完,没有及时关闭。关闭,250的连接号坚持不了一分钟就会被炸掉。那么问题只能是由于某些异常场景导致连接没有释放;因此,我们重点排查了近期上线的业务接口,尤其是数据包大、响应时间长的接口,最终将目标锁定在了特定的页面优化接口上;首先查看CLOSE_WAIT状态下的IP和端口连接对,确认对方服务器的IP地址。netstat-tulnap|grepCLOSE_WAIT与伙伴确认后,目标IP均来自伙伴,与我们的推测一致。2.3TCP抓包在定位问题的同时,也请运维同事帮忙抓取TCP数据包。结果显示客户端(浏览器服务器)没有返回ACK结束握手,导致握手失败,客户端处于失败状态。在CLOSE_WAIT状态下,数据包的大小也与疑似有问题的接口匹配。为了方便大家理解,从网上找了一张图,大家可以参考一下:CLOSE_WAIT是一种被动关机状态。如果连接被服务器主动断开,则CLIENT中会出现CLOSE_WAIT状态,反之亦然;通常情况下,如果客户端在一次http请求完成后没有及时关闭流(tcp中的streamsocket),服务器会在超时后主动发送一个FIN关闭连接,而客户端并没有主动关闭,所以它一直处于CLOSE_WAIT状态,如果是这种情况,连接池中的连接很快就会耗尽。所以,我们今天遇到的情况(CLOSE_WAIT状态的连接数量每天都在慢慢增加),更像是某种异常场景导致连接无法关闭。2.4独立连接池为了不影响其他业务场景,防范系统性风险,我们先将问题接口连接池独立管理。2.5深入分析带着2.3的问题,我们来仔细看看业务调用代码:HttpEntityhttpEntity=httpResponse.getEntity();is=httpEntity.getContent();}catch(异常e){log.error("");}最后{IOUtils.closeQuietly(is);IOUtils.closeQuietly(httpResponse);这段代码有一个明显的问题:既关闭了数据传输流(IOUtils.closeQuietly(is)),也关闭了整个连接(IOUtils.closeQuietly(httpResponse)),这样我们就无法重用连接;但更让人疑惑的是:既然每次都是手动关闭连接,为什么还会有大量处于CLOSE_WAIT状态的连接呢?如果问题不在业务调用代码,那么只能是业务接口的一些特殊性导致的;通过抓包分析,发现接口有一个明显的特征:接口返回的报文比较大,平均500KB左右。那么问题很可能是数据包过大导致某种异常,导致连接无法重用或释放。2.6源码分析在开始分析之前,我们需要了解一个基础知识:Http长连接和短连接。所谓长连接,是指连接建立后,可以多次重复使用该连接进行数据传输;而每次传输数据都需要重新建立短连接。通过在接口上抓包,我们发现responseheader中有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对于没有指定连接有效期的长连接,设置有效期为permanent(Long.MAX_VALUE)。基于以上分析,我们可以得出最终结论:HttpClient通过控制newExpiry和validityDeadline实现了对长连接有效期的管理,对于没有指定连接有效期的长连接,有效期设置为permanent。至此我们可以大胆地给出一个猜测:长期连接的有效期是永久的,但是因为某些异常,长期连接没有及时关闭,而是永远存活,不能被重用或释放。(只是根据现象的猜测,虽然最后不是完全正确,但确实提高了我们的解题效率)。基于此,我们还可以通过改变这两个参数来管理长连接:这个简单的修改上线后,处于close_wait状态的连接数不再继续增加,彻底解决了这个上线问题。但是这时候相信大家会有一个疑问:作为一个被广泛使用的开源框架,HttpClient在长连接的管理上有这么粗糙吗?一次简单的异常调用,就可能导致整个调度机制彻底崩溃,无法自行恢复;于是带着疑惑,再次详细查看了HttpClient的源码。3、关于HttpClient3.1前言在开始分析之前,先简单介绍一下以下几个核心类:[PoolingHttpClientConnectionManager]:连接池管理器类,主要功能是管理连接和连接池,封装连接创建、状态流、连接pooling相关操作是操作连接和连接池的入口方法;【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三个超时connectionRequestTimoutconnectionTimeoutsocketTimeout[connectionRequestTimout]:指从连接池中获取连接的超时时间;[connectionTimeout]:指客户端与服务端建立连接的超时时间,超时后会报ConnectionTimeOutException;[socketTimeout]:指客户端与服务端建立连接后,数据传输过程中数据包之间的最大时间,如果超过,将抛出SocketTimeOutException。必须注意,这里的超时并不是数据传输完成,只是接收到两个数据包的间隔时间,这也是网上很多奇葩问题的根源。3.4四个容器freeleasedpendingavailable【free】:空闲连接的容器,连接还没有建立,理论上freeSize=maxTotal-leasedSize-availableSize(实际上HttpClient中没有这个容器,只是专门引入的容器描述的方便)。[leased]:租用连接的容器。连接创建后,会从空闲容器转移到租用容器;您也可以直接从可用容器租用连接。租用成功后,会将连接放入租用的容器中。也是连接池的一个很重要的能力。[pending]:等待连接的容器。实际上,容器只是在等待连接被释放时作为阻塞线程使用。下面不再赘述。有兴趣的可以参考具体的实现代码,和connectionRequestTimout有关。[available]:可重用连接容器,通常直接从租用容器中调出。在长连接的情况下,通信完成后,该连接会被放入可用列表中。连接的一些管理和释放通常是围绕容器进行的。的。注意:由于maxTotal和maxPerRoute对连接数的限制,下面提到这四个容器时,如果没有前缀,则表示连接总数。如果是r.xxxx,表示路由连接大小中的某个容器。maxTotal的组成3.5连接生成和管理周期从可用容器中获取连接。如果连接没有失效(根据上面提到的expiry字段判断),则从可用容器中删除连接,并添加到租用容器中。并返回连接;如果第一步没有获取到可用连接,那么判断r.available+r.leased是否大于maxPerRoute,其实就是判断是否有空闲连接;如果不是,则需要分配冗余连接Release(r.available+r.leased-maxPerRoute),保证真正的连接数由maxPerRoute控制(至于为什么会出现r.leased+r.available>maxPerRoute,其实很好理解,虽然在整个状态流过程中是加锁的,但是状态流不是原子操作,有些异常场景会导致状态短时间不正确);所以我们可以得出结论,maxPerRoute只是一个理论上的最大值,实际上,真实的连接数在短时间内可能会大于这个值;当实际连接数(r.leased+r.available)小于maxPerRoute且maxTotal>leased时:如果free>0,重新建立连接;如果free=0,则关闭可用容器中最早创建的连接,然后重新创建连接;看起来有点绕口,其实就是先使用空闲容器中的连接,获取不到就释放可用容器中的连接;如果上述过程仍然没有获取可用连接,则只能等待一个connectionRequestTimout时间,或者有其他线程的信号通知结束整个获取连接的过程。3.6连接释放如果是长连接(可重用),从租用容器中删除连接,然后添加到可用容器头部,并设置有效期过期;如果是短连接(不可重用),则直接关闭连接Connection,并从释放的容器中删除,此时的连接被释放,处于空闲容器中;最后,在第四部分“连接生成与管理”中唤醒等待线程。分析了整个过程,了解了httpclient是如何管理连接的,回过头来看我们遇到的问题就更加清晰了:一般情况下,虽然建立了长连接,但是我们会在finally代码块中手动关闭它。该场景实际上触发了“释放连接”中的第2步,直接关闭了连接;所以一般情况下是没有问题的,实际上长连接并没有真正发挥作用;该问题只会出现在一些异常场景下,导致长连接没有及时关闭。结合初步分析,服务器主动断开连接。大概率会出现在一些因超时而导致连接断开的异常场景。我们回到org.apache.http.impl的.execchain.MainClientExec类中,找到如下几行代码:connHolder.releaseConnection()对应“释放连接”中提到的步骤1。此时连接只放入可用容器,有效期是永久的;returnnewHttpResponseProxy(response,null)返回的ConnectionHolder为null。结合IOUtils.closeQuietly(httpResponse)的具体实现,连接并没有被及时关闭,而是永久放置在可用容器中,状态为CLOSE_WAIT,不能被复用;根据《连接生成与管理》第3步的描述,当空闲容器为空时,httpclient可以主动释放可用容器中的连接。即使将连接永久放置在可用容器中,理论上也不会导致连接永远存在。它不能被释放;但是结合“连接生成与管理”的第4步,当free容器为空时,需要等待从连接池中获取连接时可用容器中的连接被释放。整个过程是单线程的,高效。如果极低,必然造成拥塞,最终导致大量等待获取连接超时,这也和我们在网上看到的场景一致。4.小结连接池主要有两个功能:连接管理和连接复用。使用连接池时一定要注意只是关闭当前数据流,而不是每次都关闭连接,除非你的目标访问地址是完全随机的;maxTotal和maxPerRoute的设置必须谨慎。合理配置参数可以实现业务隔离,但是如果不能准确评估,可以暂时设置为一样,或者使用两个独立的httpclient实例;一定要记得设置长连接的有效期,使用PoolingHttpClientConnectionManager(60,TimeUnit.SECONDS)构造函数,尤其是在大量调用的情况下,防止出现不可预知的问题;可以通过设置evictIdleConnections(5,TimeUnit.SECONDS)定时清理空闲连接,尤其是http接口响应时间短,并发量大的时候,及时清理空闲连接,避免连接时关闭连接从连接池中获取连接时发现已过期,可以在一定程度上提高接口性能。5.写在最后HttpClient是目前使用最广泛的基于Java的Http调用框架。在我看来,它有两个明显的缺点:没有提供监控连接状态的入口,也没有提供可以动态影响连接生命周期的外部干预。扩展点,一旦线上出现问题,可能是致命的;另外获取连接的方式是使用同步锁的方式,在高并发的情况下有一定的性能瓶颈,其对长连接的管理。稍有不慎,就会导致大量异常长连接的建立无法及时释放,造成系统性灾难。