本文由探探服務端高級技術專家張凱宏分享,原題“探探長鏈接項目的Go語言實踐”,因原文內容有較多錯誤,有修訂和改動。
1、引言
即時通信長連接服務處于網絡接入層,這個領域非常適合用Go語言發揮其多協程并行、異步IO的特點。
探探自長連接項目上線以后,對服務進行了多次優化:GC從5ms降到100微秒(Go版本均為1.9以上),主要gRPC接口調用延時p999從300ms下降到5ms。在業內大多把目光聚焦于單機連接數的時候,我們則更聚焦于服務的SLA(服務可用性)。
本文將要分享的是陌生人社交應用探探的IM長連接模塊從技術選型到架構設計,再到性能優化的整個技術實踐過程和經驗總結。
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設備有一個?;顧C制,目前基本在5分鐘以上,5分鐘如果不發包的話,會把你的緩存給刪掉?;旧细鬟\營商都在5分鐘以上,只不過移動4G阻礙了?;究梢栽?到10分鐘之內發一個Ping包就行,可以維持網絡運營商設備里的緩存,一直保持著,這樣就沒有問題,使長連接一直?;钪?/p>
增加Ping包間隔可以減少網絡流量,能夠進一步降低客戶端的耗電,這一塊的受益還是比較大的。

在低端安卓設備的情況下,有一些DHCP租期的問題。這個問題集中在安卓端的低版本上,安卓不會去續租過期的IP。
解決問題也比較簡單,在DHCP租期到一半的時候,去及時向DHCP服務器續租一下就能解決了。
限于篇幅,我就不在這里展開了,有興趣可以讀這些資料:
- 《為何基于TCP協議的移動端IM仍然需要心跳?;顧C制?》
- 《一文讀懂即時通訊應用中的網絡心跳包機制:作用、原理、實現思路等》
- 《微信團隊原創分享: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萬,這種場景怎么做的?
張凱宏:目前我們沒有那么大的消息推送量,有時候會發一些業務相關的推送,目前做了一個限流,通過客戶端限流實現的,大概三四千。
* 提問:如果做到后端,意味著會存在安全隱患,攻擊者會不停的建立連接,導致很難去做防御,會有這個問題嗎?因為惡意的攻擊,如果攻擊的話建立連接就可以了,不需要認證的機制。
張凱宏:明白你的意思,這一塊不只是長連接,短連接也有這個問題??蛻舳艘恢痹趥卧煸L問結果,流量還是比較大的,這一塊靠防火墻和IP層防火墻實現。
* 提問:長連接服務器是掛在最外方,中間有沒有一層?
張凱宏:目前接著如下層直接暴露在外網層,前面過一層IP的防DNSFre的防火墻。除此之外沒有別的網絡設備了。
* 提問:基于什么樣的考慮中間沒有加一層,因為前面還加了一層的情況。
張凱宏:目前沒有這個計劃,后面會在Websofte接入層前面加個LS層可以方便擴容,這個收益不是特別大,所以現在沒有去計劃。
* 提問:剛剛說的斷開重傳的三次嗅探那個是什么意思?
張凱宏:我們想更多的去觸發快速重傳,這樣對于TCP的重傳間隔更短一些,服務端根據三個循環包判斷是否快速重傳,我們會發三個循環包避免一個RTO重傳的開啟。
* 提問:探探最開始安卓服務器是使用第三方的嗎?
張凱宏:對的,剛開始是極光推送的。
* 提問:從第三方的安卓服務器到自研。
張凱宏:如果極光有一些故障的話,對我們影響還是蠻大。之前極光的故障頻率挺高,我們想是不是自己能把服務做起來。第二點,極光本身能提供一個用戶是否在線的判斷,但是它那個判斷要走通道,延時比較高,本身判斷是連接把延時降低一些。
* 提問:比如說一個新用戶上線連接過來,有一些用戶發給他消息,他是怎么把一線消息拿到的?
張凱宏:我們通過業務端保證的,未發出來的消息會存一個ID號,當用戶重新連的時候,業務端再拉一下。
14、參考資料
[1] 移動端IM/推送系統的協議選型:UDP還是TCP?
[2] 5G時代已經到來,TCP/IP老矣,尚能飯否?
[3] 為何基于TCP協議的移動端IM仍然需要心跳?;顧C制?
[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-3780-1-1.html