我將談論我大約6年前開始的開源項目。該項目名為Centrifugo。這是一個實時消息伺服器。它最初是用Python編寫的(在Tornado框架中),但隨後遷移到Go語言。就在大約一周前,我發布了Centrifugo的第2版 - 所以這實際上是第一次公布新伺服器。我在這次演講中的所有觀點都與這個新版本有關。
什麼是實時消息?當我說實時消息時,我指的是你向應用程式客戶端發送的消息,作為對應用程式中發生的某些事件的反應。這種消息的重要特性是它幾乎立即傳遞給客戶端 - 在後端發送之後的幾毫秒內。這種消息在多人遊戲,聊天室,動態計數器,實時圖表等實踐中非常有用。
實時消息的即時性質要求它通過持久連接從伺服器推送到客戶端。當客戶定期詢問伺服器更新時的輪詢策略在這裡不是很好的選擇。
動機
如果你決定在應用程式中添加實時事件,那麼你實際上有很多選擇。在選擇實時解決方案時,重要的是要考慮許多因素:後端語言和前端語言。你是從頭開始項目還是已經有生產應用程式。你願意為實時解決方案付錢嗎?當然,你需要解決的任務的性質。
有許多實時消息解決方案。如下是一部分:
如果您使用Erlang,NodeJS或Go等異步並發語言製作後端,那麼即使沒有任何框架,你也可以很好 - 儘管你需要解決前端和後端方面的一些特定問題。像可擴展性,正確的連接管理一樣。
來自pusher.com的Phil Leggetter創造了一個描述現代實時技術的精彩資源。checkout - 大量優秀的伺服器,庫和服務。深度博客中也有一個很好的概述。如果你正在開始搜索實時解決方案 - 從那裡開始。
我個人的背景是Python,更具體的是Django框架。如你所知,Django是一個經典的工作線程框架,其中每個工作程序在其自己的進程和OS線程中運行並阻塞執行請求的時間。現在使用像Websocket這樣的持久連接,你很快就會耗盡可用的工作人員,因此你的主應用程式將停止接受新的請求。這就是Centrifugo最初創建的目的 - 處理持久連接,從而為後端提供服務一般短期請求的可能性,並在需要時使用Centrifugo API向客戶發布新消息。
Django並不孤單 - 在Python和其他語言中有許多類似的模型框架。由於Centrifugo作為單獨的服務工作,因此可以與其集成,而無需在應用程式代碼中引入許多更改。實際上沒有什麼能阻止使用Centrifugo和用NodeJS或Go語言編寫的應用程式,因為它有一些我很快描述的有用功能。
設計概述
簡而言之,Centrifugo是一個伺服器,它允許處理來自應用程式客戶端的持久連接,並提供API以將實時消息發布給對該消息感興趣的連接客戶端。客戶表示他們對訂閱頻道(或者換句話說主題)的特定消息感興趣。所以Centrifugo實際上只是一個PUB / SUB伺服器。
現在我們來看看Centrifugo與應用後端集成的簡化方案:
正如你所看到的,該方案涉及3個部分 - 你的應用程式後端,Centrifugo和你的應用程式用戶。用戶通過Websocket或SockJS連接到Centrifugo,使用JSON Web令牌進行身份驗證,訂閱頻道並收聽消息。只要你的後端有新事件,它就會將其發布到Centrifugo API,並且消息將傳遞給連接的客戶端。在Centrifugo中,你可以在body或GRPC中使用帶有JSON的簡單HTTP請求來調用API方法。
讓我們假設你正在製作實時評論平臺。一旦你的用戶創建新評論,你首先使用方便的方式將其發送到後端 - 例如在瀏覽器中的AJAX請求,在你的後端,你驗證評論,如果需要保存到資料庫中,然後發布到Centrifugo API以通道與此評論相關的評論將由Centrifugo向所有活躍訂閱者播出。
實際上,這意味著實時消息在此設計中以一種方式流動 - 從伺服器到客戶端。在大多數情況下,你不需要雙向實時通信。大多數現代應用程式都是面向讀取的 - 用戶主要讀取內容,寫入請求是一件非常罕見的事情 - 因此對於許多實時應用程式,我們可以輕鬆地使用非持久連接進行寫入。
讓我們來看看Centrifugo的高級功能:
語言無關與應用程式後端分離具有到期支持的JWT身份驗證與現有應用程式的簡單集成使用Redis水平縮放性能穩定短暫斷開連接後的消息恢復有關頻道中活躍訂閱者的信息跨平臺(Linux,MacOS,Windows)MIT許可證Centrifugo用於許多項目,主要用於Web應用程式。它在Python和PHP社區中非常流行。一些使用Centrifugo的公司是Mail.Ru,Badoo,sports.ru,Spot.im。Spot.im擁有我所聽說過的最大的Centrifugo安裝 - 它是在線的30萬客戶,每分鐘有300萬條消息。
實時傳輸
我們來談談Centrifugo中使用的實時傳輸。
Websocket是目前最明顯的選擇。它具有很大的優勢 - 無處不在 - 在Web瀏覽器和行動應用程式內部。它是TCP上的低開銷協議,可以在與HTTP相同的埠上運行。連接從通用HTTP上的升級機制開始,然後切換到TCP會話。我不會談論很多關於Websocket協議的詳細信息 - 當然它很熟悉,因為它現在已經廣為流傳。
我們記得那些由於瀏覽器支持不佳而幾乎不可能在Web應用程式中使用Websocket的日子。
但現在情況好一些。
caniuse.com/#search=websocket目前的瀏覽器支持率約為93%。一些客戶端仍然使用沒有websocket支持的瀏覽器,一些瀏覽器擴展可以阻止特定域的websocket流量,因此在某些情況下,如果我們希望所有用戶成功連接,我們仍然需要回退到HTTP傳輸。
為了解決這個問題,Centrifugo使用SockJS作為Websocket polyfill。這意味著當無法建立Websocket連接時,將使用其中一種替代傳輸方式。
這些傳輸中只有2種基於持久連接 - xhr-streaming和eventsourse。但由於它們基於HTTP,因此它們不能是雙向的,因此SockJS使用從客戶端到伺服器的單獨的短期HTTP請求來模擬雙向通信。
具有後備HTTP傳輸可以在Web瀏覽器環境中提供一個有趣的優勢。我們知道,我們生活在HTTP/2越來越受歡迎的時代。如果HTTP/2使用的持久HTTP連接將通過HTTP/2實現自動在一個真實的TCP會話中復用。在打開Websocket應用程式的新選項卡時,你建立與伺服器的新TCP連接。這可以通過LocalStorage或SharedWorker上的同步來解決,但HTTP/2隻是為基於HTTP的傳輸連接提供了開箱即用的多路復用。
這是Centrifugo動機和一般概念的簡短概述。現在讓我們更具體地介紹一下實施細節 - Centrifugo如何在裡面構建?
內幕
如上所述,其中有兩個:
Websocket基於Gorilla websocket庫SockJS基於Sockjs-Go庫當我在第2版上工作時,我還嘗試使用GRPC雙向流作為客戶端伺服器傳輸。但經過一些測量後,我發現與Websocket相比,GRPC雙向流沒有任何優勢。例如,如果我們將10k客戶端連接到一個Centrifugo節點,那麼在websocket情況下,伺服器上的內存消耗將約為500mb,而在GRPC情況下,它將是4倍大 - 大約2GB的RAM。在我的同步測試中,Websocket的CPU使用率只提高了3倍。
Centrifugo有自己的協議,在protobuf模式中描述 - 它在高級別上與JSON RPC非常相似。在業務邏輯方面,與MQTT也有一些相似之處。有兩種可用的序列化格式:JSON和Protobuf。
我已經提到Centrifugo可以擴展到許多節點。為了允許功能跨多個節點工作,我們有一個Engine實體。這實際上是與大量方法的接口。引擎允許:
將消息發布到通道訂戶連接的Centrifugo節點。因此,每個客戶端都可以連接到每個節點並接收可能發布到另一個節點API的消息提供一種方法,使用配置的大小和生命周期將發布保存到緩存中。當客戶端在短時間內丟失連接然後重新連接時,這用於錯過消息恢復過程。管理存在信息 - 即關於頻道中活動用戶的信息。目前有2個引擎實現 - 在Memory和Redis引擎中。
內存引擎允許運行一個Centrifugo節點,因為沒有機制以某種方式連接節點。Redis Engine允許將Centrifugo擴展到許多將通過Redis PUB / SUB連接的節點,並且還具有內置的分片支持。
消息傳遞模型
在簡單的情況下,交付模型最多一次。無法保證將傳遞消息。顯然,基於PUB/SUB模型和Redis PUB/SUB機制的Centrifugo涉及 - 無法想像另一種保證。但這對於大多數應用程式來說已經足夠了,因為Centrifugo具有消息傳輸的作用 - 所有應用程式狀態都存在於主應用程式資料庫中,因此可以在需要時恢復。
但Centrifugo提供了一種有趣的消息恢復機制。Centrifugo在緩存中保留可配置的消息緩衝區,並且可以在客戶端重新訂閱後短暫斷開連接後發送錯過的消息後自動恢復客戶端狀態。當通道中每個發布的此機制具有增量序列號時,客戶端可以記住它們收到的最後序列號,從而使用它來重新連接時恢復狀態。在這種情況下,Cenrtifugo向客戶端發送錯過的出版物,將此過程與PUB / SUB同步,以便消息以正確的順序傳遞給客戶端。如果Centrifugo不確定所有出版物是否都恢復了,那麼客戶就此使用特殊標誌作為回應。
優化
Centrifugo原始碼中使用了幾種優化。我們來看看其中一些。
要使用協議緩衝區,我們使用gogoprotobuf庫。它使用代碼生成並生成非常優化的代碼來編組和解組protobufs。此代碼的性能比標準Protobuf庫高3-6倍,後者基於反射並分配更多。
另一個優化是自動將不同的消息合併到一個客戶端到一個幀。這允許減少負載下的寫入系統調用量。
如果我們看一下實際生產Centrifugo v1實例的火焰圖,我們可以看到與寫系統調用相關的火焰有多寬。
Protocol設計有助於將消息合併在一起。在JSON格式的情況下,可以使用JSON流格式將多個消息組合在一起,其中每個單獨的協議消息由新的行符號分隔。在Protobuf案例中,我們使用長度分隔格式,其中每個單獨的消息都以varint消息長度為前綴。因此,我們可以簡單地將不同的消息寫入臨時緩衝區,然後在一次系統調用中將它們寫入連接。當然我們也可以使用sync.Pool重用那些臨時緩衝區。
下一個優化是使用Redis流水線技術,以便在使用Redis時獲得最佳性能。Pipilining允許在單個請求中向Redis發送多個命令。我們通過使用智能批處理技術收集來自不同goroutines的個別請求來構建Redis管道。讓我們看一下這個模式。
想像一下,我們有一個源渠道,我們可以從中獲取要處理的項目。我們不希望單獨處理項目,而是批量處理。為此我們等待來自頻道的第一個項目,然後嘗試從我們想要的頻道緩衝區中收集儘可能多的項目而不會阻塞和超時。然後處理我們一次收集的物品。例如,從它們構建Redis管道並在一次連接寫入調用中發送給Redis。
當由於我們有一個由幾個相關步驟組成的操作而無法使用流水線時,我們使用Lua腳本來完成艱苦的工作。Lua腳本只需一次往返Redis執行,而且它們是以原子方式執行的。
我們嘗試在客戶端組合消息 - 在我們的客戶端 - 以減少讀取系統調用量。
離心機庫
從我的演講中可以看出,Centrifugo是一個獨立的伺服器,它有自己的Go內置機制。我多次被問過的問題是可以在Go代碼中重用Centrifugo功能嗎?答案是:
嗯,是的,你可以 - 但由於Centrifugo從未為此設計過,你需要承擔風險。
在Centrifugo v2上工作時,我的目標之一就是將獨立的庫隔離開來,這個庫將成為Centrifugo v2的核心,但仍然可以被Go社區重用。
我的工作成果是離心機庫。你仍需要了解Centrifuge作為庫是非常具體的,因為它具有從Centrifugo伺服器繼承的機制。
離心機庫提供了Centrifugo伺服器功能以外的一些功能:
使用中間件進行本機認證與業務邏輯緊密集成雙向消息傳遞內置RPC調用渠道許可管理更自由讓我們看看庫的感受。由於時間非常有限,我們無法查看所有內容,但這裡是使用Centrifuge庫的完整程序,沒有任何用處。
這是你可以設置事件處理程序來處理新連接事件,客戶端斷開連接,客戶端訂閱請求和嘗試發布的方法。
客戶端庫
目前我們有幾個客戶用於離心機和Centrifugo:
centrifuge-jscentrifuge-gocentrifuge-mobilecentrifuge-dart (WIP)客戶可以使用Centrifugo伺服器和基於Centrifuge庫構建的自定義伺服器,這一點非常重要。讓我們看一下使用Javascript客戶端可能做的一些模式。