环境配置
- Client与服务端分处两台ECS,配置为8C8G
- OS:alios7.x86_64
- JDK:ajdk-8_6_11-b380
- 启动参数:-Xms1g -Xmx1g
- 压测配置:同步调用(最常见的调用方式),200个客户端并发线程,预热30s,压测时长600s (10分钟)
若干解释
- 本次压测是单对单的同步调用,也就是一台client发送请求到server,建立的网络连接是一个,按照Netty的处理策略是一个Eventloop线程在工作处理io事件,这样的测试条件可能会导致io线程成为瓶颈。减轻io线程的工作负荷(比如把序列化和反序列化等逻辑从io线程中腾挪到biz线程阶段)在此背景下是一个优化点,但是考虑到真实使用场景下,会是多个连接,io层(也就是网络层)一般不会是瓶颈,所以减轻io的技巧不具备太多实用价值。
- 服务端的线程策略是默认的dubbo策略,即io线程转给biz线程的两阶段,这一策略在服务端是纯cpu逻辑的性能测试场景下会弱化,但考虑到其普适性,我们还是坚持以此策略为测试。相比较其他RPC库可能是不一样的,也是影响性能的重要因素。
- 网络上有一些rpc库/框架的性能比较文章或者代码库,但是基本是单纯的简单调用,也就是侧重在网络性能的分析比较。但是像Dubbo/HSF更多的考虑是软负载,服务治理等功能特性的支持,甚至扩展机制。在简单测试中肯定会吃亏。从这个角度看,单纯一味追求性能的RPC框架未必是好的。
- 最后也是重要一点,rpc测试中必须考虑RT,一方面RT对于业务应用的意义相比较TPS显得一样重要,RT延迟会受到多方面因素(比如GC,网络质量等)的影响,同时,RT的质量(也就是RT的正太分布图,比如99%的RT是8ms以内,而最长的rt可能是50ms,这个在很多场景下也是无法被接受)也是比较重要的考量; 另一方面,在压测过程中始终达不到系统资源(一般是网络和CPU)的极限,这时就会出现优化改进后tps基本没提升,但RT成为衡量因素。
- 测试中用到了JMH,JFR/JMC,这两个东东还不错,尤其是JMH不仅方便压测,也有自带一些profiler。http://psy-lob-saw.blogspot.com/2016/02/why-most-sampling-java-profilers-are.html
- 本文中多个优化点离不开@陆龟 的帮助和支持,尤其是从优化点8开始均有@陆龟提供。当然,现在的Dubbo优化也只是刚开始。
优化点及数据
数据解释
以下面出现的第一张图为例。
- 第二行Client.createUser: 在createUser这个用例下,吞吐量(thrpt)是每毫秒42.819,也就是qps是42819。
- 第四行Client.createUser:在createUser这个用例下,平均RT是4.852ms,误差在0.008ms。
- 第九行Client**·createUser·p0.99:在createUser这个用例下,99%请求的RT都是在12.665ms以内
- 第十二行Client**·createUser·p1.00:在createUser这个用例下,100%请求的RT都是在73.138ms以内,也就说最大RT的那一次调用是73.138。
未优化的情形
- Dubbo 2.7.1-release
- Dubbo 2.6.5 release
- Dubbo 3.0.0-SNAPSHOT
优化1:3.0版本Load高吞吐量低的问题
3.0的当前版本是相当让人惊讶的结果,没办法重复跑了几遍,都是如此。服务端OK,客户端的load非常高。
排查过程:考虑到load极高而CPU,而事实上CPU(无论是sys,还是us)没有沾满100%,先从thread分析。
从stackprofiler来分析,问题很明显:在AsyncToSynInvoker中调用了CompletableFuture(简称CF)的get方法。打开CF的相关代码,会发现先通过自旋2的8次方再进入阻塞队列进行等待。在RPC的场景下,网络等待一般都是ms级别的,这样自旋存在着浪费CPU的问题,更适合的方式是通过信号锁的方式来处理。
- 解决方法: 其实Google了很多CF的文章,想找到一种比较好的使用方式,也参照了些代码实现,最后还是认为是以前HSF中基于Guava的Future实现更合适。
- 优化效果:性能由6300tps提升到38458tps,提升了510%,具体见截图数据。
优化2:FGC问题
以上的CF使用问题虽然解决了一大问题,但是性能还是不及2.x版本,尤其是在rt方面,从上面的数据截图可以看出,在2.7.1版本上p99的rt在12.66ms,而3.0版本上的p99达到了44ms,这差距实在是无语。。。。
特别感谢@刘军在优化1时也在本地跑了一下,本地也跑出来OutofMemory。。。
本人在性能环境做了GC方面的压测,在接近1小时的压测过程内,没有遇到内存溢出,但是基本上每三秒就会一次FGC,这应该是导致RT增加了2倍多的原因
由于无outmemeryerror,同时加上FGC是由于old区的满引起,所以猜测是对象的生命周期太长,导致无法在NEW区中快速回收。通过几次查看HEAP的对象实例,发下排名前几位且与Dubbo相关的对象是DefaultFuture
- 问题根源:在timeout的task中持有了DefaultFuture这个比较重的对象,而timeoutTask是和超时时间一样生命市场的对象。通过优化TimeoutTask对象,解决此问题。
- 优化效果:fgc解除,rt提升明显。见下图数据:old区基本不增长了,FGC也不再随着时间推移而增长。最关键的是rt由之前的44ms降到了13ms,基本接近2.7.1版本。
以上两个问题其实更多是一种BUG存在的问题排查,经过优化1和优化2后,当前最新版本从qps和rt两方面基本上达到了Dubbo的正常性能水平。顺便提一下,这也再次印证本人之前说的:没有rt的qps性能测试都是扯淡!!
优化3:返回类型的改进
根据jmh的stackprofiler结果看,每次结果返回都需要通过java reflection api获取returnType,这里的计算比较多。见截图。
优化后的结果见下图,性能从39797提升到41784,有5%的提升。
优化4:判断invokerMode的优化
0.0% 6.1% sun.reflect.Reflection.getCallerClass
java.lang.Class.getMethod
org.apache.dubbo.rpc.support.RpcUtils.getReturnType
org.apache.dubbo.rpc.support.RpcUtils.isReturnTypeFuture
org.apache.dubbo.rpc.support.RpcUtils.getInvokeMode
org.apache.dubbo.rpc.protocol.AbstractInvoker.invoke
org.apache.dubbo.rpc.protocol.AsyncToSyncInvoker.invoke
org.apache.dubbo.monitor.support.MonitorFilter.invoke
org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke
org.apache.dubbo.rpc.protocol.dubbo.filter.FutureFilter.invoke
org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke
org.apache.dubbo.rpc.filter.ConsumerContextFilter.invoke
org.apache.dubbo.rpc.protocol.ProtocolFilterWrapper$1.invoke
org.apache.dubbo.rpc.listener.ListenerInvokerWrapper.invoke
org.apache.dubbo.rpc.proxy.InvokerInvocationHandler.invoke
org.apache.dubbo.common.bytecode.proxy3.getUser
优化后从41784提升到43659,提升在5%,同时RT也提升到13ms内,具体见下图数据。
优化5:消费端使用发起同步调用时去除额外的共享线程池
当前Dubbo的实现在同步调用方式下,对于response的处理是单独创建了一个线程池做处理,即 “I/O -> 共享线程池 -> 业务线程” 的切换,这样就额外增加了一次Context Switch。经过@刘军(陆龟)的优化改进,做一下实现。
- 同步:“I/O -> 业务线程“
- 异步:保持不变,“I/O -> 共享线程池”
看完结果,跟预期的tps与rt都提升相比较,还是有些疑惑:基本上没有提升的迹象。分析下来,有两个原因可以稍作解释:一是这一块的改动比较复杂,需要关注比较细致的并发技巧,现在的改进还不足,需要进一步完善;二是当前阶段的瓶颈不在于此,也就是说Context Switch的频繁对于当前测试的影响微不足道。
优化6:消费端实现rpc模块和Remoting模块的Future桥接
问题发现:通过jstack发现,io线程有比较多的比例是耗费在CF的complete过程,如下截图。
解决方案:remoting层复用rpc层的CompletableFuture来解决。性能提高了10%以上。
优化7:char[]的分配导致gc
从JProfiler allocation hot spot 也反映出URL相关操作分配占比较高
注:除GC意外,可能由于分配导致的耗时操作
优化8:AsyncRpcResult.thenApplyWithContext重复执行
AsyncRpcResult.thenApplyWithContext在每个filter重复执行,占用较大比例的CPU时间
可能和频繁操作RpcContext.get/setContext有关(consumer单线程场景比例较高,可能和provider端threadlocal初始化有关)
优化9:YGC频率较高
请求过程中避免一些重复的对象创建,除了5中提到URL操作导致String分配外,考虑Request、Response的复用与加速回收
如利用Netty的Recycler机制等。
优化10:序列化从I/O线程切到业务线程池
- decode.in.io=true
- 从provider端I/O thread状态来看,在32并发的情况下,decode在I/O对provider端还是有较大的runnable集中期的,可以考虑调整试一试
- 消费端可以尝试调整I/O线程数量,或者减少I/O线程中可能的阻塞/耗时操作
目前发现的存在于I/O线程中慢操作有:
- Future.complete()状态转换 park -> unpark
- ConcurrentHashMap.put("LAST_READ_TIME", long);造成的锁竞争,参见 优化11
- 业务线程send调用channel.isConnect(),与I/O线程read等造成竞争,打破了Netty关于channel绑定单线程的模型,见下图
优化11:NetUtils.getLocalHost()
比如ConsumerContextFilter中有调用
public static String getLocalHost() {
InetAddress address = getLocalAddress(); return address == null ? Constants.LOCALHOST_VALUE : address.getHostAddress();
}
优化12: 避免每次RpcContext.getContext(),注意保存引用
这个问题经充分预热后可能会好很多。
这点从theyApplyWithContext中也能反映出来
优化13: 业务线程、I/O线程同时更新Channel.ConcurrentHashMap导致的锁竞争
此点尚存义attributes.put(LAST_READ_TIME, long),没必要做到精确,可使用普通HashMap
心跳避免更新HashMap?