線上golang grpc服務資源泄露問題排查

前幾天告警羣裏報出一個go服務grpc接口出現很多超時現象,排查發現是服務有內存泄露與cpu佔用高的問題,在這裏將排查的過程記錄一下,給大家提供排查問題的方向與思路,同時借鑑教訓,優化自己服務代碼。

發現超時現象後,登錄機器看了下top,該服務總共有兩臺機器,發現02機器的cpu與內存佔用很高(如下圖第一個進程),而01機器都很低。
正常情況下不會有這麼高的資源佔用,可能是服務有資源泄露的問題,資源一致得不到釋放。首先做的,是重啓服務,優先解決問題,資源泄露的問題可以通過重啓來快速解決,重啓後接口超時現象不再出現,接口耗時恢復正常。
在這裏插入圖片描述
重啓後,開始排查問題,超時的服務是B服務,上游有A服務調用B服務,從A服務中找了幾個超時的請求,根據opentracing生成的tracer_id查詢日誌,發現A服務調用B服務超時5s就返回錯誤了,B服務收到了A服務的請求,發現有兩種情況,一種是B立即收到了A的請求,但是處理了400+秒才返回;另一種是A發出請求400+秒後,B纔開始處理請求。

另外發現grpc請求全部打到一臺機器上,另一臺機器沒什麼量。

然後去看了下歷史cpu、內存曲線,發現cpu在15分鐘內上升至很高,同時內存佔用很高的現象。
在這裏插入圖片描述在這裏插入圖片描述
拉長了內存統計時間,發現A服務的內存在緩慢增長,肯定是有內存泄露的問題:
在這裏插入圖片描述
總結下觀察到的問題

  1. 請求爲什麼全部打到一臺機器上,兩臺機器的前面是有slb的,難道slb沒有發揮作用嗎?
  2. cpu佔用在15分鐘快速增長的原因是什麼?
  3. 請求處理時間慢的原因是什麼?
  4. 爲什麼會有內存泄露現象出現?

Q1 slb沒有負載均衡

原因

針對Q1,特意去查了下slb的配置,由於slb是根據權重輪詢的,可能權重配置錯誤導致的請求分配不均,但是看了配置,slb的配置並沒有問題,兩臺後端服務器的權重相同。

然後去查了下,發現我們的slb是4層(tcp/udp層)的負載均衡,4層的slb是針對連接做負載均衡的,而不是針對請求,當連接的客戶端很少時,負載也可能不均衡,4層的負載均衡是客戶端和服務器tcp直連。
在這裏插入圖片描述

然後去兩臺機器上netstat看了下,發現01機器沒有A服務的連接,02機器有A服務的連接。同時grpc維持的是長連接並且複用連接,對於新請求不會新建連接,這樣在第一次經過slb的負載均衡創建連接後,grpc的請求會複用這個連接,請求會全部打到連接的機器上。

如何解決?

知道原因了,那麼如何解決呢?可以通過etcd與grpc兩者結合來實現服務註冊與服務發現,grpc客戶端根據所有server的ip來實現負載均衡。

Q2 cpu快速增長

原因

對於Q2,由於當時服務沒有設置pprof,無法看到運行的狀況……後面加了pprof,又不能馬上覆現,所以暫時是通過看代碼的方式來猜測哪些地方可能出了問題- -

想到之前的請求處理了400+秒,並且當時內存佔用很高,代碼中又有worker類的任務,每秒從數據庫中取出數據,對每條數據啓動一個goroutine處理。僞代碼如下:

for {
	datas := Mysql.GetDatas()
	for _, v := range datas {
		cur := v
		go func() {
			handle(cur)
		}
	}
	time.Sleep(time.Second)
}

正常情況下,這是沒問題的,但是當時機器的內存佔用接近100%,那麼goroutine的處理時間肯定變長,如果處理時間超過1秒甚至遠超一秒,那麼這個goroutine還沒處理完,worker又新起了一個goroutine,goroutine的數量沒有控制,多了以後又佔用更多資源,舊的goroutine處理時間更長,worker還是每秒啓動一個新的goroutine……後面就產生了goroutine泄露,這可能是導致cpu增長的主要原因。

所以初步猜測是內存泄露問題,導致內存佔用很高,然後導致goroutine處理時間過長,又導致goroutine泄露,goroutine進一步導致cpu、內存增長。

後來在線上加了pprof,但是內存泄露比較緩慢,需要等一段時間才能捕獲,到時候在這裏補充。

如何解決?

對於goroutine泄露的解決,自然是控制goroutine的數量,我把僞代碼改成了如下來控制goroutine(判斷超過限制數量就sleep):

int32 runningG = 0
const maxRunningG = 200
for {
	if atomic.LoadInt32(&runningG) > maxRunningG {
		time.Sleep(time.Seconds * 3)
		continue
	}
	datas := Mysql.GetDatas()
	for _, v := range datas {
		cur := v
		atomic.AddInt32(&runningG, 1)
		go func() {
			handle(cur)
			atomic.AddInt32(&runningG, -1)
		}
	}
	time.Sleep(time.Second)
}

Q3 請求處理緩慢

原因

處理請求緩慢的原因可能是Q2 goroutine泄露問題導致的cpu佔用過高,請求處理不過來了。

如何解決?

參考Q2解決方案

Q4 內存泄露問題

原因1

同樣也是直接從代碼的角度排查,借鑑了網上一些人的內存泄露經驗,發現一個方法中對於http請求的處理方式可能有問題。對於每個http請求,該方法每次都會新建一個http.Client與transport, 僞代碼如下。

...
tr := &http.Transport{
   TLSClientConfig: &tls.Config{
      ... // 證書相關
   },
}
client := &http.Client{Transport: tr}
response, err := client.Post(url, contentType, body)
if err != nil {
   return
}
responseByte, err := ioutil.ReadAll(response.Body)
if err != nil {
   return
}
...

而通過http.Client與transport的註釋我們可以看出這兩個是可以複用的。
http.Client:

// The Client's Transport typically has internal state (cached TCP
// connections), so Clients should be reused instead of created as
// needed. Clients are safe for concurrent use by multiple goroutines.
//

http.Transport:

// Transports should be reused instead of created as needed.
// Transports are safe for concurrent use by multiple goroutines.

且該方法對於http.Response.Body沒有調用**Close()**方法,這可能導致潛在的資源泄露。

如何解決?

創建全局的http Client和Transport並且設置好超時時間等參數,複用這個client。http請求返回的response需要調用http.Response.Body.Close()釋放連接使其可以被其他協程複用。

原因2

同時發現對於mysql查詢結果的處理,也可能有些問題,僞代碼如下:

rows, err := db.Query(sql, case)
if err != nil {
	return
}
for rows.Next() {
	if err = rows.Scan(...); err != nil{
		return
	}
	...
}

對於sql.Query的結果rows,如果沒有rows.Close(),但是rows.Scan()讀取了所有的數據,那麼rows的資源會自動得到釋放;
但是如果Scan發生錯誤,rows沒有讀取完,又沒有rows.Close(),就可能導致潛在的內存泄露。

如何解決?

每次都調用rows.Close()方法來釋放資源。

rows, err := db.Query(sql, case)
if err != nil {
	return
}
defer rows.Close() //
for rows.Next() {
	if err = rows.Scan(...); err != nil{
		return
	}
	...
}

其他原因

通過pprof正在分析中…… 待補充。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章