1. 程式人生 > 其它 >記一次grpc server記憶體/吞吐量優化

記一次grpc server記憶體/吞吐量優化

背景

最近,上線的採集器忽然時有OOM。採集器本質上是一個grpc服務,網路裝置通過grpc協議將資料上報後,採集器進行格式等整理後,發往下一個系統(比如分析,儲存)。

開啟執行環境,發現特性如下:

  1. 每個採集器例項,會有數千個裝置相連。並且會建立一個雙向 grpc stream,用以上報資料。
  2. 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多個裝置。

  1. 這些協程,有keepalive, 有收發包等協程。都挺正常,其實問題不大。
  2. 幾乎所有的協程都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服務端記憶體暴漲一般有這幾個原因:

  1. 沒有設定keepalive,使得連線洩露
  2. 服務端處理能力不足,流程阻塞,這個一般是下一跳IO引起。
  3. buffer使用了預設配置。ReadBufferSizeWriteBufferSize預設為每個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的資料使用到了緩衝佇列來發送。這勢必大量的使用了低速的磁碟。

這裡簡單提一下架構。

  1. 服務在收到資料之後並處理後,有多個下一跳(ai分析,儲存等微服務)等著傳送資料。
  2. 服務使用roundrobin的方式進行下一跳的選取
  3. 當下一跳繁忙的時候,則將資料寫入到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 // 超時,寫入失敗,寫到磁碟快取佇列中,等待容錯程式處理
	}
}

這種比較通用的玩法有幾個硬傷

  1. 當某個下一跳stream的延時比較高的時候,就會引發大量的阻塞。從而使得大量的資料用到快取。
  2. 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本質上是一個有鎖佇列,頻繁的加鎖會不會反而影響吞吐?

這裡需要指出:

  1. 無論是bashCh還是stream.C,都使用的無緩衝channel。理論上,無緩衝channel的效能會優於有緩衝的channel,因為不需要管理內建的佇列。這在一些測評中有所體現。
  2. 寫入channel一定要有超時或者退出機制,也就是:
  select{
      case bashCh<-data:
      case <-time.After(time.Millisecond*50): // 每次寫channel都必須防禦式的使用超時或退出進位制,避免死鎖
          buffer.Send(data)
  }

實踐是檢驗真理的唯一標準,立馬上線灰度,發現多慮了。10000個寫入端頻繁呼叫Send函式時,系統資源並沒有太大的波動。反而磁碟緩衝的使用大大減少了。

分批灰度變更,使得磁碟緩衝現在的使用幾乎歸零。

當看到監控圖後,我激動的哇的一聲哭出來,心裡比吃了蜜還甜,感到自己的技術又精甚了不少。胸口的紅領巾更紅了。