优化 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.ccsync.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.ccnotifiers?:=?[]*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?replicawg.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??stringQueue?chan?interface{}closed?int32ctx????context.Contextcancel?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
總結
以上是生活随笔為你收集整理的优化 Golang 分布式行情推送的性能瓶颈的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 从 wiscKey 看 LSMtree
- 下一篇: 高并发场景下 disk io 引发的高时