探探的IM長連線技術實踐:技術選型、架構設計、效能優化

JackJiang發表於2021-12-16

本文由探探服務端高階技術專家張凱巨集分享,原題“探探長連結專案的Go語言實踐”,因原文內容有較多錯誤,有修訂和改動。

1、引言

即時通訊長連線服務處於網路接入層,這個領域非常適合用Go語言發揮其多協程並行、非同步IO的特點。

探探自長連線專案上線以後,對服務進行了多次優化:GC從5ms降到100微秒(Go版本均為1.9以上),主要gRPC介面呼叫延時p999從300ms下降到5ms。在業內大多把目光聚焦於單機連線數的時候,我們則更聚焦於服務的SLA(服務可用性)。

本文將要分享的是陌生人社交應用探探的IM長連線模組從技術選型到架構設計,再到效能優化的整個技術實踐過程和經驗總結。

學習交流:

  • 即時通訊/推送技術開發交流5群:215477170 [推薦]
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架原始碼:https://github.com/JackJiang2...

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

2、關於作者

張凱巨集:擔任探探服務端高階技術專家。

6年Go語言開發經驗,曾用Go語言構建多個大型Web專案,其中涉及網路庫、儲存服務、長連線服務等。專注於Go語言實踐、儲存服務研發及大資料場景下的Go語言深度優化。

3、專案緣起

我們這個專案是2018年下半年開始,據今天大概1年半時間。

當時探探遇到一些技術痛點,最嚴重的就是嚴重依賴第三方Push,比如說第三方有一些故障的話,對實時IM聊天的KPS有比較大的影響。

當時通過push推送訊息,應用內的push延時比較高,平均延時五六百毫秒,這個時間我們不能接受。

而且也沒有一個 Ping Pland 機制(心跳檢查機制?),無法知道使用者是否線上。

當時產品和技術同學都覺得是機會搞一個長連線了。

4、一個小插曲

專案大概持續了一個季度時間,首先是拿IM業務落地,我們覺得長連線跟IM繫結比較緊密一些。

IM落地之後,後續長連線上線之後,各個業務比較依賴於長連線服務。

這中間有一個小插曲,主要是取名字那一塊。

專案之初給專案起名字叫Socket,看到socket比較親切,覺得它就是一個長連線,這個感覺比較莫名,不知道為什麼。但運維提出了異議,覺得UDP也是Socket,我覺得UDP其實也可以做長連線。

運維提議叫Keepcom,這個是出自於Keep Alive實現的,這個提議還是挺不錯的,最後我們也是用了這個名字。

客戶端給的建議是Longlink,另外一個是Longconn,一個是IOS端技術同事取的、一個是安卓端技術同事取的。

最後我們都敗了,運維同學勝了,運維同學覺得,如果名字定不下來就別上線的,最後我們妥協了。

5、為什麼要做長連線?

為什麼做長連線?

如上圖所示:看一下對比挺明顯,左邊是長連線,右邊是短長連線。

對於長連線來說,不需要重新進入連線,或者是釋放連線,一個X包只需要一個RTT就完事。右邊對於一個短連線需要三次握手傳送一個push包,最後做揮手。

結論:如果傳送N條訊息的資料包,對於長連線是2+N次的RTT,對於短連線是3N次RTT,最後開啟Keep Alive,N是連線的個數。

6、長連線技術優勢

我們決結了一下,長連線有以下四大優勢:

1)實時性:長連線是雙向的通道,對訊息的推送也是比較實時;
2)有狀態:長連線本身維護使用者的狀態,通過KeepAlive方式,確定使用者是否線上;
3)省流程:長連線比較省流量,可以做一些使用者自定義的資料壓縮,本身也可以省不少的歸屬包和連線包,所以說比較省流量;
4)更省電:減少網路流量之後,能夠進一步降低移動客戶端的耗電。

7、TCP在移動端能勝任嗎?

在專案開始之前,我們做了比較多的考量。

首先我們看一下對於移動端的長連線來說,TCP協議是不是能夠Work?

對於傳統的長連線來說,Web端的長連線TCP可以勝任,在移動端來說TCP能否勝任?這取決於TCP的幾個特性。

首先TCP有慢啟動和滑動視窗的特性,TCP通過這種方式控制PU包,避免網路阻塞。

TCP連線之後走一個慢啟動流程,這個流程從初始窗大小做2個N次方的擴張,最後到一定的域值,比如域值是16包,從16包開始逐步往上遞增,最後到24個資料包,這樣達到視窗最大值。

一旦遇到丟包的情況,當然兩種情況。一種是快速重傳,視窗簡單了,相當於是12個包的視窗。如果啟動一個RTO類似於狀態連線,視窗一下跌到初始的視窗大小。

如果啟動RTO重傳的話,對於後續包的阻塞蠻嚴重,一個包阻塞其他包的傳送。


(▲ 上圖引用自《邁向高階:優秀Android程式設計師必知必會的網路基礎》)

有關TCP協議的基礎知識,可以讀讀以下資料:

《TCP/IP詳解 - 第17章·TCP:傳輸控制協議》
《TCP/IP詳解 - 第18章·TCP連線的建立與終止》
《TCP/IP詳解 - 第21章·TCP的超時與重傳》
《通俗易懂-深入理解TCP協議(上):理論基礎》
《通俗易懂-深入理解TCP協議(下):RTT、滑動視窗、擁塞處理》
《網路程式設計懶人入門(一):快速理解網路通訊協議(上篇)》
《網路程式設計懶人入門(二):快速理解網路通訊協議(下篇)》
《網路程式設計懶人入門(三):快速理解TCP協議一篇就夠》
《腦殘式網路程式設計入門(一):跟著動畫來學TCP三次握手和四次揮手》
《網路程式設計入門從未如此簡單(二):假如你來設計TCP協議,會怎麼做?》

8、TCP還是UDP?

(▲ 上圖引用自《移動端IM/推送系統的協議選型:UDP還是TCP?》)

TCP實現長連線的四個問題:

1)移動端的訊息量還是比較稀疏,使用者每次拿到手機之後,發的訊息總數比較少,每條訊息的間隔比較長。這種情況下TCP的間連和保持長連結的優勢比較明顯一些;
2)弱網條件下丟包率比較高,丟包後Block後續資料傳送容易阻塞;
3)TCP連線超時時間過長,預設1秒鐘,這個由於TCP誕生的年代比較早,那會兒網路狀態沒有現在好,當時定是1s的超時,現在可以設的更短一點;
4)在沒有快速重傳的情況下,RTO重傳等待時間較長,預設15分鐘,每次是N次方的遞減。

為何最終還是選擇TCP呢?因為我們覺得UDP更嚴重一點。

首先UDP沒有滑動視窗,無流量控制,也沒有慢啟動的過程,很容易導致丟包,也很容易導致在網路中間狀態下丟包和超時。

UDP一旦丟包之後沒有重傳機制的,所以我們需要在應用層去實現一個重傳機制,這個開發量不是那麼大,但是我覺得因為比較偏底層,容易出故障,所以最終選擇了TCP。

TCP還是UDP?這一直是個比較有爭議的話題:

《網路程式設計懶人入門(四):快速理解TCP和UDP的差異》
《網路程式設計懶人入門(五):快速理解為什麼說UDP有時比TCP更有優勢》
《5G時代已經到來,TCP/IP老矣,尚能飯否?》
《Android程式設計師必知必會的網路通訊傳輸層協議——UDP和TCP》
《不為人知的網路程式設計(六):深入地理解UDP協議並用好它》
《不為人知的網路程式設計(七):如何讓不可靠的UDP變的可靠?》

如果你對UDP協議還不瞭解,可以讀讀這篇:《TCP/IP詳解 - 第11章·UDP:使用者資料包協議》。

9、選擇TCP的更多理由

我們羅列一下,主要有這3點:

1)目前在移動端、安卓、IOS來說,初始視窗大小比較大預設是10,綜合TCP慢啟動的劣勢來看;
2)在普通的文字傳輸情況下,對於丟包的嚴重不是很敏感(並不是說傳多媒體的資料流,只是傳一些文字資料,這一塊對於丟包的副作用TCP不是特別嚴重);
3)我們覺得TCP在應用層用的比較多。

關於第“3)”點,這裡有以下三個考量點。

第一個考量點:

基本現在應用程式走HTP協議或者是push方式基本都是TCP,我們覺得TCP一般不會出大的問題。

一旦拋棄TCP用UDP或者是QUIC協議的話,保不齊會出現比較大的問題,短時間解決不了,所以最終用了TCP。

第二個考量點:

我們的服務在基礎層上用哪種方式做LB,當時有兩種選擇,一種是傳統的LVS,另一種是HttpDNS(關於HttpDNS請見《全面瞭解移動端DNS域名劫持等雜症:原理、根源、HttpDNS解決方案等》)。

最後我們選擇了HttpDNS,首先我們還是需要跨機房的LB支援,這一點HttpDNS完全勝出。其次,如果需要跨網端的話,LVS做不到,需要其他的部署方式。再者,在擴容方面,LVS算是略勝一籌。最後,對於一般的LB演算法,LVS支援並不好,需要根據使用者ID的LB演算法,另外需要一致性雜湊的LB演算法,還需要根據地理位置的定位資訊,在這些方面HttpDNS都能夠完美的勝出,但是LVS都做不到。

第三個考量點:

我們在做TCP的飽和機制時通過什麼樣的方式?Ping包的方式,間隔時間怎麼確定,Ping包的時間細節怎麼樣確定?

當時比較糾結是客戶端主動發ping還是服務端主動發Ping?

對於客戶端保活的機制支援更好一些,因為客戶端可能會被喚醒,但是客戶端進入後臺之後可能發不了包。

其次:APP前後臺對於不同的Ping包間隔來保活,因為在後臺本身處於一種弱線上的狀態,並不需要去頻繁的發Ping包確定線上狀態。

所以:在後臺的Ping包的時間間隔可以長一些,前端可以短一些。

再者:需要Ping指數增長的間隔支援,在故障的時候還是比較救命的。

比如說:服務端一旦故障之後,客戶端如果拼命Ping的話,可能把服務端徹底搞癱瘓了。如果有一個指數級增長的Ping包間隔,基本服務端還能緩一緩,這個在故障時比較重要。

最後:Ping包重試是否需要Backoff,Ping包重新發Ping,如果沒有收到Bang包的話,需要等到Backoff發Ping。

10、動態Ping包時間間隔演算法

PS:在IM裡這其實有個更專業的叫法——“智慧心跳演算法”。

我們還設計了一個動態的Ping包時間間隔演算法。

因為國內的網路運營商對於NIT裝置有一個保活機制,目前基本在5分鐘以上,5分鐘如果不發包的話,會把你的快取給刪掉。基本上各運營商都在5分鐘以上,只不過移動4G阻礙了。基本可以在4到10分鐘之內發一個Ping包就行,可以維持網路運營商裝置裡的快取,一直保持著,這樣就沒有問題,使長連線一直保活著。

增加Ping包間隔可以減少網路流量,能夠進一步降低客戶端的耗電,這一塊的受益還是比較大的。

在低端安卓裝置的情況下,有一些DHCP租期的問題。這個問題集中在安卓端的低版本上,安卓不會去續租過期的IP。

解決問題也比較簡單,在DHCP租期到一半的時候,去及時向DHCP伺服器續租一下就能解決了。

限於篇幅,我就不在這裡展開了,有興趣可以讀這些資料:

《為何基於TCP協議的移動端IM仍然需要心跳保活機制?》
《一文讀懂即時通訊應用中的網路心跳包機制:作用、原理、實現思路等》
《微信團隊原創分享:Android版微信後臺保活實戰分享(網路保活篇)》
《移動端IM實踐:實現Android版微信的智慧心跳機制》
《移動端IM實踐:WhatsApp、Line、微信的心跳策略分析》
《一種Android端IM智慧心跳演算法的設計與實現探討(含樣例程式碼)》
《手把手教你用Netty實現網路通訊程式的心跳機制、斷線重連機制》

11、服務架構

11.1 基本介紹
服務架構比較簡單,大概是四個模組:

1)首先是HttpDNS;
2)另一個是Connector接入層,接入層提供IP,
3)然後是Router,類似於代理轉發訊息,根據IP選擇接入層的伺服器,最後推到使用者;
4)最後還有認證的模組Account,我們目前只是探探APP,這個在使用者中心實現。

11.2 部署
部署上相當於三個模組:

1)一個是Dispatcher;
2)一個是Redis;
3)一個是Cluser。

如下圖所示:客戶端在連線的時候:

1)需要拿到一個協議;
2)第二步通過HttpDNS拿到ConnectorIP;
3)通過IP連長連線,下一步傳送Auth訊息認證;
4)連線成功,後面傳送Ping包保活;
5)之後斷開連線。

11.3 訊息轉發流程
訊息轉發的流程分為兩個部分。

首先是訊息上行:服務端發起一個訊息包,通過Connector接入服務,客戶端通過Connector傳送訊息,再通過Connector把訊息發到微服務上,如果不需要微服務的話直接去轉發到Vetor就行的,這種情況下Connector更像一個Gateway。

對於下行:業務方都需要請求Router,找到具體的Connector,根據Connector部署訊息。

各個公司都是微服務的架構,長連線跟微服務的互動基本兩塊。一塊是訊息上行時,更像是Gateway,下行通過Router接入,通過Connector傳送訊息。

11.4 一些實現細節
下面是一些是細節,我們用了GO語言1.13.4,內部訊息傳輸上是gRPC,傳輸協議是Http2,我們在內部通過ETCD做LB的方式,提供服務註冊和發現的服務。

如下圖所示:Connector就是狀態,它從使用者ID到連線的一個狀態資訊。

我們看下圖的右邊:它其實是存在一個比較大的MAP,為了防止MAP的鎖競爭過於嚴重,把MAP拆到2到56個子MAP,通過這種方式去實現高讀寫的MAP。對於每一個MAP從一個ID到連線狀態的對映關係,每一個連線是一個Go Ping,實現細節讀寫是4KB,這個沒改過。

我們看一下Router:它是一個無狀態的CommonGRPC服務,它比較容易擴容,現在狀態資訊都存在Redis裡面,Redis大概一組一層,目前峰值是3000。

我們有兩個狀態:一個是Connector,一個是Router。

首先以Connector狀態為主,Router是狀態一致的保證。

這個裡面分為兩種情況:如果連線在同一個Connector上的話,Connector需要保證向Router複製的順序是正確的,如果順序不一致,會導致Router和Connector狀態不一致。通過統一Connector的視窗實現訊息一致性,如果跨Connector的話,通過在Redis Lua指令碼實現Compare And Update方式,去保證只有自己Connector寫的狀態才能被自己更新,如果是別的Connector的話,更新不了其他人的信心。我們保證跨Connector和同一Connector都能夠去按照順序通過一致的方式更新Router裡面連線的狀態。

Dispatche比較簡單:是一個純粹的Common Http API服務,它提供Http API,目前延時比較低大概20微秒,4個CPU就可以支撐10萬個併發。

目前通過無單點的結構實現一個高可用:首先是Http DNS和Router,這兩個是無障礙的服務,只需要通過LB保證。對於Connector來說,通過Http DNS的客戶端主動漂移實現連線層的Ordfrev,通過這種方式保證一旦一個Connector出問題了,客戶端可以立馬漂到下一個Connector,去實現自動的工作轉移,目前是沒有單點的。

12、效能優化

12.1 基本情況
後續有優化主要有以下幾個方面:

1)網路優化:這一塊拉著客戶端一起做,首先客戶端需要重傳包的時候發三個嗅探包,通過這種方式做一個快速重傳的機制,通過這種機制提高快速重傳的比例;
2)心跳優化:通過動態的Ping包間隔時間,減少Ping包的數量,這個還在開發中;
3)防止劫持:是通過客戶端使用IP直連方式,迴避域名劫持的操作;
4)DNS優化:是通過HttpDNS每次返回多個IP的方式,來請求客戶端的HttpDNS。
12.2 網路優化
對於接入層來說,其實Connector的連線數比較多,並且Connector的負載也是比較高。

我們對於Connector做了比較大的優化,首先看Connector最早的GC時間到了4、5毫秒,慘不忍睹的。

我們看一下下面這張圖(圖上)是優化後的結果,大概平均100微秒,這算是比較好。第二張圖(圖下)是第二次優化的結果,大概是29微秒,第三張圖大概是20幾微秒。

12.3 訊息延遲
看一下訊息延遲,探探對im訊息的延遲要求比較高,特別注重使用者的體驗。

這一塊剛開始大概到200ms,如果對於一個操作的話,200ms還是比較嚴重的。

第一次優化之後(下圖-上)的狀態大概1點幾毫秒,第二次優化之後(下圖-下)現在降到最低點差不多100微秒,跟一般的Net操作時間維度上比較接近。

12.4 Connector優化過程
優化過程是這樣的:

1)首先需要關鍵路徑上的Info日誌,通過取樣實現Access Log,info日誌是接入層比較重的操作;
2)第二通過Sync.Poll快取物件;
3)第三通過Escape Analysis物件儘可能線上上分配。
後面還實現了Connector的無損發版:這一塊比較有價值。長連線剛上線發版比較多,每次發版對於使用者來說都有感,通過這種方式讓使用者儘量無感。

實現了Connector的Graceful Shutdown的方式,通過這種方式優化連線。

首先:在HttpDNS上下線該機器,下線之後緩慢斷開使用者連線,直到連線數小於一定閾值。後面是重啟服務,發版二進位制。

最後:是HttpDNS上線該機器,通過這種方式實現使用者發版,時間比較長,當時測了挺長時間,去衡量每秒鐘斷開多少個連線,最後閾值是多少。

後面是一些資料:剛才GC也是一部分,目前連線數都屬於比較關鍵的資料。首先看連線數單機連線數比較少,不敢放太開,最多是15萬的單機連線數,大約100微秒。

Goroutine數量跟連線數一樣,差不多15萬個:

看一下記憶體使用狀態,下圖(上)是GO的記憶體總量,大概是2:3,剩下五分之一是屬於未佔用,記憶體總量是7.3個G。

下圖是GC狀態,GC比較健康,紅線是GC每次活躍記憶體數,紅線遠遠高於綠線。

看到GC目前的狀況大概是20幾微秒,感覺目前跟GO的官方時間比較能對得上,我們感覺GC目前都已經優化到位了。

12.5 後續要做的優化
最後是規劃後續還要做優化。

首先:對系統上還是需要更多優化Connector層,更多去減少記憶體的分配,儘量把記憶體分配到堆上而不是站上,通過這種方式減少GC壓力,我們看到GO是非Generational Collection GE,堆的記憶體越多的話,掃的記憶體也會越多,這樣它不是一個線性的增長。

第二:在內部更多去用Sync Pool做短暫的記憶體分配,比如說Context或者是臨時的Dbyle。

協議也要做優化:目前用的是WebSocket協議,後面會加一些功能標誌,把一些重要資訊傳給服務端。比如說一些重傳標誌,如果客戶端加入重傳標誌的話,我們可以先校驗這個包是不是重傳包,如果是重傳包的話會去判斷這個包是不是重複,是不是之前發過,如果發過的話就不需要去解包,這樣可以少做很多的服務端操作。

另外:可以去把Websocket目前的Mask機制去掉,因為Mask機制防止Web端的改包操作,但是基本是客戶端的傳包,所以並不需要Mask機制。

業務上:目前規劃後面需要做比較多的事情。我們覺得長連線因為是一個接入層,是一個非常好的地方去統計一些客戶端的分佈。比如說客戶端的安卓、IOS的分佈狀況。

進一步:可以做使用者畫像的統計,男的女的,年齡是多少,地理位置是多少。大概是這些,謝謝!

13、熱門問題回覆

  • 提問:剛才說連線層對話重啟,間接的過程中那些斷掉的使用者就飄到其他的,是這樣做的嗎?

張凱巨集:目前是這樣的,客戶端做自動飄移。

  • 提問:現在是1千萬日活,如果服務端往客戶端一下推100萬,這種場景怎麼做的?

張凱巨集:目前我們沒有那麼大的訊息推送量,有時候會發一些業務相關的推送,目前做了一個限流,通過客戶端限流實現的,大概三四千。

  • 提問:如果做到後端,意味著會存在安全隱患,攻擊者會不停的建立連線,導致很難去做防禦,會有這個問題嗎?因為惡意的攻擊,如果攻擊的話建立連線就可以了,不需要認證的機制。

張凱巨集:明白你的意思,這一塊不只是長連線,短連線也有這個問題。客戶端一直在偽造訪問結果,流量還是比較大的,這一塊靠防火牆和IP層防火牆實現。

  • 提問:長連線伺服器是掛在最外方,中間有沒有一層?

張凱巨集:目前接著如下層直接暴露在外網層,前面過一層IP的防DNSFre的防火牆。除此之外沒有別的網路裝置了。

  • 提問:基於什麼樣的考慮中間沒有加一層,因為前面還加了一層的情況。

張凱巨集:目前沒有這個計劃,後面會在Websofte接入層前面加個LS層可以方便擴容,這個收益不是特別大,所以現在沒有去計劃。

  • 提問:剛剛說的斷開重傳的三次嗅探那個是什麼意思?

張凱巨集:我們想更多的去觸發快速重傳,這樣對於TCP的重傳間隔更短一些,服務端根據三個迴圈包判斷是否快速重傳,我們會發三個迴圈包避免一個RTO重傳的開啟。

  • 提問:探探最開始安卓伺服器是使用第三方的嗎?

張凱巨集:對的,剛開始是極光推送的。

  • 提問:從第三方的安卓伺服器到自研。

張凱巨集:如果極光有一些故障的話,對我們影響還是蠻大。之前極光的故障頻率挺高,我們想是不是自己能把服務做起來。第二點,極光本身能提供一個使用者是否線上的判斷,但是它那個判斷要走通道,延時比較高,本身判斷是連線把延時降低一些。

  • 提問:比如說一個新使用者上線連線過來,有一些使用者發給他訊息,他是怎麼把一線訊息拿到的?

張凱巨集:我們通過業務端保證的,未發出來的訊息會存一個ID號,當使用者重新連的時候,業務端再拉一下。

14、參考資料

[1] 移動端IM/推送系統的協議選型:UDP還是TCP?
[2] 5G時代已經到來,TCP/IP老矣,尚能飯否?
[3] 為何基於TCP協議的移動端IM仍然需要心跳保活機制?
[4] 一文讀懂即時通訊應用中的網路心跳包機制:作用、原理、實現思路等
[5] 微信團隊原創分享:Android版微信後臺保活實戰分享(網路保活篇)
[6] 移動端IM實踐:實現Android版微信的智慧心跳機制
[7] 邁向高階:優秀Android程式設計師必知必會的網路基礎
[8] 全面瞭解移動端DNS域名劫持等雜症:原理、根源、HttpDNS解決方案等
[9] 技術掃盲:新一代基於UDP的低延時網路傳輸層協議——QUIC詳解
[10] 新手入門一篇就夠:從零開發移動端IM
[11] 長連線閘道器技術專題(二):知乎千萬級併發的高效能長連線閘道器技術實踐
[12] 長連線閘道器技術專題(三):手淘億級移動端接入層閘道器的技術演進之路
[13] 長連線閘道器技術專題(五):喜馬拉雅自研億級API閘道器技術實踐
[14] 一套億級使用者的IM架構技術乾貨(上篇):整體架構、服務拆分等
[15] 一套億級使用者的IM架構技術乾貨(下篇):可靠性、有序性、弱網優化等
[16] 從新手到專家:如何設計一套億級訊息量的分散式IM系統

本文已同步釋出於“即時通訊技術圈”公眾號。
同步釋出連結是:http://www.52im.net/thread-37...

相關文章