HttpClient在高并发场景下的优化实战
在项目中使用HttpClient可能是很普遍,尤其在当下微服务大火形势下,如果服务之间是http调用就少不了跟http客户端找交道.由于项目用户规模不同以及应用场景不同,很多时候可能不需要特别处理也.然而在一些高并发场景下必须要做一些优化.
项目是快递公司的快件轨迹查询项目,目前平均每小时调用量千万级别.轨迹查询以Oracle为主要数据源,Mongodb为备用,当Oracle不可用时,数据源切换到Mongodb.今年菜鸟团队加入后,主要数据迁移到了阿里云上,以Hbase为主要存储.其中Hbase数据查询服务由数据解析组以Http方式提供.原有Mongodb弃用,云上数据源变为主数据源,Oracle作为备用.当数据源切换以后,主要的调用方式也就变成了http方式.在第10月初第一轮双11压测试跑上,qps不达标.当然这个问题很好定位,因为十一假之间轨迹域组内已经进行过试跑,当时查的是oracle.十一假期回来后,只有这一处明显的改动,很容易定位到问题出现在调用上.但具体是云上Hbase慢,还是网络传输问题(Hbase是阿里云上的服务,轨迹查询项目部署在IDC机房).通过云服务,解析组和网络运维的配合,确定问题出现在应用程序上.在Http服务调用处打日志记录,发现以下问题:
可以看到每隔一段时间,就会有不少请求的耗时明显比其它的要高.
导致这种情况可能可能是HttpClient反复创建销毁造成引起来销,首先凭经验可能是对HttpClient进行了Dispose操作(Using(HttpClient client=new HttpClient){...})
如果你装了一些第三方插件,当你写的HttpClient没有被Using包围的时候会给出重构建议,建议加上Using或者手动dispose.然而实际中是否要dispose还要视情况而定,对于一般项目大家的感觉可能是不加也没有大问题,加了也还ok.但是实际项目中,一般不建议反复重新创建这个对象,关于HttpClient是否需要Dispose请看这里
在对这个问题的答案里,提问者指出微软的一些示例也是没有使用using的.下面一个比较热的回答指出HttpClient生命周期应该和应用程序生命周期一致,只要应用程序需要Http请求,就不应用把它Dispose掉.下面的一个仍然相对比较热的回答指出一般地,带有Dispose方法的对象都应当被dispose掉,但是HttpClient是一个例外.
当然以上只是结合自己的经验对一个大家都可能比较困惑的问题给出一个建议,实际上对于一般的项目,用还是不用Dispose都不会造成很大问题.本文中上面提到的问题跟HttpClient也没有关系,因为程序中使用的Http客户端是基于HttpWebRequest
封装的.
问题排查及优化
经过查询相关资料以及同行的经验分享(这篇文章给了很大启发)
查看代码,request.KeepAlive = true;查询微软相关文档,这个属性其实是设置一个'Keep-alive'请求header,当时同事封装Http客户端的场景现场无从得知,然而对于本文中提到的场景,由于每次请求的都是同一个接口,因此保持持续连接显然能够减少反复创建tcp连接的开销.因此注释掉这一行再发布测试,以上问题便不复出现了!
当然实际中做的优化绝不仅仅是这一点,如果仅仅是这样,一句话就能够说完了,大家都记住以后就这样做就Ok了.实际上还参考了不少大家在实际项目中的经验或者坑.下面把整个HttpClient代码贴出来,下面再对关键部分进行说明.
public static string Request(string requestUrl, string requestData, out bool isSuccess, string contentType = "application/x-www-form-urlencoded;charset=utf8")
{
string apiResult = "";
isSuccess = false;
if (string.IsNullOrEmpty(requestData))
{
return apiResult;
}
HttpWebRequest request = null;
HttpWebResponse response = null;
try
{
byte[] buffer = Encoding.UTF8.GetBytes(requestData);
request = WebRequest.Create($"{requestUrl}") as HttpWebRequest;
request.ContentType = "application/json";
request.Method = "POST";
request.ContentLength = buffer.Length;
request.Timeout =200;
request.ReadWriteTimeout = Const.HttpClientReadWriteTimeout
request.ServicePoint.Expect100Continue = false;
request.ServicePoint.UseNagleAlgorithm = false;
request.ServicePoint.ConnectionLimit = 2000
request.AllowWriteStreamBuffering = false;
request.Proxy = null;
using (var stream = request.GetRequestStream())
{
stream.Write(buffer, 0, buffer.Length);
}
using (response = (HttpWebResponse)request.GetResponse())
{
string encoding = response.ContentEncoding;
using (var stream = response.GetResponseStream())
{
if (string.IsNullOrEmpty(encoding) || encoding.Length < 1)
{
encoding = "UTF-8"; //默认编码
}
if (stream != null)
{
using (StreamReader reader = new StreamReader(stream, Encoding.GetEncoding(encoding)))
{
apiResult = reader.ReadToEnd();
//byte[] bty = stream.ReadBytes();
//apiResult = Encoding.UTF8.GetString(bty);
}
}
else
{
throw new Exception("响应流为null!");
}
}
}
isSuccess = true;
}
catch (Exception err)
{
isSuccess = false;
LogUtilities.WriteException(err);
}
finally
{
response?.Close();
request?.Abort();
}
return apiResult;
}
-
首先是
TimeOut
问题,不仅仅是在高并发场景下,实际项目中建议不管是任何场景都要设置它的值.在HttpWebRequest对象中,它的默认值是100000
毫秒,也就是100秒.如果服务端出现问题,默认设置将会造成严重阻塞,对于普通项目也会严重影响用户体验.返回失败让用户重试也比这样长时间等待体验要好. -
ReadWriteTimeout很多朋友可能没有接触过这个属性,尤其是使用.net 4.5里HttpClient对象的朋友.有过Socket编程经验的朋友可能会知道,socket连接有连接超时时间和传输超时时间,这里的
ReadWriteTimeout
类似于Socket编程里的传输超时时间.从字面意思上看,就是读写超时时间,防止数据量过大或者网络问题导致流传入很长时间都无法完成.当然在一般场景下大家可以完全不理会它,如果由于网络原因造成读写超时也很有可能造成连接超时.这里之所以设置这个值是由于实际业务场景决定的.大家可能已经看到,以上代码对于ReadWriteTimeout
的设置并不像Timeout
一样设置为一个固定值,而是放在了一个Const类中,它实际上是读取一个配置,根据配置动态决定值的大小.实际中的场景是这样的,由于压测环境无法完全模拟真实的用户访问场景.压测的时候都是使用单个单号进行轨迹查询,但是实际业务中允许用户一次请求查询最多多达数百个单号.一个单号的轨迹记录一般都是几十KB大小,如果请求的单号数量过多数量量就会极大增加长,查询时间和传输时间都会极大增加,为了保证双11期间大多数用户能正常访问,必要时会把这个时间设置的很小(默认是3000毫秒),让单次查询量大的用户快速失败.
以上只是一种备用方案,不得不承认,既然系统允许一次查询多个单号,因此在用户在没有达到上限之前所有的请求都是合法的,也是应该予以支持的,因此以上做法实际上有损用户体验的,然而系统的资源是有限的,要必要的时候只能牺牲特殊用户的利益,保证绝大多数用户的利益.双11已经渡过,实际中双11也没有改动以上配置的值,但是做为风险防范增加动态配置是必要的.
这里再多差一下嘴,就是关于
ContentLength
它的值是可以不设置的,不设置时程序会自动计算,但是设置的时候一定要设置字节数组的长度,而不是字符串的长度,因为包含中文时,根据编码规则的不同,中文字符可能占用两个字节或者更长字节长度.
-
关于
request.ServicePoint.Expect100Continue = false; request.ServicePoint.UseNagleAlgorithm = false;
这两项笔者也不是特别清楚,看了相关文档也没有特别明白,还请了解的朋友指点,大家共同学习进步. -
request.ServicePoint.ConnectionLimit = 2000
是设置最大的连接数,不少朋友是把这个数值设置为65536,实际上单台服务器web并发连接远太不到这个数值.这里根据项目的实际情况,设置为2000.以防止处理能力不足时,请求队列过长. -
request.AllowWriteStreamBuffering = false;
根据[微软文档(https://docs.microsoft.com/zh-cn/dotnet/api/system.net.httpwebrequest.allowwritestreambuffering?redirectedfrom=MSDN&view=netframework-4.8#System_Net_HttpWebRequest_AllowWriteStreamBuffering)]这个选项设置为true时,数据将缓冲到内存中,以便在重定向或身份验证请求时可以重新发送数据.
最为重要的是,文档中说将 AllowWriteStreamBuffering 设置为 true 可能会在上传大型数据集时导致性能问题,因为数据缓冲区可能会使用所有可用内存。
由于发送的请求仅仅是单号,数据量很小,并且很少有用户一个单号反复查询的需求.加上可能会有副作用.这里设置为false.
request.Proxy = null;
这里是参考了一个一位网友的文章,里面提到默认的Proxy导致超时怪异行为.由于解决问题是在10月份,据写这篇文章已经有一段时间了,因此再寻找时便找不到这篇文章了.有兴趣的朋友可以自己搜索一下.
很多朋友可能会关心,通过以上配置到底有没有解决问题.实际中以上配置后已经经历了双11峰值qps过万的考验.下面给出写本文时候请求耗时的监控
可以看到,整体上请求耗时比较平稳.
可能看了这个图,有些朋友还是会有疑问,通过上面日志截图可以看到,除了耗时在100ms以上的请求外,普通的耗时在四五十毫秒的还是有很多的,但是下面这个截图里都是在10到20区间浮动,最高的也就30ms.这其实是由于在压测的过程中,发现Hbase本身也有不稳定的因素(大部分请求响应耗时都很平稳,但是偶尔会有个别请求娄千甚至数万毫秒(在监控图上表现为一个很突兀的线,一般习惯称为毛刺),这在高并发场景下是不能接受的,问题反馈以后阿里云对Hbase进行了优化,优化以后耗时也有所下降.)