优化结果:接口响应时间从300ms降到30ms,并且节约CPU和内存近10倍。
一、背景及问题
在公司的Mis系统中,服务调用使用的技术是WebService,但是每次在WebService添加一个方法,就要在调用方更新一次引用从而拿到新增的调用方法,所以在6年前有一位前辈大佬为了提高大家的开发效率,写了一个名叫AllInOne的通用方法,这个方法只要给出类名和方法名就可以调用目标方法,是不是有点类似RPC,调用方再也不用为每次都要更新引用而头痛了。原理大家也都猜到了,就是使用了反射来调用目标方法。
但是现在系统有个问题,就是每次只要接口使用了该方法,这个接口的响应时间就会达到300ms以上,根本就无法实现高效率,当初直播几百人时服务崩了就是直播业务包含了几个调用了AllInOne方法的接口。这个方法也在我们系统已经达到了1700多次的调用,如果将其优化定然对整个系统的响应和吞吐量有质的飞跃。于是一个优化该方法的想法萌发了,当时我在想,虽然使用反射性能会差,但也不至于达到这个程度。如果WebService刚问世就是这样的效率,那这门技术当时流行起来就很奇怪了,肯定有什么方式能够优化该方法,不管如何都要试一试。
二、问题排查
我先对调用了该方法的接口使用Postman访问,果不其然都是在300ms以上,然后我看了以下AllInOne的方法,它的客户端对象SoapClient获取代码如下:
using (var scope = serviceProvider.CreateScope())
{
var webService = scope.ServiceProvider.GetRequiredService<WebService.SoapClient>();
}
AllInOne是SoapCient的对象方法,所以要调用AllInOne方法首先要获取SoapClient对象,大家可以看到代码中SoapClient是在IOC容器中获取的,于是我看来了一下SoapClient的生命周期配置:
services.AddScoped<SoapClient>(fac => new SoapClient(SoapClient.EndpointConfiguration.Soap, wsUrl));
这个配置表示SoapClient的生存周期为整个请求范围内,也就是每次请求都会创建一个这个对象,等请求处理完后这个对象就会销毁。于是我在猜测这个对象可能是个重量级对象,时间都是消耗在了创建这个对象的过程中,于是抱着试一试的想法,我把它的声明周期改为了单例模式,就是生命周期随着IOC容器,容器中只有一个SoapClient,所有接口共用这一个对象。配置如下:
services.AddSingleton<SoapClient>(fac => new SoapClient(DataAccessSoapClient.EndpointConfiguration.Soap, wsUrl));
于是惊喜的是出现了,访问接口竟然都在30ms左右,说明请求的过程中几乎都是耗时在SoapClient对象的创建和销毁的过程中。结论,SoapClient对象是一个重量级对象。
三、解决过程
3.1 使用对象池解决问题
此时我脑海中蹦出了解决方案,验证这个对象的是否是线程安全的,如果是线程安全的,也就说明可以使用单例模式,如果不是线程安全的,就使用对象池去解决这个问题。但是很遗憾我搁置了一段时间,忘记了去验证它是否是线程安全的,直接就找了一个对象池库,叫做SafeObjectPool,代码如下:
try
{
// 从对象池中获取SoapClient对象
var soapClient = WebServicePool.Get();
// 调用AllInOne方法
result = await soapClient.Value.AllInOneAsync(request);
......
}
finally
{
// 归还给对象池
webServicePool.Return(soapClient);
}
池化技术主要就是复用对象来解决频繁创建和销毁对象消耗系统资源的问题,像常见的连接池和线程池就是如此。上面代码中,在使用AllInOne方法前向对象池索要SoapClient对象,使用完成后就将该对象归还给对象池中,以达到反复创建和销毁SoapClient对象来节约系统资源的目的。先用每秒100请求,重复10次来进行测试,测试结果如下:
参数 | 原方法 | 使用对象池 | |
内存情况/MB | 1433(+85.38%) | 773 | |
响应时间 (ms) | 最大值 | 104997(+11.14%) | 94470 |
最小值 | 522(+383.33%) | 108 | |
中位数 | 6661(+536.20%) | 1047 | |
平均值 | 16116(+56.57%) | 10293 | |
90%低于 | 11804(+412.77%) | 2302 | |
95%低于 | 100971(+78.15%) | 93652 | |
99%低于 | 103848(+10.38%) | 94080 | |
TPS | 6.1 | 9.6(+57.38%) |
内存情况
原方法 | 使用对象池 |
可以看到,使用对象池跟不使用对象池的性能天壤之别。响应方面,我们主要看中位数和平均值,可以看到使用对象池的响应时间远远小于没有使用对象池,内存方面,首先没有使用对象池内存消耗直接翻了几番,而且CPU几乎是在满载情况,这对系统肯定是非常不利的。
综上情况,采用对象池可以极大节约系统资源,提高系统的吞吐量。
好了,到此为止,已经找出了一个不错的优化手段,但为了保险起见,还是要将该优化上测试环境压测几天,毕竟AllInOne方法在这个Mis庞大的系统中有近1700次调用。
3.2 出现线程安全问题
然后问题就出现了!!!我发现日志大量报错。
图一 抛出的异常日志
该报错在原方法中也会复现,只是频率不高,如100次才会出现一次。但是使用对象池后,该报错几乎是每秒抛出几个,再加上AllInOne方法这个多地方这个多年调用都没有问题,所以第一直觉就是我这边修改代码有问题。于是为了验证是否是我这边调用方的问题,我做了大量测试,如异常是在加密解密方法中抛出来的,所以测试加密解密方法在并发情况下是否存在问题,还有测试使用的对象池库是否存在问题等等。经过一番测试后,最终确认这边是没有问题的,但没有找到答案的我还是不甘心,于是我去看一下AllInOne这个方法是否存在问题,这一看就发现了一个致命的问题,首先我们看一下AllOne通过反射调用目标方法的过程:
图二 AllInOne调用过程
在调用过程中,使用了一个 全局变量N
来做为存放目标方法结果的载体,目标方法会将执行结果放入到这个变量N中,然后AllInOne方法再从中获取结果,再返回给调用方。如果对多线程编程比较敏感,一眼就可以发现问题。再可变变量中发生线程逃逸,如果没有做好安全措施是一件非常危险的事,如在该情况下,如果线程1刚好将结果放入到 全局变量N
中,但是此时AllInOne方法还没有拿到该结果,此时线程2对该 全局变量N
进行了初始化,这时线程1的AllInOne方法拿到就是一个空值了,所以就会出现图一的 未将对象引用设置到对象的实例
和 Object reference not set to an instance of an object
的错误。还有一种情况就是如果线程2的结果放入到 全局变量N
中后被线程1拿到, 导致用户拿到不属于他的数据,这是非常致命的。解决该问题迫在眉睫。
3.3 使用ConcurrentDictionary解决线程安全问题
这个AllInOne方法上千次调用,如何能够在不影响上层应用的情况下修改该方法呢。思来想去,想到了两个方法。
方法一,就是加锁互斥,如在调用AllInOne方法时进行加锁,再调用完后在释放锁,这样就能保证各各线程在使用变量时不会交叉导致出现危险。但是坏处也显而易见,这样使得AllInOne方法只能串行执行,系统的吞吐量会非常差。
方法二,使用一个全局线程安全的Map/Dictionary对象,给每个线程对象一个存放结果的变量副本,这样就可以保证线程之间的互不影响,代码如下:
// 全局变量声明
private static ConcurrentDictionary<int, List<object>> objRtnsDic = new ConcurrentDictionary<int, List<object>>();
// 使用前初始化
objRtnsDic[Thread.CurrentThread.ManagedThreadId] = new List<object>();
......
可以看到,原理就是通过线程的ID来给每个线程分配一个变量副本,使用完后将该变量删除。要注意的是目标方法不能开子线程再对objRtnDic进行操作,否则会取不到结果或者无法删除发生内存泄漏,不过这些方法再目前系统中是不存在的,所以采用该方法是最有效的。
3.4 SoapClient是线程安全的且自己维护了一套TCP连接池
AllInOne优化过程到此也差不多OK了。但我还是疑惑SoapCleint是一个重量级的对象,每次使用都要创建一次的话会非常影响系统执行效率,如果真的是这样的话当初这门技术就不应该会流行起来,于是我猜测SoapClient可能是线程安全的,换个说法就是可以在整个应用程序中使用一个SoapClient对象。
于是我查询了大量的资料,但是可能是现在使用WebService的项目比较少,并没有找到想要的答案,既然找不到答案,那就自己动手实践来证明。
我使用Jemeter压测来查看服务在并发调用下会不会存在问题,第一次使用每秒100次调用,发现调用都是成功的,然后加大并发力度,还是没有问题,此时初步证明SoapClient对象是线程安全的。然后使用Windump查看不同并发情况下TCP的连接数,结果如下:
并发数/秒 | TCP连接数 |
1 | 1 |
3 | 3 |
100 | 77 |
这个结果非常让我惊喜,这说明SoapClient自己实现了连接池,也就是说它其实是线程安全的!!!
经过一番战斗,一路过关斩将,终于将AllInOne方法尽我所能优化到极致了。接下来我们再次对比一下优化前和最终优化后的效果(100QPS维持30秒):
参数 | 优化前 | 优化后 | |
内存情况/MB | 3276(+392.63%) | 665 | |
响应时间 (ms) | 最大值 | 99876(+732.65%) | 11995 |
最小值 | 490(+1860.00%) | 25 | |
中位数 | 7490(+416.91%) | 1449 | |
平均值 | 10674(+587.31%) | 1553 | |
90%低于 | 12194(+475.73%) | 2118 | |
95%低于 | 16389(+594.45%) | 2360 | |
99%低于 | 97472(+1272.85%) | 7100 | |
TPS | 9.0 | 58.6(+551.11%) |
内存情况
优化前 | 优化后 |
差距有一点夸张,但是确是事实。
六、结论
本次最终优化结论为以下两点:
- 调用方将SoapClient有每次请求创建改为全局单例,可以使用 IOC容器 来实现或者使用 双重检验锁 来实现。
- WebService服务采用 ConcurrentDictionary 来解决线程安全问题。
虽然最终优化修改的代码不多,但是排查起来还是需要挺多知识点来驱动你思考的,我也在这个过程收获了许多。
好的,本次优化到此结束,期待以后越来越好!!!^_^