腾讯 Go 性能优化实战

腾讯 Go 性能优化实战

首页枪战射击Idle Race Riot更新时间:2024-06-27

作者:trumanyan,腾讯 CSIG 后台开发工程师

项目背景

网关服务作为统一接入服务,是大部分服务的统一入口。为了避免成功瓶颈,需要对其进行尽可能地优化。因此,特别总结一下 golang 后台服务性能优化的方式,并对网关服务进行优化。

技术背景:

性能指标

网关服务本身没有业务逻辑处理,仅作为统一入口进行请求转发,因此我们主要关注下列指标

定位瓶颈

一般后台服务的瓶颈主要为 CPU,内存,IO 操作中的一个或多个。若这三者的负载都不高,但系统吞吐量低,基本就是代码逻辑出问题了。

在代码正常运行的情况下,我们要针对某个方面的高负载进行优化,才能提高系统的性能。golang 可通过 benchmark 加 pprof 来定位具体的性能瓶颈。

benchmark 简介

go test -v gate_test.go -run=none -bench=. -benchtime=3s -cpuprofile cpu.prof -memprofile mem.prof

benchmark 测试用例常用函数

pprof 简介

生成方式

查看方式


pprof 接入 tarsgo

  1. 服务中 main 方法插入代码cfg := tars.GetServerConfig()
    profMux := &tars.TarsHttpMux{}
    profMux.HandleFunc("/debug/pprof/", pprof.Index)
    profMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
    profMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
    profMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
    profMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
    tars.AddHttpServant(profMux, cfg.App "." cfg.Server ".ProfObj")
  2. taf 管理平台中,添加 servant:ProfObj (名字可自己修改)
  3. 发布服务

查看 tasrgo 服务的 pprof

  1. 保证开发机能直接访问到 tarsgo 节点部署的 ip 和 port。
  2. 查看 profile(http 地址中的 ip,port 为 ProfObj 的 ip 和 port)# 下载cpu profile
    go tool pprof http://ip:port/debug/pprof/profile?seconds=120 # 等待120s,不带此参数时等待30s
    # 下载heap profile
    go tool pprof http://ip:port/debug/pprof/heap
    # 下载goroutine profile
    go tool pprof http://ip:port/debug/pprof/goroutine
    # 下载block profile
    go tool pprof http://ip:port/debug/pprof/block
    # 下载mutex profile
    go tool pprof http://ip:port/debug/pprof/mutex
    # 下载20秒的trace记录(遇到棘手问题时,查看trace会比较容易定位)
    curl http://100.97.1.35:10078/debug/pprof/trace?seconds=20 > trace.out
    go tool trace trace.out 查看
  3. 直接在终端中通过 pprof 命令查看
  4. sz 上面命令执行时出现的Saved profile in /root/pprof/pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz到本地
  5. 在本地环境,执行go tool pprof -http=":8081" pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz 即可直接通过http://localhost:8081页面查看。包括topN,火焰图信息等,会更方便一点。

GC Trace

golang 具备 GC 功能,而 GC 是最容易被忽视的性能影响因素。尤其是在本地使用 benchmark 测试时,由于时间较短,占用内存较少。往往不会触发 GC。而一旦线上出现 GC 问题,又不太好定位。目前常用的定位方式有两种:

本地 gctrace

线上 trace

在线上业务中添加net/http/pprof后,可通过下列命令采集 20 秒的 trace 信息

curl http://ip:port/debug/pprof/trace?seconds=20 > trace.out

再通过go tool trace trace.out 即可在本地浏览器中查看 trace 信息。

GC 相关的信息可以在 View trace 中看到

可通过点击 heap 的色块区域,查看 heap 信息。

点击 GC 对应行的蓝色色块,查看 GC 耗时及相关回收信息。

通过这两个信息就可以确认是否存在 GC 问题,以及造成高 GC 的可能原因。

使用问题

trace 的展示仅支持 chrome 浏览器。但是目前常用的 chrome 浏览器屏蔽了 go tool trace 使用的 HTML import 功能。即打开“view trace”时,会出现一片空白。并可以在 console 中看到警告信息:

HTML Imports is deprecated and has now been removed as of M80. See https://www.chromestatus.com/features/5144752345317376 and https://developers.google.com/web/updates/2019/07/web-components-time-to-upgrade for more details.

解决办法
申请 token

修改 trace.go
重新编译 go
查看 trace

go tool trace -http=localhost:8001 trace.out

若打开 view trace 还是空白,则检查一下浏览器地址栏中的地址,是否与注册时的一样。即注册用的 localhost 或 127.0.0.1 则地址栏中也要一样。

常见性能瓶颈

业务逻辑

出现无效甚至降低性能的逻辑。常见的有

存储

未选择恰当的存储方式,常见的有:

并发处理

并发操作的问题主要出现在资源竞争上,常见的有:

golang 部分细节简介

在优化之前,我们需要对 golang 的实现细节有一个简单的了解,才能明白哪些地方有问题,哪些地方可以优化,以及怎么优化。以下内容的详细讲解建议查阅网上优秀的 blog。对语言的底层实现机制最好有个基本的了解,否则有时候掉到坑里都不知道为啥。

协程调度

Golang 调度是非抢占式多任务处理,由协程主动交出控制权。遇到如下条件时,才有可能交出控制权

因此,若存在较长时间的 for 循环处理,并且循环内没有上述逻辑时,会阻塞住其他的协程调度。在实际编码中一定要注意。

内存管理

Go 为每个逻辑处理器(P)提供了一个称为mcache的本地内存线程缓存。每个 mcache 中持有 67 个级别的 mspan。每个 msapn 又包含两种:scan(包含指针的对象)和 noscan(不包含指针的对象)。在进行垃圾收集时,GC 无需遍历 noscan 对象

GC 处理

GC 的工作就是确定哪些内存可以释放,它是通过扫描内存查找内存分配的指针来完成这个工作的。GC 触发时机:

为啥要注意 GC,是因为 GC 时出现 2 次 Stop the world,即停止所有协程,进行扫描操作。若是 GC 耗时高,则会严重影响服务器性能。

变量逃逸

注意,golang 中的栈是跟函数绑定的,函数结束时栈被回收。

变量内存回收:

而变量逃逸就意味着增加了堆中的对象个数,影响 GC 耗时。一般要尽量避免逃逸。

逃逸分析不变性:
  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;

在逃逸分析过程中,凡是发现出现违反上述约定的变量,就将其移到堆中。

逃逸常见的情况:

包含指针类型的底层结构

string

typeStringHeaderstruct{ Datauintptr Lenint }

slice

typeSliceHeaderstruct{ Datauintptr Lenint Capint }

map

typehmapstruct{ countint flagsuint8 Buint8 noverflowuint16 hash0uint32 bucketsunsafe.Pointer oldbucketsunsafe.Pointer nevacuateuintptr extra*mapextra }

这些是常见会包含指针的对象。尤其是 string,在后台应用中大量出现。并经常会作为 map 的 key 或 value。若数据量较大时,就会引发 GC 耗时上升。同时,我们可以注意到 string 和 slice 非常相似,从某种意义上说它们之间是可以直接互相转换的。这就可以避免 string 和[]byte 之间类型转换时,进行内存拷贝

类型转换优化

funcString(b[]byte)string{ return*(*string)(unsafe.Pointer(&b)) } funcStr2Bytes(sstring)[]byte{ x:=(*[2]uintptr)(unsafe.Pointer(&s)) h:=[3]uintptr{x[0],x[1],x[1]} return*(*[]byte)(unsafe.Pointer(&h)) }

性能测试方式

本地测试

将服务处理的核心逻辑,使用 go test 的 benchmark 加 pprof 来测试。建议上线前,就对整个业务逻辑的性能进行测试,提前优化瓶颈。

线上测试

一般 http 服务可以通过常见的测试工具进行压测,如 wrk,locust 等。taf 服务则需要我们自己编写一些测试脚本。同时,要注意的是,压测的目的是定位出服务的最佳性能,而不是盲目的高并发请求测试。因此,一般需要逐步提升并发请求数量,来定位出服务的最佳性能点。

注意:由于 taf 平台具备扩容功能,因此为了更准确的测试,我们应该在测试前关闭要测试节点的自动扩容。

实际项目优化

为了避免影响后端服务,也为了避免后端服务影响网关自身。因此,我们需要在压测前,将对后端服务的调用屏蔽。

QPS 现状

首先看下当前业务的性能指标,使用 wrk 压测网关服务

可以看出,在总链接数为 70 的时候,QPS 最高,为 13245。

火焰图

根据火焰图我们定位出 cpu 占比较高的几个方法为:

为了方便测试,将代码改为本地运行,并通过 benchmark 的方式来对比修改前后的差异。

由于正式环境使用的 golang 版本为 1.12,因此本地测试时,也要使用同样的版本。

benchmark

Benchmark 50000000 3669 ns/op 4601 B/op 73 allocs/op

查看 cpu 和 memory 的 profile,发现健康度上报的数据结构填充占比较高。这部分逻辑基于 tars 框架实现。暂时忽略,为避免影响其他测试,先注释掉。再看看 benchmark。

Benchmark 500000 3146 ns/op 2069 B/op 55 allocs/op

优化策略

JSON 优化

先查看 json 解析的部分,看看是否有优化空间

请求处理

//RootHandleview.ReadReq2JsonreadJsonReq中进行json解析 typeGatewayReqBodystruct{ HeaderGatewayReqBodyHeader`json:"header"` Payloadmap[string]interface{}`json:"payload"` } funcreadJsonReq(data[]byte,req*model.GatewayReqBody)error{ dataMap:=make(map[string]interface{}) err:=jsoniter.Unmarshal(data,&dataMap) ... headerMap,ok:=header.(map[string]interface{}) businessName,ok:=headerMap["businessName"] qua,ok:=headerMap["qua"] sessionId,ok:=headerMap["sessionId"] ... payload,ok:=dataMap["payload"] req.Payload,ok=payload.(map[string]interface{}) }

这个函数本质上将 data 解析为 model.GatewayReqBody 类型的结构体。但是这里却存在 2 个问题

  1. 使用了复杂的解析方式,先将 data 解析为 map,再通过每个字段的名字来取值,并进行类型转换。
  2. Req.Playload 解析为一个 map。但又未使用。我们看看后面这个 payload 是用来做啥。确认是否为无效代码。

funcinvokeTafServant(resphttp.ResponseWriter,gatewayHttpReq*model.GatewayHttpReq){ ... payloadBytes,err:=json.Marshal(gatewayHttpReq.ReqBody.Payload) iferr==nil{ commonReq.Payload=string(payloadBytes) }else{ responseData(gatewayHttpReq,StatusInternalServerError,"封装json异常","",resp) return } ... }

后续的使用中,我们可以看到,又将这个 payload 转为 string。因此,我们可以确定,上面的 json 解析是没有意义,同时也会浪费资源(payload 数据量一般不小)。

优化方法

typeGatewayReqBodystruct{ HeaderGatewayReqBodyHeader`json:"header"` Payloadjson.RawMessage`json:"payload"` } funcreadJsonReq(data[]byte,req*model.GatewayReqBody)error{ err:=jsoniter.Unmarshal(data,req) iferr!=nil{ returnjsonParseErr } fork,v:=rangereq.Header.Qua{ req.Header.Qua[k]=v iflen(req.Header.QuaStr)==0{ req.Header.QuaStr=k "=" v }else{ req.Header.QuaStr ="&" k "=" v } } returnnil }

funcinvokeTafServant(resphttp.ResponseWriter,gatewayHttpReq*model.GatewayHttpReq){ commonReq.Payload=string(gatewayHttpReq.ReqBody.Payload) }

回包处理

typeGatewayRespBodystruct{ HeaderGatewayRespBodyHeader`json:"header"` Payloadmap[string]interface{}`json:"payload"` } funcresponseData(gatewayReq*model.GatewayHttpReq,codeint32,messagestring,payloadstring,resphttp.ResponseWriter){ jsonPayload:=make(map[string]interface{}) iflen(payload)!=0{ err:=json.Unmarshal([]byte(payload),&jsonPayload) iferr!=nil{ ... } } body:=model.GatewayRespBody{ Header:model.GatewayRespBodyHeader{ Code:code, Message:message, }, Payload:jsonPayload, } data,err:=view.RenderResp("json",&body) ... resp.WriteHeader(http.StatusOK) resp.Write(data) }

同样的,这里的 jsonPayload,也是出现了不必要的 json 解析。我们可以改为

typeGatewayRespBodystruct{ HeaderGatewayRespBodyHeader`json:"header"` Payloadjson.RawMessage`json:"payload"` } body:=model.GatewayRespBody{ Header:model.GatewayRespBodyHeader{ Code:code, Message:message, }, Payload:encode.Str2Bytes(payload), }

然后在 view.RenderResp 方法中

funcRenderResp(formatstring,respinterface{})([]byte,error){ if"json"==format{ returnjsoniter.Marshal(resp) } returnnil,errors.New("formaterror") }

benchmark

Benchmark 500000 3326 ns/op 2842 B/op 50 allocs/op

虽然对象 alloc 减少了,但单次操作内存使用增加了,且性能下降了。这就有点奇怪了。我们来对比一下 2 个情况下的 pprof。

逃逸分析及处理

go tool pprof -base

可以看出 RootHandle 多了 478.96M 的内存使用。通过 list RootHandle 对比 2 个情况下的内存使用。发现修改后的 RootHandle 中多出了这一行:475.46MB 475.46MB 158: gatewayHttpReq := model.GatewayHttpReq{} 这一般意味着变量 gatewayHttpReq 出现了逃逸。

benchmark

Benchmark 500000 2994 ns/op 1892 B/op 50 allocs/op

可以看到堆内存使用明显下降。性能也提升了。再看一下 pprof,寻找下个瓶颈。

cpu profile

抛开 responeseData(他内部主要是日志打印占比高),占比较高的为 util.GenerateSessionId,先来看看这个怎么优化。

随机字符串生成

varletterRunes=[]rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") funcRandStringRunes(nint)string{ b:=make([]rune,n) fori:=rangeb{ b[i]=letterRunes[rand.Intn(len(letterRunes))] } returnstring(b) }

目前的生成方式使用的类型是 rune,但其实用 byte 就够了。另外,letterRunes 是 62 个字符,即最大需要 6 位的 index 就可以遍历完成了。而随机数获取的是 63 位。即每个随机数,其实可以产生 10 个随机字符。而不用每个字符都获取一次随机数。所以我们改为

const( letterBytes="0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" letterIdxBits=6 letterIdxMask=1<<letterIdxBits-1 letterIdxMax=63/letterIdxBits ) funcRandStringRunes(nint)string{ b:=make([]byte,n) fori,cache,remain:=n-1,rand.Int63(),letterIdxMax;i>=0;{ ifremain==0{ cache,remain=rand.Int63(),letterIdxMax } ifidx:=int(cache&letterIdxMask);idx<len(letterBytes){ b[i]=letterBytes[idx] i-- } cache>>=letterIdxBits remain-- } returnstring(b) }

benchmark

Benchmark 1000000 1487 ns/op 1843 B/op 50 allocs/op

类型转换及字符串拼接

一般情况下,都会说将 string 和[]byte 的转换改为 unsafe;以及在字符串拼接时,用 byte.Buffer 代替 fmt.Sprintf。但是网关这里的情况比较特殊,字符串的操作基本集中在打印日志的操作。而 tars 的日志打印本身就是通过 byte.Buffer 拼接的。所以这可以避免。另外,由于日志打印量大,使用 unsafe 转换[]byte 为 string 带来的收益,往往会因为逃逸从而影响 GC,反正会影响性能。因此,不同的场景下,不能简单的套用一些优化方法。需要通过压测及结果分析来判断具体的优化策略。

优化结果

可以看到优化后,最大链接数为 110,最高 QPS 为21153.35。对比之前的13245,大约提升 60%。

后续

从 pprof 中可以看到日志打印,远程日志,健康上报等信息占用较多 cpu 资源,且导致多个数据逃逸(尤其是日志打印)。过多的日志基本等于没有日志。后续可考虑裁剪日志,仅保留出错时的上下文信息。

总结

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved