小结:
1、
2、
并发操作的问题主要出现在资源竞争上,常见的有:
3、
Golang 调度是非抢占式多任务处理,由协程主动交出控制权。遇到如下条件时,才有可能交出控制权
因此,若存在较长时间的 for 循环处理,并且循环内没有上述逻辑时,会阻塞住其他的协程调度。在实际编码中一定要注意。
4、
GC 的工作就是确定哪些内存可以释放,它是通过扫描内存查找内存分配的指针来完成这个工作的。GC 触发时机:
为啥要注意 GC,是因为 GC 时出现 2 次 Stop the world,即停止所有协程,进行扫描操作。若是 GC 耗时高,则会严重影响服务器性能。
5、
注意,golang 中的栈是跟函数绑定的,函数结束时栈被回收。
如果分配在栈中,则函数执行结束可自动将内存回收;
如果分配在堆中,则函数执行结束可交给 GC(垃圾回收)处理;
而变量逃逸就意味着增加了堆中的对象个数,影响 GC 耗时。一般要尽量避免逃逸。
在逃逸分析过程中,凡是发现出现违反上述约定的变量,就将其移到堆中。
原创 trumanyan 腾讯技术工程 2020-04-29
https://mp.weixin.qq.com/s/ogtRE_LbllN2Tla97LnFrQ
作者:trumanyan,腾讯 CSIG 后台开发工程师
网关服务作为统一接入服务,是大部分服务的统一入口。为了避免成功瓶颈,需要对其进行尽可能地优化。因此,特别总结一下 golang 后台服务性能优化的方式,并对网关服务进行优化。
技术背景:
网关服务本身没有业务逻辑处理,仅作为统一入口进行请求转发,因此我们主要关注下列指标
一般后台服务的瓶颈主要为 CPU,内存,IO 操作中的一个或多个。若这三者的负载都不高,但系统吞吐量低,基本就是代码逻辑出问题了。
在代码正常运行的情况下,我们要针对某个方面的高负载进行优化,才能提高系统的性能。golang 可通过 benchmark 加 pprof 来定位具体的性能瓶颈。
go test -v gate_test.go -run=none -bench=. -benchtime=3s -cpuprofile cpu.prof -memprofile mem.prof
runtime.StartCPUProfile
或者runtime.StopCPUProfile
等 API 来生成和写入采样文件,灵活性高。主要用于本地测试。go test -bench . -cpuprofile cpuprofile.out
生成采样文件,主要用于本地基准测试。可用于重点测试某些函数。go tool pprof [options][binary] …
--text 纯文本
--web 生成 svg 并用浏览器打开(如果 svg 的默认打开方式是浏览器)
--svg 只生成 svg
--list funcname 筛选出正则匹配 funcname 的函数的信息
-http=":port" 直接本地浏览器打开 profile 查看(包括 top,graph,火焰图等)
go tool pprof -base profile1 profile2
对比查看 2 个 profile,一般用于代码修改前后对比,定位差异点。
通过命令行方式查看 profile 时,可以在命令行对话中,使用下列命令,查看相关信息
flat flat% sum% cum cum% 5.95s 27.56% 27.56% 5.95s 27.56% runtime.usleep 4.97s 23.02% 50.58% 5.08s 23.53% sync.(RWMutex).RLock 4.46s 20.66% 71.24% 4.46s 20.66% sync.(RWMutex).RUnlock 2.69s 12.46% 83.70% 2.69s 12.46% runtime.pthread_cond_wait 1.50s 6.95% 90.64% 1.50s 6.95% runtime.pthread_cond_signal
flat: 采样时,该函数正在运行的次数*采样频率(10ms),即得到估算的函数运行”采样时间”。这里不包括函数等待子函数返回。
flat%: flat / 总采样时间值
sum%: 前面所有行的 flat% 的累加值,如第三行 sum% = 71.24% = 27.56% + 50.58%
cum: 采样时,该函数出现在调用堆栈的采样时间,包括函数等待子函数返回。因此 flat <= cum
cum%: cum / 总采样时间值
topN [-cum] 查看前 N 个数据:
list ncname 查看某个函数的详细信息,可以明确具体的资源(cpu,内存等)是由哪一行触发的。
服务中 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")
taf 管理平台中,添加 servant:ProfObj (名字可自己修改)
发布服务
直接在终端中通过 pprof 命令查看
sz 上面命令执行时出现的Saved profile in /root/pprof/pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz
到本地
在本地环境,执行go tool pprof -http=":8081" pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz
即可直接通过http://localhost:8081页面查看。包括topN,火焰图信息等,会更方便一点。
golang 具备 GC 功能,而 GC 是最容易被忽视的性能影响因素。尤其是在本地使用 benchmark 测试时,由于时间较短,占用内存较少。往往不会触发 GC。而一旦线上出现 GC 问题,又不太好定位。目前常用的定位方式有两种:
在执行程序前加 GODEBUG=gctrace=1
,每次 gc 时会输出一行如下内容
gc 1 @0.001s 11%: 0.007+1.5+0.004 ms clock, 0.089+1.5/2.8/0.27+0.054 ms cpu, 4->4->3 MB, 5 MB goal, 12 Pscvg: inuse: 4, idle: 57, sys: 62, released: 57, consumed: 4 (MB)
也通过日志转为图形化:
GODEBUG=gctrace=1 godoc -index -http=:6060 2> stderr.logcat stderr.log | gcvis
inuse:使用了多少 M 内存
idle:剩下要清除的内存
sys:系统映射的内存
released:释放的系统内存
consumed:申请的系统内存
gc 1 表示第 1 次 gc
@0.001s 表示程序执行的总时间
11% 表示垃圾回收时间占用总的运行时间百分比
0.007+1.5+0.004 ms clock 表示工作线程完成 GC 的 stop-the-world,sweeping,marking 和 waiting 的时间
0.089+1.5/2.8/0.27+0.054 ms cpu 垃圾回收占用 cpu 时间
4->4->3 MB 表示堆的大小,gc 后堆的大小,存活堆的大小
5 MB goal 整体堆的大小
12 P 使用的处理器数量
scvg: inuse: 4, idle: 57, sys: 62, released: 57, consumed: 4 (MB) 表示系统内存回收信息
采用图形化的方式查看:https://github.com/davecheney/gcvis
GODEBUG=gctrace=1 go test -v *.go -bench=. -run=none -benchtime 3m |& gcvis
在线上业务中添加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.
<meta http-equiv="origin-trial" content="你复制的token">
ERROR: Cannot find go1.4\bin\go Set GOROOT_BOOTSTRAP to a working Go tree >= Go 1.4
则需要先安装一个 go1.4 的版本,再通过它来编译 go。(下载链接https://dl.google.com/go/go1.4-bootstrap-20171003.tar.gz) 在 go1.4/src 下执行./make.bash. 指定 GOROOT_BOOTSTRAP 为 go1.4 的根目录。然后就可以重新编译 gogo tool trace -http=localhost:8001 trace.out
若打开 view trace 还是空白,则检查一下浏览器地址栏中的地址,是否与注册时的一样。即注册用的 localhost 或 127.0.0.1 则地址栏中也要一样。
出现无效甚至降低性能的逻辑。常见的有
未选择恰当的存储方式,常见的有:
并发操作的问题主要出现在资源竞争上,常见的有:
在优化之前,我们需要对 golang 的实现细节有一个简单的了解,才能明白哪些地方有问题,哪些地方可以优化,以及怎么优化。以下内容的详细讲解建议查阅网上优秀的 blog。对语言的底层实现机制最好有个基本的了解,否则有时候掉到坑里都不知道为啥。
Golang 调度是非抢占式多任务处理,由协程主动交出控制权。遇到如下条件时,才有可能交出控制权
因此,若存在较长时间的 for 循环处理,并且循环内没有上述逻辑时,会阻塞住其他的协程调度。在实际编码中一定要注意。
Go 为每个逻辑处理器(P)提供了一个称为mcache的本地内存线程缓存。每个 mcache 中持有 67 个级别的 mspan。每个 msapn 又包含两种:scan(包含指针的对象)和 noscan(不包含指针的对象)。在进行垃圾收集时,GC 无需遍历 noscan 对象。
GC 的工作就是确定哪些内存可以释放,它是通过扫描内存查找内存分配的指针来完成这个工作的。GC 触发时机:
为啥要注意 GC,是因为 GC 时出现 2 次 Stop the world,即停止所有协程,进行扫描操作。若是 GC 耗时高,则会严重影响服务器性能。
注意,golang 中的栈是跟函数绑定的,函数结束时栈被回收。
如果分配在栈中,则函数执行结束可自动将内存回收;
如果分配在堆中,则函数执行结束可交给 GC(垃圾回收)处理;
而变量逃逸就意味着增加了堆中的对象个数,影响 GC 耗时。一般要尽量避免逃逸。
在逃逸分析过程中,凡是发现出现违反上述约定的变量,就将其移到堆中。
type StringHeader struct { Data uintptr Len int}
type SliceHeader struct { Data uintptr Len int Cap int}
type hmap struct { count int flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets unsafe.Pointer oldbuckets unsafe.Pointer nevacuate uintptr extra *mapextra}
这些是常见会包含指针的对象。尤其是 string,在后台应用中大量出现。并经常会作为 map 的 key 或 value。若数据量较大时,就会引发 GC 耗时上升。同时,我们可以注意到 string 和 slice 非常相似,从某种意义上说它们之间是可以直接互相转换的。这就可以避免 string 和[]byte 之间类型转换时,进行内存拷贝
func String(b []byte) string { return *(*string)(unsafe.Pointer(&b))}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))}
将服务处理的核心逻辑,使用 go test 的 benchmark 加 pprof 来测试。建议上线前,就对整个业务逻辑的性能进行测试,提前优化瓶颈。
一般 http 服务可以通过常见的测试工具进行压测,如 wrk,locust 等。taf 服务则需要我们自己编写一些测试脚本。同时,要注意的是,压测的目的是定位出服务的最佳性能,而不是盲目的高并发请求测试。因此,一般需要逐步提升并发请求数量,来定位出服务的最佳性能点。
注意:由于 taf 平台具备扩容功能,因此为了更准确的测试,我们应该在测试前关闭要测试节点的自动扩容。
为了避免影响后端服务,也为了避免后端服务影响网关自身。因此,我们需要在压测前,将对后端服务的调用屏蔽。
首先看下当前业务的性能指标,使用 wrk 压测网关服务
可以看出,在总链接数为 70 的时候,QPS 最高,为 13245。
根据火焰图我们定位出 cpu 占比较高的几个方法为:
为了方便测试,将代码改为本地运行,并通过 benchmark 的方式来对比修改前后的差异。
由于正式环境使用的 golang 版本为 1.12,因此本地测试时,也要使用同样的版本。
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 解析的部分,看看是否有优化空间
//RootHandle view.ReadReq2Json readJsonReq 中进行json解析type GatewayReqBody struct { Header GatewayReqBodyHeader `json:"header"` Payload map[string]interface{} `json:"payload"`}func readJsonReq(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 个问题
使用了复杂的解析方式,先将 data 解析为 map,再通过每个字段的名字来取值,并进行类型转换。
Req.Playload 解析为一个 map。但又未使用。我们看看后面这个 payload 是用来做啥。确认是否为无效代码。
func invokeTafServant(resp http.ResponseWriter, gatewayHttpReq *model.GatewayHttpReq) { … payloadBytes, err := json.Marshal(gatewayHttpReq.ReqBody.Payload) if err == nil { commonReq.Payload = string(payloadBytes) } else { responseData(gatewayHttpReq, StatusInternalServerError, "封装json异常", "", resp) return } … }
后续的使用中,我们可以看到,又将这个 payload 转为 string。因此,我们可以确定,上面的 json 解析是没有意义,同时也会浪费资源(payload 数据量一般不小)。
golang 自带的 json 解析性能较低,这里我们可以替换为github.com/json-iterator
来提升性能
在 golang 中,遇到不需要解析的 json 数据,可以将其类型声明为json.RawMessage
. 即,可以将上述 2 个方法优化为
type GatewayReqBody struct { Header GatewayReqBodyHeader json:"header"
Payload json.RawMessage json:"payload"
}func readJsonReq(data []byte, req *model.GatewayReqBody) error { err := jsoniter.Unmarshal(data, req) if err != nil { return jsonParseErr } for k, v := range req.Header.Qua { req.Header.Qua[k] = v if len(req.Header.QuaStr) == 0 { req.Header.QuaStr = k + "=" + v } else { req.Header.QuaStr += "&" + k + "=" + v } } return nil}
func invokeTafServant(resp http.ResponseWriter, gatewayHttpReq *model.GatewayHttpReq) { commonReq.Payload = string(gatewayHttpReq.ReqBody.Payload)}
这里注意!出现了 string 和[]byte 之间的类型转换.为了避免内存拷贝,这里将 string()改为上面的类型转换优化中所定义的转换函数,即commonReq.Payload = encode.String(gatewayHttpReq.ReqBody.Payload)
type GatewayRespBody struct { Header GatewayRespBodyHeader `json:"header"` Payload map[string]interface{} `json:"payload"`}func responseData(gatewayReq *model.GatewayHttpReq, code int32, message string, payload string, resp http.ResponseWriter) { jsonPayload := make(map[string]interface{}) if len(payload) != 0 { err := json.Unmarshal([]byte(payload), &jsonPayload) if err != 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 解析。我们可以改为
type GatewayRespBody struct { Header GatewayRespBodyHeader `json:"header"` Payload json.RawMessage `json:"payload"`}body := model.GatewayRespBody{ Header: model.GatewayRespBodyHeader{ Code: code, Message: message, }, Payload: encode.Str2Bytes(payload), }
然后在 view.RenderResp 方法中
func RenderResp(format string, resp interface{}) ([]byte, error) { if "json" == format { return jsoniter.Marshal(resp) } return nil, errors.New("format error")}
Benchmark 500000 3326 ns/op 2842 B/op 50 allocs/op
虽然对象 alloc 减少了,但单次操作内存使用增加了,且性能下降了。这就有点奇怪了。我们来对比一下 2 个情况下的 pprof。
cpu 差异
flat flat% sum% cum cum% 0.09s 1.17% 1.17% 0.40s 5.20% runtime.mallocgc 0.01s 0.13% 1.30% 0.35s 4.55% /vendor/github.com/json-iterator/go.(*Iterator).readObjectStart 0 0% 1.30% 0.35s 4.55% /vendor/github.com/json-iterator/go.(*twoFieldsStructDecoder).Decode
mem 差异
flat flat% sum% cum cum% 478.96MB 20.33% 20.33% 279.94MB 11.88% gateway.RootHandle 0 0% 20.33% 279.94MB 11.88% command-line-arguments.BenchmarkTestHttp.func1 0 0% 20.33% 279.94MB 11.88% testing.(*B).RunParallel.func1
可以看出 RootHandle 多了 478.96M 的内存使用。通过 list RootHandle 对比 2 个情况下的内存使用。发现修改后的 RootHandle 中多出了这一行:475.46MB 475.46MB 158: gatewayHttpReq := model.GatewayHttpReq{}
这一般意味着变量 gatewayHttpReq 出现了逃逸。
go build -gcflags "-m -m" gateway/*.go
gateway/logic.go:270:26: &gatewayHttpReq escapes to heap
可以看到确实出现了逃逸。这个对应的代码为err = view.ReadReq2Json(&gatewayHttpReq)
,而造成逃逸的本质是因为上面改动了函数 readJsonReq(动态类型逃逸,即函数参数为 interface 类型,无法在编译时确定具体类型的)
func readJsonReq(data []byte, req *model.GatewayReqBody) error { err := jsoniter.Unmarshal(data, req) ...}
因此,这里需要特殊处理一下,改为
func readJsonReq(data []byte, req *model.GatewayReqBody) error { var tmp model.GatewayReqBody err := jsoniter.Unmarshal(data, &tmp) ...}
Benchmark 500000 2994 ns/op 1892 B/op 50 allocs/op
可以看到堆内存使用明显下降。性能也提升了。再看一下 pprof,寻找下个瓶颈。
抛开 responeseData(他内部主要是日志打印占比高),占比较高的为 util.GenerateSessionId,先来看看这个怎么优化。
var letterRunes = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")func RandStringRunes(n int) string { b := make([]rune, n) for i := range b { b[i] = letterRunes[rand.Intn(len(letterRunes))] } return string(b)}
目前的生成方式使用的类型是 rune,但其实用 byte 就够了。另外,letterRunes 是 62 个字符,即最大需要 6 位的 index 就可以遍历完成了。而随机数获取的是 63 位。即每个随机数,其实可以产生 10 个随机字符。而不用每个字符都获取一次随机数。所以我们改为
const ( letterBytes = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" letterIdxBits = 6 letterIdxMask = 1<<letterIdxBits - 1 letterIdxMax = 63 / letterIdxBits)func RandStringRunes(n int) string { b := make([]byte, n) for i, cache, remain := n-1, rand.Int63(), letterIdxMax; i >= 0; { if remain == 0 { cache, remain = rand.Int63(), letterIdxMax } if idx := int(cache & letterIdxMask); idx < len(letterBytes) { b[i] = letterBytes[idx] i-- } cache >>= letterIdxBits remain-- } return string(b)}
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 资源,且导致多个数据逃逸(尤其是日志打印)。过多的日志基本等于没有日志。后续可考虑裁剪日志,仅保留出错时的上下文信息。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章