阿里妹导读
目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,本文把高德过去go服务开发中的性能调优经验进行总结和沉淀,希望能为正在使用go语言的同学在性能优化方面带来一些参考价值。
前言
一、性能调优-理论篇
1.1 衡量指标
以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:
1.2 制定优化方案
1)提高复用性,将通用的代码抽象出来,减少重复开发。
2)池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。
3)并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。
4)异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。
5)算法优化,使用时间复杂度更低的算法。
从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。
二、性能调优-工具篇
2.1 benchmark
package main
import (
"fmt"
"strconv"
"testing"
)
func BenchmarkStrconv(b *testing.B) {
for n := 0; n < b.N; n++ {
strconv.Itoa(n)
}
}
func BenchmarkFmtSprint(b *testing.B) {
for n := 0; n < b.N; n++ {
fmt.Sprint(n)
}
}
我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
41988014 27.41 ns/op
13738172 81.19 ns/op
ok main 7.039s
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
211533207 31.60 ns/op
69481287 89.58 ns/op
PASS
ok main 18.891s
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
217894554 31.76 ns/op
217140132 31.45 ns/op
219136828 31.79 ns/op
70683580 89.53 ns/op
63881758 82.51 ns/op
64984329 82.04 ns/op
PASS
ok main 54.296s
结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
43700922 27.46 ns/op 7 B/op 0 allocs/op
143412 80.88 ns/op 16 B/op 2 allocs/op
PASS
ok main 7.031s
可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
const smallsString = "00010203040506070809" +
"10111213141516171819" +
"20212223242526272829" +
"30313233343536373839" +
"40414243444546474849" +
"50515253545556575859" +
"60616263646566676869" +
"70717273747576777879" +
"80818283848586878889" +
"90919293949596979899"
// small returns the string for an i with 0 <= i < nSmalls.
func small(i int) string {
if i < 10 {
return digits[i : i+1]
}
return smallsString[i*2 : i*2+2]
}
func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string) {
...
for j := 4; j > 0; j-- {
is := us % 100 * 2
us /= 100
i -= 2
a[i+1] = smallsString[is+1]
a[i+0] = smallsString[is+0]
}
...
}
而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。
// fmtInteger formats signed and unsigned integers.
func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) {
...
switch base {
case 10:
for u >= 10 {
i--
next := u / 10
buf[i] = byte('0' + u - next*10)
u = next
}
...
}
benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。
2.2 pprof
package main
import (
"os"
"runtime/pprof"
"time"
)
func main() {
w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644)
pprof.StartCPUProfile(w)
time.Sleep(time.Second)
pprof.StopCPUProfile()
}
我们也可以使用另外一种方法,net/http/pprof:
package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
err := http.ListenAndServe(":6060", nil)
if err != nil {
panic(err)
}
}
将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:
go tool pprof -http :6060 test_cpu
2.3 trace
2.4 后记
三、性能调优-技巧篇
3.1 字符串拼接
// 推荐:用+进行字符串拼接
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
s := "a" + "b"
_ = s
}
}
// 不推荐:用fmt.Sprintf进行字符串拼接
func BenchmarkFmt(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("%s%s", "a", "b")
_ = s
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlus-12 1000000000 0.2658 ns/op 0 B/op 0 allocs/op
BenchmarkFmt-12 16559949 70.83 ns/op 2 B/op 1 allocs/op
PASS
ok main 5.908s
// 推荐:初始化时指定容量
func BenchmarkGenerateWithCap(b *testing.B) {
nums := make([]int, 0, 10000)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
// 不推荐:初始化时不指定容量
func BenchmarkGenerate(b *testing.B) {
nums := make([]int, 0)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkGenerateWithCap-12 23508 336485 ns/op 476667 B/op 0 allocs/op
BenchmarkGenerate-12 22620 68747 ns/op 426141 B/op 0 allocs/op
PASS
ok main 16.628s
3.3 遍历 []struct{} 使用下标而不是 range
func getIntSlice() []int {
nums := make([]int, 1024, 1024)
for i := 0; i < 1024; i++ {
nums[i] = i
}
return nums
}
// 用下标遍历[]int
func BenchmarkIndexIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for k := 0; k < len(nums); k++ {
tmp = nums[k]
}
_ = tmp
}
}
// 用range遍历[]int元素
func BenchmarkRangeIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for _, num := range nums {
tmp = num
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexIntSlice-12 3923230 270.2 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIntSlice-12 4518495 287.8 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 3.303s
可以看到,在遍历[]int时,两种方式并无差别。
type Item struct {
id int
val [1024]byte
}
// 推荐:用下标遍历[]struct{}
func BenchmarkIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for j := 0; j < len(items); j++ {
tmp = items[j].id
}
_ = tmp
}
}
// 推荐:用range的下标遍历[]struct{}
func BenchmarkRangeIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for k := range items {
tmp = items[k].id
}
_ = tmp
}
}
// 不推荐:用range遍历[]struct{}的元素
func BenchmarkRangeStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for _, item := range items {
tmp = item.id
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12 4413182 266.7 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIndexStructSlice-12 4545476 269.4 ns/op 0 B/op 0 allocs/op
BenchmarkRangeStructSlice-12 33300 35444 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 5.282s
可以看到,用for循环下标的方式性能都差不多,但是用range遍历数组里的元素时,性能则相差很多,前面两种方法是第三种方法的130多倍。主要原因是通过for k, v := range获取到的元素v实际上是原始值的一个拷贝。所以在面对复杂的struct进行遍历的时候,推荐使用下标。但是当遍历对象是复杂结构体的指针([]*struct{})时,用下标还是用range迭代元素的性能就差不多了。
3.4 利用unsafe包避开内存copy
func Str2bytes(s string) []byte {
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}
func Bytes2str(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
我们通过benchmark来验证一下是否性能更优:
// 推荐:用unsafe.Pointer实现string到bytes
func BenchmarkStr2bytes(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = Str2bytes(s)
}
_ = bs
}
// 不推荐:用类型转换实现string到bytes
func BenchmarkStr2bytes2(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = []byte(s)
}
_ = bs
}
// 推荐:用unsafe.Pointer实现bytes到string
func BenchmarkBytes2str(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = Bytes2str(bs)
}
_ = s
}
// 不推荐:用类型转换实现bytes到string
func BenchmarkBytes2str2(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = string(bs)
}
_ = s
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStr2bytes-12 1000000000 0.2938 ns/op 0 B/op 0 allocs/op
BenchmarkStr2bytes2-12 38193139 28.39 ns/op 16 B/op 1 allocs/op
BenchmarkBytes2str-12 1000000000 0.2552 ns/op 0 B/op 0 allocs/op
BenchmarkBytes2str2-12 60836140 19.60 ns/op 16 B/op 1 allocs/op
PASS
ok demo/test 3.301s
可以看到使用unsafe.Pointer比强制类型转换性能是要高不少的,从内存分配上也可以看到完全没有新的内存被分配。
3.5 协程池
var wg sync.WaitGroup
ch := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
ch <- struct{}{}
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
wg.Wait()
这里通过限制channel长度为3,可以实现最多只有3个协程被创建的效果。
func Test_ErrGroupRun(t *testing.T) {
errgroup := WithTimeout(nil, 10*time.Second)
errgroup.SetMaxProcs(4)
for index := 0; index < 10; index++ {
errgroup.Run(nil, index, "test", func(context *gin.Context, i interface{}) (interface{},
error) {
t.Logf("[%s]input:%+v, time:%s", "test", i, time.Now().Format("2006-01-02 15:04:05"))
time.Sleep(2*time.Second)
return i, nil
})
}
errgroup.Wait()
}
输出结果如下:
=== RUN Test_ErrGroupRun
errgroup_test.go:23: [test]input:0, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:3, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:1, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:2, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:4, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:5, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:6, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:7, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:8, time:2022-12-04 17:31:33
errgroup_test.go:23: [test]input:9, time:2022-12-04 17:31:33
--- PASS: Test_ErrGroupRun (6.00s)
PASS
errgroup可以通过SetMaxProcs设定协程池的大小,从上面的结果可以看到,最多就4个协程在运行。
3.6 sync.Pool 对象复用
type ActTask struct {
Id int64 `ddb:"id"` // 主键id
Status common.Status `ddb:"status"` // 状态 0=初始 1=生效 2=失效 3=过期
BizProd common.BizProd `ddb:"biz_prod"` // 业务类型
Name string `ddb:"name"` // 活动名
Adcode string `ddb:"adcode"` // 城市
RealTimeRuleByte []byte `ddb:"realtime_rule"` // 实时规则json
...
}
type RealTimeRuleStruct struct {
Filter []*struct {
PropertyId int64 `json:"property_id"`
PropertyCode string `json:"property_code"`
Operator string `json:"operator"`
Value []string `json:"value"`
} `json:"filter"`
ExtData [1024]byte `json:"ext_data"`
}
func (at *ActTask) RealTimeRule() *form.RealTimeRule {
if err := json.Unmarshal(at.RealTimeRuleByte, &at.RealTimeRuleStruct); err != nil {
return nil
}
return at.RealTimeRuleStruct
}
以这里的实时投放规则为例,我们会将过滤规则反序列化为字节数组。每次json.Unmarshal都会申请一个临时的结构体对象,而这些对象都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。
var realTimeRulePool = sync.Pool{
New: func() interface{} {
return new(RealTimeRuleStruct)
},
}
然后调用 Pool 的 Get() 和 Put() 方法来获取和放回池子中。
rule := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(buf, rule)
realTimeRulePool.Put(rule)
var realTimeRule = []byte("{\\\"filter\\\":[{\\\"property_id\\\":2,\\\"property_code\\\":\\\"search_poiid_industry\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"yimei\\\"]},{\\\"property_id\\\":4,\\\"property_code\\\":\\\"request_page_id\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"all\\\"]}],\\\"white_list\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"white_list_for_adiu\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"j838ef77bf227chcl89888f3fb0946\\\",\\\"lb89bea9af558589i55559764bc83e\\\"]}],\\\"ipc_user_tag\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"ipc_crowd_tag\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"test_20227041152_mix_ipc_tag\\\"]}],\\\"relation_id\\\":0,\\\"is_copy\\\":true}")
// 推荐:复用一个对象,不用每次都生成新的
func BenchmarkUnmarshalWithPool(b *testing.B) {
for n := 0; n < b.N; n++ {
task := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(realTimeRule, task)
realTimeRulePool.Put(task)
}
}
// 不推荐:每次都会生成一个新的临时对象
func BenchmarkUnmarshal(b *testing.B) {
for n := 0; n < b.N; n++ {
task := &RealTimeRuleStruct{}
json.Unmarshal(realTimeRule, task)
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkUnmarshalWithPool-12 3627546 319.4 ns/op 312 B/op 7 allocs/op
BenchmarkUnmarshal-12 2342208 490.8 ns/op 1464 B/op 8 allocs/op
PASS
ok demo/test 3.525s
可以看到,两种方法在时间消耗上差不太多,但是在内存分配上差距明显,使用sync.Pool后内存占用仅为不使用的1/5。
3.7 避免系统调用
// 推荐:不使用系统调用
func BenchmarkNoSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if configs.PUBLIC_KEY != nil {
}
}
})
}
// 不推荐:使用系统调用
func BenchmarkSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if os.Getenv("PUBLIC_KEY") != "" {
}
}
})
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNoSytemcall-12 1000000000 0.1495 ns/op 0 B/op 0 allocs/op
BenchmarkSytemcall-12 37224988 31.10 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 1.877s
四、性能调优-实战篇
案例1: go协程创建数据库连接不释放导致内存暴涨
案例2: 优惠索引内存分配大,gc 耗时高
案例3:流量上涨导致cpu异常飙升
1、优化toEntity方法,简化为单独的ID()方法
2、优化数组、map初始化结构
3、优化adCode转换为string过程
案例4:内存对象未释放导致内存泄漏
结语