本文來自知乎官方技術團隊的“知乎技術專欄”,感謝原作者faceair的無私分享。
1、引言
實時的響應總是讓人興奮的,就如你在微信裡看到對方正在輸入,如你在王者峽谷裡一呼百應,如你們在直播彈幕裡不約而同的 666,它們的背後都離不開長連接配接技術的加持。
每個網際網路公司裡幾乎都有一套長連接配接系統,它們被應用在消息提醒、即時通訊、推送、直播彈幕、遊戲、共享定位、股票行情等等場景。而當公司發展到一定規模,業務場景變得更複雜後,更有可能是多個業務都需要同時使用長連接配接系統。
業務間分開設計長連接配接會導緻研發和維護成本陡增、浪費基礎設施、增加用戶端耗電、無法複用已有經驗等等問題。共享長連接配接系統又需要協調好不同系統間的認證、鑒權、資料隔離、協定拓展、消息送達保證等等需求,疊代過程中協定需要向前相容,同時因為不同業務的長連接配接彙聚到一個系統導緻容量管理的難度也會增大。
經過了一年多的開發和演進,經過我們服務面向内和外的數個 App、接入十幾個需求和形态各異的長連接配接業務、數百萬裝置同時線上、突發大規模消息發送等等場景的錘煉,我們提煉出一個長連接配接系統網關的通用解決方案,解決了多業務共用長連接配接時遇到的種種問題。
知乎長連接配接網關緻力于業務資料解耦、消息高效分發、解決容量問題,同時提供一定程度的消息可靠性保證。
(本文同步釋出于:http://www.52im.net/thread-2737-1-1.html)
2、相關文章
- 《Netty幹貨分享:京東京麥的生産級TCP網關技術實踐總結》
- 《絕對幹貨:基于Netty實作海量接入的推送服務技術要點》
- 《通俗易懂-深入了解TCP協定(下):RTT、滑動視窗、擁塞處理》
- 《高性能網絡程式設計(二):上一個10年,著名的C10K并發連接配接問題》
- 《高性能網絡程式設計(三):下一個10年,是時候考慮C10M并發問題了》
- 《高性能網絡程式設計(四):從C10K到C10M高性能網絡應用的理論探索》
- 《知乎技術分享:從單機到2000萬QPS并發的Redis高性能緩存實踐之路》
3、我們怎麼設計通訊協定?
3.1 業務解耦
支撐多業務的長連接配接網關實際上是同時對接多用戶端和多業務後端的,是多對多的關系,他們之間隻使用一條長連接配接通訊。
這種多對多的系統在設計時要避免強耦合。業務方邏輯也是會動态調整的,如果将業務的協定和邏輯與網關實作耦合會導緻所有的業務都會互相牽連,協定更新和維護都會異常困難。
是以我們嘗試使用經典的釋出訂閱模型來解耦長連接配接網關跟用戶端與業務後端,它們之間隻需要約定 Topic 即可自由互相釋出訂閱消息。傳輸的消息是純二進制資料,網關也無需關心業務方的具體協定規範和序列化方式。
3.2 權限控制
我們使用釋出訂閱解耦了網關與業務方的實作,我們仍然需要控制用戶端對 Topic 的釋出訂閱的權限,避免有意或無意的資料污染或越權通路。
假如講師正在知乎 Live 的 165218 頻道開講,當用戶端進入房間嘗試訂閱 165218 頻道的 Topic 時就需要知乎 Live 的後端判斷目前使用者是否已經付費。這種情況下的權限實際上是很靈活的,當使用者付費以後就能訂閱,否則就不能訂閱。權限的狀态隻有知乎 Live 業務後端知曉,網關無法獨立作出判斷。
是以我們在 ACL 規則中設計了基于回調的鑒權機制,可以配置 Live 相關 Topic 的訂閱和釋出動作都通過 HTTP 回調給 Live 的後端服務判斷。
同時根據我們對内部業務的觀察,大部分場景下業務需要的隻是一個目前使用者的私有 Topic 用來接收服務端下發的通知或消息,這種情況下如果讓業務都設計回調接口來判斷權限會很繁瑣。
是以我們在 ACL 規則中設計了 Topic 模闆變量來降低業務方的接入成本,我們給業務方配置允許訂閱的 Topic 中包含連接配接的使用者名變量辨別,表示隻允許使用者訂閱或發送消息到自己的 Topic。
此時網關可以在不跟業務方通信的情況下,獨立快速判斷用戶端是否有權限訂閱或往 Topic 發送消息。
3.3 消息可靠性保證
網關作為消息傳輸的樞紐,同時對接業務後端和用戶端,在轉發消息時需要保證消息在傳輸過程的可靠性。
TCP 隻能保證了傳輸過程中的順序和可靠性,但遇到 TCP 狀态異常、用戶端接收邏輯異常或發生了 Crash 等等情況時,傳輸中的消息就會發生丢失。
為了保證下發或上行的消息被對端正常處理,我們實作了回執和重傳的功能。重要業務的消息在用戶端收到并正确處理後需要發送回執,而網關内暫時儲存用戶端未收取的消息,網關會判斷用戶端的接收情況并嘗試再次發送,直到正确收到了用戶端的消息回執。
而面對服務端業務的大流量場景,服務端發給網關的每條消息都發送回執的方式效率較低,我們也提供了基于消息隊列的接收和發送方式,後面介紹釋出訂閱實作時再詳細闡述。
在設計通訊協定時我們參考了 MQTT 規範(詳見《掃盲貼:認識MQTT通信協定》),拓展了認證和鑒權設計,完成了業務消息的隔離與解耦,保證了一定程度的傳輸可靠性。同時保持了與 MQTT 協定一定程度上相容,這樣便于我們直接使用 MQTT 的各端用戶端實作,降低業務方接入成本。
4、我們怎麼設計系統架構?
在設計項目整體架構時,我們優先考慮的是:
- 1)可靠性;
- 2)水準擴充能力;
- 3)依賴元件成熟度;
- 4)簡單才值得信賴。
為了保證可靠性,我們沒有考慮像傳統長連接配接系統那樣将内部資料存儲、計算、消息路由等等元件全部集中到一個大的分布式系統中維護,這樣增大系統實作和維護的複雜度。我們嘗試将這幾部分的元件獨立出來,将存儲、消息路由交給專業的系統完成,讓每個元件的功能盡量單一且清晰。
同時我們也需要快速地水準擴充能力。網際網路場景下各種營銷活動都可能導緻連接配接數陡增,同時釋出訂閱模型系統中下發消息數會随着 Topic 的訂閱者的個數線性增長,此時網關暫存的用戶端未接收消息的存儲壓力也倍增。将各個元件拆開後減少了程序内部狀态,我們就可以将服務部署到容器中,利用容器來完成快速而且幾乎無限制的水準擴充。
最終設計的系統架構如下圖:
系統主要由四個主要元件組成:
- 1)接入層使用 OpenResty 實作,負責連接配接負載均衡和會話保持;
- 2)長連接配接 Broker,部署在容器中,負責協定解析、認證與鑒權、會話、釋出訂閱等邏輯;
- 3)Redis 存儲,持久化會話資料;
- 4)Kafka 消息隊列,分發消息給 Broker 或業務方。
其中 Kafka 和 Redis 都是業界廣泛使用的基礎元件,它們在知乎都已平台化和容器化 (詳見:《Redis at Zhihu》、《知乎基于 Kubernetes 的 Kafka 平台的設計和實作》),它們也都能完成分鐘級快速擴容。
5、我們如何建構長連接配接網關?
5.1 接入層
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。
5.2 釋出與訂閱
我們引入了業界廣泛使用的消息隊列 Kafka 來作為内部消息傳輸的樞紐。
前面提到了一些這麼使用的原因:
- 1)減少長連接配接 Broker 内部狀态,讓 Broker 可以無壓力擴容;
- 2)知乎内部已平台化,支援水準擴充。
還有一些原因是:
- 1)使用消息隊列削峰,避免突發性的上行或下行消息壓垮系統;
- 2)業務系統中大量使用 Kafka 傳輸資料,降低與業務方對接成本。
其中利用消息隊列削峰好了解,下面我們看一下怎麼利用 Kafka 與業務方更好地完成對接。
5.3 釋出
連接配接 Broker 會根據路由配置将消息釋出到 Kafka Topic,同時也會根據訂閱配置去消費 Kafka 将消息下發給訂閱用戶端。
路由規則和訂閱規則是分别配置的,那麼可能會出現四種情況。
情況一:消息路由到 Kafka Topic,但不消費,适合資料上報的場景,如下圖所示。
情況二:消息路由到 Kafka Topic,也被消費,普通的即時通訊場景,如下圖所示。
情況三:直接從 Kafka Topic 消費并下發,用于純下發消息的場景,如下圖所示。
情況四:消息路由到一個 Topic,然後從另一個 Topic 消費,用于消息需要過濾或者預處理的場景,如下圖所示。
這套路由政策的設計靈活性非常高,可以解決幾乎所有的場景的消息路由需求。同時因為釋出訂閱基于 Kafka,可以保證在處理大規模資料時的消息可靠性。
5.4 訂閱
當長連接配接 Broker 從 Kafka Topic 中消費出消息後會查找本地的訂閱關系,然後将消息分發到用戶端會話。
我們最開始直接使用 HashMap 存儲用戶端的訂閱關系。當用戶端訂閱一個 Topic 時我們就将用戶端的會話對象放入以 Topic 為 Key 的訂閱 Map 中,當反查消息的訂閱關系時直接用 Topic 從 Map 上取值就行。
因為這個訂閱關系是共享對象,當訂閱和取消訂閱發生時就會有連接配接嘗試操作這個共享對象。為了避免并發寫我們給 HashMap 加了鎖,但這個全局鎖的沖突非常嚴重,嚴重影響性能。
最終我們通過分片細化了鎖的粒度,分散了鎖的沖突。
本地同時建立數百個 HashMap,當需要在某個 Key 上存取資料前通過 Hash 和取模找到其中一個 HashMap 然後進行操作,這樣将全局鎖分散到了數百個 HashMap 中,大大降低了操作沖突,也提升了整體的性能。
5.5 會話持久化
當消息被分發給會話 Session 對象後,由 Session 來控制消息的下發。
Session 會判斷消息是否是重要 Topic 消息, 是的話将消息标記 QoS 等級為 1,同時将消息存儲到 Redis 的未接收消息隊列,并将消息下發給用戶端。等到用戶端對消息的 ACK 後,再将未确認隊列中的消息删除。
有一些業界方案是在記憶體中維護了一個清單,在擴容或縮容時這部分資料沒法跟着遷移。也有部分業界方案是在長連接配接叢集中維護了一個分布式記憶體存儲,這樣實作起來複雜度也會變高。
我們将未确認消息隊列放到了外部持久化存儲中,保證了單個 Broker 當機後,用戶端重新上線連接配接到其他 Broker 也能恢複 Session 資料,減少了擴容和縮容的負擔。
5.6 滑動視窗
在發送消息時,每條 QoS 1 的消息需要被經過傳輸、用戶端處理、回傳 ACK 才能确認下發完成,路徑耗時較長。如果消息量較大,每條消息都等待這麼長的确認才能下發下一條,下發通道帶寬不能被充分利用。
為了保證發送的效率,我們參考 TCP 的滑動視窗設計了并行發送的機制(詳見:《通俗易懂-深入了解TCP協定(下):RTT、滑動視窗、擁塞處理》)。我們設定一定的門檻值為發送的滑動視窗,表示通道上可以同時有這麼多條消息正在傳輸和被等待确認。
我們應用層設計的滑動視窗跟 TCP 的滑動視窗實際上還有些差異。
TCP 的滑動視窗内的 IP 封包無法保證順序到達,而我們的通訊是基于 TCP 的是以我們的滑動視窗内的業務消息是順序的,隻有在連接配接狀态異常、用戶端邏輯異常等情況下才可能導緻部分視窗内的消息亂序。
因為 TCP 協定保證了消息的接收順序,是以正常的發送過程中不需要針對單條消息進行重試,隻有在用戶端重新連接配接後才對視窗内的未确認消息重新發送。消息的接收端同時會保留視窗大小的緩沖區用來消息去重,保證業務方接收到的消息不會重複。
我們基于 TCP 建構的滑動視窗保證了消息的順序性同時也極大提升傳輸的吞吐量。
6、寫在最後
知乎長連接配接網關由基礎架構組 (Infra) 開發和維護,主要貢獻者是@faceair、@安江澤 。
基礎架構組負責知乎的流量入口和内部基礎設施建設,對外我們奮鬥在直面海量流量的的第一戰線,對内我們為所有的業務提供堅如磐石的基礎設施,使用者的每一次通路、每一個請求、内網的每一次調用都與我們的系統息息相關。