新聞中心
優(yōu)化 Golang 分布式行情推送的性能瓶頸
作者:峰云就她了 2021-07-05 08:58:17
開發(fā)
前端
分布式 最近一直在優(yōu)化行情推送系統(tǒng),有不少優(yōu)化心得跟大家分享下。性能方面提升最明顯的是時(shí)延,在單節(jié)點(diǎn)8萬(wàn)客戶端時(shí),時(shí)延從1500ms優(yōu)化到40ms,這里是內(nèi)網(wǎng)mock客戶端的得到的壓測(cè)數(shù)據(jù)。

創(chuàng)新互聯(lián)公司基于分布式IDC數(shù)據(jù)中心構(gòu)建的平臺(tái)為眾多戶提供服務(wù)器托管 四川大帶寬租用 成都機(jī)柜租用 成都服務(wù)器租用。
本文轉(zhuǎn)載自微信公眾號(hào)「碼農(nóng)桃花源」,作者峰云就她了 。轉(zhuǎn)載本文請(qǐng)聯(lián)系碼農(nóng)桃花源公眾號(hào)。
最近一直在優(yōu)化行情推送系統(tǒng),有不少優(yōu)化心得跟大家分享下。性能方面提升最明顯的是時(shí)延,在單節(jié)點(diǎn)8萬(wàn)客戶端時(shí),時(shí)延從1500ms優(yōu)化到40ms,這里是內(nèi)網(wǎng)mock客戶端的得到的壓測(cè)數(shù)據(jù)。
對(duì)于訂閱客戶端數(shù)沒有太執(zhí)著量級(jí)的測(cè)試,弱網(wǎng)絡(luò)下單機(jī)8w客戶端是沒問題的。當(dāng)前采用的是kubenetes部署方案,可靈活地?cái)U(kuò)展擴(kuò)容。
架構(gòu)圖
push-gateway是推送的網(wǎng)關(guān),有這么幾個(gè)功能:第一點(diǎn)是為了做鑒權(quán);第二點(diǎn)是為了做接入多協(xié)議,我們這里實(shí)現(xiàn)了websocket, grpc, grpc-web,sse的支持;第三點(diǎn)是為了實(shí)現(xiàn)策略調(diào)度及親和綁定等。
push-server 是推送服務(wù),這里維護(hù)了訂閱關(guān)系及監(jiān)聽mq的新消息,繼而推送到網(wǎng)關(guān)。
問題一:并發(fā)操作map帶來的鎖競(jìng)爭(zhēng)及時(shí)延
推送的服務(wù)需要維護(hù)訂閱關(guān)系,一般是用嵌套的map結(jié)構(gòu)來表示,這樣造成map并發(fā)競(jìng)爭(zhēng)下帶來的鎖競(jìng)爭(zhēng)和時(shí)延高的問題。
- // xiaorui.cc
- {"topic1": {"uuid1": client1, "uuid2": client2}, "topic2": {"uuid3": client3, "uuid4": client4} ... }
已經(jīng)根據(jù)業(yè)務(wù)拆分了4個(gè)map,但是該訂閱關(guān)系是嵌套的,直接上鎖會(huì)讓其他協(xié)程都阻塞,阻塞就會(huì)造成時(shí)延高。
加鎖操作map本應(yīng)該很快,為什么會(huì)阻塞?上面我們有說過該map是用來存topic和客戶端列表的訂閱關(guān)系,當(dāng)我進(jìn)行推送時(shí),必然是需要拿到該topic的所有客戶端,然后進(jìn)行一個(gè)個(gè)的send通知。(這里的send不是io.send,而是chan send,每個(gè)客戶端都綁定了緩沖的chan)
解決方法:在每個(gè)業(yè)務(wù)里劃分256個(gè)map和讀寫鎖,這樣鎖的粒度降低到1/256。除了該方法,開始有嘗試過把客戶端列表放到一個(gè)新的slice里返回,但造成了 GC 的壓力,經(jīng)過測(cè)試不可取。
- // xiaorui.cc
- sync.RWMutex
- map[string]map[string]client
- 改成這樣
- m *shardMap.shardMap
分段map的庫(kù)已經(jīng)推到github[1]了,有興趣的可以看看。
問題二:串行消息通知改成并發(fā)模式
簡(jiǎn)單說,我們?cè)谕扑头?wù)維護(hù)了某個(gè)topic和1w個(gè)客戶端chan的映射,當(dāng)從mq收到該topic消息后,再通知給這1w個(gè)客戶端chan。
客戶端的chan本身是有大buffer,另外發(fā)送的函數(shù)也使用 select default 來避免阻塞。但事實(shí)上這樣串行發(fā)送chan耗時(shí)不小。對(duì)于channel底層來說,需要goready等待channel的goroutine,推送到runq里。
下面是我寫的benchmark[2],可以對(duì)比串行和并發(fā)的耗時(shí)對(duì)比。在mac下效果不是太明顯,因?yàn)閙ac cpu頻率較高,在服務(wù)器里效果明顯。
串行通知,拿到所有客戶端的chan,然后進(jìn)行send發(fā)送。
- for _, notifier := range notifiers {
- s.directSendMesg(notifier, mesg)
- }
并發(fā)send,這里使用協(xié)程池來規(guī)避morestack的消耗,另外使用sync.waitgroup里實(shí)現(xiàn)異步下的等待。
- // 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()
按線上的監(jiān)控表現(xiàn)來看,時(shí)延從200ms降到30ms。這里可以做一個(gè)更深入的優(yōu)化,對(duì)于少于5000的客戶端,可直接串行調(diào)用,反之可并發(fā)調(diào)用。
問題三:過多的定時(shí)器造成cpu開銷加大
行情推送里有大量的心跳檢測(cè),及任務(wù)時(shí)間控速,這些都依賴于定時(shí)器。go在1.9之后把單個(gè)timerproc改成多個(gè)timerproc,減少了鎖競(jìng)爭(zhēng),但四叉堆數(shù)據(jù)結(jié)構(gòu)的時(shí)間復(fù)雜度依舊復(fù)雜,高精度引起的樹和鎖的操作也依然頻繁。
所以,這里改用時(shí)間輪解決上述的問題。數(shù)據(jù)結(jié)構(gòu)改用簡(jiǎn)單的循環(huán)數(shù)組和map,時(shí)間的精度弱化到秒的級(jí)別,業(yè)務(wù)上對(duì)于時(shí)間差是可以接受的。
Golang時(shí)間輪的代碼已經(jīng)推到github[3]了,時(shí)間輪很多方法都兼容了golang time原生庫(kù)。有興趣的可以看下。
問題四:多協(xié)程讀寫chan會(huì)出現(xiàn)send closed panic的問題
解決的方法很簡(jiǎn)單,就是不要直接使用channel,而是封裝一個(gè)觸發(fā)器,當(dāng)客戶端關(guān)閉時(shí),不主動(dòng)去close chan,而是關(guān)閉觸發(fā)器里的ctx,然后直接刪除topic跟觸發(fā)器的映射。
- // xiaorui.cc
- // 觸發(fā)器的結(jié)構(gòu)
- 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協(xié)議來實(shí)現(xiàn)的,http2本身實(shí)現(xiàn)流的多路復(fù)用。通常來說,內(nèi)網(wǎng)的兩個(gè)節(jié)點(diǎn)使用單連接就可以跑滿網(wǎng)絡(luò)帶寬,無性能問題。但在golang里實(shí)現(xiàn)的grpc會(huì)有各種鎖競(jìng)爭(zhēng)的問題。
如何優(yōu)化?多開grpc客戶端,規(guī)避鎖競(jìng)爭(zhēng)的沖突概率。測(cè)試下來qps提升很明顯,從8w可以提到20w左右。
可參考以前寫過的grpc性能測(cè)試[4]。
問題六:減少協(xié)程數(shù)量
有朋友認(rèn)為等待事件的協(xié)程多了無所謂,只是占內(nèi)存,協(xié)程拿不到調(diào)度,不會(huì)對(duì)runtime性能產(chǎn)生消耗。這個(gè)說法是錯(cuò)誤的。雖然拿不到調(diào)度,看起來只是占內(nèi)存,但是會(huì)對(duì) GC 有很大的開銷。所以,不要開太多的空閑的協(xié)程,比如協(xié)程池開的很大。
在推送的架構(gòu)里,push-gateway到push-server不僅幾個(gè)連接就可以,且?guī)资畟€(gè)stream就可以。我們自己實(shí)現(xiàn)大量消息在十幾個(gè)stream里跑,然后調(diào)度通知。在golang grpc streaming的實(shí)現(xiàn)里,每個(gè)streaming請(qǐng)求都需要一個(gè)協(xié)程去等待事件。所以,共享stream通道也能減少協(xié)程的數(shù)量。
問題七:GC 問題
對(duì)于頻繁創(chuàng)建的結(jié)構(gòu)體采用sync.Pool進(jìn)行緩存。有些業(yè)務(wù)的緩存先前使用list鏈表來存儲(chǔ),在不斷更新新數(shù)據(jù)時(shí),會(huì)不斷的創(chuàng)建新對(duì)象,對(duì) GC 造成影響,所以改用可復(fù)用的循環(huán)數(shù)組來實(shí)現(xiàn)熱緩存。
后記
有坑不怕,填上就可以了。
參考資料
[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]測(cè)試: https://github.com/rfyiamcool/grpc_batch_test
本文標(biāo)題:優(yōu)化 Golang 分布式行情推送的性能瓶頸
文章起源:http://m.fisionsoft.com.cn/article/cdgddgs.html


咨詢
建站咨詢
