GO千萬級訊息推送服務

NO IMAGE

https://yuerblog.cc/2018/06/26/go-push-service/

https://github.com/owenliang/go-push

公司此前有一個簡單的文章訂閱業務,但是採用的是定時拉取的模式,週期比較長,時效性不佳。

於是考慮做一個長連線服務,主動把新產生的文章推送下去。

因為是web場景,所以優先考慮成熟的websocket協議,很多程式語言都有成熟的服務端開發框架。

技術核心難點

系統呼叫的瓶頸

假設有100萬人線上,那麼1篇文章會導致100萬次推送,10篇文章就是1000萬次推送。

根據經驗值,linux系統在處理TCP網路系統呼叫的時候,大概每秒只能處理100萬左右個包。

這麼看的話,推送1篇文章就已經到達了單機的處理能力極限,這是第一個難點。

鎖瓶頸

我們在推送時,需要遍歷所有的線上連線,通常這些連線被放在一個集合裡。

遍歷100萬個連線去傳送訊息,肯定需要花費一個可觀的時間,而在推送期間客戶端仍舊在不停的上線與下線,所以這個集合是需要上鎖做併發保護的。

可見,遍歷期間上鎖的時間會非常長,而且只能有一個執行緒順序遍歷集合,這個耗時是無法接受的。

CPU瓶頸

一般客戶端與服務端之間基於JSON協議通訊,給每個客戶端推送訊息前需要對訊息做json encode編碼。

當線上連線比較少(比如1萬)而推送訊息比較頻繁(每秒10萬條)的情況下,我們可以計算得到每秒要json encode編碼的次數是:10000 * 100000 = 10^9次。

即便我們提前對10萬條訊息做json encode後,再向1萬個連線做分發,那麼每秒也需要10萬次的編碼。

JSON編碼是一個純CPU計算行為,非常耗費CPU,我們仍舊面臨不小的優化壓力。

解決技術難點

系統呼叫瓶頸

仍舊假設100萬人線上,那麼單機極限就是每秒推送1篇文章,這會帶來每秒100萬次的網路系統呼叫。

如果我們想推送100篇文章,仍舊使用單機處理,優化的思路是什麼呢?

很簡單,我們把100篇文章作為一條訊息推送,那麼仍舊是每秒100萬次系統呼叫。

無論是10篇,50篇,80篇,我們都合併成1條訊息推送,那麼100萬人線上的推送頻次就是恆定的每秒100萬次,不隨著文章數量的變化而變化。

當然,合併訊息不可能無限大,當超過一定的閾值之後,TCP/IP層會進行大包拆分,此時底層實際包頻就會超過每秒100萬次,再次到達系統呼叫的極限。

鎖瓶頸

在做海量服務架構設計的時候,一個很有用的思路就是:大拆小。

既然100萬連線放在一個集合裡導致鎖粒度太大,那麼我們就可以把連線通過雜湊的方式雜湊到多個集合中,每個集合有自己的鎖。

當我們推送的時候,可以通過多個執行緒,分別負責若干個集合的推送任務。

因為推送的集合不同,所以執行緒之間沒有鎖競爭關係。而對於同一個集合併發推送多條不同的訊息,我們可以把互斥鎖換成讀寫鎖,從而支援多執行緒併發遍歷同一個集合傳送不同的訊息。

其實作業系統管理CPU也是分時的,就像我們的推送任務被拆分成若干小集合一樣,每個集合只需要佔用一點點的時間片快速完成,而多個集合則儘可能的利用多核的優勢實現真並行。

CPU瓶頸

其實當我們通過訊息合併的方式減少網路系統呼叫的時候,我們已經完成了對sys cpu的優化,作業系統用來處理網路系統呼叫的CPU時間大幅減少。

但是user CPU需要我們繼續做優化,我們如果在每個連線級別做json encode,那麼1篇文章就會帶來100萬次encode,是完全無法接受的效能。

因為業務上訊息推送分2類,一種是按客戶端關注的主題做推送,一種是推送給所有客戶端。

基於上述特點,我們可以把訊息合併動作提前到訊息入口層,即把近一段時間所有要推往某個主題、推給所有線上的訊息做訊息合併成batch,每個batch可能包含100條訊息。當1個batch塞滿後或者超時後,經過對其進行一次json encode編碼後,即可直接向目標客戶端做遍歷分發。

經過訊息合併前置,編碼的CPU消耗不再與線上的連線數有關,也不再直接與要推送的訊息條數有關,而是與打包後的batch個數有關,具有量級上的銳減效果。

架構考量

叢集化gateway

經過上述的設計後,我們可以用GO來實現一個高併發的websocket長連線閘道器(gateway)。

gateway可以橫向部署構成叢集,前端採用LVS/HA/DNS負載均衡。

當我們採用gateway叢集化部署之後,當我們想要推送一條訊息的時候,需要將訊息分發給所有的gateway程序。

邏輯服務logic

因此,我實現了一個Logic服務,它本身是無狀態的,負責2個核心功能:

1,為業務提供了HTTP介面提交推送訊息,因為作為推送系統的推送頻次不會太高。而且業務方在推送前會有很多業務邏輯判定,最終通過HTTP完成推送,相信是一個比較易於接入的方式。

2,負責將推送訊息向各gateway程序做分發,在這裡採用了HTTP/2作為RPC協議(GRPC就是HTTP/2)保障了單連線的高併發能力,同時保障了不同gateway之間的故障隔離,互不影響。

認證服務

目前尚未引入websocket連線的登入認證,今後存在向特定使用者推送的需求時,需要實現認證服務。

認證服務獨立於gateway與logic,可以稱作Passport。

客戶端首先基於公司帳號體系向passport完成登入,得到一個自驗證的Login token(例如JWT),然後再發起gateway連線。

gateway驗證token後完成uid的識別,整個過程不需要與其他業務系統額外互動,當然也可以增加額外的呼叫服務驗證。

那麼當logic希望向特定uid推送訊息的時候,當前架構下仍舊必須將訊息分發給所有gateway,由gateway找到uid對應的連線。但是這無疑造成了浪費,因為uid可能只連在某一個gateway上,對其他gateway毫無意義。

session會話層

未來可以考慮增加會話層,記錄uid與gateway之間的連線關係,這樣logic經過session層反查詢到uid所在的gateway,完成定向推送即可。

會話層可以做一層單獨的服務,採用純記憶體的方式儲存uid與gateway的關係。

因為gateway宕機等原因,可能導致我們無法及時剔除掉線的會話,所以gateway與session之間應該定時傳輸健康客戶端的心跳資訊。

當然,也可以簡單粗暴的將會話層用redis叢集取代,僅僅提供單一的uid->gateway的反查能力。

原始碼

專案程式碼我開源到了github,程式碼量非常少,所以感興趣的話不如讀一下原始碼嘍。

go-push