記一次grpc server記憶體/吞吐量優化
背景
最近,上線的採集器忽然時有OOM。採集器本質上是一個grpc服務,網路裝置通過grpc協議將資料上報後,採集器進行格式等整理後,發往下一個系統(比如分析,儲存)。
開啟執行環境,發現特性如下:
- 每個採集器例項,會有數千個裝置相連。並且會建立一個雙向 grpc stream,用以上報資料。
- cpu的負載並不高,但記憶體居高不下。
初步猜想,記憶體和stream的數量相關,下面來驗證一下。
優化記憶體
這次,很有先見之明的在上線就部署了pprof。這成為了線上debug的關鍵所在。
import _ "net/http/pprof" go func() { logrus.Errorln(http.ListenAndServe(":6060", nil)) }()
先看協程
一般記憶體問題會和協程洩露有關,所以先抓一下協程:
go tool pprof http://localhost:6060/debug/pprof/goroutine
得到了抓包的檔案 /root/pprof/pprof.grpc_proxy.goroutine.001.pb.gz
,為了方便看,scp到本機。
在本地執行:
go tool pprof -http=0.0.0.0:8080 ./pprof.grpc_proxy.goroutine.001.pb.gz
如果報錯沒有graphviz,安裝之:
yum install graphviz
此時進入瀏覽器輸入http://127.0.0.1:8080/ui/
,會有一個很好看的頁面。
在這裡,會發現有13W個協程。有點多,但考慮到連線了10000多個裝置。
- 這些協程,有keepalive, 有收發包等協程。都挺正常,其實問題不大。
- 幾乎所有的協程都gopark了。在等待。這也解釋了為什麼cpu其實不高,因為裝置連上了但是不上報資料。佔著資源不XX。
再看記憶體
協程雖然多,但沒看出什麼有價值的東西。那麼再看看記憶體的佔用。這次換個命令:
go tool pprof -inuse_space http://127.0.0.1:6060/debug/pprof/heap
-inuse_space 代表觀察使用中的記憶體
繼續得到資料檔案,然後scp到本機執行:
go tool pprof -http=0.0.0.0:8080 ./pprof.grpc_proxy.alloc_objects.alloc_space.inuse_objects.inuse_space.003.pb.gz
發現grpc.Serve.func3 ->...-> newBufWriter
佔用了大量記憶體。
問題很明顯,是buf的配置不太合適。
這裡多提一句,grpc服務端記憶體暴漲一般有這幾個原因:
- 沒有設定keepalive,使得連線洩露
- 服務端處理能力不足,流程阻塞,這個一般是下一跳IO引起。
- buffer使用了預設配置。
ReadBufferSize
和WriteBufferSize
預設為每個stream配置了32KB的大小。如果連線了很多裝置,但其實cpu開銷並不大,可以考慮減少這個值。
修改後程式碼新增grpc.ReadBufferSize(1024*8)/grpc.WriteBufferSize(1024*8)
配置
var keepAliveArgs = keepalive.ServerParameters{
Time: 10 * time.Second,
Timeout: 15 * time.Second,
MaxConnectionIdle: 3 * time.Minute,
}
s := grpc.NewServer(
.......
grpc.KeepaliveParams(keepAliveArgs),
grpc.MaxSendMsgSize(1024*1024*8), // 最大訊息8M
grpc.MaxRecvMsgSize(1024*1024*8),
grpc.ReadBufferSize(1024*8), // 就是這兩個引數
grpc.WriteBufferSize(1024*8),
)
if err := s.Serve(lis); err != nil {
logger.Errorf("failed to serve: %v", err)
return
}
重新發布程式,發現記憶體佔用變成了原來的一半。記憶體佔用大的問題基本解決。
注意:減少buffer代表存取資料的頻次會增加。理論上會帶來更大的cpu開銷。這也符合優化之道在於,CPU佔用大就(增加buffer)用記憶體換,記憶體佔用大就(減少buffer)用cpu換。水多了加面,面多了加水。如果cpu和記憶體都佔用大,那就到了買新機器的時候了。
優化吞吐
在優化記憶體的時候,順便看了一眼之前不怎麼關注的緩衝佇列監控。驚掉下巴。居然有1/4的資料使用到了緩衝佇列來發送。這勢必大量的使用了低速的磁碟。
這裡簡單提一下架構。
- 服務在收到資料之後並處理後,有多個下一跳(ai分析,儲存等微服務)等著傳送資料。
- 服務使用roundrobin的方式進行下一跳的選取
- 當下一跳繁忙的時候,則將資料寫入到buffer中,buffer是一個磁碟佇列。並且有另一個執行緒負責消費buffer中的資料。
簡單用程式碼來表示就是:
func SendData(data *Data){
i+=1
targetStream:= streams[i%len(streams)]
select{
case targetStream.c<- data:
//寫入成功
case <-time.After(time.Millisecond*50):
bufferStream.c<-data // 超時,寫入失敗,寫到磁碟快取佇列中,等待容錯程式處理
}
}
這種比較通用的玩法有幾個硬傷
- 當某個下一跳stream的延時比較高的時候,就會引發大量的阻塞。從而使得大量的資料用到快取。
-
time.After
裡的超時時間設成什麼,很讓人頭痛。如果設得太大,雖然減少了緩衝的使用率,但增加了資料的延時。
思考了一下,能不能利用go的機制,從之前的輪循傳送,換成哪個stream快就往誰發。
於是,我把程式碼寫成了這樣:
// 引入baseCh,所有的資料先發到這
baseCh:= make(chan *Data)
// 為每個下一跳的stream建立一個協程,用來發送資料
for _,stream := range streams{
stream:=stream
go func(){
for data:=range baseCh{
select{
// 在stream實現中使用一個獨立的協程管理本stream的傳送
case stream.c <- data:
case <-stream.ctx.Done():
// 這個資料為了它不丟失,讓它重新進入buffer
buffer.Send(data)
return
}
}
}()
}
func Send(data *Data){
select{
case bashCh<-data:
case <-time.After(time.Millisecond*50):
buffer.Send(data)
}
}
這相當於引入一個baseCh,把Send函式改造成了一進多出的模式。從而不會讓一個stream的阻塞頻繁的卡住所有資料的傳送。讓所有的資料傳送被歸集到baseCh,而不是每次傳送都等待超時。
在做這一個改動時,有一點顧慮:
chan本質上是一個有鎖佇列,頻繁的加鎖會不會反而影響吞吐?
這裡需要指出:
- 無論是
bashCh
還是stream.C
,都使用的無緩衝channel。理論上,無緩衝channel的效能會優於有緩衝的channel,因為不需要管理內建的佇列。這在一些測評中有所體現。 - 寫入channel一定要有超時或者退出機制,也就是:
select{
case bashCh<-data:
case <-time.After(time.Millisecond*50): // 每次寫channel都必須防禦式的使用超時或退出進位制,避免死鎖
buffer.Send(data)
}
實踐是檢驗真理的唯一標準,立馬上線灰度,發現多慮了。10000個寫入端頻繁呼叫Send
函式時,系統資源並沒有太大的波動。反而磁碟緩衝的使用大大減少了。
分批灰度變更,使得磁碟緩衝現在的使用幾乎歸零。
當看到監控圖後,我激動的哇的一聲哭出來,心裡比吃了蜜還甜,感到自己的技術又精甚了不少。胸口的紅領巾更紅了。