實(shí)時的響應(yīng)總是讓人興奮的特別是物聯(lián)網(wǎng)應(yīng)用場景,就如你在火災(zāi)發(fā)生時你立刻就要知道時間地點(diǎn)火場狀態(tài),它們的背后都離不開長連接技術(shù)的加持。每個物聯(lián)網(wǎng)網(wǎng)公司里幾乎都有一套長連接系統(tǒng),它們被應(yīng)用在消息提醒、即時通訊、推送、數(shù)據(jù)更新、共享定位等場景。而當(dāng)公司發(fā)展到一定規(guī)模,業(yè)務(wù)場景變得更復(fù)雜后,更有可能是多個業(yè)務(wù)都需要同時使用長連接系統(tǒng)。業(yè)務(wù)間分開設(shè)計長連接會導(dǎo)致研發(fā)和維護(hù)成本陡增、浪費(fèi)基礎(chǔ)設(shè)施、增加客戶端耗電、無法復(fù)用已有經(jīng)驗(yàn)等等問題。共享長連接系統(tǒng)又需要協(xié)調(diào)好不同系統(tǒng)間的認(rèn)證、鑒權(quán)、數(shù)據(jù)隔離、協(xié)議拓展、消息送達(dá)保證等等需求,迭代過程中協(xié)議需要向前兼容,同時因?yàn)椴煌瑯I(yè)務(wù)的長連接匯聚到一個系統(tǒng)導(dǎo)致容量管理的難度也會增大。經(jīng)過了一年的開發(fā)和演進(jìn),我們服務(wù)面向內(nèi)和外的數(shù)個 App、接入十幾個需求和形態(tài)各異的長連接業(yè)務(wù)、數(shù)百萬設(shè)備同時在線、突發(fā)大規(guī)模消息發(fā)送等等場景的錘煉,我們提煉出一個長連接系統(tǒng)網(wǎng)關(guān)的通用解決方案,解決了多業(yè)務(wù)共用長連接時遇到的種種問題、同時支持短鏈接。騰軟長短連接網(wǎng)關(guān)致力于業(yè)務(wù)數(shù)據(jù)解耦、消息高效分發(fā)、解決容量問題,同時提供一定程度的消息可靠性保證。
我們怎么設(shè)計通訊協(xié)議?
業(yè)務(wù)解耦
支撐多業(yè)務(wù)的長連接網(wǎng)關(guān)實(shí)際上是同時對接多客戶端和多業(yè)務(wù)后端的,是多對多的關(guān)系,他們之間只使用一條長連接通訊。
這種多對多的系統(tǒng)在設(shè)計時要避免強(qiáng)耦合。業(yè)務(wù)方邏輯也是會動態(tài)調(diào)整的,如果將業(yè)務(wù)的協(xié)議和邏輯與網(wǎng)關(guān)實(shí)現(xiàn)耦合會導(dǎo)致所有的業(yè)務(wù)都會互相牽連,協(xié)議升級和維護(hù)都會異常困難。所以我們嘗試使用經(jīng)典的發(fā)布訂閱模型來解耦長連接網(wǎng)關(guān)跟客戶端與業(yè)務(wù)后端,它們之間只需要約定 Topic 即可自由互相發(fā)布訂閱消息。傳輸?shù)南⑹羌兌M(jìn)制數(shù)據(jù),網(wǎng)關(guān)也無需關(guān)心業(yè)務(wù)方的具體協(xié)議規(guī)范和序列化方式。
權(quán)限控制
我們使用發(fā)布訂閱解耦了網(wǎng)關(guān)與業(yè)務(wù)方的實(shí)現(xiàn),我們?nèi)匀恍枰刂瓶蛻舳藢opic 的發(fā)布訂閱的權(quán)限,避免有意或無意的數(shù)據(jù)污染或越權(quán)訪問。假如消防安全系統(tǒng)正在騰軟的999頻道使用,當(dāng)客戶端嘗試訂閱999頻道的Topic時就需要后端判斷當(dāng)前用戶是否已經(jīng)綁定設(shè)備。這種情況下的權(quán)限實(shí)際上是很靈活的,當(dāng)用戶綁定以后就能訂閱,否則就不能訂閱。權(quán)限的狀態(tài)只有騰軟消防業(yè)務(wù)后端知曉,網(wǎng)關(guān)無法獨(dú)立作出判斷。
所以我們在 ACL 規(guī)則中設(shè)計了基于回調(diào)的鑒權(quán)機(jī)制,可以配置消防相關(guān) Topic 的訂閱和發(fā)布動作都通過 HTTP 回調(diào)給消防的后端服務(wù)判斷。
同時根據(jù)我們對內(nèi)部業(yè)務(wù)的觀察,大部分場景下業(yè)務(wù)需要的只是一個當(dāng)前用戶的私有 Topic 用來接收服務(wù)端下發(fā)的通知或消息,這種情況下如果讓業(yè)務(wù)都設(shè)計回調(diào)接口來判斷權(quán)限會很繁瑣。所以我們在 ACL 規(guī)則中設(shè)計了 Topic 模板變量來降低業(yè)務(wù)方的接入成本,我們給業(yè)務(wù)方配置允許訂閱的 Topic 中包含連接的用戶名變量標(biāo)識,表示只允許用戶訂閱或發(fā)送消息到自己的 Topic。
此時網(wǎng)關(guān)可以在不跟業(yè)務(wù)方通信的情況下,獨(dú)立快速判斷客戶端是否有權(quán)限訂閱或往 Topic 發(fā)送消息。
消息可靠性保證
網(wǎng)關(guān)作為消息傳輸?shù)臉屑~,同時對接業(yè)務(wù)后端和客戶端,在轉(zhuǎn)發(fā)消息時需要保證消息在傳輸過程的可靠性。TCP 只能保證了傳輸過程中的順序和可靠性,但遇到 TCP 狀態(tài)異常、客戶端接收邏輯異常或發(fā)生了 Crash 等等情況時,傳輸中的消息就會發(fā)生丟失。為了保證下發(fā)或上行的消息被對端正常處理,我們實(shí)現(xiàn)了回執(zhí)和重傳的功能。重要業(yè)務(wù)的消息在客戶端收到并正確處理后需要發(fā)送回執(zhí),而網(wǎng)關(guān)內(nèi)暫時保存客戶端未收取的消息,網(wǎng)關(guān)會判斷客戶端的接收情況并嘗試再次發(fā)送,直到正確收到了客戶端的消息回執(zhí)。
而面對服務(wù)端業(yè)務(wù)的大流量場景,服務(wù)端發(fā)給網(wǎng)關(guān)的每條消息都發(fā)送回執(zhí)的方式效率較低,我們也提供了基于消息隊列的接收和發(fā)送方式,后面介紹發(fā)布訂閱實(shí)現(xiàn)時再詳細(xì)闡述。在設(shè)計通訊協(xié)議時我們參考了 MQTT 規(guī)范,拓展了認(rèn)證和鑒權(quán)設(shè)計,完成了業(yè)務(wù)消息的隔離與解耦,保證了一定程度的傳輸可靠性。同時保持了與 MQTT 協(xié)議一定程度上兼容,這樣便于我們直接使用 MQTT 的各端客戶端實(shí)現(xiàn),降低業(yè)務(wù)方接入成本。
我們怎么設(shè)計系統(tǒng)架構(gòu)?
在設(shè)計項(xiàng)目整體架構(gòu)時,我們優(yōu)先考慮的是:
? 可靠性
? 水平擴(kuò)展能力
? 依賴組件成熟度
簡單才值得信賴。為了保證可靠性,我們沒有考慮像傳統(tǒng)長連接系統(tǒng)那樣將內(nèi)部數(shù)據(jù)存儲、計算、消息路由等等組件全部集中到一個大的分布式系統(tǒng)中維護(hù),這樣增大系統(tǒng)實(shí)現(xiàn)和維護(hù)的復(fù)雜度。我們嘗試將這幾部分的組件獨(dú)立出來,將存儲、消息路由交給專業(yè)的系統(tǒng)完成,讓每個組件的功能盡量單一且清晰。同時我們也需要快速地水平擴(kuò)展能力。物聯(lián)網(wǎng)場景下各種營銷活動都可能導(dǎo)致連接數(shù)陡增,同時發(fā)布訂閱模型系統(tǒng)中下發(fā)消息數(shù)會隨著 Topic 的訂閱者的個數(shù)線性增長,此時網(wǎng)關(guān)暫存的客戶端未接收消息的存儲壓力也倍增。將各個組件拆開后減少了進(jìn)程內(nèi)部狀態(tài),我們就可以將服務(wù)部署到容器中,利用容器來完成快速而且?guī)缀鯚o限制的水平擴(kuò)展。最終設(shè)計的系統(tǒng)架構(gòu)如下圖:
系統(tǒng)主要由四個主要組件組成:
1 騰軟TSRPC接入層負(fù)責(zé)連接負(fù)載均衡和會話保持
2 長連接Broker,部署在容器中,負(fù)責(zé)協(xié)議解析、認(rèn)證與鑒權(quán)、會話、發(fā)布訂閱等邏輯
3 Redis 存儲,持久化會話數(shù)據(jù)
4 騰軟消息隊列(TSQueue),分發(fā)消息給 Broker 或業(yè)務(wù)方其中 TSQueue和 Redis 都是業(yè)界廣泛使用的基礎(chǔ)組件,它們在騰軟都已平臺化和容器化它們也都能完成分鐘級快速擴(kuò)容。
我們?nèi)绾螛?gòu)建長連接網(wǎng)關(guān)?
接入層
TSRPC是使用非常廣泛靈活性、穩(wěn)定性和性能都非常優(yōu)異,目前騰軟所有平臺都在使用,支撐上百萬用戶的生產(chǎn)環(huán)境。
接入層是最靠近用戶的一側(cè),在這一層需要完成兩件事:
1 負(fù)載均衡,保證各長連接 Broker 實(shí)例上連接數(shù)相對均衡
2 會話保持,單個客戶端每次連接到同一個 Broker,用來提供消息傳輸可靠性保證負(fù)載均衡其實(shí)有很多算法都能完成,不管是隨機(jī)還是各種 Hash 算法都能比較好地實(shí)現(xiàn),麻煩一些的是會話保持。常見的四層負(fù)載均衡策略是根據(jù)連接來源 IP 進(jìn)行一致性 Hash,在節(jié)點(diǎn)數(shù)不變的情況下這樣能保證每次都 Hash 到同一個 Broker 中,甚至在節(jié)點(diǎn)數(shù)稍微改變時也能大概率找到之前連接的節(jié)點(diǎn)。
之前我們也使用過來源 IP Hash 的策略,主要有兩個缺點(diǎn):
1 分布不夠均勻,部分來源 IP 是大型局域網(wǎng) NAT 出口,上面的連接數(shù)多,導(dǎo)致 Broker 上連接數(shù)不均衡
2 不能準(zhǔn)確標(biāo)識客戶端,當(dāng)移動客戶端掉線切換網(wǎng)絡(luò)就可能無法連接回剛才的 Broker 了所以我們考慮七層的負(fù)載均衡,根據(jù)客戶端的唯一標(biāo)識來進(jìn)行一致性 Hash,這樣隨機(jī)性更好,同時也能保證在網(wǎng)絡(luò)切換后也能正確路由。常規(guī)的方法是需要完整解析通訊協(xié)議,然后按協(xié)議的包進(jìn)行轉(zhuǎn)發(fā),這樣實(shí)現(xiàn)的成本很高,而且增加了協(xié)議解析出錯的風(fēng)險。
發(fā)布與訂閱
我們引入了騰軟廣泛使用的消息隊列 TSQueue來作為內(nèi)部消息傳輸?shù)臉屑~,前面提到了一些這么使用的原因:
1 減少長連接 Broker 內(nèi)部狀態(tài),讓 Broker 可以無壓力擴(kuò)容
2 騰軟內(nèi)部已平臺化,支持水平擴(kuò)展
還有一些原因是:
1 使用消息隊列削峰,避免突發(fā)性的上行或下行消息壓垮系統(tǒng)
2 業(yè)務(wù)系統(tǒng)中大量使用 TSQueue傳輸數(shù)據(jù),降低與業(yè)務(wù)方對接成本其中利用消息隊列削峰好理解,下面我們看一下怎么利用 TSQueue與業(yè)務(wù)方更好地完成對接。
發(fā)布
連接 Broker 會根據(jù)路由配置將消息發(fā)布到 TSQueue Topic,同時也會根據(jù)訂閱配置去消費(fèi) TSQueue將消息下發(fā)給訂閱客戶端。
路由規(guī)則和訂閱規(guī)則是分別配置的,那么可能會出現(xiàn)四種情況:
一、消息路由到 TSQueue Topic,但不消費(fèi),適合數(shù)據(jù)上報的場景。
二、消息路由到 TSQueue Topic,也被消費(fèi),普通的即時通訊場景。
三、直接從 TSQueue Topic 消費(fèi)并下發(fā),用于純下發(fā)消息的場景。
四、消息路由到一個 Topic,然后從另一個 Topic 消費(fèi),用于消息需要過濾或者預(yù)處理的場景。
這套路由策略的設(shè)計靈活性非常高,可以解決幾乎所有的場景的消息路由需求。同時因?yàn)榘l(fā)布訂閱基于 TSQueue,可以保證在處理大規(guī)模數(shù)據(jù)時的消息可靠性。
訂閱
當(dāng)長連接 Broker 從 TSQueue Topic 中消費(fèi)出消息后會查找本地的訂閱關(guān)系,然后將消息分發(fā)到客戶端會話。我們最開始直接使用 HashMap 存儲客戶端的訂閱關(guān)系。當(dāng)客戶端訂閱一個 Topic 時我們就將客戶端的會話對象放入以 Topic 為 Key 的訂閱 Map 中,當(dāng)反查消息的訂閱關(guān)系時直接用 Topic 從 Map 上取值就行。因?yàn)檫@個訂閱關(guān)系是共享對象,當(dāng)訂閱和取消訂閱發(fā)生時就會有連接嘗試操作這個共享對象。為了避免并發(fā)寫我們給 HashMap 加了鎖,但這個全局鎖的沖突非常嚴(yán)重,嚴(yán)重影響性能。最終我們通過分片細(xì)化了鎖的粒度,分散了鎖的沖突。本地同時創(chuàng)建數(shù)百個 HashMap,當(dāng)需要在某個 Key 上存取數(shù)據(jù)前通過 Hash 和取模找到其中一個 HashMap 然后進(jìn)行操作,這樣將全局鎖分散到了數(shù)百個 HashMap 中,大大降低了操作沖突,也提升了整體的性能。
會話持久化
當(dāng)消息被分發(fā)給會話 Session 對象后,由 Session 來控制消息的下發(fā)。
Session 會判斷消息是否是重要 Topic 消息, 是的話將消息標(biāo)記 QoS 等級為 1,同時將消息存儲到 Redis 的未接收消息隊列,并將消息下發(fā)給客戶端。等到客戶端對消息的 ACK 后,再將未確認(rèn)隊列中的消息刪除。有一些業(yè)界方案是在內(nèi)存中維護(hù)了一個列表,在擴(kuò)容或縮容時這部分?jǐn)?shù)據(jù)沒法跟著遷移。也有部分業(yè)界方案是在長連接集群中維護(hù)了一個分布式內(nèi)存存儲,這樣實(shí)現(xiàn)起來復(fù)雜度也會變高。我們將未確認(rèn)消息隊列放到了外部持久化存儲中,保證了單個 Broker 宕機(jī)后,客戶端重新上線連接到其他 Broker 也能恢復(fù) Session 數(shù)據(jù),減少了擴(kuò)容和縮容的負(fù)擔(dān)。
滑動窗口
在發(fā)送消息時,每條 QoS 1 的消息需要被經(jīng)過傳輸、客戶端處理、回傳 ACK 才能確認(rèn)下發(fā)完成,路徑耗時較長。如果消息量較大,每條消息都等待這么長的確認(rèn)才能下發(fā)下一條,下發(fā)通道帶寬不能被充分利用。為了保證發(fā)送的效率,我們參考 TCP 的滑動窗口設(shè)計了并行發(fā)送的機(jī)制。我們設(shè)置一定的閾值為發(fā)送的滑動窗口,表示通道上可以同時有這么多條消息正在傳輸和被等待確認(rèn)。
我們應(yīng)用層設(shè)計的滑動窗口跟 TCP 的滑動窗口實(shí)際上還有些差異。TCP 的滑動窗口內(nèi)的 IP 報文無法保證順序到達(dá),而我們的通訊是基于 TCP 的所以我們的滑動窗口內(nèi)的業(yè)務(wù)消息是順序的,只有在連接狀態(tài)異常、客戶端邏輯異常等情況下才可能導(dǎo)致部分窗口內(nèi)的消息亂序。因?yàn)?TCP 協(xié)議保證了消息的接收順序,所以正常的發(fā)送過程中不需要針對單條消息進(jìn)行重試,只有在客戶端重新連接后才對窗口內(nèi)的未確認(rèn)消息重新發(fā)送。消息的接收端同時會保留窗口大小的緩沖區(qū)用來消息去重,保證業(yè)務(wù)方接收到的消息不會重復(fù)。我們基于 TCP 構(gòu)建的滑動窗口保證了消息的順序性同時也極大提升傳輸?shù)耐掏铝俊?/p>