優化 Golang 分布式行情推送的性能瓶頸
本文轉載自微信公眾號「碼農桃花源」,作者峰云就她了 。轉載本文請聯系碼農桃花源公眾號。
最近一直在優化行情推送系統,有不少優化心得跟大家分享下。性能方面提升最明顯的是時延,在單節點8萬客戶端時,時延從1500ms優化到40ms,這里是內網mock客戶端的得到的壓測數據。
對于訂閱客戶端數沒有太執著量級的測試,弱網絡下單機8w客戶端是沒問題的。當前采用的是kubenetes部署方案,可靈活地擴展擴容。
架構圖
push-gateway是推送的網關,有這么幾個功能:第一點是為了做鑒權;第二點是為了做接入多協議,我們這里實現了websocket, grpc, grpc-web,sse的支持;第三點是為了實現策略調度及親和綁定等。
push-server 是推送服務,這里維護了訂閱關系及監聽mq的新消息,繼而推送到網關。
問題一:并發操作map帶來的鎖競爭及時延
推送的服務需要維護訂閱關系,一般是用嵌套的map結構來表示,這樣造成map并發競爭下帶來的鎖競爭和時延高的問題。
- // xiaorui.cc
- {"topic1": {"uuid1": client1, "uuid2": client2}, "topic2": {"uuid3": client3, "uuid4": client4} ... }
已經根據業務拆分了4個map,但是該訂閱關系是嵌套的,直接上鎖會讓其他協程都阻塞,阻塞就會造成時延高。
加鎖操作map本應該很快,為什么會阻塞?上面我們有說過該map是用來存topic和客戶端列表的訂閱關系,當我進行推送時,必然是需要拿到該topic的所有客戶端,然后進行一個個的send通知。(這里的send不是io.send,而是chan send,每個客戶端都綁定了緩沖的chan)
解決方法:在每個業務里劃分256個map和讀寫鎖,這樣鎖的粒度降低到1/256。除了該方法,開始有嘗試過把客戶端列表放到一個新的slice里返回,但造成了 GC 的壓力,經過測試不可取。
- // xiaorui.cc
- sync.RWMutex
- map[string]map[string]client
- 改成這樣
- m *shardMap.shardMap
分段map的庫已經推到github[1]了,有興趣的可以看看。
問題二:串行消息通知改成并發模式
簡單說,我們在推送服務維護了某個topic和1w個客戶端chan的映射,當從mq收到該topic消息后,再通知給這1w個客戶端chan。
客戶端的chan本身是有大buffer,另外發送的函數也使用 select default 來避免阻塞。但事實上這樣串行發送chan耗時不小。對于channel底層來說,需要goready等待channel的goroutine,推送到runq里。
下面是我寫的benchmark[2],可以對比串行和并發的耗時對比。在mac下效果不是太明顯,因為mac cpu頻率較高,在服務器里效果明顯。
串行通知,拿到所有客戶端的chan,然后進行send發送。
- for _, notifier := range notifiers {
- s.directSendMesg(notifier, mesg)
- }
并發send,這里使用協程池來規避morestack的消耗,另外使用sync.waitgroup里實現異步下的等待。
- // xiaorui.cc
- notifiers := []*mapping.StreamNotifier{}
- // conv slice
- for _, notifier := range notifierMap {
- notifiers = append(notifiers, notifier)
- }
- // optimize: direct map struct
- taskChunks := b.splitChunks(notifiers, batchChunkSize)
- // concurrent send chan
- wg := sync.WaitGroup{}
- for _, chunk := range taskChunks {
- chunkCopy := chunk // slice replica
- wg.Add(1)
- b.SubmitBlock(
- func() {
- for _, notifier := range chunkCopy {
- b.directSendMesg(notifier, mesg)
- }
- wg.Done()
- },
- )
- }
- wg.Wait()
按線上的監控表現來看,時延從200ms降到30ms。這里可以做一個更深入的優化,對于少于5000的客戶端,可直接串行調用,反之可并發調用。
問題三:過多的定時器造成cpu開銷加大
行情推送里有大量的心跳檢測,及任務時間控速,這些都依賴于定時器。go在1.9之后把單個timerproc改成多個timerproc,減少了鎖競爭,但四叉堆數據結構的時間復雜度依舊復雜,高精度引起的樹和鎖的操作也依然頻繁。
所以,這里改用時間輪解決上述的問題。數據結構改用簡單的循環數組和map,時間的精度弱化到秒的級別,業務上對于時間差是可以接受的。
Golang時間輪的代碼已經推到github[3]了,時間輪很多方法都兼容了golang time原生庫。有興趣的可以看下。
問題四:多協程讀寫chan會出現send closed panic的問題
解決的方法很簡單,就是不要直接使用channel,而是封裝一個觸發器,當客戶端關閉時,不主動去close chan,而是關閉觸發器里的ctx,然后直接刪除topic跟觸發器的映射。
- // xiaorui.cc
- // 觸發器的結構
- type StreamNotifier struct {
- Guid string
- Queue chan interface{}
- closed int32
- ctx context.Context
- cancel context.CancelFunc
- }
- func (sc *StreamNotifier) IsClosed() bool {
- if sc.ctx.Err() == nil {
- return false
- }
- return true
- }
- ...
問題五:提高grpc的吞吐性能
grpc是基于http2協議來實現的,http2本身實現流的多路復用。通常來說,內網的兩個節點使用單連接就可以跑滿網絡帶寬,無性能問題。但在golang里實現的grpc會有各種鎖競爭的問題。
如何優化?多開grpc客戶端,規避鎖競爭的沖突概率。測試下來qps提升很明顯,從8w可以提到20w左右。
可參考以前寫過的grpc性能測試[4]。
問題六:減少協程數量
有朋友認為等待事件的協程多了無所謂,只是占內存,協程拿不到調度,不會對runtime性能產生消耗。這個說法是錯誤的。雖然拿不到調度,看起來只是占內存,但是會對 GC 有很大的開銷。所以,不要開太多的空閑的協程,比如協程池開的很大。
在推送的架構里,push-gateway到push-server不僅幾個連接就可以,且幾十個stream就可以。我們自己實現大量消息在十幾個stream里跑,然后調度通知。在golang grpc streaming的實現里,每個streaming請求都需要一個協程去等待事件。所以,共享stream通道也能減少協程的數量。
問題七:GC 問題
對于頻繁創建的結構體采用sync.Pool進行緩存。有些業務的緩存先前使用list鏈表來存儲,在不斷更新新數據時,會不斷的創建新對象,對 GC 造成影響,所以改用可復用的循環數組來實現熱緩存。
后記
有坑不怕,填上就可以了。
參考資料
[1]github: https://github.com/rfyiamcool/ccmap/blob/master/syncmap.go
[2]benchmark: https://github.com/rfyiamcool/go-benchmark/tree/master/batch_notify_channel
[3]github: https://github.com/rfyiamcool/go-timewheel
[4]測試: https://github.com/rfyiamcool/grpc_batch_test