B乎千萬級高性能長連接網關是如何搭建的

實時的響應總是讓人興奮的,就如你在微信裏看到對方正在輸入,如你在王者峽谷裏一呼百應,如你們在直播彈幕裏不約而同的 666,它們的背後都離不開長連接技術的加持。

每個互聯網公司裏幾乎都有一套長連接系統,它們被應用在消息提醒、即時通訊、推送、直播彈幕、遊戲、共享定位、股票行情等等場景。而當公司發展到一定規模,業務場景變得更復雜後,更有可能是多個業務都需要同時使用長連接系統。

業務間分開設計長連接會導致研發和維護成本陡增、浪費基礎設施、增加客戶端耗電、無法複用已有經驗等等問題。共享長連接系統又需要協調好不同系統間的認證、鑑權、數據隔離、協議拓展、消息送達保證等等需求,迭代過程中協議需要向前兼容,同時因爲不同業務的長連接匯聚到一個系統導致容量管理的難度也會增大。

經過了一年多的開發和演進,經過我們服務面向內和外的數個 App、接入十幾個需求和形態各異的長連接業務、數百萬設備同時在線、突發大規模消息發送等等場景的錘鍊,我們提煉出一個長連接系統網關的通用解決方案,解決了多業務共用長連接時遇到的種種問題。

知乎長連接網關致力於業務數據解耦、消息高效分發、解決容量問題,同時提供一定程度的消息可靠性保證。

我們怎麼設計通訊協議?

業務解耦

支撐多業務的長連接網關實際上是同時對接多客戶端和多業務後端的,是多對多的關係,他們之間只使用一條長連接通訊。

知乎千萬級高性能長連接網關是如何搭建的

 

這種多對多的系統在設計時要避免強耦合。業務方邏輯也是會動態調整的,如果將業務的協議和邏輯與網關實現耦合會導致所有的業務都會互相牽連,協議升級和維護都會異常困難。

所以我們嘗試使用經典的發佈訂閱模型來解耦長連接網關跟客戶端與業務後端,它們之間只需要約定 Topic 即可自由互相發佈訂閱消息。傳輸的消息是純二進制數據,網關也無需關心業務方的具體協議規範和序列化方式。

知乎千萬級高性能長連接網關是如何搭建的

 

權限控制

我們使用發佈訂閱解耦了網關與業務方的實現,我們仍然需要控制客戶端對 Topic 的發佈訂閱的權限,避免有意或無意的數據污染或越權訪問。

假如講師正在知乎 Live 的 165218 頻道開講,當客戶端進入房間嘗試訂閱 165218 頻道的 Topic 時就需要知乎 Live 的後端判斷當前用戶是否已經付費。這種情況下的權限實際上是很靈活的,當用戶付費以後就能訂閱,否則就不能訂閱。權限的狀態只有知乎 Live 業務後端知曉,網關無法獨立作出判斷。

所以我們在 ACL 規則中設計了基於回調的鑑權機制,可以配置 Live 相關 Topic 的訂閱和發佈動作都通過 HTTP 回調給 Live 的後端服務判斷。

知乎千萬級高性能長連接網關是如何搭建的

 

同時根據我們對內部業務的觀察,大部分場景下業務需要的只是一個當前用戶的私有 Topic 用來接收服務端下發的通知或消息,這種情況下如果讓業務都設計回調接口來判斷權限會很繁瑣。

所以我們在 ACL 規則中設計了 Topic 模板變量來降低業務方的接入成本,我們給業務方配置允許訂閱的 Topic 中包含連接的用戶名變量標識,表示只允許用戶訂閱或發送消息到自己的 Topic。

知乎千萬級高性能長連接網關是如何搭建的

 

此時網關可以在不跟業務方通信的情況下,獨立快速判斷客戶端是否有權限訂閱或往 Topic 發送消息。

消息可靠性保證

網關作爲消息傳輸的樞紐,同時對接業務後端和客戶端,在轉發消息時需要保證消息在傳輸過程的可靠性。

TCP 只能保證了傳輸過程中的順序和可靠性,但遇到 TCP 狀態異常、客戶端接收邏輯異常或發生了 Crash 等等情況時,傳輸中的消息就會發生丟失。

爲了保證下發或上行的消息被對端正常處理,我們實現了回執和重傳的功能。重要業務的消息在客戶端收到並正確處理後需要發送回執,而網關內暫時保存客戶端未收取的消息,網關會判斷客戶端的接收情況並嘗試再次發送,直到正確收到了客戶端的消息回執。

知乎千萬級高性能長連接網關是如何搭建的

 

而面對服務端業務的大流量場景,服務端發給網關的每條消息都發送回執的方式效率較低,我們也提供了基於消息隊列的接收和發送方式,後面介紹發佈訂閱實現時再詳細闡述。

在設計通訊協議時我們參考了 MQTT 規範,拓展了認證和鑑權設計,完成了業務消息的隔離與解耦,保證了一定程度的傳輸可靠性。同時保持了與 MQTT 協議一定程度上兼容,這樣便於我們直接使用 MQTT 的各端客戶端實現,降低業務方接入成本。

我們怎麼設計系統架構?

在設計項目整體架構時,我們優先考慮的是:

  • 可靠性
  • 水平擴展能力
  • 依賴組件成熟度

簡單才值得信賴。

爲了保證可靠性,我們沒有考慮像傳統長連接系統那樣將內部數據存儲、計算、消息路由等等組件全部集中到一個大的分佈式系統中維護,這樣增大系統實現和維護的複雜度。我們嘗試將這幾部分的組件獨立出來,將存儲、消息路由交給專業的系統完成,讓每個組件的功能儘量單一且清晰。

同時我們也需要快速的水平擴展能力。互聯網場景下各種營銷活動都可能導致連接數陡增,同時發佈訂閱模型系統中下發消息數會隨着 Topic 的訂閱者的個數線性增長,此時網關暫存的客戶端未接收消息的存儲壓力也倍增。將各個組件拆開後減少了進程內部狀態,我們就可以將服務部署到容器中,利用容器來完成快速而且幾乎無限制的水平擴展。

最終設計的系統架構如下圖:

知乎千萬級高性能長連接網關是如何搭建的

 

系統主要由四個主要組件組成:

  1. 接入層使用 OpenResty 實現,負責連接負載均衡和會話保持
  2. 長連接 Broker,部署在容器中,負責協議解析、認證與鑑權、會話、發佈訂閱等邏輯
  3. Redis 存儲,持久化會話數據
  4. Kafka 消息隊列,分發消息給 Broker 或業務方

其中 Kafka 和 Redis 都是業界廣泛使用的基礎組件,它們在知乎都已平臺化和容器化,它們也都能完成分鐘級快速擴容。

我們如何構建長連接網關?

接入層

OpenResty 是業界使用非常廣泛的支持 Lua 的 Nginx 拓展方案,靈活性、穩定性和性能都非常優異,我們在接入層的方案選型上也考慮使用 OpenResty。

接入層是最靠近用戶的一側,在這一層需要完成兩件事:

  1. 負載均衡,保證各長連接 Broker 實例上連接數相對均衡
  2. 會話保持,單個客戶端每次連接到同一個 Broker,用來提供消息傳輸可靠性保證

負載均衡其實有很多算法都能完成,不管是隨機還是各種 Hash 算法都能比較好地實現,麻煩一些的是會話保持。

常見的四層負載均衡策略是根據連接來源 IP 進行一致性 Hash,在節點數不變的情況下這樣能保證每次都 Hash 到同一個 Broker 中,甚至在節點數稍微改變時也能大概率找到之前連接的節點。

之前我們也使用過來源 IP Hash 的策略,主要有兩個缺點:

  1. 分佈不夠均勻,部分來源 IP 是大型局域網 NAT 出口,上面的連接數多,導致 Broker 上連接數不均衡
  2. 不能準確標識客戶端,當移動客戶端掉線切換網絡就可能無法連接回剛纔的 Broker 了

所以我們考慮七層的負載均衡,根據客戶端的唯一標識來進行一致性 Hash,這樣隨機性更好,同時也能保證在網絡切換後也能正確路由。常規的方法是需要完整解析通訊協議,然後按協議的包進行轉發,這樣實現的成本很高,而且增加了協議解析出錯的風險。

最後我們選擇利用 Nginx 的 preread 機制實現七層負載均衡,對後面長連接 Broker 的實現的侵入性小,而且接入層的資源開銷也小。

Nginx 在接受連接時可以指定預讀取連接的數據到 preread buffer 中,我們通過解析 preread buffer 中的客戶端發送的第一個報文提取客戶端標識,再使用這個客戶端標識進行一致性 Hash 就拿到了固定的 Broker。

發佈與訂閱

我們引入了業界廣泛使用的消息隊列 Kafka 來作爲內部消息傳輸的樞紐。

前面提到了一些這麼使用的原因:

  • 減少長連接 Broker 內部狀態,讓 Broker 可以無壓力擴容
  • 知乎內部已平臺化,支持水平擴展

還有一些原因是:

  • 使用消息隊列削峯,避免突發性的上行或下行消息壓垮系統
  • 業務系統中大量使用 Kafka 傳輸數據,降低與業務方對接成本

其中利用消息隊列削峯好理解,下面我們看一下怎麼利用 Kafka 與業務方更好地完成對接。

發佈

長連接 Broker 會根據路由配置將消息發佈到 Kafka Topic,同時也會根據訂閱配置去消費 Kafka 將消息下發給訂閱客戶端。

路由規則和訂閱規則是分別配置的,那麼可能會出現四種情況:

  1. 消息路由到 Kafka Topic,但不消費,適合數據上報的場景。
  2. 消息路由到 Kafka Topic,也被消費,普通的即時通訊場景。
  3. 直接從 Kafka Topic 消費並下發,用於純下發消息的場景。
  4. 消息路由到一個 Topic,然後從另一個 Topic 消費,用於消息需要過濾或者預處理的場景。

這套路由策略的設計靈活性非常高,可以解決幾乎所有的場景的消息路由需求。同時因爲發佈訂閱基於 Kafka,可以保證在處理大規模數據時的消息可靠性。

訂閱

當長連接 Broker 從 Kafka Topic 中消費出消息後會查找本地的訂閱關係,然後將消息分發到客戶端會話。

我們最開始直接使用 HashMap 存儲客戶端的訂閱關係。當客戶端訂閱一個 Topic 時我們就將客戶端的會話對象放入以 Topic 爲 Key 的訂閱 Map 中,當反查消息的訂閱關係時直接用 Topic 從 Map 上取值就行。

因爲這個訂閱關係是共享對象,當訂閱和取消訂閱發生時就會有連接嘗試操作這個共享對象。爲了避免併發寫我們給 HashMap 加了鎖,但這個全局鎖的衝突非常嚴重,嚴重影響性能。

最終我們通過分片細化了鎖的粒度,分散了鎖的衝突。

本地同時創建數百個 HashMap,當需要在某個 Key 上存取數據前通過 Hash 和取模找到其中一個 HashMap 然後進行操作,這樣將全局鎖分散到了數百個 HashMap 中,大大降低了操作衝突,也提升了整體的性能。

會話

持久化

當消息被分發給會話 Session 對象後,由 Session 來控制消息的下發。

Session 會判斷消息是否是重要 Topic 消息, 是的話將消息標記 QoS 等級爲 1,同時將消息存儲到 Redis 的未接收消息隊列,並將消息下發給客戶端。等到客戶端對消息的 ACK 後,再將未確認隊列中的消息刪除。

有一些業界方案是在內存中維護了一個列表,在擴容或縮容時這部分數據沒法跟着遷移。也有部分業界方案是在長連接集羣中維護了一個分佈式內存存儲,這樣實現起來複雜度也會變高。

我們將未確認消息隊列放到了外部持久化存儲中,保證了單個 Broker 宕機後,客戶端重新上線連接到其他 Broker 也能恢復 Session 數據,減少了擴容和縮容的負擔。

滑動窗口

在發送消息時,每條 QoS 1 的消息需要被經過傳輸、客戶端處理、回傳 ACK 才能確認下發完成,路徑耗時較長。如果消息量較大,每條消息都等待這麼長的確認才能下發下一條,下發通道帶寬不能被充分利用。

爲了保證發送的效率,我們參考 TCP 的滑動窗口設計了並行發送的機制。我們設置一定的閾值爲發送的滑動窗口,表示通道上可以同時有這麼多條消息正在傳輸和被等待確認。

知乎千萬級高性能長連接網關是如何搭建的

 

我們應用層設計的滑動窗口跟 TCP 的滑動窗口實際上還有些差異。

TCP 的滑動窗口內的 IP 報文無法保證順序到達,而我們的通訊是基於 TCP 之所以我們的滑動窗口內的業務消息是順序的,只有在連接狀態異常、客戶端邏輯異常等情況下才可能導致部分窗口內的消息亂序。

因爲 TCP 協議保證了消息的接收順序,所以正常的發送過程中不需要針對單條消息進行重試,只有在客戶端重新連接後纔對窗口內的未確認消息重新發送。消息的接收端同時會保留窗口大小的緩衝區用來消息去重,保證業務方接收到的消息不會重複。

我們基於 TCP 構建的滑動窗口保證了消息的順序性同時也極大提升傳輸的吞吐量。

寫在最後

基礎架構組負責知乎的流量入口和內部基礎設施建設,對外我們奮鬥在直面海量流量的的第一戰線,對內我們爲所有的業務提供堅如磐石的基礎設施,用戶的每一次訪問、每一個請求、內網的每一次調用都與我們的系統息息相關。

推薦閱讀

馬士兵20年架構師告訴你阿里巴巴P5到P8級別需要掌握什麼技術?

https://blog.csdn.net/weixin_45132238/article/details/106526166

從零開始學架構,每天花四小時學spring全家桶、高併發、分佈式..

https://blog.csdn.net/weixin_45132238/article/details/106357002

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章