長連線閘道器技術專題(七):小米小愛單機120萬長連線接入層的架構演進

JackJiang發表於2022-03-23

本文由小米技術團隊分享,原題“小愛接入層單機百萬長連線演進”,有修訂。

1、引言

小愛接入層是小愛雲端負責裝置接入的第一個服務,也是最重要的服務之一,本篇文章介紹了小米技術團隊2020至2021年在這個服務上所做的一些優化和嘗試,最終將單機可承載長連線數從30w提升至120w+,節省了機器30+臺。

提示:什麼是“小愛”?

小愛(全名“小愛同學”)是小米旗下的人工智慧語音互動引擎,搭載在小米手機、小米AI音響、小米電視等裝置中,在個人移動、智慧家庭、智慧穿戴、智慧辦公、兒童娛樂、智慧出行、智慧酒店、智慧學習共八大類場景中使用。

(本文同步釋出於:http://www.52im.net/thread-38...

2、專題目錄

本文是專題系列文章的第7篇,總目錄如下:

《長連線閘道器技術專題(一):京東京麥的生產級TCP閘道器技術實踐總結》
《長連線閘道器技術專題(二):知乎千萬級併發的高效能長連線閘道器技術實踐》
《長連線閘道器技術專題(三):手淘億級移動端接入層閘道器的技術演進之路》
《長連線閘道器技術專題(四):愛奇藝WebSocket實時推送閘道器技術實踐》
《長連線閘道器技術專題(五):喜馬拉雅自研億級API閘道器技術實踐》
《長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐》
《長連線閘道器技術專題(七):小米小愛單機120萬長連線接入層的架構演進》(* 本文)

3、什麼是小愛接入層

整個小愛的架構分層如下:

接入層主要的工作在鑑權授權層和傳輸層,它是所有小愛裝置和小愛大腦互動的第一個服務。

由上圖我們知道小愛接入層的重要功能有如下幾個:

1)安全傳輸和鑑權:維護裝置和大腦的安全通道,保障身份認證有效和傳輸資料安全;
2)維護長連線:維持裝置和大腦的長連線(Websocket等),做好連線狀態儲存,心跳維護等工作;
3)請求轉發:針對每一次小愛裝置的請求做好轉發,保障每一次請求的穩定。

4、早期接入層的技術實現

小愛接入層最早的實現是基於Akka和Play,我們使用它們搭建了第一個版本,該版本特點如下:

1)基於Akka我們基本做到了初步的非同步化,保障核心執行緒不被阻塞,效能尚可。
2)Play框架天然支援Websocket,因此我們在有限的人力下能夠快速搭建和實現,且能夠保障協議實現的標準性。

5、早期接入層的技術問題

隨著小愛長連線的數量突破千萬大關,針對早期的接入層方案,我們發現了一些問題。

主要的問題如下:

1)長連線數量上來後,需要維護的記憶體資料越來越多,JVM的GC成為不可忽略的效能瓶頸,且一旦程式碼寫的不好有GC風險。經過之前事故分析,Akka+Play版的接入層其單例項長連線數量的上限在28w左右。

2)老版本的接入層實現比較隨意,其Akka Actor之間存在非常多的狀態依賴而不是基於不可變的訊息傳遞這樣使得Actor之間的通訊變成了函式呼叫,導致程式碼可讀性差且維護很困難,沒有發揮出Akka Actor在構建併發程式的優勢。

3)作為接入層服務,老版本對協議的解析是有很強的依賴的,這導致它要隨著版本變動而頻繁上線,其上線會引起長連線重連,隨時有雪崩的風險。

4)由於依賴Play框架,我們發現其長連線打點有不準確的問題(因為拿不到底層TCP連線的資料),這個會影響我們每日巡檢對服務容量的評估,且依賴其他框架在長連線數量上來後我們沒有辦法做更細緻的優化。

6、新版接入層的設計目標

基於早期接入層技術方案的種種問題,我們打算重構接入層。

對於新版接入層我們制定的目標是:

1)足夠穩定:上線儘可能不斷連線且服務穩定;
2)極致效能:目標單機至少100w長連線,最好不要受GC影響;
3)最大限度可控:除了底層網路I/O的系統呼叫,其他所有程式碼都要是自己實現/或者內部實現的元件,這樣我們有足夠的自主權。

於是,我們開始了單機百萬長連線的漫漫實踐之路。。。

7、新版接入層的優化思路

7.1 接入層的依賴關係
接入層與外部服務的關係理清如下:

7.2 接入層的功能劃分
接入層的主要功能劃分如下:

1)WebSocket解析:收到的客戶端位元組流,要按照WebSocket協議要求解析出資料;
2)Socket狀態保持:儲存連線的基本狀態資訊;
3)加密解密:與客戶端通訊的所有資料都是加密過的,而與後端模組之間傳輸是json明文的;
4)順序化:同一個物理連線上,先後兩個請求A、B到達伺服器,後端服務中B可能先於A得到了應答,但是我們收到B不能立刻傳送給客戶端,必須等待A完成後,再按照A,B的順序發給客戶端;
5)後端訊息分發:接入層後面不止對接單個服務,可能根據不同的訊息轉發給不同的服務;
6)鑑權:安全相關驗證,身份驗證等。

7.3 接入層的拆分思路
把之前的單一模組按照是否有狀態,拆分為兩個子模組。

具體如下:

1)前端:有狀態,功能最小化,儘量少上線;
2)後端:無狀態,功能最大化,上線可做到使用者無感知。

所以,按照上面的原則,理論上我們會做出這樣的功能劃分,即前端很小、後端很大。示意圖如下圖所示。

8、新版接入層的技術實現

8.1 總覽

模組拆分為前後端:

1)前端有狀態,後端無狀態;
2)前後端是獨立程式,同機部署。

補充:前端負責建立與維護裝置長連線的狀態,為有狀態服務;後端負責具體業務請求,為無狀態服務。後端服務上線不會導致裝置連線斷開重連及鑑權呼叫,避免了長連線狀態因版本升級或邏輯調整而引起的不必要抖動;

前端使用CPP實現:

1)Websocket協議完全自己解析:可以從Socket層面獲取所有資訊,任何Bug都可以處理;
2)更高的CPU利用率:沒有任何額外JVM代價,無GC拖累效能;
3)更高的記憶體利用率:連線數量變大後與連線相關的記憶體開銷變大,自己管理可以極端優化。

後端暫時使用Scala實現:

1)已實現的功能直接遷移,比重寫代價要低得多;
2)依賴的部分外部服務(比如鑑權)有可直接利用的Scala(Java)SDK庫,而沒有C++版本,若用C++重寫代價非常大;
3)全部功能無狀態化改造,可以做到隨時重啟而使用者無感知。

通訊使用ZeroMQ:
程式間通訊最高效的方式是共享記憶體,ZeroMQ基於共享記憶體實現,速度沒問題。

8.2 前端實現
整體架構:

如上圖所示,由四個子模組組成:

1)傳輸層:Websocket協議解析,XMD協議解析;
2)分發層:遮蔽傳輸層的差異,不管傳輸層使用的什麼介面,在分發層轉化成統一的事件投遞到狀態機;
3)狀態機層:為了實現純非同步服務,使用自研的基於Actor模型的類Akka狀態機框架XMFSM,這裡面實現了單執行緒的Actor抽象;
4)ZeroMQ通訊層:由於ZeroMQ介面是阻塞實現,這一層通過兩個執行緒分別負責傳送和接收。

8.2.1)傳輸層:

WebSocket 部分使用 C++ 和 ASIO 實現 websocket-lib。小愛長連線基於WebSocket協議,因此我們自己實現了一個WebSocket長連線庫。

這個長連線庫的特點是:

a. 無鎖化設計,保障效能優異;
b. 基於BOOST ASIO 開發,保障底層網路效能。

壓測顯示該庫的效能十分優異的:

這一層同時也承擔了除原始WebSocket外,其他兩種通道的的收發任務。

目前傳輸層一共支援以下3種不同的客戶端介面:

a. websocket(tcp):簡稱ws;
b. 基於ssl的加密websocket(tcp):簡稱wss;
c. xmd(udp):簡稱xmd。

8.2.2)分發層:

把不同的傳輸層事件轉化成統一事件投遞到狀態機,這一層起到介面卡的作用,確保無論前面的傳輸層使用哪種型別,到達分發層變都變成一致的事件向狀態機投遞。

8.2.3)狀態機處理層:

主要的處理邏輯都位於這一層中,這裡非常重要的一個部分是對於傳送通道的封裝。

對於小愛應用層協議,不同的通道處理邏輯是完全一致的,但是在處理和安全相關邏輯上每個通道又有細節差異。

比如:

a. wss 收發不需要加解密,加解密由更前端的Nginx做了,而ws需要使用AES加密傳送;
b. wss 在鑑權成功後不需要向客戶端下發challenge文字,因為wss不需要做加解密;
c. xmd 傳送的內容與其他兩個不同,是基於protobuf封裝的私有協議,且xmd需要處理髮送失敗後的邏輯,而ws/wss不用考慮傳送失敗的問題,由底層Tcp協議保證。

針對這種情況:我們使用C++的多型特性來處理,專門抽象了一個Channel介面,這個介面中提供的方法包含了一個請求處理的一些關鍵差非同步驟,比如如何傳送訊息到客戶端,如何stop連線,如何處理髮送失敗等等。對於3種(ws/wss/xmd)不同的傳送通道,每個通道有自己的Channel實現。

客戶端連線物件一建立,對應型別的具體Channel物件就立刻被例項化。這樣狀態機主邏輯中只實現業務層的公共邏輯即可,當在有差異邏輯呼叫時,直接呼叫Channel介面完成,這樣一個簡單的多型特性幫助我們分割了差異,確保程式碼整潔。

8.2.4)ZeroMQ 通訊層:

通過兩個執行緒將ZeroMQ的讀寫操作非同步化,同時負責若干私有指令的封裝和解析。

8.3 後端實現
8.3.1)無狀態化改造:

後端做的最重要改造之一就是將所有與連線狀態相關的資訊進行剔除。

整個服務以 Request(一次連線上可以傳輸N個Request)為核心進行各種轉發和處理,每次請求與上一次請求沒有任何關聯。一個連線上的多次請求在後端模組被當做獨立請求處理。

8.3.2)架構:

Scala 服務採用 Akka-Actor 架構實現了業務邏輯。

服務從 ZeroMQ 收到訊息後,直接投遞到 Dispatcher 中進行資料解析與請求處理,在 Dispatcher 中不同的請求會傳送給對應的 RequestActor進行 Event 協議解析並分發給該 event 對應的業務 Actor 進行處理。最後將處理後的請求資料通過XmqActor 傳送給後端 AIMS&XMQ 服務。

一個請求在後端多個 Actor 中的處理流程:

8.3.3)Dispatcher 請求分發:

前端與後端之間通過 Protobuf 進行互動,避免了Json 解析的效能消耗,同時使得協議更加規範化。

後端服務從 ZeroMQ 收到訊息後,會在 DispatcherActor 中進行PB協議解析並根據不同的分類(簡稱CMD)進行資料處理,分類包括如下幾種。

  • BIND 命令:

鑑權功能,由於鑑權功能邏輯複雜,使用C++語言實現起來較為困難,目前依然放在 scala 業務層進行鑑權。該部分對裝置端請求的 HTTP Headers 進行解析,提取其中的 token 進行鑑權,並將結果返回前端。

  • LOGIN 命令:

裝置登入,裝置鑑權通過後當前連線已成功建立,此時會進行 Login 命令的執行,用於將該長連線資訊傳送至AIMS並記錄於Varys服務中,方便後續的主動下推等功能。在 Login 過程中,服務首先將請求 Account 服務獲取長連線的 uuid(用於連線過程中的路由定址),然後將裝置資訊+uuid 傳送至AIMS進行裝置登入操作。

  • LOGOUT 命令:

裝置登出,裝置在與服務端斷開連線時需要進行 Logout 操作,用於從 Varys 服務中刪除該長連線記錄。

  • UPDATE 與 PING 命令:

a. Update 命令,裝置狀態資訊更新,用於更新該裝置在資料庫中儲存的相關資訊;
b. Ping 命令,連線保活,用於確認該裝置處於線上連線狀態。

  • TEXT_MESSAGE 與 BINARY_MESSAGE:

文字訊息與二進位制訊息,在收到文字訊息或二進位制訊息時將根據 requestid 傳送給該請求對應的RequestActor進行處理。

8.3.4)Request 請求解析:

針對收到的文字和二進位制訊息,DispatcherActor 會根據 requestId 將其傳送給對應的RequestActor進行處理。

其中:文字訊息將會被解析為Event請求,並根據其中的 namespace 和 name 將其分發給指定的業務Actor。二進位制訊息則會根據當前請求的業務場景被分發給對應的業務Actor。

8.4 其他優化
在完成新架構 1.0 調整過程中,我們也在不斷壓測長連線容量,總結幾點對容量影響較大的點。

8.4.1)協議優化:

a. JSON替換為Protobuf: 早期的前後端通訊使用的是 json 文字協議,後來發現 json 序列化、反序列化這部分對CPU的佔用較大,改為了 protobuf 協議後,CPU佔用率明顯下降。

b. JSON支援部分解析:業務層的協議是基於json的,沒有辦法直接替換,我們通過"部分解析json"的方式,只解析很小的 header 部分拿到 namespace 和 name,然後將大部分直接轉發的訊息轉發出去,只將少量 json 訊息進行完整反序列化成物件。此種優化後CPU佔用下降10%。

8.4.2)延長心跳時間:

在第一次測試20w連線時,我們發現在前後端收發的訊息中,一種用來保持使用者線上狀態的心跳PING訊息佔了總訊息量的75%,收發這個訊息耗費了大量CPU。因此我們延長心跳時間也起到了降低CPU消耗的目的。

8.4.3)自研內網通訊庫:

為了提高與後端服務通訊的效能,我們使用自研的TCP通訊庫,該庫是基於Boost ASIO開發的一個純非同步的多執行緒TCP網路庫,其卓越的效能幫助我們將連線數提升到120w+。

9、未來規劃
經過新版架構1.0版的優化,驗證了我們的拆分方向是正確的,因為預設的目標已經達到:

1)單機承載的連線數 28w => 120w+(普通服務端機器 16G記憶體 40核 峰值請求QPS過萬),接入層下線節省了50%+的機器成本;
2)後端可以做到無損上線。

再重新審視下我們的理想目標,以這個為方向,我們就有了2.0版的雛形:

具體就是:

1)後端模組使用C++重寫,進一步提高效能和穩定性。同時將後端模組中無法使用C++重寫的部分,作為獨立服務模組運維,後端模組通過網路庫呼叫;
2)前端模組中非必要功能嘗試遷移到後端,讓前端功能更少,更穩定;
3)如果改造後,前端與後端處理能力差異較大,考慮到ZeroMQ實際是效能過剩的,可以考慮使用網路庫替換掉ZeroMQ,這樣前後端可以從1:1單機部署變為1:N多機部署,更好的利用機器資源。

2.0版目標是:經過以上改造後,期望單前端模組可以達到200w+的連線處理能力。

10、參考資料

[1] 上一個10年,著名的C10K併發連線問題
[2] 下一個10年,是時候考慮C10M併發問題了
[3] 一文讀懂高效能網路程式設計中的執行緒模型
[4] 深入作業系統,一文讀懂程式、執行緒、協程
[5] Protobuf通訊協議詳解:程式碼演示、詳細原理介紹等
[6] WebSocket從入門到精通,半小時就夠!
[7] 如何讓你的WebSocket斷網重連更快速?
[8] 從100到1000萬高併發的架構演進之路

學習交流:

(本文同步釋出於:http://www.52im.net/thread-38...

相關文章