这次天池中间件性能大赛初赛和复赛的成绩都正好是第五名
,出乎意料的是作为Golang是这次比赛的“稀缺物种”,这次在前十名中我也是侥幸存活在C大佬和Java大佬的中间。
关于这次初赛《Service Mesh for Dubbo》难度相对复赛《单机百万消息队列的存储设计》简单一些,最终成绩是6983分
,因为一些Golang的小伙伴在正式赛512并发压测的时候大多都卡在6000分大关,这里主要跟大家分享下我在这次Golang版本的一些心得和踩过的坑。
由于工作原因实在太忙,比赛只有周末的时间可以突击,下一篇我会抽空整理下复赛《单机百万消息队列的存储设计》的思路方案分享给大家,个人感觉实现方案上也是决赛队伍中比较特别的。
Service Mesh另辟蹊径,实现服务治理的过程不需要改变服务本身。通过以proxy或sidecar形式部署的 Agent,所有进出服务的流量都会被Agent拦截并加以处理,这样一来微服务场景下的各种服务治理能力都可以通过Agent来完成,这大大降低了服务化改造的难度和成本。而且Agent作为两个服务之间的媒介,还可以起到协议转换的作用,这能够使得基于不同技术框架和通讯协议建设的服务也可以实现互联互通,这一点在传统微服务框架下是很难实现的。
下图是一个官方提供的一个评测框架,整个场景由5个Docker 实例组成(蓝色的方框),分别运行了 etcd、Consumer、Provider服务和Agent代理。Provider是服务提供者,Consumer是服务消费者,Consumer消费Provider提供的服务。Agent是Consumer和Provider服务的代理,每个Consumer或 Provider都会伴随一个Agent。etcd是注册表服务,用来记录服务注册信息。从图中可以看出,Consumer 与Provider 之间的通讯并不是直接进行的,而是经过了Agent代理。这看似多余的一环,却在微服务的架构演进中带来了重要的变革。
有关Service Mesh的更多内容,请参考下列文章:
当然Agent Proxy最重要的就是通用性、可扩展性强,通过增加不同的协议转换可以支持更多的应用服务。最后Agent Proxy的资源占用率一定要小,因为Agent与服务是共生的,服务一旦失去响应,Agent即使拥有再好的性能也是没有意义的。
个人认为关于Service Mesh的选型一定会在Cpp和Golang之间,这个要参考公司的技术栈。如果追求极致的性能还是首选Cpp,这样可以避免Gc问题。因为Service Mesh链路相比传统Rpc要长,Agent Proxy需要保证轻量、稳定、性能出色。
关于技术选型为什么是Golang?这里不仅仅是为了当做一次锻炼自己Golang的机会,当然还出于以下一些原因:
官方提供了一个基于Netty实现的Java Demo,由于是阻塞版本,所以性能并不高,当然这也是对Java选手的一个福音了,可以快速上手。其他语言相对起步较慢,全部都要自己重新实现。
不管什么语言,大家的优化思路大部分都是一样的。这里分享一下Kirito徐靖峰非常细致的思路总结(Java版本):天池中间件大赛dubboMesh优化总结(qps从1000到6850),大家可以作为参考。
下面这张图基本涵盖了在整个agent所有优化的工作,图中绿色的箭头都是用户可以自己实现的。
异步非阻塞、无锁
,所有请求均采用异步回调的形式。这也是提升最大的一点。ByteBuffer复用
。批量打包
发送。
1ForBlock: 2for { 3 httpReqList[reqCount] = req 4 agentReqList[reqCount] = &AgentRequest{ 5 Interf: req.interf, 6 Method: req.callMethod, 7 ParamType: ParamType_String, 8 Param: []byte(req.parameter), 9 } 10 reqCount++ 11 if reqCount == *config.HttpMergeCountMax { 12 break 13 } 14 select { 15 case req = <-workerQueue: 16 default: 17 break ForBlock 18 } 19}最小响应时间
(效果并不是非常明显)批量encode
。Go因为有协程以及高质量的网络库,协程切换代价较小,所以大部分场景下Go推荐的网络玩法是每个连接都使用对应的协程来进行读写。
这个版本的网络模型也取得了比较客观的成绩,QPS最高大约在4400~4500。对这个网络选型简单做下总结:
然而在正式赛512并发压测的时候我们的程序并没有取得一个稳定提升的成绩,大约5500 ~ 5600左右,cpu的资源占用率也是比较高的,高达约100%
。
获得高分的秘诀分析:
尽可能降低Consumer Agent的资源开销
。这是一个比较简单、常用的优化思路,类似线程池。虽然有所突破,但是并没有达到理想的效果,cpu还是高达约70~80%。Goroutine虽然开销很小,毕竟高并发情况下还是有一定上下文切换的代价,只能想办法再去寻找一些性能的突破。
经过慎重思考,我最终还是决定尝试采用类似netty的reactor网络模型
。关于Netty的架构学习在这就不再赘述,推荐同事的一些分享总结闪电侠的博客。
选型之前咨询了几位好朋友,都是遭到一顿吐槽。当然他们没法理解我只有不到50%的Cpu资源可以利用的困境,最终还是毅然决然地走向这条另类的路。
经过一番简单的调研,我找到了一个看上去还挺靠谱(Github Star2000, 没有一个PR)的开源第三方库evio,但是真正实践下来遇到太多坑,而且功能非常简易。不禁感慨Java拥有Netty真的是太幸福了!Java取得成功的原因在于它的生态如此成熟,Go语言这方面还需要时间的磨炼,高质量的资源太少了。
当然不能全盘否定evio,它可以作为一个学习网络方面很好的资源。先看Github上一个简单的功能介绍:
1evio is an event loop networking framework that is fast and small. It makes direct epoll and kqueue syscalls rather than using the standard Go net package, and works in a similar manner as libuv and libevent.
说明:关于kqueue是FreeBSD上的一种的多路复用机制,推荐学习。
为了能够达到极致的性能,我对evio进行了大量改造:
改造之后的网络模型也是取得了很好的效果,可以达到6700+
的分数,但这还远远不够,还需要再去寻找一些突破。
对优化之后的网络模式再进行一次梳理(见下图):
可以把eventLoop理解为io线程,在此之前每个网络通信c->ca,ca->pa,pa->p都单独使用的一个eventLoop。如果入站的io协程和出站的io协程使用相同的协程,可以进一步降低Cpu切换的开销
。于是做了最后一个关于网络模型的优化:复用EventLoop
,通过判断连接类型分别处理不同的逻辑请求。
1func CreateAgentEvent(loops int, workerQueues []chan *AgentRequest, processorsNum uint64) *Events {
2 events := &Events{}
3 events.NumLoops = loops
4
5 events.Serving = func(srv Server) (action Action) {
6 logger.Info("agent server started (loops: %d)", srv.NumLoops)
7 return
8 }
9
10 events.Opened = func(c Conn) (out []byte, opts Options, action Action) {
11 if c.GetConnType() != config.ConnTypeAgent {
12 return GlobalLocalDubboAgent.events.Opened(c)
13 }
14 lastCtx := c.Context()
15 if lastCtx == nil {
16 c.SetContext(&AgentContext{})
17 }
18
19 opts.ReuseInputBuffer = true
20
21 logger.Info("agent opened: laddr: %v: raddr: %v", c.LocalAddr(), c.RemoteAddr())
22 return
23 }
24
25 events.Closed = func(c Conn, err error) (action Action) {
26 if c.GetConnType() != config.ConnTypeAgent {
27 return GlobalLocalDubboAgent.events.Closed(c, err)
28 }
29 logger.Info("agent closed: %s: %s", c.LocalAddr(), c.RemoteAddr())
30 return
31 }
32
33 events.Data = func(c Conn, in []byte) (out []byte, action Action) {
34 if c.GetConnType() != config.ConnTypeAgent {
35 return GlobalLocalDubboAgent.events.Data(c, in)
36 }
37
38 if in == nil {
39 return
40 }
41 agentContext := c.Context().(*AgentContext)
42
43 data := agentContext.is.Begin(in)
44
45 for {
46 if len(data) > 0 {
47 if agentContext.req == nil {
48 agentContext.req = &AgentRequest{}
49 agentContext.req.conn = c
50 }
51 } else {
52 break
53 }
54
55 leftover, err, ready := parseAgentReq(data, agentContext.req)
56
57 if err != nil {
58 action = Close
59 break
60 } else if !ready {
61 data = leftover
62 break
63 }
64
65 index := agentContext.req.RequestID % processorsNum
66 workerQueues[index] <- agentContext.req
67 agentContext.req = nil
68 data = leftover
69 }
70 agentContext.is.End(data)
71 return
72 }
73 return events
74}
复用eventloop得到了一个比较稳健的成绩提升,每个阶段的eventloop的资源数都设置为1个,最终512并发压测下cpu资源占用率约50%。
最后阶段只能丧心病狂地寻找一些细节点,所以也对语言层面做了一些尝试:
RingBuffer在高并发任务分发的场景中比Channel性能有小幅度提升,但是站在工程的角度,个人还是推荐Go channel这种更加优雅的做法。
使用字符串自己拼装Json数据,这样压测的数据越多,节省的时间越多。
性能优化离不开的一些套路:异步、去锁、复用、零拷贝、批量等
。最后抛出几个想继续探讨的Go网络问题,和大家一起讨论,有经验的朋友还希望能指点一二: