Golang 后臺服務性能優(yōu)化,實用 Tips 梳理大全
作者 | rossixiao
性能優(yōu)化是一個經(jīng)久不衰的課題了,我們都常做。本文列舉了很多常用的Tips,基本都是我日常開發(fā)中遇到的問題,我將這些問題和方法梳理了下來。

一、GC原理
這一節(jié)會較為詳細介紹go的內存管理機制,也即GC。之所以要重點介紹是因為:
- GC是很多服務的性能瓶頸,在性能優(yōu)化問題上是舉足輕重的。
- 許多常見的優(yōu)化手段都是圍繞著內存管理進行的,只有了解了原理處理起來才游刃有余。另外go自身的內存管理方案也一直在迭代優(yōu)化,了解后我們可能會發(fā)現(xiàn)自己遇到的性能問題是因為go版本太低了。
- go這一套內存自動管理方案本身很有借鑒意義,如果學習到,碰到相似的業(yè)務可以效仿其設計方案。如一些復雜的調度系統(tǒng)。
二、標記-清除(Stop-the-world)

STW是指暫停所有goroutine,標記可達和不可達的對象,最后清除不可達對象,完成垃圾回收的過程。 在go1.3之前垃圾回收就是依賴的全局STW,因此性能很低,一次STW帶來的停頓時間可達數(shù)百毫秒。
三、三色標記法+寫屏障
1. 三色標記法
(1) 初始時所有的對象節(jié)點都被標記成白色

(2) 第一次掃描從根節(jié)點出發(fā),把能遍歷到的對象從白色集合放到灰色集合

(3) 重復掃描灰色集合,將灰色對象引用的對象從白色集合放到灰色集合,然后把此灰色對象放到黑色集合

(4) 直到灰色集合清空,內存中只有黑白兩種顏色。此時可以回收所有白色對象

很多人把三色標記法稱為三色并發(fā)標記法,因為它存儲了對象的中間狀態(tài),不需要一次性遍歷完。但實際上和程序并發(fā)運行時,對象之間的引用關系會發(fā)生更改(寫操作),而染色會讀引用關系,也即發(fā)生了讀寫沖突。這種沖突可能導致白色對象斷開和灰色對象的鏈接,掛在一個黑色對象上,而黑色對象是不會作為掃描的根節(jié)點的,因此白色對象被誤刪除,如圖中的對象3。因此內存回收的一個很關鍵的操作就是把白色對象保護起來,可以延時刪除,但不能誤刪除。

2. 觸發(fā)三色標記法不安全的必要條件
- 白色對象被黑色對象引用
- 且白色對象斷開了所有灰色對象與它之間的可達關系
其實只要破壞了這兩個必要條件之一,就能避免白色對象被誤刪除。后面的優(yōu)化都是圍繞著這個規(guī)則來的。
3. 加入寫屏障,保護白色節(jié)點
- 插入屏障:將B節(jié)點掛在A的下游時,B節(jié)點會被標記為灰色。 保障了白色節(jié)點不會被掛在黑色節(jié)點下。
- 刪除屏障:對刪除的對象,如果自身為白色,會被標記為灰色。保障了被刪除的白色節(jié)點有灰色節(jié)點與之鏈接(對,自己給自己撐腰)。
go1.5就升級到了三色標記法+寫屏障的策略,保證了掃描和程序可以并發(fā)執(zhí)行,無需停頓。但由于棧寫操作頻繁且要保障運行效率,寫屏障只運用到了堆上,如果白色節(jié)點被掛在黑色節(jié)點上,為了保障安全性,棧還是要進行一次STW掃描,以修正狀態(tài)。這一STW停頓一般在10~100ms。
四、混合寫屏障
go1.8引入了混合寫屏障,避免了對棧的重復掃描,極大減少了STW的時間。和寫屏障對比,加了以下兩個操作:
- GC開始時會將棧上的所有可達對象標記為黑色
- GC期間,棧上新創(chuàng)建的對象會被初始化為黑色
這樣做的道理在,棧的可達對象全標黑了,受顏色保護。而也不會出現(xiàn)白色(不可達)對象被掛在黑色對象的情況,因為它,不可達。

引入混合寫之后,以及幾乎不需要STW了。
五、GC優(yōu)化
1. GC瓶頸分析
(1) 癥結在GC掃描
已知go已經(jīng)把STW壓縮到極致了,所以這并非是大多數(shù)系統(tǒng)的問題所在,真正消耗性能的是gc掃描的計算過程。和GC回收相比也是掃描過程更消耗cpu。
掃描的時機:
- 堆內存達到閾值時觸發(fā)。下次GC閾值 = 上次GC后存活對象大小 × (1 + GOGC/100),默認 GOGC=100(內存翻倍時觸發(fā)),可通過環(huán)境變量調整。
- 定時觸發(fā)。若持續(xù) 2 分鐘未觸發(fā) GC,強制啟動掃描(避免長期未回收的內存泄漏)。
- 手動觸發(fā)。調用 runtime.GC() 強制啟動掃描,常用于調試或內存敏感操作后。
- 內存分配時觸發(fā) 。申請大對象(>32KB)或小對象時本地緩存不足(mcache 耗盡),可能觸發(fā)掃描。
內存回收的時機:
- 標記終止后立即啟動 。標記階段完成后,清除階段回收所有白色(未標記)對象,此階段與用戶代碼并發(fā)執(zhí)行。
- 內存分配時觸發(fā)輔助回收 。若程序在 GC 過程中分配新內存,可能被要求協(xié)助執(zhí)行部分回收任務。一般來講,gc掃描是更加消耗性能的那一步,但我們一般不分開說,統(tǒng)稱一次gc。
(2) 如何定位GC問題
利用火焰圖,可以看到gcBgMarkWorker占用cpu的百分比,一般超過10%就需要優(yōu)化了。 需要留意的是mallocgc屬于內存分配帶來的瓶頸,并非GC掃描問題。

2. 減少堆對象分配
GC優(yōu)化的方向之一是減少堆對象的分配,這是因為和棧相比,堆對象要GC掃描的時候要遞歸掃描所有對象,且棧對象會隨著生命周期的結束而被釋放,而堆對象全部需要gc掃描來回收。
(1) 小對象使用結構體而非指針
func createUser() *User {
return &User{ID: 1, Name: "Alice"} // 逃逸到堆
}改成下面的寫法編成棧分配,隨著生命周期被自動回收:
func createUser() User {
return User{ID: 1, Name: "Alice"} // 棧分配,函數(shù)結束后自動回收
}(2) 通過參數(shù)傳遞替代閉包捕獲
func main() {
x := 42
go func() {
fmt.Println(x) // x 逃逸到堆
}()
}
func main() {
x := 42
go func(val int) {
fmt.Println(val) // val 通過值傳遞,保留在棧上
}(x)
}(3) 利用bigcache存儲大對象(GB級別)
bigcache利用[]byte數(shù)組存儲對象,會被當成是一個整體只會掃描一次,完全規(guī)避了gc掃描問題。但需要自己做內存管理,且使用的時候要加一次編解碼操作。
3. 內存池減少對象分配
var pool = sync.Pool{
New: func() interface{} { return make([]byte, 1024) },
}
func processRequest() {
buf := pool.Get().([]byte) // 從池中獲?。赡軓陀茫? defer pool.Put(buf) // 放回池中
// 使用 buf...
}4. 減少GC掃描次數(shù)
(1) 調整GO GC大小
默認是增加一倍觸發(fā)GC回收,可以適當調大:
import "runtime/debug"
func main() {
debug.SetGCPercent(200) // 堆增長 200% 即觸發(fā) GC
}(2) 抬高堆大小基數(shù)
初始化的時候分配一個比較大的對象,提高觸發(fā)GC的基數(shù):
func main() {
ballast := make([]byte, 10<<30) // 10GB 虛擬內存(實際 RSS 不增加)
runtime.KeepAlive(ballast) // 阻止回收
// 主邏輯...
}預分配內存:
// 未優(yōu)化:多次擴容
var data []int
for i := 0; i < 1000; i++ {
data = append(data, i) // 可能觸發(fā)多次堆分配
}預分配內存,可以避免中途多次觸發(fā)GC掃描,且也減少了數(shù)據(jù)遷移帶來的開銷:
// 優(yōu)化:單次預分配
data := make([]int, 0, 1000) // 一次性分配底層數(shù)組
for i := 0; i < 1000; i++ {
data = append(data, i)
}(3) 為什么不能手動回收,減少GC壓力呢?
go不支持
六、善用緩存
緩存在我們這的使用場景還是挺多的,我們需要合理設計緩存,使得對外接口的平均耗時在100ms以下。我們曾經(jīng)有因為沒加緩存,在for循環(huán)中拉游戲詳情,導致大部分線程都阻塞在I/O等待中,導致接口耗時高,系統(tǒng)吞吐量低。
for appid := range appids {
detail := GetDetailInfo(appid) // rpc調用
...
}但加緩存并非是無腦加的,緩存本身也可能會到來性能問題。如:
- 商城首頁因異步更新緩存的時候頻繁分配內存,導致cpu利用率出現(xiàn)周期性尖刺。這種也是會浪費cpu的,可以適當打散,減小尖刺。

- 使用了bigcache,但設置的內存清理時間太長,導致期間內內存打滿引起OOM,機器頻繁重啟。

七、善用并發(fā)
1. 非關鍵路徑異步化處理
對于耗時很高的非關鍵路徑,要異步化處理,防止阻塞主流程。如一些非關鍵上報異步處理,再如暢玩好友在玩耗時較高,前端采用了異步加載的方式。
2. 盡量減少鎖競爭或者無鎖化
如果資源競爭激烈,很可能會導致鎖等待時間太長,和增加調度壓力而浪費cpu。
減少鎖的范圍:
func main() {
lock()
defer Unlock()
newA,newB := get() // 復雜的賦值操作
cache.A = newA
cache.B = newB
}
func main() {
newA,newB := get()
newCache.A = newA
newCache.B = newB // 先賦值,再直接替換,只需要鎖住替換這一步
Lock()
cache = newCache
Unlock()
}選用讀寫鎖/樂觀鎖來優(yōu)化鎖方案:

選用原子操作替代鎖:
func main() {
var counter int32 = 0
// 模擬10個goroutine并發(fā)自增
for i := 0; i < 10; i++ {
go func() {
atomic.AddInt32(&counter, 1) // 原子加1
}()
}
}使用協(xié)程池防止OOM:盡管go的協(xié)程已經(jīng)非常輕量了,在一些場景還是要控制協(xié)程的數(shù)目,防止協(xié)程無節(jié)制得擴增,導致資源耗盡或者調度壓力大。
八、一些高性能編程的好習慣
1. 避免大日志
在一次壓測中,一個2核2G的服務,日志200k字節(jié),吞吐量只能到80tps,后排查到瓶頸在于日志打太多了,減少日志輸出后性能提升了幾十倍。

可以看到當前主要性能消耗在字符串編解碼這里。
2. 避免深度拷貝
我們應該盡量減少深度拷貝的使用,在商城首頁這里,由于過度使用了clone,導致了gc性能瓶頸。去掉clone,只對部分數(shù)據(jù)賦值后,性能提升了50%

3. 避免反射
由于每個qgame的配置項是不同的結構體,為了通用化,qgameclient最開始利用反射來獲取配置
type configItem struct {
attr *qgame.ConfigAttr // 公共屬性
item reflect.Value // 配置項
}
// GetConfig 拉取配置
func GetConfig(ctx context.Context,
qryReq *QueryReq,
attrs map[string]*qgame.ConfigAttr,
confs interface{}) error {
itemType := reflect.TypeOf(confs).Elem().Elem() // 堆分配
// 獲取配置
c, err := getConfWithCache(ctx, itemType, qryReq)
if err != nil {
return err
}
// 解析并填充attrs和confs
d := reflect.New(itemType) // 堆分配
reflectMap := reflect.ValueOf(confs) // 堆分配
for k, v := range c {
if d.Type() != v.item.Type() {
return errs.Newf(ErrParams, "data type unmatch:%v,%v", d.Type(), v.item.Type())
}
attrs[k] = v.attr
reflectMap.SetMapIndex(reflect.ValueOf(k), v.item) // 堆分配
}
returnnil
}后續(xù)遇到了性能瓶頸,反射需要在運行時動態(tài)檢查數(shù)據(jù)類型和創(chuàng)建臨時對象(每次reflect.ValueOf()或reflect.TypeOf()調用至少產生1次堆分配)。引入泛型,泛型在編譯時期會生成對應類型的代碼,運行時無需校驗類型和分配臨時對象。
type ConfigItem[T any] struct {
Attr *qgame.ConfigAttr // 公共屬性
Config *T // config 配置map,key為配置id
}
func (c *QgameCli[T]) GetConfig(ctx context.Context, req *QueryReq) *QueryRsp[T] {
rsp := &QueryRsp[T]{
Items: map[string]*ConfigItem[T]{},
Env: cache.env,
}
items := cache.getCachedConfig(req)
for key, item := range items {
rsp.Items[key] = item
}
return rsp
}最后性能上:泛型>強制類型轉換/斷言>反射
4. 任務打散
當某個機器性能跟不上任務的計算復雜度時,可以考慮把計算任務打散到不同機器執(zhí)行。我們用生產消費者模式執(zhí)行任務的時候,消費者經(jīng)常利用北極星的負載均衡能力,把任務平均分配到每個機器執(zhí)行。
// StartConsume 開始消費
func StartConsume() {
for i := 0; i < 100; i++ {
util.GoWithRecover(func() {
for item := range ch {
item := item
consumeRpc(item) // 走rpc調用,打散消費任務
}
})
}
}5. 編解碼選型
目前比較常用的編解碼類型有pb、json、yaml和sonic,編解碼性能還是相差很大的, 之前gameinfoclient序列化從json改成pb時,獲取游戲詳情的耗時從100ms優(yōu)化到了20ms。vtproto是司內大神提供的pb編解碼優(yōu)化版本,去掉了pb官方編碼中的反射過程。實踐發(fā)現(xiàn)trpc-proxy利用了vtproto,性能提高了20%。
(1) 性能對比
這是ai給出的通識結論:

我自己實測了發(fā)現(xiàn)單編碼json的編碼性能居然高于pb(sonic > json > vtproto > proto > yaml),這不是一個結論別記憶,后面會解釋:
BenchmarkProtoMarshal-16 1000000 1074 ns/op
BenchmarkVtProtoMarshal-16 1157985 903.8 ns/op
BenchmarkJsonMarshal-16 1743318 688.7 ns/op
BenchmarkSonicMarshal-16 3684892 335.9 ns/op
BenchmarkYamlMarshal-16 154058 7499 ns/op
測試對象:
TestData := &pb.TestStruct{
A: 1,
B: []string{
"test",
},
C: map[int32]string{
1: "test",
},
}實際上是因為對于小對象而言json的反射機制開銷較小,且go1.22版本優(yōu)化了json反射機制,性能有所提升,所以表現(xiàn)為小對象 json的編碼性能高于proto。 我嘗試換成大對象,印證了這一點(sonic > vtproto > pb > json > yaml)
BenchmarkProtoMarshal-16 912 1325582 ns/op
BenchmarkVtProtoMarshal-16 908 1316555 ns/op
BenchmarkJsonMarshal-16 666 1792485 ns/op
BenchmarkSonicMarshal-16 4705 252298 ns/op
BenchmarkYamlMarshal-16 79 13914111 ns/op
測試對象:
a := int32(12345)
b := make([]string, 0, 5000)
for i := 0; i < 5000; i++ {
b = append(b, fmt.Sprintf("str-%d-abcdefghijklmnopqrstuvwxyz", i))
}
c := make(map[int32]string, 3000)
for i := int32(0); i < 3000; i++ {
c[i] = fmt.Sprintf("value-%d-0123456789ABCDEF", i)
}
TestData = &TestDataStruct{
A: a,
B: b,
C: c,
}最后,一般編碼和解碼是對稱使用的,這里也測了一下對稱使用編解碼的性能:
小對象:
BenchmarkProto-16 560961 2082 ns/op
BenchmarkVtProto-16 559299 2011 ns/op
BenchmarkJson-16 375512 3248 ns/op
BenchmarkSonic-16 1224876 974.7 ns/op
BenchmarkYaml-16 62400 18995 ns/op
大對象:
BenchmarkProto-16 369 3260424 ns/op
BenchmarkVtProto-16 364 3349230 ns/op
BenchmarkJson-16 201 5934529 ns/op
BenchmarkSonic-16 1483 798178 ns/op
BenchmarkYaml-16 42 28831239 ns/op至此我的結論是: sonic性能最卓越,如果對壓縮大小、平臺不敏感,能使用sonic盡量使用sonic;如果對可讀性要求比較高用json/yaml;跨端通信用pb,對性能敏感可升級用vtproto。
6. 字符串拼接
- strings.Builder性能最高,底層是[]byte,可動態(tài)擴容
- strings.Join底層是strings.Builder,一次性計算分配內存,性能差不多
- +運算符。需要不斷創(chuàng)建新的臨時對象
- fmt.Sprintf()。性能很差,涉及到反射。性能敏感場景避免使用。
九、利用工具排查性能問題
1. pprof
也即火焰圖,伽利略已經(jīng)集成了火焰圖插件,可直接使用。下面一個例子是分析game_switch服務的性能瓶頸:
查看cpu time。發(fā)現(xiàn)SsoGetShareTails 、編解碼、 GcBgMarkWorker占用了大部分cpu時間。

繼續(xù)往下看,分析出SsoGetShareTails主要消耗在Sprintf函數(shù)上。

到這里已經(jīng)可以猜測出:
- 編解碼占大部分推測出可能回包包體很大,影響了性能。
- gcBgMarkWorker表示gc掃描消耗的性能,可能頻繁分配內存,或者內存占用比較高。
- SsoGetShareTails中的Sprintf前面已經(jīng)提到過是一個性能很低的字符串拼接方案,可以直接優(yōu)化掉。
- 繼續(xù)查看堆內存大小,包含gc回收的??梢远ㄎ坏酱箢^在接口、序列化和壓縮上。

代碼定位如下圖。序列化和壓縮是框架自帶的,符合前面的推斷---回包太大導致的。

最后看看還存活的堆內存大小。發(fā)現(xiàn)是游戲詳情的緩存,符合預期。雖然在存活的堆內存里它算大頭,但和總的堆內存大小對比還是挺小的,不是目前主要優(yōu)化點,可降低優(yōu)先級后續(xù)優(yōu)化。

2. trace
對于一般的程序,pprof已經(jīng)夠用了。如果要更精細得定位問題,可以使用trace,和pprof不同的是,pprof是基于統(tǒng)計維度的,原理是定期采樣生成cpu和內存的快照,而trace直接追蹤到整個程序的運行,能提供時間線上的事件流。像這樣:

上面提供是一段有死鎖的代碼:
func main() {
// 創(chuàng)建trace文件
f, err := os.Create("deadlock_trace.out")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// 啟動trace
err = trace.Start(f)
if err != nil {
log.Fatal(err)
}
defer trace.Stop()
// 創(chuàng)建兩個互斥鎖
var mutex1, mutex2 sync.Mutex
// goroutine1 先鎖mutex1,再嘗試鎖mutex2
f1 := func() {
mutex1.Lock()
log.Println("goroutine1 獲得 mutex1")
time.Sleep(1 * time.Second) // 確保死鎖發(fā)生
mutex2.Lock()
log.Println("goroutine1 獲得 mutex2")
mutex2.Unlock()
mutex1.Unlock()
}
go f1()
// goroutine2 先鎖mutex2,再嘗試鎖mutex1
gofunc() {
mutex2.Lock()
log.Println("goroutine2 獲得 mutex2")
time.Sleep(1 * time.Second) // 確保死鎖發(fā)生
mutex1.Lock()
log.Println("goroutine2 獲得 mutex1")
mutex1.Unlock()
mutex2.Unlock()
}()
for i := 0; i < 10; i++ {
gofunc() {
time.Sleep(5 * time.Second)
}()
}
// 等待足夠時間讓死鎖發(fā)生
for {
}
}通過 Goroutine analysis,可以看到func1對應的協(xié)程編號是23,且大部分時間都處于阻塞中:

點擊查看協(xié)程23具體的事件流,func1最后一次執(zhí)行停留在sleep這,雖然很疑惑為什么不在Lock()這里,但也印證了后續(xù)的流程被阻塞了


十、需求階段
最近被問到:“如果下游接口就是很慢,你要怎么辦?”。最近暢玩就遇到了類似的問題,暢玩要接入ams廣告,但一個廣告接口卻有接近800ms的耗時,明顯對體驗是有損的,使得我們不得不推動下游做性能優(yōu)化。除此之外也提醒了我不應該僅限于需求開發(fā),而應該從用戶的角度出發(fā),思考需求是否合理、是否可優(yōu)化,協(xié)同產品一起保障體驗。
1. 體驗要穩(wěn)定
(1) 做好兜底
- 異常兜底。
- 如算法側掛了,要展示兜底素材;游戲封面缺失,過濾不展示或者展示兜底封面。邊界兜底。
如帖子瀏覽完了的文案兜底;完善錯誤碼機制,提示用戶當前狀態(tài)。
(2) 穩(wěn)定排序
有時候產品會忘記提供排序策略,除了被指定的隨機資源位,其他應該協(xié)調一個排序方案,避免每次刷新都展示不同的內容。
(3) 數(shù)據(jù)源一致性
如前段時間遇到的不同頁面展示的可領取禮包數(shù)不一致,實際是一個頁面展示的所有禮包,一個頁面沒展示貴族禮包,這要求我們開發(fā)前和產品溝通數(shù)據(jù)和哪個需求的頁面保持一致。
2. 畫面要流暢
(1) 數(shù)據(jù)分頁/分屏
如果一次請求的數(shù)據(jù)量太多,不僅會給后臺帶來性能問題,也可能引起前端前端渲染卡頓了,所以必要時需要溝通設計分頁或者分屏(如設計列表頁和二級頁)。
(2) 隱藏高耗時的數(shù)據(jù)
延遲加載非首屏內容,優(yōu)先保障核心功能可快速操作。
(3) 資源大小控制
如資料小卡的游戲段位圖,一開始誤給了一個10241024的高清圖,會導致UI加載很慢,后續(xù)改成了120120的就能滿足清晰度要求。
(4) 數(shù)據(jù)實時性分級
區(qū)分強實時性和弱實時性。如對于禮包數(shù)量、游戲在玩人數(shù)等允許有一段時間的延時,以減少對db的壓力。
(5) 用戶操作限頻
如已經(jīng)預約后按鈕置灰、限制點擊次數(shù)等,可以減少接口調用量,也能提高系統(tǒng)用戶吞吐量。
(6) 成本溝通
如周報tips,產品要求每個人都生成特有的圖片,已知圖像處理會帶來很多額外的開銷,應該提醒產品并切換其他方案。
(7) 回包大小控制
回包太大不僅會影響服務性能,也會加大網(wǎng)絡傳輸耗時和前端加載耗時。






















