记一次对mosn中dubbo、hessian-go的性能优化
背景
蚂蚁内部对 service mesh 的稳定性和性能要求是比较高的,内部mosn 广泛用于生产环境。在云上和开源社区,RPC 领域dubbo和spring cloud 同样广泛用于生产环境,我们在 mosn 基础上,支持了 dubbo 和 spring cloud 流量代理。我们发现在支持 dubbo 协议过程中,经过 mesh 流量代理后,性能有非常大的性能损耗,在大商户落地mesh中也对性能有较高要求,因此本文会重点描述在基于 Go 语言库 dubbo-go-hessian2 、dubbo协议中对mosn所做的性能优化。
性能优化概述
根据实际业务部署场景,并没有选用高性能机器,使用普通linux机器,配置和压测参数如下:
- Intel(R) Xeon(R) Platinum 8163 CPU @ 2.50GHz 4核16G
- pod配置
2c、1g
,jvm参数-server -Xms1024m -Xmx1024m
- 网络延迟0.23ms, 2台linux机器,分别部署server+mosn, 压测程序rpc-perfomance
经过3轮性能优化后,使用优化版本mosn将会获得以下性能收益(框架随机512和1k字节压测):
- 512字节:mosn+dubbo服务调用tps整体提升55-82.8%,rt降低45%左右,内存占用40M
- 1k数据:mosn+dubbo服务调用tps整体提升51.1-69.3%,rt降低41%左右, 内存占用41M
性能优化工具pprof
磨刀不误砍柴工,在性能优化前首先要找到性能卡点,找到性能卡点后,另一个难点就是如何用高效代码优化替代slow code。因为蚂蚁service mesh是基于go语言实现的,我们首选go自带的pprof性能工具,我们简要介绍这个工具如何使用。如果我们go库自带http.Server时并且在main头部导入import _ "net/http/pprof"
,go会帮我们挂载对应的handler, 详细可以参考godoc 。
因为mosn默认会在34902
端口暴露http服务,通过以下命令轻松获取mosn的性能诊断文件:
1 | go tool pprof -seconds 60 http://benchmark-server-ip:34902/debug/pprof/profile |
然后继续用pprof打开诊断文件,方便在浏览器查看,在图1-1给出压测后profiler火焰图:
1 | # http=:8000代表pprof打开8000端口然后用于web浏览器分析 |
在获得诊断数据后,可以切到浏览器Flame Graph(火焰图,go 1.11以上版本自带),火焰图的x轴坐标代表cpu消耗情况,y轴代码方法调用堆栈。在优化开始之前,我们借助go工具pprof可以诊断出大致的性能卡点在以下几个方面(直接压server端mosn):
- mosn在接收dubbo请求,cpu卡点在streamConnection.Dispatch
- mosn在转发dubbo请求,cpu卡点在downStream.Receive
可以点击火焰图任意横条,进去查看长方块耗时和堆栈明细(请参考图1-2和1-3所示):
性能优化思路
本文重点记录优化了哪些case才能提升50%+的吞吐量和降低rt,因此后面直接分析当前优化了哪些case。在此之前,我们以Dispatch为例,看下它为啥那么吃性能 。在terminal中通过以下命令可以查看代码行耗费cpu数据(代码有删减):
1 | go tool pprof mosnd pprof.mosn.samples.cpu.001.pb.gz |
通过上面 list Dispatch
命令,性能卡点主要分布在 159
、 171
、172
、 181
、和 190
等行,主要卡点在解码dubbo参数、重复解参数、tracer、反序列化和log等。
1. 优化dubbo解码GetMetas
我们通过解码dubbo的body可以获得以下信息,调用的目标接口(interface)和调用方法的服务分组(group)等信息,但是需要跳过所有业务方法参数,目前使用开源的hessian-go库,解析string和map性能较差, 提升hessian库解码性能,会在本文后面讲解。
优化思路:
在mosn的ingress端(mosn直接转发请求给本地java server进程), 我们根据请求的path和version去窥探用户使用的interface和group, 构建正确的dataId可以进行无脑转发,无需解码body,榨取性能提升。
我们可以在服务注册时,构建服务发布的path、version和group到interface、group映射。在mosn转发dubbo请求时可以通过读锁查cache+跳过解码body,加速mosn性能。
因此我们构建以下cache实现(数组+链表数据结构), 可参见优化代码diff :
1 | // metadata.go |
通过服务注册时构建好的cache,可以在mosn的stream做解码时命中cache, 无需解码参数获取接口和group信息,可参见优化代码diff :
1 | // decoder.go |
在mosn的egress端(mosn直接转发请求给本地java client进程), 我们采用类似的思路, 我们根据请求的path和version去窥探用户使用的interface和group, 构建正确的dataId可以进行无脑转发,无需解码body,榨取性能提升。
2. 优化dubbo解码参数
在dubbo解码参数值的时候 ,mosn采用的是hessian的正则表达式查找,非常耗费性能。我们先看下优化前后benchmark对比, 性能提升50倍!!!
1 | go test -bench=BenchmarkCountArgCount -run=^$ -benchmem |
优化思路:
可以消除正则表达式,采用简单字符串解析识别参数类型个数, dubbo编码参数个数字符串实现 并不复杂, 主要给对象加L前缀、数组加[、primitive类型有单字符代替。采用go可以实现同等解析, 可以参考优化代码diff :
1 | func getArgumentCount(desc string) int { |
3. 优化hessian go解码string性能
在图1-2中可以看到hessian go在解码string占比cpu采样较高,我们在解码dubbo请求时,会解析dubbo框架版本、调用path、接口版本和方法名,这些都是string类型,hessian go解析string会影响rpc性能。
我们首先跑一下benchmark前后解码string性能对比,性能提升56.11%!!! 对应到rpc中有5%左右提升。
1 | BenchmarkDecodeStringOriginal-12 1967202 613 ns/op 272 B/op 6 allocs/op |
优化思路:
直接使用utf-8 byte解码,性能最高,之前先解码byte成rune, 对rune解码成string,及其耗费性能。增加批量string chunk copy, 降低read调用,并且使用unsafe转换string(避免一些校验),因为代码优化diff较多,这里给出优化代码pr 。
go sdk代码runtime/string.go#slicerunetostring
(rune转换成string), 同样是把rune转成byte数组,这里给了我优化思路启发。
4. 优化hessian库编解码对象
虽然消除了dubbo的body解码部分,但是mosn在处理dubbo请求时,必须要借助hessian去decode请求头部的框架版本、请求path和接口版本值。但是每次在解码的时候都会创建序列化对象,开销非常高,因为hessian每次在创建reader的时候会allocate 4k数据并reset。
1 | 10ms 10ms 75:func unSerialize(serializeId int, data []byte, parseCtl unserializeCtl) *dubboAttr { |
我们可以写个池化内存前后性能对比, 性能提升85.4%!!! , benchmark用例 :
1 | BenchmarkNewDecoder-12 1487685 803 ns/op 4528 B/op 9 allocs/op |
优化思路:
在每次编解码时,池化hessian的decoder对象,新增NewCheapDecoderWithSkip并支持reset复用decoder。
1 | var decodePool = &sync.Pool{ |
5. 优化重复解码service和methodName值
xprotocol在实现xprotocol.Tracing获取服务名称和方法时,会触发调用并解析2次,调用开销比较大。
1 | 10ms 1.91s 171: serviceName := tracingCodec.GetServiceName(request) |
优化思路:
因为在GetMetas里面已经解析过一次了,可以把解析过的headers传进去,如果headers有了就不用再去解析了,并且重构接口名称为一个,返回值为二元组,消除一次调用。
6. 优化streamId类型转换
在go中将byte数组和streamId进行互转的时候,比较费性能。
优化思路:
生产代码中, 尽量不要使用fmt.Sprintf和fmt.Printf去做类型转换和打印信息。可以使用strconv去转换。
1 | . 430ms 147: reqIDStr := fmt.Sprintf("%d", reqID) |
7. 优化昂贵的系统调用
mosn在解码dubbo的请求时,会在header中塞一份远程host的地址,并且在for循环中获取remoteIp,系统调用开销比较高。
优化思路:
1 | 50ms 920ms 136: headers[strings.ToLower(protocol.MosnHeaderHostKey)] = conn.connection.RemoteAddr().String() |
在获取远程地址时,尽可能在streamConnection中cache远程ip值,不要每次都去调用RemoteAddr。
8. 优化slice和map触发扩容和rehash
在mosn处理dubbo请求时,会根据接口、版本和分组去构建dataId,然后匹配cluster, 会创建默认slice和map对象,经过性能诊断,导致不断allocate slice和grow map容量比较费性能。
优化思路:
使用slice和map时,尽可能预估容量大小,使用make(type, capacity)去指定初始大小。
9. 优化trace日志级别输出
mosn中不少代码在处理逻辑时,会打很多trace级别的日志,并且会传递不少参数值。
优化思路:
调用trace输出前,尽量判断一下日志级别,如果有多个trace调用,尽可能把所有字符串写到buf中,然后把buf内容写到日志中,并且尽可能少的调用trace日志方法。
10. 优化tracer、log和metrics
在大促期间,对机器的性能要求较高,经过性能诊断,tracer、mosn log和cloud metrics写日志(io操作)非常耗费性能。
优化思路:
通过配置中心下发配置或者增加大促开关,允许api调用这些feature的开关。
1 | /api/v1/downgrade/on |
11. 优化route header解析
mosn中在做路由前,需要做大量的header的map访问,比如ldc、antvip等逻辑判断,商业版或者开源mosn不需要这些逻辑,这些也会占用一些开销。
优化思路:
如果是云上逻辑,主站的逻辑都不走。
12. 优化featuregate调用
在mosn中处理请求时,为了区分主站和商业版路由逻辑,会通过featuregate判断逻辑走哪部分。通过featuregate调用开销较大,需要频繁的做类型转换和多层map去获取。
优化思路:
通过一个bool变量记录featuregate对应开关,如果没有初始化过,就主动调用一下featuregate。
未来性能优化思考
经过几轮性能优化 ,目前看火焰图,卡点都在connection的read和write,可以优化的空间比较小了。但是可能从以下场景中获得收益:
- 减少connection的read和write次数(syscall)
- 优化io线程模型,减少携程和上下文切换等
作为结束,给出了最终优化后的火焰图 ,大部分卡点都在系统调用和网络读写, 请参考图1-4。
关于我
花名诣极,开源Apache Dubbo PMC。目前就职于蚂蚁金服中间件团队,主攻rpc和Service mesh方向。 ‘’深入理解Apache Dubbo与实战’’图书作者。github: https://github.com/zonghaishang
其他
pprof工具异常强大,可以诊断cpu、memory、go协程、tracer和死锁等,该工具可以参考godoc,性能优化参考: