優化 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/DF8GeW2Nx3s9yjzcvEunJA