Golang 性能优化实战
作者:trumanyan,騰訊 CSIG 后臺(tái)開發(fā)工程師
項(xiàng)目背景
網(wǎng)關(guān)服務(wù)作為統(tǒng)一接入服務(wù),是大部分服務(wù)的統(tǒng)一入口。為了避免成功瓶頸,需要對其進(jìn)行盡可能地優(yōu)化。因此,特別總結(jié)一下 golang 后臺(tái)服務(wù)性能優(yōu)化的方式,并對網(wǎng)關(guān)服務(wù)進(jìn)行優(yōu)化。
技術(shù)背景:
基于 tarsgo 框架的 http 接入服務(wù),下游服務(wù)使用 tarsgo 協(xié)議進(jìn)行交互
性能指標(biāo)
網(wǎng)關(guān)服務(wù)本身沒有業(yè)務(wù)邏輯處理,僅作為統(tǒng)一入口進(jìn)行請求轉(zhuǎn)發(fā),因此我們主要關(guān)注下列指標(biāo)
吞吐量:每秒鐘可以處理的請求數(shù)
響應(yīng)時(shí)間:從客戶端發(fā)出請求,到收到回包的總耗時(shí)
定位瓶頸
一般后臺(tái)服務(wù)的瓶頸主要為 CPU,內(nèi)存,IO 操作中的一個(gè)或多個(gè)。若這三者的負(fù)載都不高,但系統(tǒng)吞吐量低,基本就是代碼邏輯出問題了。
在代碼正常運(yùn)行的情況下,我們要針對某個(gè)方面的高負(fù)載進(jìn)行優(yōu)化,才能提高系統(tǒng)的性能。golang 可通過 benchmark 加 pprof 來定位具體的性能瓶頸。
benchmark 簡介
go test -v gate_test.go -run=none -bench=. -benchtime=3s -cpuprofile cpu.prof -memprofile mem.prof-run 知道單次測試,一般用于代碼邏輯驗(yàn)證
-bench=. 執(zhí)行所有 Benchmark,也可以通過用例函數(shù)名來指定部分測試用例
-benchtime 指定測試執(zhí)行時(shí)長
-cpuprofile 輸出 cpu 的 pprof 信息文件
-memprofile 輸出 heap 的 pprof 信息文件。
-blockprofile 阻塞分析,記錄 goroutine 阻塞等待同步(包括定時(shí)器通道)的位置
-mutexprofile 互斥鎖分析,報(bào)告互斥鎖的競爭情況
benchmark 測試用例常用函數(shù)
b.ReportAllocs() 輸出單次循環(huán)使用的內(nèi)存數(shù)量和對象 allocs 信息
b.RunParallel() 使用協(xié)程并發(fā)測試
b.SetBytes(n int64) 設(shè)置單次循環(huán)使用的內(nèi)存數(shù)量
pprof 簡介
生成方式
runtime/pprof: 手動(dòng)調(diào)用如runtime.StartCPUProfile或者runtime.StopCPUProfile等 API 來生成和寫入采樣文件,靈活性高。主要用于本地測試。
net/http/pprof: 通過 http 服務(wù)獲取 Profile 采樣文件,簡單易用,適用于對應(yīng)用程序的整體監(jiān)控。通過 runtime/pprof 實(shí)現(xiàn)。主要用于服務(wù)器端測試。
go test: 通過 go test -bench . -cpuprofile cpuprofile.out生成采樣文件,主要用于本地基準(zhǔn)測試。可用于重點(diǎn)測試某些函數(shù)。
查看方式
go tool pprof [options][binary] ...
--text 純文本
--web 生成 svg 并用瀏覽器打開(如果 svg 的默認(rèn)打開方式是瀏覽器)
--svg 只生成 svg
--list funcname 篩選出正則匹配 funcname 的函數(shù)的信息
-http=":port" 直接本地瀏覽器打開 profile 查看(包括 top,graph,火焰圖等)
go tool pprof -base profile1 profile2
對比查看 2 個(gè) profile,一般用于代碼修改前后對比,定位差異點(diǎn)。
通過命令行方式查看 profile 時(shí),可以在命令行對話中,使用下列命令,查看相關(guān)信息
- flat flat% sum% cum cum%5.95s 27.56% 27.56% 5.95s 27.56% runtime.usleep4.97s 23.02% 50.58% 5.08s 23.53% sync.(*RWMutex).RLock4.46s 20.66% 71.24% 4.46s 20.66% sync.(*RWMutex).RUnlock2.69s 12.46% 83.70% 2.69s 12.46% runtime.pthread_cond_wait1.50s 6.95% 90.64% 1.50s 6.95% runtime.pthread_cond_signal
flat: 采樣時(shí),該函數(shù)正在運(yùn)行的次數(shù)*采樣頻率(10ms),即得到估算的函數(shù)運(yùn)行”采樣時(shí)間”。這里不包括函數(shù)等待子函數(shù)返回。
flat%: flat / 總采樣時(shí)間值
sum%: 前面所有行的 flat% 的累加值,如第三行 sum% = 71.24% = 27.56% + 50.58%
cum: 采樣時(shí),該函數(shù)出現(xiàn)在調(diào)用堆棧的采樣時(shí)間,包括函數(shù)等待子函數(shù)返回。因此 flat <= cum
cum%: cum / 總采樣時(shí)間值
topN [-cum] 查看前 N 個(gè)數(shù)據(jù):
list ncname 查看某個(gè)函數(shù)的詳細(xì)信息,可以明確具體的資源(cpu,內(nèi)存等)是由哪一行觸發(fā)的。
pprof 接入 tarsgo
服務(wù)中 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 管理平臺(tái)中,添加 servant:ProfObj (名字可自己修改)
發(fā)布服務(wù)
查看 tasrgo 服務(wù)的 pprof
保證開發(fā)機(jī)能直接訪問到 tarsgo 節(jié)點(diǎn)部署的 ip 和 port。
查看 profile(http 地址中的 ip,port 為 ProfObj 的 ip 和 port)
# 下載cpu profile go tool pprof http://ip:port/debug/pprof/profile?seconds=120 # 等待120s,不帶此參數(shù)時(shí)等待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記錄(遇到棘手問題時(shí),查看trace會(huì)比較容易定位)curl http://100.97.1.35:10078/debug/pprof/trace?seconds=20 > trace.outgo tool trace trace.out 查看直接在終端中通過 pprof 命令查看
sz 上面命令執(zhí)行時(shí)出現(xiàn)的Saved profile in /root/pprof/pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz到本地
在本地環(huán)境,執(zhí)行g(shù)o tool pprof -http=":8081" pprof.binary.alloc_objects.xxxxxxx.xxxx.pb.gz 即可直接通過http://localhost:8081頁面查看。包括topN,火焰圖信息等,會(huì)更方便一點(diǎn)。
GC Trace
golang 具備 GC 功能,而 GC 是最容易被忽視的性能影響因素。尤其是在本地使用 benchmark 測試時(shí),由于時(shí)間較短,占用內(nèi)存較少。往往不會(huì)觸發(fā) GC。而一旦線上出現(xiàn) GC 問題,又不太好定位。目前常用的定位方式有兩種:
本地 gctrace
在執(zhí)行程序前加 GODEBUG=gctrace=1,每次 gc 時(shí)會(huì)輸出一行如下內(nèi)容
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 P scvg: inuse: 4, idle: 57, sys: 62, released: 57, consumed: 4 (MB)也通過日志轉(zhuǎn)為圖形化:
GODEBUG=gctrace=1 godoc -index -http=:6060 2> stderr.log cat stderr.log | gcvisinuse:使用了多少 M 內(nèi)存
idle:剩下要清除的內(nèi)存
sys:系統(tǒng)映射的內(nèi)存
released:釋放的系統(tǒng)內(nèi)存
consumed:申請的系統(tǒng)內(nèi)存
gc 1 表示第 1 次 gc
@0.001s 表示程序執(zhí)行的總時(shí)間
11% 表示垃圾回收時(shí)間占用總的運(yùn)行時(shí)間百分比
0.007+1.5+0.004 ms clock 表示工作線程完成 GC 的 stop-the-world,sweeping,marking 和 waiting 的時(shí)間
0.089+1.5/2.8/0.27+0.054 ms cpu 垃圾回收占用 cpu 時(shí)間
4->4->3 MB 表示堆的大小,gc 后堆的大小,存活堆的大小
5 MB goal 整體堆的大小
12 P 使用的處理器數(shù)量
scvg: inuse: 4, idle: 57, sys: 62, released: 57, consumed: 4 (MB) 表示系統(tǒng)內(nèi)存回收信息
采用圖形化的方式查看:https://github.com/davecheney/gcvis
GODEBUG=gctrace=1 go test -v *.go -bench=. -run=none -benchtime 3m |& gcvis
線上 trace
在線上業(yè)務(wù)中添加net/http/pprof后,可通過下列命令采集 20 秒的 trace 信息
curl http://ip:port/debug/pprof/trace?seconds=20 > trace.out再通過go tool trace trace.out 即可在本地瀏覽器中查看 trace 信息。
View trace:查看跟蹤
Goroutine analysis:Goroutine 分析
Network blocking profile:網(wǎng)絡(luò)阻塞概況
Synchronization blocking profile:同步阻塞概況
Syscall blocking profile:系統(tǒng)調(diào)用阻塞概況
Scheduler latency profile:調(diào)度延遲概況
User defined tasks:用戶自定義任務(wù)
User defined regions:用戶自定義區(qū)域
Minimum mutator utilization:最低 Mutator 利用率
GC 相關(guān)的信息可以在 View trace 中看到
可通過點(diǎn)擊 heap 的色塊區(qū)域,查看 heap 信息。
點(diǎn)擊 GC 對應(yīng)行的藍(lán)色色塊,查看 GC 耗時(shí)及相關(guān)回收信息。
通過這兩個(gè)信息就可以確認(rèn)是否存在 GC 問題,以及造成高 GC 的可能原因。
使用問題
trace 的展示僅支持 chrome 瀏覽器。但是目前常用的 chrome 瀏覽器屏蔽了 go tool trace 使用的 HTML import 功能。即打開“view trace”時(shí),會(huì)出現(xiàn)一片空白。并可以在 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
https://developers.chrome.com/origintrials/#/register_trial/2431943798780067841 然后登錄
web origin 處填寫 http://localhost:8001 端口只能是 8000 - 8003,支持 http 和 https。(也可以填寫 127.0.0.1:8001,依賴于你瀏覽器中顯示的地址,否則對不上的話,還要手動(dòng)改一下)
點(diǎn)擊注冊后即可看到 token
修改 trace.go
編輯${GOROOT}/src/cmd/trace/trace.go 文件,在文件中找到 templTrace 然后在 ?標(biāo)簽的下一行添加<meta http-equiv="origin-trial" content="你復(fù)制的token">
重新編譯 go
${GOROOT}/src 目錄,執(zhí)行 ./all.bash
若提示:ERROR: Cannot find go1.4\bin\go Set GOROOT_BOOTSTRAP to a working Go tree >= Go 1.4 則需要先安裝一個(gè) go1.4 的版本,再通過它來編譯 go。(下載鏈接https://dl.google.com/go/go1.4-bootstrap-20171003.tar.gz) 在 go1.4/src 下執(zhí)行./make.bash. 指定 GOROOT_BOOTSTRAP 為 go1.4 的根目錄。然后就可以重新編譯 go
查看 trace
go tool trace -http=localhost:8001 trace.out
若打開 view trace 還是空白,則檢查一下瀏覽器地址欄中的地址,是否與注冊時(shí)的一樣。即注冊用的 localhost 或 127.0.0.1 則地址欄中也要一樣。
常見性能瓶頸
業(yè)務(wù)邏輯
出現(xiàn)無效甚至降低性能的邏輯。常見的有
邏輯重復(fù):相同的操作在不同的位置做了多次或循環(huán)跳出的條件設(shè)置不當(dāng)。
資源未復(fù)用:內(nèi)存頻繁申請和釋放,數(shù)據(jù)庫鏈接頻繁建立和銷毀等。
無效代碼。
存儲(chǔ)
未選擇恰當(dāng)?shù)拇鎯?chǔ)方式,常見的有:
臨時(shí)數(shù)據(jù)存放到數(shù)據(jù)庫中,導(dǎo)致頻繁讀寫數(shù)據(jù)庫。
將復(fù)雜的樹狀結(jié)構(gòu)的數(shù)據(jù)用 SQL 數(shù)據(jù)庫存儲(chǔ),出現(xiàn)大量冗余列,并且在讀寫時(shí)要進(jìn)行拆解和拼接。
數(shù)據(jù)庫表設(shè)計(jì)不當(dāng),無法有效利用索引查詢,導(dǎo)致查詢操作耗時(shí)高甚至出現(xiàn)大量慢查詢。
熱點(diǎn)數(shù)據(jù)未使用緩存,導(dǎo)致數(shù)據(jù)庫負(fù)載過高,響應(yīng)速度下降。
并發(fā)處理
并發(fā)操作的問題主要出現(xiàn)在資源競爭上,常見的有:
死鎖/活鎖導(dǎo)致大量阻塞,性能嚴(yán)重下降。
資源競爭激烈:大量的線程或協(xié)程搶奪一個(gè)鎖。
臨界區(qū)過大:將不必要的操作也放入臨界區(qū),導(dǎo)致鎖的釋放速度過慢,引起其他線程或協(xié)程阻塞。
golang 部分細(xì)節(jié)簡介
在優(yōu)化之前,我們需要對 golang 的實(shí)現(xiàn)細(xì)節(jié)有一個(gè)簡單的了解,才能明白哪些地方有問題,哪些地方可以優(yōu)化,以及怎么優(yōu)化。以下內(nèi)容的詳細(xì)講解建議查閱網(wǎng)上優(yōu)秀的 blog。對語言的底層實(shí)現(xiàn)機(jī)制最好有個(gè)基本的了解,否則有時(shí)候掉到坑里都不知道為啥。
協(xié)程調(diào)度
Golang 調(diào)度是非搶占式多任務(wù)處理,由協(xié)程主動(dòng)交出控制權(quán)。遇到如下條件時(shí),才有可能交出控制權(quán)
I/O,select
channel
等待鎖
函數(shù)調(diào)用(是一個(gè)切換的機(jī)會(huì),是否會(huì)切換由調(diào)度器決定)
runtime.Gosched()
因此,若存在較長時(shí)間的 for 循環(huán)處理,并且循環(huán)內(nèi)沒有上述邏輯時(shí),會(huì)阻塞住其他的協(xié)程調(diào)度。在實(shí)際編碼中一定要注意。
內(nèi)存管理
Go 為每個(gè)邏輯處理器(P)提供了一個(gè)稱為mcache的本地內(nèi)存線程緩存。每個(gè) mcache 中持有 67 個(gè)級(jí)別的 mspan。每個(gè) msapn 又包含兩種:scan(包含指針的對象)和 noscan(不包含指針的對象)。在進(jìn)行垃圾收集時(shí),GC 無需遍歷 noscan 對象。
GC 處理
GC 的工作就是確定哪些內(nèi)存可以釋放,它是通過掃描內(nèi)存查找內(nèi)存分配的指針來完成這個(gè)工作的。GC 觸發(fā)時(shí)機(jī):
到達(dá)堆閾值:默認(rèn)情況下,它將在堆大小加倍時(shí)運(yùn)行,可通過 GOGC 來設(shè)定更高閾值(不建議變更此配置)
到達(dá)時(shí)間閾值:每兩分鐘會(huì)強(qiáng)制啟動(dòng)一次 GC 循環(huán)
為啥要注意 GC,是因?yàn)?GC 時(shí)出現(xiàn) 2 次 Stop the world,即停止所有協(xié)程,進(jìn)行掃描操作。若是 GC 耗時(shí)高,則會(huì)嚴(yán)重影響服務(wù)器性能。
變量逃逸
注意,golang 中的棧是跟函數(shù)綁定的,函數(shù)結(jié)束時(shí)棧被回收。
變量內(nèi)存回收:
如果分配在棧中,則函數(shù)執(zhí)行結(jié)束可自動(dòng)將內(nèi)存回收;
如果分配在堆中,則函數(shù)執(zhí)行結(jié)束可交給 GC(垃圾回收)處理;
而變量逃逸就意味著增加了堆中的對象個(gè)數(shù),影響 GC 耗時(shí)。一般要盡量避免逃逸。
逃逸分析不變性:
指向棧對象的指針不能存在于堆中;
指向棧對象的指針不能在棧對象回收后存活;
在逃逸分析過程中,凡是發(fā)現(xiàn)出現(xiàn)違反上述約定的變量,就將其移到堆中。
逃逸常見的情況:
指針逃逸:返回局部變量的地址(不變性 2)
棧空間不足
動(dòng)態(tài)類型逃逸:如 fmt.Sprintf,json.Marshel 等接受變量為...interface{}函數(shù)的調(diào)用,會(huì)導(dǎo)致傳入的變量逃逸。
閉包引用
包含指針類型的底層結(jié)構(gòu)
string
type?StringHeader?struct?{Data?uintptrLen??int }slice
type?SliceHeader?struct?{Data?uintptrLen??intCap??int }map
type?hmap?struct?{count?????intflags?????uint8B?????????uint8noverflow?uint16hash0?????uint32buckets????unsafe.Pointeroldbuckets?unsafe.Pointernevacuate??uintptrextra?*mapextra }這些是常見會(huì)包含指針的對象。尤其是 string,在后臺(tái)應(yīng)用中大量出現(xiàn)。并經(jīng)常會(huì)作為 map 的 key 或 value。若數(shù)據(jù)量較大時(shí),就會(huì)引發(fā) GC 耗時(shí)上升。同時(shí),我們可以注意到 string 和 slice 非常相似,從某種意義上說它們之間是可以直接互相轉(zhuǎn)換的。這就可以避免 string 和[]byte 之間類型轉(zhuǎn)換時(shí),進(jìn)行內(nèi)存拷貝
類型轉(zhuǎn)換優(yōu)化
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)) }性能測試方式
本地測試
將服務(wù)處理的核心邏輯,使用 go test 的 benchmark 加 pprof 來測試。建議上線前,就對整個(gè)業(yè)務(wù)邏輯的性能進(jìn)行測試,提前優(yōu)化瓶頸。
線上測試
一般 http 服務(wù)可以通過常見的測試工具進(jìn)行壓測,如 wrk,locust 等。taf 服務(wù)則需要我們自己編寫一些測試腳本。同時(shí),要注意的是,壓測的目的是定位出服務(wù)的最佳性能,而不是盲目的高并發(fā)請求測試。因此,一般需要逐步提升并發(fā)請求數(shù)量,來定位出服務(wù)的最佳性能點(diǎn)。
注意:由于 taf 平臺(tái)具備擴(kuò)容功能,因此為了更準(zhǔn)確的測試,我們應(yīng)該在測試前關(guān)閉要測試節(jié)點(diǎn)的自動(dòng)擴(kuò)容。
實(shí)際項(xiàng)目優(yōu)化
為了避免影響后端服務(wù),也為了避免后端服務(wù)影響網(wǎng)關(guān)自身。因此,我們需要在壓測前,將對后端服務(wù)的調(diào)用屏蔽。
測試準(zhǔn)備:屏蔽遠(yuǎn)程調(diào)用:下游服務(wù)調(diào)用,健康度上報(bào),統(tǒng)計(jì)上報(bào),遠(yuǎn)程日志。以便關(guān)注網(wǎng)關(guān)自身性能。
QPS 現(xiàn)狀
首先看下當(dāng)前業(yè)務(wù)的性能指標(biāo),使用 wrk 壓測網(wǎng)關(guān)服務(wù)
可以看出,在總鏈接數(shù)為 70 的時(shí)候,QPS 最高,為 13245。
火焰圖
根據(jù)火焰圖我們定位出 cpu 占比較高的幾個(gè)方法為:
json.Marshal
json.Unmarshal
rogger.Infof
為了方便測試,將代碼改為本地運(yùn)行,并通過 benchmark 的方式來對比修改前后的差異。
由于正式環(huán)境使用的 golang 版本為 1.12,因此本地測試時(shí),也要使用同樣的版本。
benchmark
Benchmark 50000000 3669 ns/op 4601 B/op 73 allocs/op查看 cpu 和 memory 的 profile,發(fā)現(xiàn)健康度上報(bào)的數(shù)據(jù)結(jié)構(gòu)填充占比較高。這部分邏輯基于 tars 框架實(shí)現(xiàn)。暫時(shí)忽略,為避免影響其他測試,先注釋掉。再看看 benchmark。
Benchmark 500000 3146 ns/op 2069 B/op 55 allocs/op優(yōu)化策略
JSON 優(yōu)化
先查看 json 解析的部分,看看是否有優(yōu)化空間
請求處理
//RootHandle?view.ReadReq2Json?readJsonReq?中進(jìn)行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{}) }這個(gè)函數(shù)本質(zhì)上將 data 解析為 model.GatewayReqBody 類型的結(jié)構(gòu)體。但是這里卻存在 2 個(gè)問題
使用了復(fù)雜的解析方式,先將 data 解析為 map,再通過每個(gè)字段的名字來取值,并進(jìn)行類型轉(zhuǎn)換。
Req.Playload 解析為一個(gè) map。但又未使用。我們看看后面這個(gè) payload 是用來做啥。確認(rèn)是否為無效代碼。
后續(xù)的使用中,我們可以看到,又將這個(gè) payload 轉(zhuǎn)為 string。因此,我們可以確定,上面的 json 解析是沒有意義,同時(shí)也會(huì)浪費(fèi)資源(payload 數(shù)據(jù)量一般不小)。
優(yōu)化方法
golang 自帶的 json 解析性能較低,這里我們可以替換為github.com/json-iterator來提升性能
在 golang 中,遇到不需要解析的 json 數(shù)據(jù),可以將其類型聲明為json.RawMessage. 即,可以將上述 2 個(gè)方法優(yōu)化為
這里注意!出現(xiàn)了 string 和[]byte 之間的類型轉(zhuǎn)換.為了避免內(nèi)存拷貝,這里將 string()改為上面的類型轉(zhuǎn)換優(yōu)化中所定義的轉(zhuǎn)換函數(shù),即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,也是出現(xiàn)了不必要的 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
Benchmark 500000 3326 ns/op 2842 B/op 50 allocs/op雖然對象 alloc 減少了,但單次操作內(nèi)存使用增加了,且性能下降了。這就有點(diǎn)奇怪了。我們來對比一下 2 個(gè)情況下的 pprof。
逃逸分析及處理
go tool pprof -base
cpu 差異
flat flat% sum% cum cum%0.09s 1.17% 1.17% 0.40s 5.20% runtime.mallocgc0.01s 0.13% 1.30% 0.35s 4.55% /vendor/github.com/json-iterator/go.(*Iterator).readObjectStart0 0% 1.30% 0.35s 4.55% /vendor/github.com/json-iterator/go.(*twoFieldsStructDecoder).Decodemem 差異
flat flat% sum% cum cum%478.96MB 20.33% 20.33% 279.94MB 11.88% gateway.RootHandle0 0% 20.33% 279.94MB 11.88% command-line-arguments.BenchmarkTestHttp.func10 0% 20.33% 279.94MB 11.88% testing.(*B).RunParallel.func1
可以看出 RootHandle 多了 478.96M 的內(nèi)存使用。通過 list RootHandle 對比 2 個(gè)情況下的內(nèi)存使用。發(fā)現(xiàn)修改后的 RootHandle 中多出了這一行:475.46MB 475.46MB 158: gatewayHttpReq := model.GatewayHttpReq{} 這一般意味著變量 gatewayHttpReq 出現(xiàn)了逃逸。
go build -gcflags "-m -m" gateway/*.go
gateway/logic.go:270:26: &gatewayHttpReq escapes to heap可以看到確實(shí)出現(xiàn)了逃逸。這個(gè)對應(yīng)的代碼為err = view.ReadReq2Json(&gatewayHttpReq),而造成逃逸的本質(zhì)是因?yàn)樯厦娓膭?dòng)了函數(shù) readJsonReq(動(dòng)態(tài)類型逃逸,即函數(shù)參數(shù)為 interface 類型,無法在編譯時(shí)確定具體類型的)
func?readJsonReq(data?[]byte,?req?*model.GatewayReqBody)?error?{err?:=?jsoniter.Unmarshal(data,?req)... }因此,這里需要特殊處理一下,改為
func readJsonReq(data []byte, req *model.GatewayReqBody) error {var tmp model.GatewayReqBodyerr := jsoniter.Unmarshal(data, &tmp)... }
benchmark
Benchmark 500000 2994 ns/op 1892 B/op 50 allocs/op可以看到堆內(nèi)存使用明顯下降。性能也提升了。再看一下 pprof,尋找下個(gè)瓶頸。
cpu profile
拋開 responeseData(他內(nèi)部主要是日志打印占比高),占比較高的為 util.GenerateSessionId,先來看看這個(gè)怎么優(yōu)化。
隨機(jī)字符串生成
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,但其實(shí)用 byte 就夠了。另外,letterRunes 是 62 個(gè)字符,即最大需要 6 位的 index 就可以遍歷完成了。而隨機(jī)數(shù)獲取的是 63 位。即每個(gè)隨機(jī)數(shù),其實(shí)可以產(chǎn)生 10 個(gè)隨機(jī)字符。而不用每個(gè)字符都獲取一次隨機(jī)數(shù)。所以我們改為
const?(letterBytes???=?"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"letterIdxBits?=?6letterIdxMask?=?1<<letterIdxBits?-?1letterIdxMax??=?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?>>=?letterIdxBitsremain--}return?string(b) }benchmark
Benchmark 1000000 1487 ns/op 1843 B/op 50 allocs/op類型轉(zhuǎn)換及字符串拼接
一般情況下,都會(huì)說將 string 和[]byte 的轉(zhuǎn)換改為 unsafe;以及在字符串拼接時(shí),用 byte.Buffer 代替 fmt.Sprintf。但是網(wǎng)關(guān)這里的情況比較特殊,字符串的操作基本集中在打印日志的操作。而 tars 的日志打印本身就是通過 byte.Buffer 拼接的。所以這可以避免。另外,由于日志打印量大,使用 unsafe 轉(zhuǎn)換[]byte 為 string 帶來的收益,往往會(huì)因?yàn)樘右輳亩绊?GC,反正會(huì)影響性能。因此,不同的場景下,不能簡單的套用一些優(yōu)化方法。需要通過壓測及結(jié)果分析來判斷具體的優(yōu)化策略。
優(yōu)化結(jié)果
可以看到優(yōu)化后,最大鏈接數(shù)為 110,最高 QPS 為21153.35。對比之前的13245,大約提升 60%。
后續(xù)
從 pprof 中可以看到日志打印,遠(yuǎn)程日志,健康上報(bào)等信息占用較多 cpu 資源,且導(dǎo)致多個(gè)數(shù)據(jù)逃逸(尤其是日志打印)。過多的日志基本等于沒有日志。后續(xù)可考慮裁剪日志,僅保留出錯(cuò)時(shí)的上下文信息。
總結(jié)
性能查看工具 pprof,trace 及壓測工具 wrk 或其他壓測工具的使用要比較了解。
代碼邏輯層面的走讀非常重要,要盡量避免無效邏輯。
對于 golang 自身庫存在缺陷的,可以尋找第三方庫或自己改造。
golang 版本盡量更新,這次的測試是在 golang1.12 下進(jìn)行的。而 go1.13 甚至 go1.14 在很多地方進(jìn)行了改進(jìn)。比如 fmt.Sprintf,sync.Pool 等。替換成新版本應(yīng)該能進(jìn)一步提升性能。
本地 benchmark 結(jié)果不等于線上運(yùn)行結(jié)果。尤其是在使用緩存來提高處理速度時(shí),要考慮 GC 的影響。
傳參數(shù)或返回值時(shí),盡量按 golang 的設(shè)計(jì)哲學(xué),少用指針,多用值對象,避免引起過多的變量逃逸,導(dǎo)致 GC 耗時(shí)暴漲。struct 的大小一般在 2K 以下的拷貝傳值,比使用指針要快(可針對不同的機(jī)器壓測,判斷各自的閾值)。
值類型在滿足需要的情況下,越小越好。能用 int8,就不要用 int64。
資源盡量復(fù)用,在 golang1.13 以上,可以考慮使用 sync.Pool 緩存會(huì)重復(fù)申請的內(nèi)存或?qū)ο蟆;蛘咦约菏褂貌⒐芾泶髩K內(nèi)存,用來存儲(chǔ)小對象,避免 GC 影響(如本地緩存的場景)。
推薦閱讀:
總結(jié)
以上是生活随笔為你收集整理的Golang 性能优化实战的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 东南亚版“QQ 音乐”:JOOX 的音乐
- 下一篇: 程序员黑科技 | 用13块钱DIY微信小