[TOC]
即時通訊IM技術領域基礎篇
議題
-
準備工作(協議選型)
- 網路傳輸協議選擇 和 資料通訊協議選擇
-
xxx專案架構
- 架構優缺點
- 架構改進之路
-
IM 關鍵技術點 & 策略機制
- 如何保證訊息不丟/不亂序/不重複
- 心跳策略
- 重連策略
-
典型IM業務場景
- 使用者A傳送訊息給使用者B
- 使用者A傳送訊息到群C
-
儲存結構簡析
準備工作(協議選型)
選用什麼網路傳輸協議(TCP/UDP/HTTP) ?
-
udp協議雖然實時性更好,但是如何處理安全可靠的傳輸並且處理不同客戶端之間的訊息互動是個難題,實現起來過於複雜. 目前大部分IM架構都不採用UDP來實現.
-
但是為啥還需要HTTP呢?
-
核心的TCP長連線,用來實時收發訊息,其他資源請求不佔用此連線,保證實時性
-
http可以用來實現狀態協議(可以用php開發)
- 朋友圈
- 使用者個人資訊(好友資訊,賬號,搜尋等..)
- 離線訊息用拉模式,避免 tcp 通道壓力過大,影響即時訊息下發效率
- 等等...
-
IM進行圖片/語言/大塗鴉聊天的時候: http能夠很方便的處理 斷點續傳和分片上傳等功能.
-
-
TCP: 維護長連線,保證訊息的實時性, 對應資料傳輸協議.
- 目的: 及時收發訊息
選用什麼資料通訊協議?
-
IM協議選擇原則一般是:易於擴充,方便覆蓋各種業務邏輯,同時又比較節約流量。節約流量這一點的需求在移動端IM上尤其重要 !!!
-
xmpp: 協議開源,可擴充性強,在各個端(包括伺服器)有各種語言的實現,開發者接入方便。但是缺點也是不少:XML表現力弱,有太多冗餘資訊,流量大,實際使用時有大量天坑。
-
MQTT: 協議簡單,流量少,但是它並不是一個專門為IM設計的協議,多使用於推送. 需要自己在業務上實現群,好友相關等等(目前公司有用MQTT實現通用IM框架).
-
SIP: 多用於VOIP相關的模組,是一種文字協議. sip信令控制比較複雜
-
私有協議: 自己實現協議.大部分主流IM APP都是是使用私有協議,一個被良好設計的私有協議一般有如下優點:高效,節約流量(一般使用二進位制協議),安全性高,難以破解。 xxx專案基本屬於私有定製協議<參考了蘑菇街開源的TeamTalk>, 後期通用IM架構使用MQTT
-
-
協議設計的考量:
-
網路資料大小 —— 佔用頻寬,傳輸效率:雖然對單個使用者來說,資料量傳輸很小,但是對於伺服器端要承受眾多的高併發資料傳輸,必須要考慮到資料佔用頻寬,儘量不要有冗餘資料,這樣才能夠少佔用頻寬,少佔用資源,少網路IO,提高傳輸效率;
-
網路資料安全性 —— 敏感資料的網路安全:對於相關業務的部分資料傳輸都是敏感資料,所以必須考慮對部分傳輸資料進行加密(xxx專案目前提供C++的加密庫給客戶端使用)
-
編碼複雜度 —— 序列化和反序列化複雜度,效率,資料結構的可擴充套件性
-
協議通用性 —— 大眾規範:資料型別必須是跨平臺,資料格式是通用的
-
-
常用序列化協議
-
提供序列化和反序列化庫的開源協議: pb,Thrift. 擴充套件相當方便,序列化和反序列化方便(xxx專案目前使用pb)
-
文字化協議: xml,json. 序列化,反序列化容易,但是佔用體積大(一般http介面採用json格式).
-
xxx專案系統架構
前期架構
...
改進後架構
...
架構的優缺點
優點
-
同時支援TCP 和 HTTP 方式, 關聯性不大的業務服務獨立開來
- php server
- router server
- user center
- Access server
- oracle server
-
服務支援平行擴充套件,平行擴充套件方便且對使用者無感知
-
cache db層的封裝,業務呼叫方直接呼叫介面即可.
-
除了Access server是有狀態的,其他服務無狀態
-
各個服務之間,通過rpc通訊,可以跨機器.
-
oracle裡面都是模組化,有點類似MVC模式, 程式碼解耦, 功能解耦.
缺點
-
oracle 太過龐大, 可以把某些業務抽取出來
-
缺點
- 業務太龐大,多人開發不方便,容易引起code衝突
- 如果某個小功能有異常,可能導致整個服務不可用
-
改進
- oracle裡面耦合了apns server, 可以把apns 單獨抽取出來. (xxx專案目前已經開始接入通用push推送系統了,類似把apns抽取出來).
-
-
push server 沒有業務,僅僅是轉發Access和oracle之間的請求
-
缺點
- 需要單獨維護一個比較雞肋的服務,增加運維成本
-
改進
- 把push server合併到Access中,減少一層rpc呼叫中間環節.減少運維成本還能提高效率(xxx專案新架構已經把push server幹掉融合到Access裡面)
-
-
Access server和使用者緊密連線,維持長連線的同時,還有部分業務
-
缺點
- 維持著長連線,如果升級更新的話,勢必會影響線上使用者的連線狀態
- 偶爾部分業務,降低長連線的穩定性
-
改進:
- 把Access server 中維持長連線部分抽取出來一個connd server:
- 僅僅維持長連線,收發包. 不耦合任何業務(xxx專案目前正在改進這個架構,還未上線)
- 把Access server 中維持長連線部分抽取出來一個connd server:
-
IM 關鍵技術點
技術點一之: 如何保證訊息可達(不丟)/唯一(不重複)/保序(不亂序)
最簡單的保序(不亂序)
-
為什麼有可能會亂序?
-
對於線上訊息, 一發一收,正常情況當然不會有問題
- 但是,如果收到訊息的時候,突然網路異常了,收不到訊息了呢?
- 服務端就會重發或者轉離線儲存(xxx專案的機制立即轉離線儲存)
- 但是,如果收到訊息的時候,突然網路異常了,收不到訊息了呢?
-
對於離線訊息, 可能有很多條.
- 拉取的時候,一般會把離線的訊息都一次性的拉取過來
- 多條訊息的時候,就要保證收取到的訊息的順序性.
- 拉取的時候,一般會把離線的訊息都一次性的拉取過來
-
-
怎麼保證不亂序?
-
每條訊息到服務端後,都會生成一個全域性唯一的msgid, 這個msgid一定都是遞增增長的(msgid的生成會有同步機制保證併發時的唯一性)
-
針對每條訊息,會有訊息的生成時間,精確到毫秒
-
拉取多條訊息的時候,取出資料後,再根據msgid的大小進行排序即可.
-
保證唯一性(不重複)
-
訊息為什麼可能會重複呢?
- 行動網路的不穩定性,可能導致某天訊息傳送不出去,或者傳送出去了,迴應ack沒有收到.
-
這種情況下,就可能會需要有重發機制. 客戶端和服務端都可能需要有這種機制.
-
既然有重複機制,就有可能收到的訊息是重複的.
-
- 行動網路的不穩定性,可能導致某天訊息傳送不出去,或者傳送出去了,迴應ack沒有收到.
-
怎麼解決呢? 保證不重複最好是客戶端和服務端相關處理
-
訊息meta結構裡面增加一個欄位isResend. 客戶端重複傳送的時候置位此欄位,標識這個是重複的,服務端用來後續判斷
-
服務端為每個使用者快取一批最近的msgids(所謂的localMsgId),如快取50條
-
服務端收到訊息後, 通過判斷isResend和此msgid是否在localMsgId list中. 如果重複傳送,則服務端不做後續處理.
-
因為僅僅靠isResend不能夠準備判斷,因為可能客戶端確實resend,但是服務端確實就是沒有收到......
-
保證可達(不丟且不重)
-
最簡單的就是服務端每傳遞一條訊息到接收方都需要一個ack來確保可達
- 但是ack也有可能在弱網環境下丟失.
-
服務端返回給客戶端的資料,有可能客戶端沒有收到,或者客戶端收到了沒有迴應.
- 因此,就一定要有完善的確認機制來告知客戶端確實收到了. 有且僅有一次.
-
考慮一個賬號在不同終端登入後的情況.
- 訊息要能夠傳送到當前登入的終端,而且又不能重複傳送或者拉取之前已經拉取過的資料.
技術點二之: msgID機制
這裡提供兩種方案供參考(本質思想一樣,實現方式不同)
序列號msgid機制 & msgid確認機制(方案一):
-
每個使用者的每條訊息都一定會分配一個唯一的msgid
-
服務端會儲存每個使用者的msgid 列表
-
客戶端儲存已經收到的最大msgid
優點:
-
根據伺服器和手機端之間sequence的差異,可以很輕鬆的實現增量下發手機端未收取下去的訊息
-
對於在弱網路環境差的情況,丟包情況發生概率是比較高的,此時經常會出現伺服器的回包不能到達手機端的現象。由於手機端只會在確切的收取到訊息後才會更新本地的sequence,所以即使伺服器的回包丟了,手機端等待超時後重新拿舊的sequence上伺服器收取訊息,同樣是可以正確的收取未下發的訊息。
-
由於手機端儲存的sequence是確認收到訊息的最大sequence,所以對於手機端每次到伺服器來收取訊息也可以認為是對上一次收取訊息的確認。一個帳號在多個手機端輪流登入的情況下,只要伺服器儲存手機端已確認的sequence,那就可以簡單的實現已確認下發的訊息不會重複下發,不同手機端之間輪流登入不會收到其他手機端已經收取到的訊息。
使用者在不同終端登入的情況下獲取訊息情況
假如手機A拿Seq_cli = 100 上伺服器收取訊息,此時伺服器的Seq_svr = 150,那手機A可以將sequence為[101 - 150]的訊息收取下去,同時手機A會將本地的Seq_cli 置為150
手機A在下一次再次上來伺服器收取訊息,此時Seq_cli = 150,伺服器的 Seq_svr = 200,那手機A可以將sequence為[151 - 200]的訊息收取下去. 假如原手機A使用者換到手機B登入,並使用Seq_cli = 120上伺服器收取訊息,由於伺服器已經確認sequence <= 150的訊息已經被手機收取下去了,故不會再返回sequence為[121 - 150]的訊息給手機B,而是將sequence為[151 - 200]的訊息下發給手機B。序列號msgid機制 & msgid確認機制(方案二: xxx專案目前方案):
-
每個使用者的每條訊息都一定會分配一個唯一的msgid
-
服務端會儲存每個使用者的msgid 列表
-
客戶端儲存已經收到的最大msgid
- 對於單聊,群聊,匿名分別儲存(某人對應的id,某群對應的id).
思考
這兩種方式的優缺點?
-
方式二中,確認機制都是多一次http請求. 但是能夠保證及時淘汰資料
-
方式一中,確認機制是等到下一次拉取資料的時候進行確定, 不額外增加請求, 但是淘汰資料不及時.
技術點三之: 心跳策略
心跳功能: 維護TCP長連線,保證長連線穩定性, 對於行動網路, 僅僅只有這個功能嗎?
-
心跳其實有兩個作用
-
心跳保證客戶端和服務端的連線保活功能,服務端以此來判斷客戶端是否還線上
-
心跳還需要維持行動網路的GGSN
-
運營商通過NAT(network adddress translation)來轉換移動內網ip和外網ip,從而最終實現連上Internet,其中GGSN(gateway GPRS support Node)模組就是來實現NAT的過程,但是大部分運營商為了減少閘道器NAT的對映表的負荷,若一個鏈路有一段時間沒有通訊就會刪除其對應表,造成鏈路中斷,因此運營商採取的是刻意縮短空閒連線的釋放超時,來節省通道資源,但是這種刻意釋放的行為就可能會導致我們的連線被動斷開(xxx專案之前心跳有被運營商斷開連線的情況,後面改進了心跳策略,後續還將繼續改進心跳策略)
-
NAT方案說白了就是將過去每個寬頻使用者獨立分配公網IP的方式改為分配內網IP給每個使用者,運營商再對接入的使用者統一部署NAT裝置,NAT的作用就是將使用者網路連線發起的內網IP,以埠連線的形式翻譯成公網IP,再對外網資源進行連線。
-
從mobile 到GGSN都是一個內網,然後在GGSN上做地址轉換NAT/PAT,轉換成GGSN公網地址池的地址,所以你的手機在Internet 上呈現的地址就是這個地址池的公網地址
-
-
-
最常見的就是每隔固定時間(如4分半)傳送心跳,但是這樣不夠智慧.
-
4分半的原因就是綜合了各家移動運營商的NAT超時時間
-
心跳時間太短,消耗流量/電量,增加伺服器壓力.
-
心跳時間太長,可能會被因為運營商的策略淘汰NAT表中的對應項而被動斷開連線
-
-
智慧心跳策略
-
維護移動網GGSN(閘道器GPRS支援節點)
- 大部分移動無線網路運營商都在鏈路一段時間沒有資料通訊時,會淘汰 NAT 表中的對應項,造成鏈路中斷。NAT超時是影響TCP連線壽命的一個重要因素(尤其是國內),所以客戶端自動測算NAT超時時間,來動態調整心跳間隔,是一個很重要的優化點。
-
參考微信的一套自適應心跳演算法:
-
為了保證收訊息及時性的體驗,當app處於前臺活躍狀態時,使用固定心跳。
-
app進入後臺(或者前臺關屏)時,先用幾次最小心跳維持長連結。然後進入後臺自適應心跳計算。這樣做的目的是儘量選擇使用者不活躍的時間段,來減少心跳計算可能產生的訊息不及時收取影響。
-
-
-
精簡心跳包,保證一個心跳包大小在10位元組之內, 根據APP前後臺狀態調整心跳包間隔 (主要是安卓)
技術點四之: 斷線重連策略
掉線後,根據不同的狀態需要選擇不同的重連間隔。如果是本地網路出錯,並不需要定時去重連,這時只需要監聽網路狀態,等到網路恢復後重連即可。如果網路變化非常頻繁,特別是 App 處在後臺執行時,對於重連也可以加上一定的頻率控制,在保證一定訊息實時性的同時,避免造成過多的電量消耗。
-
斷線重連的最短間隔時間按單位秒(s)以4、8、16...(最大不超過30)數列執行,以避免頻繁的斷線重連,從而減輕伺服器負擔。當服務端收到正確的包時,此策略重置
-
有網路但連線失敗的情況下,按單位秒(s)以間隔時間為2、2、4、4、8、8、16、16...(最大不超過120)的數列不斷重試
-
為了防止雪崩效應的出現,我們在檢測到socket失效(伺服器異常),並不是立馬進行重連,而是讓客戶端隨機Sleep一段時間(或者上述其他策略)再去連線服務端,這樣就可以使不同的客戶端在服務端重啟的時候不會同時去連線,從而造成雪崩效應。
典型IM業務場景流程
-
使用者A傳送訊息給使用者B
- A 通過賬號密碼獲取token.
- A 拿著token進行login
- 服務端快取使用者資訊並維持登入狀態
- A 打包資料傳送給服務端
- 服務端檢測A使用者是否風險使用者
- 服務端對訊息進行敏感詞檢查(這個重要)
- 服務端生成msgid
- 服務端進行好友檢測(A/B)
- 服務端進行重複傳送檢測
- 服務端獲取B的連線資訊,並判斷線上狀態
- 如果線上,直接傳送給B,併入cache和db
- 如果不線上,直接儲存.如果是ios,則進行apns.
- 線上的B,收到訊息後迴應ack進行確認.
-
使用者A傳送訊息到群C
儲存結構
未讀索引列表
-
未讀訊息索引存在的意義在於保證訊息的可靠性以及作為離線使用者獲取未讀訊息列表的一個索引結構。
-
未讀訊息索引由兩部分構成,都存在redis中:
- 記錄使用者每個好友的未讀數的hash結構
- 每個好友對應一個zset結構,裡面存著所有未讀訊息的id。
-
假設A有三個好友B,C,D。A離線。B給A發了1條訊息,C給A發了2條訊息,D給A發了3條訊息,那麼此時A的未讀索引結構為:
-
hash結構
- B-1
- C-2
- D-3
-
zset結構
User | MsgId 1 | MsgId 2 | MsgId 3 |
---|---|---|---|
B | 1 | - | - |
C | 4 | 7 | - |
D | 8 | 9 | 10 |
-
訊息上行以及佇列更新未讀訊息索引是指,hash結構對應的field加1,然後將訊息id追加到相應好友的zset結構中。
-
接收ack維護未讀訊息索引則相反,hash結構對應的field減1,然後將訊息id從相應好友中的zset結構中刪除。
訊息下行(未讀訊息的獲取)
該流程使用者在離線狀態的未讀訊息獲取。
該流程主要由sessions/recent介面提供服務。流程如下:
- hgetall讀取未讀訊息索引中的hash結構。
- 遍歷hash結構,若未讀數不為0,則讀取相應好友的zset結構,取出未讀訊息id列表。
- 通過訊息id列表到快取(或穿透到資料庫)讀取訊息內容,下發給客戶端。
和線上的流程相同,離線客戶端讀取了未讀訊息後也要傳送接收ack到業務端,告訴它未讀訊息已經下發成功,業務端負責維護該使用者的未讀訊息索引。
和線上流程不同的是,這個接收ack是通過呼叫messages/lastAccessedId介面來實現的。客戶端需要傳一個hash結構到服務端,key為通過sessions/recent介面下發的好友id,value為sessions/recent介面的未讀訊息列表中對應好友的最大一條訊息id。
服務端收到這個hash結構後,遍歷它
- 清空相應快取
- 通過zremrangebyscore操作清空相應好友的zset結構
- 將未讀訊息索引中的hash結構減掉zremrangebyscore的返回值
這樣就完成了離線流程中未讀訊息索引的維護。
佇列處理流程
-
如果訊息標記為offline,則將訊息入庫,寫快取(只有離線訊息才寫快取),更新未讀訊息索引,然後呼叫apns進行推送。
-
如果訊息標記為online,則直接將訊息入庫即可,因為B已經收到這條訊息。
-
如果訊息標記為redeliver,則將訊息寫入快取,然後呼叫apns進行推送。
討論後的疑問
把連線層Access拆一層connd server出來的考量和目的,到底有沒有必要?
-
拆分出來的目的:
- 連線層更穩定
- 減少重啟,方便Access服務升級
-
真的能夠起到這樣的效果麼?
-
連線層更穩定 - - - 需要有硬性指標來判斷才能確定更穩定,因為Access的服務不重,目前也不是瓶頸點.
-
目前Access服務不重, 拆分出來真有必要嗎?
-
真要拆分, 那也不是這麼拆分, 是在Oracle上做拆分, 類似微服務的那種概念
-
穩定性不是這麼體現,原來 connd 的設計,更薄不承擔業務,而現在的 access 還是有一些業務邏輯,那麼它升級的可能性就比較高。
-
access 拆分,目的就是讓保持連線的那一層足夠薄,薄到怎麼改業務,它都不用升級程式碼(tcp 不會斷)。
-
-
減少重啟,方便Access服務升級 - - - 不能通過增加一層服務來實現重啟升級,需要有其他機制來確保服務端進行升級而不影響TCP長連線上的使用者
-
拆分出來的connd server 還是有可能會需要重啟的, 這時候怎麼辦呢 ?關鍵性問題還是沒有解決
-
加一層服務,是打算通過共享記憶體的方式,connd 只管理連線。access 更新升級的時候,使用者不會掉線。
-
-
增加一個服務,就多了一條鏈路, 就可能會導致服務鏈路過長,請求經過更多的服務,會導致服務更加不可用. 因為要保證每個服務的可用性都到99.999%(5個9)是很難的,增加一個服務,就會降低整個服務的可用性.
-
-
架構改進一定要有資料支撐, 要確實起到效果, 要有資料輸出才能證明這個改進是有效果的,要不然花了二個月時間做改進,結果沒有用,浪費人力和時間,還降低開發效率
- 每個階段的架構可能都不一樣,根據當前階段的使用者量和熱度來決定
怎麼保證接入層服務重啟升級? 服務擴/縮容?
-
方案: 增加一條信令互動,服務端如果要重啟/縮容, 告知連線在此Access上的所有客戶端,服務端要升級了,客戶端需要重連其他節點
- 這其實是屬於一種主動遷移的策略,這樣客戶端雖然還是有重連,比我們直接斷連線會好一些.
-
等確定當前Access節點上的所有客戶端都連線到其他節點後, 當前Access節點再進行重啟/下線/縮容.
-
怎麼擴容? 如果需要擴容,則增加新的節點後,通過etcd進行服務發現註冊.客戶端通過router server請求資料後,拉取到相關節點.
-
如果當前3個節點扛不住了,增加2個節點, 這個時候,要能夠馬上緩解當前3個節點壓力,需要怎麼做?
-
按照之前的方式,客戶端重新登入請求router server,然後再進行連線的話,這是不能夠馬上緩解壓力的,因為新增的節點後, 當前壓力還是在之前幾個節點
-
所以, 服務端需要有更好的機制,來由服務端控制
-
服務端傳送命令給當前節點上的客戶端,讓客戶端連線到新增節點上.
-
服務端還需要確定是否有部分連線到其他節點了,然後再有相應的策略.
-
-
怎麼防止攻擊
-
線上機器都有防火牆策略(包括硬體防火牆/軟體防火牆)
-
硬體防火牆: 硬體防火牆裝置,很貴,目前有采購,但是用的少
-
軟體防火牆: 軟體層面上的如iptable, 設定iptable的防火牆策略
-
-
TCP 通道層面上
-
socket建連速度的頻率控制, 不能讓別人一直建立socket連線,要不然socket很容易就爆滿了,撐不住了
- 目前設定的是獨立ip建連速度超過100/s,則認為被攻擊了,封禁此ip
-
收發訊息頻率控制, 不能讓別人一直能夠傳送訊息,要不然整個服務就掛掉了
-
要能夠傳送訊息, 必須要先登入
-
要登入, 必須有token,有祕鑰
-
收發訊息也可以設定頻率控制
-
-
目前市面上的開源/通用協議的比較選型
-
為啥xmpp不適合,僅僅是因為xml資料量大嗎 ?
-
目前也有方案是針對xmpp進行優化處理的. 因此流量大並不是主要缺點
-
還有一點就是訊息不可靠,它的請求及應答機制也是主要為穩定長連網路環境所設計,對於頻寬偏窄及長連不穩定的行動網路並不是特別優化
-
因此設計成支援多終端狀態的XMPP在移動領域並不是擅長之地
-
-
為啥mqtt不適合? 為啥xxx專案沒有用mqtt ?
-
mqtt 適合推送,不適合IM, 需要業務層面上額外多做處理, 目前已經開始再用
-
xxx專案不用mqtt是歷史遺留問題,因為剛開始要迅速開展,迅速搭建架構實現,因此用來蘑菇街的teamtalk.
-
如果後續選型的話, 如果沒有歷史遺留問題,那麼就會選擇使用mqtt
-
-
除了資料量大, 還要考慮協議的複雜度, 客戶端和服務端處理協議的複雜度?
-
協議要考慮容易擴充套件, 方便後續新增欄位, 支援多平臺
-
要考慮客戶端和服務端的實現是否簡單
-
編解碼的效率
-
跨機房, 多機房容災
-
服務需要能夠跨機房,尤其是有狀態的節點.
-
需要儲備多機房容災,防止整個機房掛掉.
剛討論說到接入層有哪些功能的:
-
維持TCP長連線,包括心跳/超時檢測
-
收包解包
-
防攻擊機制
-
等待接收訊息迴應(這個之前沒有說到,就是把訊息傳送給接收方後還需要接收方迴應)
思考點(考核關鍵點)
-
訊息為什麼可能會亂序? 怎麼保證訊息不亂序?
- 考慮離線
- 考慮網路異常
-
對於離線訊息,儲存方式/儲存結構要怎麼設計?
- 考慮會有多個人傳送訊息
- 考慮快取+db的方式
-
如何保證訊息不丟,不重? 怎麼設計訊息防丟失機制?
- 考慮同一賬號可能會多終端登入
- 考慮弱網環境下,ACK也可能會丟失
-
對於長連線, 怎管理這些長連線?
- 考慮快速查詢
- 後端資料來了, 怎麼快速找到這個請求對應的連線呢
- 考慮快速查詢
-
接入層節點有多個,而且是有狀態的.通過什麼機制保證從節點1下發的請求,其對應的響應還是會回到節點1呢?
- 或者說如果響應不回到節點1,而是回到節點2了會有什麼弊端?