「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

http 通信既然换了,干脆一换到底,ca 的 springmvc 服务器也可以使用 netty 实现,这样更加有利于实现 ca 整体的 reactive。使用 netty 实现 http 服务器很简单,使用 netty 提供的默认编码解码器即可。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

http 服务器的实现也踩了一个坑,解码 http request 请求时没注意好 ByteBuf 的释放,导致 qps 跌倒了 2000+,反而不如 springmvc 的实现。在队友@闪电侠的帮助下成功定位到了内存泄露的问题。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

在正式赛后发现还有更快的 decode 方式,不需要借助于上述的 HttpPostRequestDecoder,而是改用 QueryStringDecoder:

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

节省篇幅,直接在这儿将之后的优化贴出来,后续不再对这个优化赘述了。

Qps 4200 到 4400 (netty复用eventLoop)

这个优化点来自于比赛认识的一位好友@半杯水,由于没有使用过 netty,比赛期间恶补了一下 netty 的线程模型,得知了 netty 可以从客户端引导 channel,从而复用 eventLoop。不了解 netty 的朋友可以把 eventLoop 理解为 io 线程,如果入站的 io 线程和 出站的 io 线程使用相同的线程,可以减少不必要的上下文切换,这一点在 256 并发下可能还不明显,只有 200 多 qps 的差距,但在 512 下尤为明显。复用 eventLoop 在《netty实战》中是一个专门的章节,篇幅虽然不多,但非常清晰地向读者阐释了如何复用 eventLoop(注意复用同时存在于 ca 和 pa 中)。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

使用入站服务端的 eventLoopGroup 为出站客户端预先创建好 channel,这样可以达到复用 eventLoop 的目的。并且此时还有一个伴随的优化点,就是将存储 Map 的数据结构,从 concurrentHashMap 替换为了 ThreadLocal ,因为入站线程和出站线程都是相同的现成,省去一个 concurrentHashMap 可以进一步降低锁的竞争。

到了这一步,整体架构已经清晰了,c->ca,ca->pa,pa->p 都实现了异步非阻塞的 reactor 模型,qps 在 256 并发下,也达到了 4400 qps。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

优化后的dubbo mesh方案

正式赛 512 连接带来的新格局

上述这份代码在预热赛 256 并发下表现尚可,但正式赛为了体现出大家的差距,将最高并发数直接提升了一倍,但 qps 却并没有得到很好的提升,卡在了 5400 qps。和 256 连接下同样 4400 的朋友交流过后,发现我们之间的差距主要体现在 ca 和 pa 的 io 线程数,以及 pa 到 p 的连接数上。5400 qps 显然低于我的预期,为了降低连接数,我修改了原来 provider-agent 的设计。从以下优化开始,是正式赛 512 连接下的优化,预热赛只有 256 连接。

Qps 5400 到 5800 (降低连接数)

对 netty 中 channel 的优化搜了很多文章,依旧不是很确定连接数到底是不是影响我代码的关键因素,在和小伙伴沟通之后实在找不到 qps 卡在 5400 的原因,于是乎抱着试试的心态修改了下 provider-agent 的设计,采用了和 consumer-agent 一样的设计,预先拿到 provder-agent 入站服务器的 woker 线程组,创建出站请求的 channel,将原来的 4 个线程,4 个 channel 降低到了 1 个线程,一个 channel。其他方面未做任何改动,qps 顺利达到了 5800。

理论上来说,channel 数应该不至于成为性能的瓶颈,可能和 provider dubbo 的线程池策略有关,最终得出的经验就是:在 server 中合理的在 io 事件处理能力的承受范围内,使用尽可能少的连接数和线程数,可以提升 qps,减少不必要的线程切换。顺带一提(此时 ca 的线程数为 4,入站连接为 http 连接,最高为 512 连接,出站连接由于和线程绑定,又需要做负载均衡,所以为

线程数pa数=43=12

这个阶段,还存在另一个问题,由于 provider 线程数固定为 200 个线程,如果 large-pa 继续分配 3/1+2+3=0.5 即 50% 的请求,很容易出现 provider 线程池饱满的异常,所以调整了加权值为 1:2:2。限制加权负载均衡的不再仅仅是机器性能,还要考虑到 provider 的连接处理能力。

Qps 5800 到 6100 (Epoll替换Nio)

依旧感谢@半杯水的提醒,由于评测环境使用了 linux 作为评测环境,所以可以使用 netty 自己封装的 EpollSocketChannel 来代替 NioSocketChannel,这个提升远超我的想象,直接帮助我突破了 6000 的关卡。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

本地调试由于我是 mac 环境,没法使用 Epoll,所以加了如上的判断。

NioServerSocketChannel 使用了 jdk 的 nio,其会根据操作系统选择使用不同的 io 模型,在 linux 下同样是 epoll,但默认是 level-triggered ,而 netty 自己封装的 EpollSocketChannel 默认是 edge-triggered。 我原先以为是 et 和 lt 的差距导致了 qps 如此大的悬殊,但后续优化 Epoll 参数时发现 EpollSocketChannel 也可以配置为 level-triggered,qps 并没有下降,在比赛的特殊条件下,个人猜想并不是这两种触发方式带来的差距,而仅仅是 netty 自己封装 epoll 带来的优化。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

Qps 6100 到 6300 (agent自定义协议优化)

agent 之间的自定义协议我之前已经介绍过了,由于一开始我使用了 protoBuf,发现了性能问题,就是在这儿发现的。在 512 下 protoBuf 的问题尤为明显,最终为了保险起见,以及为了和我后面的一个优化兼容,最终替换为了自定义协议—Simple 协议,这一点优化之前提到了,不在过多介绍。

Qps 6300 到 6500 (参数调优与zero-copy)

这一段优化来自于和 @折袖-许华建 的交流,非常感谢。又是一个对 netty 不太了解而没注意的优化点:

  1. 关闭 netty 的内存泄露检测:

-Dio.netty.leakDetectionLevel=disabled

netty 会在运行期定期抽取 1% 的 ByteBuf 进行内存泄露的检测,关闭这个参数后,可以获得性能的提升。

  1. 开启 quick_ack:

bootstrap.option(EpollChannelOption.TCP_QUICKACK, java.lang.Boolean.TRUE)

tcp 相比 udp ,一个区别便是为了可靠传输而进行的 ack,netty 为 Epoll 提供了这个参数,可以进行 quick ack,具体原理没来及研究。

  1. 开启 TCP_NODELAY

serverBootstrap.childOption(ChannelOption.TCP_NODELAY, true)

这个优化可能大多数人都知道,放在这儿一起罗列出来。网上搜到了一篇阿里毕玄的 rpc 优化文章,提到高并发下 ChannelOption.TCP_NODELAY=false 可能更好,但实测之后发现并不会。

其他调优的参数可能都是玄学了,对最终的 qps 影响微乎其微。参数调优并不能体现太多的技巧,但对结果产生的影响却是很可观的。

在这个阶段还同时进行了一个优化,和参数调优一起进行的,所以不知道哪个影响更大一些。demo 中 dubbo 协议编码没有做到 zero-copy,这无形中增加了一份数据从内核态到用户态的拷贝;自定义协议之间同样存在这个问题,在 dubbo mesh 的实践过程中应该尽可能做到:能用 ByteBuf 的地方就不要用其他对象,ByteBuf 提供的 slice 和 CompositeByteBuf 都可以很方便的实现 zero-copy。

Qps 6500 到 6600 (自定义http协议编解码)

看着榜单上的人 qps 逐渐上升,而自己依旧停留在 6500,于是乎动了歪心思,GTMD 的通用性,自己解析 http 协议得了,不要 netty 提供的 http 编解码器,不需要比 HttpPostRequestDecoder 更快的 QueryStringDecoder,就一个偏向于固定的 http 请求,实现自定义解析非常简单。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

http 文本协议本身还是稍微有点复杂的,所以 netty 的实现考虑到通用性,必然不如我们自己解析来得快,具体的粘包过程就不叙述了,有点 hack 的倾向。

同理,response 也自己解析:

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

Qps 6600 到 6700 (去除对象)

继续丧心病狂,不考虑通用性,把之前所有的中间对象都省略,encode 和 decode 尽一切可能压缩到 handler 中去处理,这样的代码看起来非常难受,存在不少地方的 hardcoding。但效果是存在的,ygc 的次数降低了不少,全程使用 ByteBuf 和 byte[] 来进行数据交互。这个优化点同样存在存在 hack 倾向,不过多赘述。

Qps 6700 到 6850 (批量flush,批量decode)

事实上到了 6700 有时候还是需要看运气的,从群里的吐槽现象就可以发现,512 下的网路 io 非常抖,不清楚是机器的问题还是高并发下的固有现象,6700的代码都能抖到 5000 分。所以 6700 升 6850 的过程比较曲折,而且很不稳定,提交 20 次一共就上过两次 6800+。

所做的优化是来自队友@闪电侠的批量flush类,一次传输的字节数可以提升,使得网络 io 次数可以降低,原理可以简单理解为:netty 中 write 10 次,flush 1 次。一共实现了两个版本的批量 flush。一个版本是根据同一个 channel write 的次数积累,最终触发 flush;另一个版本是根据一次 eventLoop 结束才强制flush。经过很多测试,由于环境抖动太厉害,这两者没测出多少差距。

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

批量 decode 的思想来自于蚂蚁金服的 rpc 框架 sofa-bolt 中提供的一个抽象类:AbstractBatchDecoder

「每日分享」QPS从1000到6850,Dubbo Mesh的优化总结

Netty 提供了一个方便的解码工具类 ByteToMessageDecoder ,如图上半部分所示,这个类具备 accumulate批量解包能力,可以尽可能的从 socket 里读取字节,然后同步调用 decode 方法,解码出业务对象,并组成一个 List 。最后再循环遍历该 List ,依次提交到 ChannelPipeline 进行处理。此处我们做了一个细小的改动,如图下半部分所示,即将提交的内容从单个 command ,改为整个 List 一起提交,如此能减少 pipeline 的执行次数,同时提升吞吐量。这个模式在低并发场景,并没有什么优势,而在高并发场景下对提升吞吐量有不小的性能提升。

值得指出的一点:这个对于 dubbo mesh 复用 eventLoop 的特殊场景下的优化效果其实是存疑的,但我的最好成绩的确是使用了 AbstractBatchDecoder 之后跑出来的。我曾经单独将 ByteToMessageDecoder 和 AbstractBatchDecoder 拉出跑了一次分,的确是后者 qps 更高。

总结

其实在 qps 6500 时,整体代码还是挺漂亮的,至少感觉能拿的出手给别人看。但最后为了性能,加上时间比较赶,不少地方都进行了 hardcoding,而实际能投入生产使用的代码必然要求通用性和扩展性,赛后有空会整理出两个分支:一个 highest-qps 追求性能,另一个分支保留下通用性。这次比赛从一个 netty 小白,最终学到了不少的知识点,还是收获很大的,最后感谢一下比赛中给过我指导的各位老哥。代码由于通用性的问题在后面整理过后会贴在公众号中分享,本文暂时只分享思路。

最高 qps 分支:highest-qps

考虑通用性的分支(适合 netty 入门):master

https://code.aliyun.com/250577914/agent-demo.git


分享到:


相關文章: