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