合集文章传送门:
在前面,我们讨论了性能优化方法论,也讨论了性能指标和资源使用分析,那么接下来就可以聊聊代码优化的问题。
依照老规矩,我们还是尽可能从场景出发,讨论每种情况下都有哪些代码优化方式(不一定全部都是和Go代码直接相关)。
要提前说明一下,本篇不是非常有体系,主要还是罗列了一些自己遇到过比较典型的case,帮助大家从多个角度来借鉴思考。
资源(CPU)未使用满
一般资源未使用满的情况,大都是应用程序自己没有充分利用多线程的能力,导致系统的某一关键环节依赖单个线程,多核资源无法被使用,且相关代码处理的不够好,连一个核都用不满。
Case 1,我们的系统依赖了Confluent做数据的写入,其中有一个组件叫做Kafka-Rest,接收HTTP协议写入的数据,检查schema之后将数据写入Kafka,它是一个Java写的组件,它只有一个线程做select(网络请求接入),这一个线程的处理能力极为有限,我们的机器是32核的,但是整个CPU使用率极低吞吐量也极低。后来增加了单台机器上的Kafka-Rest数量,解决了系统吞吐问题。(后来改为单个Kafka-Rest进程中提供多个select,提升了系统吞吐)。
Case 2,我们一个项目中,需要解析一个压缩过的数据文件,采用了客户自定义的二进制数据格式,文件中有几万行数据,我们第一版的解析工具效率极低,每次读取一行数据解析一行数据。经过对文件格式的分析,发现每行的数据长度是完全一致的,直接改为IO和解析解耦,一个线程每次读一行数据丢到队列里面,然后有N个线程从队列里面解析行数据,这一改动直接将解析性能提升了接近100倍。
在Case 2中,是一个Go语言的解析器,我们实现IO与解析解耦,采用的是Go中的channel机制,IO线程向channel中写入行数据([]byte类型),多个解析线程忽略行的顺序性,直接从channel中读取数据做解析,解析线程之间是无关的。
避免重复的内存分配与拷贝
Case 1,在Go语言中,我们最常用的有几种数据结构:buffer、slice、map,它们都有一个共同的特点,那就是可以预分配空间,在日常编码中,我经常发现我的同事们没有使用预分配能力,在容量不足发生扩容的情况下会发生一次新的空间分配,并且进行一次数据拷贝,这是非常重的操作,在CPU密集型系统中将会带来严重的性能滑坡。
// 错误代码示范
array := make([]int64, 0) // slice大小初始化为0
for _, v := range input {
array = append(array, v) // 每次append,都发现array的空间不足,会申请一块新内存,大小为现在内存的2倍,然后把之前的数据拷贝到新申请的空间上
}
... // do something
// 正确代码示范
array := make([]int64, 0, len(input)) // slice大小初始化为input的长度,一次性申请好内存
for _, v := range input {
array = append(array, v) // 每次append,array的空间足够,直接将数据追加在array尾部
}
... // do something
与slice类似,buffer和map的机制接近,当内存不足的时候都会有新的内存申请与数据拷贝的操作,map还更为复杂,有一个哈希重算的逻辑,总而言之,这是应该尽量避免的操作。
Case 2,有些逻辑中依赖大量临时对象的分配,每次新生成的对象都需要重新走一遍内存申请、数据初始化的过程,比较消耗CPU资源。Go提供了一种机制,可以缓存这些临时对象达成服用的目的。
这个机制就是sync.Pool,代码示例如下:
// 对象池缓存处
var pool *sync.Pool
type Record struct {
Name string
Expire time.Time
}
func initPool() {
pool = &sync.Pool {
New: func()interface{} { // 当pool中缓存的对象不够时,调用New方法新生成对象
return new(Record)
},
}
}
// 对象池使用处
r := pool.Get().(*Record) // 从pool中取出pool
...
pool.Put(r) // 用完对象之后,将它还给pool
...
这样子使用sync.Pool,可以减少临时对象的生成,从而节省CPU的消耗,达到性能优化的目的。
在某些极端场景下,甚至我们连sync.Pool也不能容忍,因为在每一轮GC的时候,sync.Pool都会归还内存,导致临时对象减少,这种情况下我们应当自己实现内存池,想办法手动控制内存的归还,以期达到更高的性能。
懒加载对应不必要的分配
在某些代码中,我们需要new一些对象出来完成某种功能,但是这里要判断条件,只有在某些情况下才需要分配对象,那么我们如果对它进行了判断,就可以减少对象分配的次数,或者延后分配的时机,在宏观上降低对象分配的密度,降低CPU的消耗,从而提升性能。
降低函数时间复杂度
某些时候我们会写一些从slice中查找大量数据的函数,从slice中顺序查询,时间复杂度为O(n),大量数据查询会极度消耗CPU,而且也增加了函数的执行时间,不妨借助一些技巧让时间复杂度降为O(1),代码示例如下:
// 不好的代码示范
records := []int64{}
records = GetRecord(ctx, arg1, arg2)
output := make(map[int64]bool, 10000)
for _, query := range input {
var data int64
for _, v := range records { // O(n)复杂度
if query == v {
output[v] = true
break
}
}
}
return output
// 好的代码示范:
records := []int64{}
records = GetRecord(ctx, arg1, arg2)
recordMap := make(map[int64]bool, len(records))
output := make(map[int64]bool, 10000)
for _, v := range records { // slice转为map,查找时间复杂度降低
recordMap[v] = true
}
for _, query := range input {
if _, ok := recordMap[query]; ok { // O(1)复杂度
output[query] = true
break
}
}
reutrn output
处理网络IO打满
我们之前处理过一个case,经过HTTP协议向系统中写入数据,性能测试一直不达标,后来发现定义的数据包格式太过于冗余,双网卡机器的IO完全被打满,后来我们精简了数据包的协议,让数据包大小降到之前的三分之一左右,消除了网络上的瓶颈,系统的上限重新变成了CPU,于是我们又开始想办法降低CPU的消耗。
更换更高版本的Go
每一个新版本的Go,我们都可以认为它比前面的版本更为优越,事实上确实也是这样子,Go一直在优化GC算法,一直在优化标准库的性能,一直在优化依赖管理的方式。
我们有一次在优化一个高吞吐量的系统,发现GC占了非常大比例的CPU,该用的手段都已经用完但效果还是不够理想,由于将Go 1.4升级到了Go 1.6,再测了一遍发现性能有了明显提升(大约20%以上)。
自己花再大的力气做优化,有时候也不如站在巨人的肩膀上,可以事半功倍。
更换高性能依赖库
在一次性能优化中,我们发现代码中反序列化JSON串用的是Go标准库中的encoding/json库,它是一个性能相对来说不那么高的库,我们调研了之后用了其他几个开源的JSON库,更新到了其中一个,发现性能有了明显的提升。
这里说的几个JSON库在下面,大家可以了解一下:
- encoding/json
- jsoniter
- easyjson
当然,上文也说了,Go一直在提升标准库的性能,encoding/json的性能也一直在提升,这里想说的是,在某些场景下可以打开思路,他山之石,可以攻玉。
合理化应用逻辑
Case 1,我们曾经有一个系统,在每次数据写入的时候都会查询一次关于用户数据仓库的元数据,元数据存储在MongoDB中,直接查询有一定的IO和延迟,导致数据写入的性能一直不理想。后来进行了改造,元数据这里做了一个二级缓存,去除了不合理的IO和延迟,系统性能得以大幅提升。
Case 2,有一个接口,在一次下游请求之前做了一个权限的判断(要请求另一个系统,有延迟),这是一个必要操作,然后实际代码中在请求之前隔了几行的地方又做了一次权限判断,追问作者才发现这里是手误,复制代码的时候多复制了几行。
上面这个例子比较极(弱)端(智),但往往系统中会有这样那样不可思议的冗余逻辑,这些冗余逻辑的出现主要原因是代码由多人维护,且维护者时常在变动,遇到这样的逻辑留心一下会有意外的收获。
写在最后
这个Go语言应用程序性能优化三部曲系列到这里就写完了,不过总感觉意犹未尽,限于篇幅很多内容没有包含进来,也没有就某一话题进行非常深入的探讨,总感觉非常的可惜。
所以,后面有机会笔者还是会再写几篇更加深入的文章多聊聊一些底层机制,当然,大家有感兴趣的话题也可以留言或私信我,我会尽力输出,大家以文章为载体多多交流,共同提升技术。
本文暂时没有评论,来添加一个吧(●'◡'●)