目录
-
如何构建压测环境
-
如何分析性能瓶颈
- 如何做性能优化
语言层面
设计层面
- 总结
主要内容
我是来自迅雷的后台开发架构师,今天很高兴给大家分享一下我在迅雷连通系统中的性能优化实践经验。P2P连通系统是我们迅雷下载过程中用来辅助P2P打洞和穿透的系统,C++开发,性能要求很高,是迅雷访问量最大的系统。16年的时候,我们发现P2P连通率有较大的提升空间,所以决定升级P2P连通系统后台,以提高P2P连通率以及用户下载体验。在此之前,我们零星使用过Go来开发模块,体验到Go的高效开发效率,所以我们决定用Go来实现新的连通系统。
大家可以看一下三个圈:首先是性能压测,压测之后做性能分析,性能分析出来之后做性能优化,三个圈形成了一个闭环,我的分享也是围绕这三个圈来讲的。左边目录是我讲的主要内容,最后会做一个总结。
优化效果
我们可以先来看下系统性能优化后的效果:
日访问量
• 1500亿
• 峰值2000亿
单机QPS (24core/32G)
• 线上峰值20w
• 压测峰值37w
响应时间
• AVG:25ms
1.如何构建压测环境
如何构造压测环境?大家通常做压测一般是单独压测某个命令,但线上真实环境往往是存在多个命令,而且多个命令之间存在资源竞争,比如会有锁等。这导致真实的QPS会比你的压测要低,所以我们按线上环境中的命令比例混合压测,构造贴近真实的压测环境。
目的
• 仿真测试
我们构造压测环境,用的是流量回放工具,比如说tcpcopy、udpcopy、goreplay,它目的是通过线上引流功能来构造一个真实的压测环境。
• 离线重放
另外一个是离线重放,把线上流量录制下来,然后我们离线回放这些请求,构造稳定的压测环境。
• 流量放大
可以放大N倍的流量,逐步加压,测试系统的极限。
开源工具
• tcpcopy
• udpcopy
• goreplay
2.如何分析性能瓶颈
性能分析G里面会带一个工具pprof,我们也是用这个工具来做。它有两种方式:一种是工具型应用,运行一段时间就结束;另一种是服务型应用,死循环,一直运行。观察指标中,注意下block可以查看死锁的协程。
2.1 两种收集方式
工具型应用: runtime/pprof
服务型应用: net/http/pprof
2.2 观察指标
cpu profile
memory profile
block profile
goroutine profile
生成调用关系的图
flat: 本函数栈
cum:本函数栈+子函数栈
主要几个指标,一个是flat,表示抽样命中本函数栈的CPU时间; 另一个是cum,表示命中本函数栈以及其子函数栈的CPU时间。调用关系树有一个问题:如果调用关系简单还好,可以很快找到瓶颈在哪里,但如果生成出来的图比较繁杂、比较大的时候,其实不太方便去找。
像这个图:
当我们的调用关系图比这个更复杂的时候,这个时候是不太方便找到它的瓶颈的,需要一定的时间。
那怎么办呢?我们会用到火焰图。
go-torch
openresty-systemtap-toolkit
sample-bt-cpu
sample-bt-off-cpu
火焰图一般是用go-torch,还有openresty都有这样的工具,我用go-torch比较多。火焰图有横坐标和纵坐标,横坐标表示所占CPU的时间,纵向表示函数调用关系,颜色不代表任何意思。X抓轴的排序也不代表任何意思。用这个就比较容易发现它到底哪块占的时间比较多。
3.性能优化
3.1语言层面
slice
make([]T, length, capacity)
map
make(map[keytype]valuetype, capacity)
byte.Buffer
Grow()
如果已经知道slice或者map的容量,预设容量可以减少动态扩展时的内存移动和拷贝,包括byte.buffer也是一样。
字符串拼接
常见的拼接方式有4种
bytes.Buffer 可以预设容量
strings.Join 需要构建slice
fmt.Sprintf 反射
operator+ 多次对象的分配与值拷贝
BenchmarkStrCatWithBuffer-8 20000000 80.3 ns/op
BenchmarkStrCatWithJoin-8 30000 119035 ns/op
BenchmarkStrCatWithSprintf-8 50000 141812 ns/op
BenchmarkStrCatWithOperator-8 100000 175080 ns/op
bytes.buffer可以预设容量,所以性能最好。 strings.jon有一个问题,你需要构建slice,构建slice也是需要成本的。fmt.Spsintf会用到反射,大家知道反射的性能不好。还有operator+,我们平常用得比较多,它涉及多次对象的分配与拷贝值。压测的结果差距还是挺大的。
所以在一些性能要求较高的场合,尽量使用 buffer.Buffer以获得更好的性能。性能要求不太高的场合,直接使用运算符,代码更简短清晰,能获得比较好的可读性,如果需要拼接的不仅仅是字符串,还有数字之类的其他需求的话,可以考虑 fmt.Sprintf。
defer
性能:
defer Unlock 性能4x差异
主要过程:
runtime.deferproc 插入defer链表
runtime.deferreturn 调用defer链表
涉及对象分配、缓存、多次函数调用
右边的图,G表示协程,里面会有defer的头指针,指向一个defer结构体的链表,defer结构体里面包括我们定义的defer函数。sp表示Go栈指针,siz表示结构体后面跟着的参数大小,因为参数是直接跟在结构后面分配的,siz是指它有多大。link指向下一个的defer结构体。defer是先进后出,先定义,后调用,它这里就是用一个栈式链表结构来实现。因为defer涉及多次对象的分配、函数调用等,不像我们调用函数那样只是一个简单的call指令调用,它是有代价的。使用用它的场景:假如我们的分支跳出比较清晰的时候,就不建议用defer;反之,在跳转比较复杂,不太清晰的时候,可以用下defer。我们有做实验,简单调用一个lock和unlock,和通过defer去lock和unlock做对比,发现有3、4倍的性能差距。
反射
(log)(json)
反射的性能也是有些问题,我们可以看一下一些库的对比。左边是日志库, logrus这个库在docker里面有用。我们这边是用的是zap日志库,里面是没有用到反射来实现。它们性能差距非常大。 右边是标准的json库跟其他的几个库的比较,decode的性能差距是比较大的。所以是否使用反射还是要在开发效率和性能之间的做取舍。
另外Rob Pike箴言中说过:Clear is better than clever ,Reflections is never clear。清晰比聪明更好,反射永远不清晰。
闭包
对从C或者C++转过来的开发来说,闭包这个概念应该很少听到。闭包是现代语言比较高级的特性,像lua、java、js等语言里面都早就出现了。在lua中,sum变量被叫做upvalue, 在js中被称为自由变量。C++11中也有类似的特性:lambda,但还有点不一样,但本质是一样的。Go也加入闭包的语言特性。
从C语言的角度来看,引入函数内部栈上的变量是很危险的:当函数退出的时候,栈随着函数的销毁而销毁。但是在Go里面不一样, 函数内的局部变量sum有生命周期,跟着函数的生命周期相同。它是怎么做的呢?原来Go在编译的时候会做逃逸分析,把这个栈上变量逃逸到堆上,所以闭包其实还是增加了垃圾扫描的数量。
sync.pool
我们可以看一下它的源码:
在垃圾回收启动的时候,会清理池,就是对sync.Pool的清理,相当于把池里面空闲的对象在垃圾回收开始的的时候清理掉。所以在下次做垃圾回收时候,池中空闲对象都已经被GC清理了,我们还是要从内存管理器里分配。sync.Pool的注释里说减少了扫描的数量,减轻垃圾回收的压力,但是我们在把它当做传统对象池使用的时候,发现它的效果好像没有达到我们的预期。我后面又看了它描述的适用场景,说是不太适合维护一个短生命周期对象。我这里有困惑,什么叫短,短是指多短,GC间隔?可能是因为每次Put或者Get的时候要通过反射获取,另外放到(或者取)P的share队列时,都需要加锁,也就是说有一定的代价,是不是这个导致效果不是特别好?有对这块熟的同学可以场下和我交流下。
3.2设计层面
多级缓存
local cache
remote cache
批量操作
redis pipeline/mget/mset
合并收发数据包
系统参数优化
读写缓冲区大小
numa
日志
删除非必要的日志
如果分布式缓存扛不住,可以增加进程内本地缓存,以前遇到用户下载一个热门资源,对应请求经过哈希落到同一个redis实例,单个redis扛不住,这个时候你可以加一个本地缓存。优化系统参数等。删除非必要的日志: 这里注意下, 你把fmt.Sprintf()语句作为参数传进日志API打印时,即使你把日志级别调高,fmt.Sprintf()还是会执行的。 fmt.Sprintf()用到反射,性能还是有很大损失的。
对象池
减少分配次数
减小垃圾回收压力
这里推荐一个模仿java里面实现的连接池。 一般连接池底层要么用slice实现,要么用channel来实现。
定时器
我们可以看一下,addtimer和deltimer函数,用来增加和删除定时器,这两个函数占比加起来CPU占用将近30%。 子函数加锁耗费将近15%的CPU,解锁将近9%的CPU! 怎么回事?通过看源码,发现所有定时器是由一个全局变量timers来管理,其实就是用最小堆,底层是用slice实现,全局变量里有一个锁,相当于这个锁也是全局的。
由于加定时器和删除定时器都要操作这个锁,所以这个全局锁的粒度是非常大的。因此我们刚才也就看到了CPU占比是非常高的。
这个问题怎么解决?我们可以通过高效低精度定时器来解决,本质是通过牺牲时间精度来换取高性能。
这就是时间轮片,轮片中标示的是剩余时间,比如用来维持心跳,超时时间为7秒,当心跳过来,我们把定时器放到7秒这个桶里,等过1秒的时候,这个时间齿轮拨动一下,就剩下6秒,一直拨动下去,只剩下0秒时,就发生超时,对该桶里所有定时器进行处理。实现时间轮片只需要一个定时器,所以加上这个优化之后,基本上刚才的增加定时器、删除定时器的操作都看不到了。
协程池
我们借鉴了java SEDA的思想,把一个请求处理过程分为多个stage,不同阶段使用不同的协程池处理。协程达到百万级别的时候,它的调度和切换的代价也是比较大的。我们使用协程池后,由于有一个最大的协程数量来限制它,然后通过对任务队列的观察,如果任务队列过大,会自动调大协程数,直至上限;如果任务队列空闲的话,会收缩协程池的数量,直至下限。协程池和协程池之间通过任务队列交互。通过合理分配协程数,减少协程切换,提升并发性能。
进程的架构:
有一个监听的协程池,还有读取、解析的、业务逻辑处理、响应等协程池,这样就控制协程的数量,另外顺便还可以做优化处理,比如和其他模块交互时合并批量请求和响应,并行批量读写redis等。
4.总结
4.1性能分析
ON-CPU
CPU是瓶颈
OFF-CPU
IO或锁是瓶颈
两类性能分析问题,一类是ON-CPU,另外一类是OFF-CPU。如果你发现CPU跑不满,然后吞吐又提升不上去,这就是OFF-CPU问题。 这个问题之前有遇到过,发现CPU老是上不去,吞吐量又达到一个瓶颈。这类问题,要么是等待IO,要么是等待锁。我们怎么定位?之前看到一个OFF-CPU的检测工具,我们用了,发现效果不是很好,可能是受多线程的干扰比较大。后来我们用排除法,列出可疑的代码块,按高优先级依次删除,删掉之后还是跑不满,那就继续删下一个地方,直至发现性能瓶颈。我们通过这个笨方法,最后发现是统计库存在一个很大粒度的锁导致的,后来我们把这个大锁拆成了许多小粒度锁,性能一下子就上去了,CPU跑满了。
4.2优化的本质
减少垃圾回收
减少内存分配
减少内存移动拷贝
4.3注意事项
优化关键路径
不要开始就优化
谢谢大家!
【提问环节】
提问:刚刚您有提到defer的优化,SQL包里面有很多defer,可以分享迅雷在这方面的小经验吗?你们是不是重写了SQL包?
朱文:没有重写,SQL包里的defer未必是性能瓶颈,要看实际性能测试数据。
提问:真的要减少defer做优化,有很多包不可避免的使用到,我想了解具体的实践。
朱文:这还是要看你访问的频率和性能情况,实际的性能测试数据和你想象出来的瓶颈未必一样,比如MySQL,频率有那么高吗?是一个瓶颈吗?单机QPS达到27万,肯定不会直接访问MySQL,而是直接访问缓存和redis。
有疑问加站长微信联系(非本文作者)