即時通訊IM技術領域基礎篇

  • 2019 年 12 月 12 日
  • 筆記

[TOC]

即時通訊IM技術領域基礎篇

即時通訊IM技術領域提高篇

議題

  1. 準備工作(協議選型)
    • 網路傳輸協議選擇 和 數據通訊協議選擇
  2. xxx項目架構
    • 架構優缺點
    • 架構改進之路
  3. IM 關鍵技術點 & 策略機制
    • 如何保證消息不丟/不亂序/不重複
    • 心跳策略
    • 重連策略
  4. 典型IM業務場景
    • 用戶A發送消息給用戶B
    • 用戶A發送消息到群C
  5. 存儲結構簡析

準備工作(協議選型)

選用什麼網路傳輸協議(TCP/UDP/HTTP) ?

  1. udp協議雖然實時性更好,但是如何處理安全可靠的傳輸並且處理不同客戶端之間的消息交互是個難題,實現起來過於複雜. 目前大部分IM架構都不採用UDP來實現.
  2. 但是為啥還需要HTTP呢?
    • 朋友圈
    • 用戶個人資訊(好友資訊,帳號,搜索等..)
    • 離線消息用拉模式,避免 tcp 通道壓力過大,影響即時消息下發效率
    • 等等…
    • 核心的TCP長連接,用來實時收發消息,其他資源請求不佔用此連接,保證實時性
    • http可以用來實現狀態協議(可以用php開發)
    • IM進行圖片/語言/大塗鴉聊天的時候: http能夠很方便的處理 斷點續傳和分片上傳等功能.
  3. TCP: 維護長連接,保證消息的實時性, 對應數據傳輸協議.
    • 目的: 及時收發消息

選用什麼數據通訊協議?

  1. IM協議選擇原則一般是:易於拓展,方便覆蓋各種業務邏輯,同時又比較節約流量。節約流量這一點的需求在移動端IM上尤其重要 !!!
    • xmpp: 協議開源,可拓展性強,在各個端(包括伺服器)有各種語言的實現,開發者接入方便。但是缺點也是不少:XML表現力弱,有太多冗餘資訊,流量大,實際使用時有大量天坑。
    • MQTT: 協議簡單,流量少,但是它並不是一個專門為IM設計的協議,多使用於推送. 需要自己在業務上實現群,好友相關等等(目前公司有用MQTT實現通用IM框架).
    • SIP: 多用於VOIP相關的模組,是一種文本協議. sip信令控制比較複雜
    • 私有協議: 自己實現協議.大部分主流IM APP都是是使用私有協議,一個被良好設計的私有協議一般有如下優點:高效,節約流量(一般使用二進位協議),安全性高,難以破解。 xxx項目基本屬於私有訂製協議<參考了蘑菇街開源的TeamTalk>, 後期通用IM架構使用MQTT
  2. 協議設計的考量:
    • 網路數據大小 —— 佔用頻寬,傳輸效率:雖然對單個用戶來說,數據量傳輸很小,但是對於伺服器端要承受眾多的高並發數據傳輸,必須要考慮到數據佔用頻寬,盡量不要有冗餘數據,這樣才能夠少佔用頻寬,少佔用資源,少網路IO,提高傳輸效率;
    • 網路數據安全性 —— 敏感數據的網路安全:對於相關業務的部分數據傳輸都是敏感數據,所以必須考慮對部分傳輸數據進行加密(xxx項目目前提供C++的加密庫給客戶端使用)
    • 編碼複雜度 —— 序列化和反序列化複雜度,效率,數據結構的可擴展性
    • 協議通用性 —— 大眾規範:數據類型必須是跨平台,數據格式是通用的
  3. 常用序列化協議
    • 提供序列化和反序列化庫的開源協議: pb,Thrift. 擴展相當方便,序列化和反序列化方便(xxx項目目前使用pb)
    • 文本化協議: xml,json. 序列化,反序列化容易,但是佔用體積大(一般http介面採用json格式).

xxx項目系統架構

前期架構

改進後架構

架構的優缺點

優點

  1. 同時支援TCP 和 HTTP 方式, 關聯性不大的業務服務獨立開來
    • php server
    • router server
    • user center
    • Access server
    • oracle server
  2. 服務支援平行擴展,平行擴展方便且對用戶無感知
  3. cache db層的封裝,業務調用方直接調用介面即可.
  4. 除了Access server是有狀態的,其他服務無狀態
  5. 各個服務之間,通過rpc通訊,可以跨機器.
  6. oracle裡面都是模組化,有點類似MVC模式, 程式碼解耦, 功能解耦.

缺點

  1. oracle 太過龐大, 可以把某些業務抽取出來
    • oracle裡面耦合了apns server, 可以把apns 單獨抽取出來. (xxx項目目前已經開始接入通用push推送系統了,類似把apns抽取出來).
    • 業務太龐大,多人開發不方便,容易引起code衝突
    • 如果某個小功能有異常,可能導致整個服務不可用
    • 缺點
    • 改進
  2. push server 沒有業務,僅僅是轉發Access和oracle之間的請求
    • 把push server合併到Access中,減少一層rpc調用中間環節.減少運維成本還能提高效率(xxx項目新架構已經把push server幹掉融合到Access裡面)
    • 需要單獨維護一個比較雞肋的服務,增加運維成本
    • 缺點
    • 改進
  3. Access server和用戶緊密連接,維持長連接的同時,還有部分業務
    • 把Access server 中維持長連接部分抽取出來一個connd server:
    • 僅僅維持長連接,收發包. 不耦合任何業務(xxx項目目前正在改進這個架構,還未上線)
    • 維持著長連接,如果升級更新的話,勢必會影響在線用戶的連接狀態
    • 偶爾部分業務,降低長連接的穩定性
    • 缺點
    • 改進:

IM 關鍵技術點

技術點一之: 如何保證消息可達(不丟)/唯一(不重複)/保序(不亂序)

最簡單的保序(不亂序)

  1. 為什麼有可能會亂序?
    • 拉取的時候,一般會把離線的消息都一次性的拉取過來
    • 多條消息的時候,就要保證收取到的消息的順序性.
    • 但是,如果收到消息的時候,突然網路異常了,收不到消息了呢?
    • 服務端就會重發或者轉離線存儲(xxx項目的機制立即轉離線存儲)
    • 對於在線消息, 一發一收,正常情況當然不會有問題
    • 對於離線消息, 可能有很多條.
  2. 怎麼保證不亂序?
    • 每條消息到服務端後,都會生成一個全局唯一的msgid, 這個msgid一定都是遞增增長的(msgid的生成會有同步機制保證並發時的唯一性)
    • 針對每條消息,會有消息的生成時間,精確到毫秒
    • 拉取多條消息的時候,取出數據後,再根據msgid的大小進行排序即可.

保證唯一性(不重複)

  1. 消息為什麼可能會重複呢?
    • 這種情況下,就可能會需要有重發機制. 客戶端和服務端都可能需要有這種機制.
    • 既然有重複機制,就有可能收到的消息是重複的.
    • 移動網路的不穩定性,可能導致某天消息發送不出去,或者發送出去了,回應ack沒有收到.
  2. 怎麼解決呢? 保證不重複最好是客戶端和服務端相關處理
    • 消息meta結構裡面增加一個欄位isResend. 客戶端重複發送的時候置位此欄位,標識這個是重複的,服務端用來後續判斷
    • 服務端為每個用戶快取一批最近的msgids(所謂的localMsgId),如快取50條
    • 服務端收到消息後, 通過判斷isResend和此msgid是否在localMsgId list中. 如果重複發送,則服務端不做後續處理.
    • 因為僅僅靠isResend不能夠準備判斷,因為可能客戶端確實resend,但是服務端確實就是沒有收到……

保證可達(不丟且不重)

  1. 最簡單的就是服務端每傳遞一條消息到接收方都需要一個ack來確保可達
    • 但是ack也有可能在弱網環境下丟失.
  2. 服務端返回給客戶端的數據,有可能客戶端沒有收到,或者客戶端收到了沒有回應.
    • 因此,就一定要有完善的確認機制來告知客戶端確實收到了. 有且僅有一次.
  3. 考慮一個帳號在不同終端登錄後的情況.
    • 消息要能夠發送到當前登錄的終端,而且又不能重複發送或者拉取之前已經拉取過的數據.

技術點二之: msgID機制

這裡提供兩種方案供參考(本質思想一樣,實現方式不同)

序列號msgid機制 & msgid確認機制(方案一):

  • 每個用戶的每條消息都一定會分配一個唯一的msgid
  • 服務端會存儲每個用戶的msgid 列表
  • 客戶端存儲已經收到的最大msgid

image.png

優點:

  1. 根據伺服器和手機端之間sequence的差異,可以很輕鬆的實現增量下發手機端未收取下去的消息
  2. 對於在弱網路環境差的情況,丟包情況發生概率是比較高的,此時經常會出現伺服器的回包不能到達手機端的現象。由於手機端只會在確切的收取到消息後才會更新本地的sequence,所以即使伺服器的回包丟了,手機端等待超時後重新拿舊的sequence上伺服器收取消息,同樣是可以正確的收取未下發的消息。
  3. 由於手機端存儲的sequence是確認收到消息的最大sequence,所以對於手機端每次到伺服器來收取消息也可以認為是對上一次收取消息的確認。一個帳號在多個手機端輪流登錄的情況下,只要伺服器存儲手機端已確認的sequence,那就可以簡單的實現已確認下發的消息不會重複下發,不同手機端之間輪流登錄不會收到其他手機端已經收取到的消息。
用戶在不同終端登錄的情況下獲取消息情況

image.png

假如手機A拿Seq_cli = 100 上伺服器收取消息,此時伺服器的Seq_svr = 150,那手機A可以將sequence為[101 – 150]的消息收取下去,同時手機A會將本地的Seq_cli 置為150

image.png

手機A在下一次再次上來伺服器收取消息,此時Seq_cli = 150,伺服器的 Seq_svr = 200,那手機A可以將sequence為[151 – 200]的消息收取下去.

image.png

假如原手機A用戶換到手機B登錄,並使用Seq_cli = 120上伺服器收取消息,由於伺服器已經確認sequence <= 150的消息已經被手機收取下去了,故不會再返回sequence為[121 – 150]的消息給手機B,而是將sequence為[151 – 200]的消息下發給手機B。

序列號msgid機制 & msgid確認機制(方案二: xxx項目目前方案):

  • 每個用戶的每條消息都一定會分配一個唯一的msgid
  • 服務端會存儲每個用戶的msgid 列表
  • 客戶端存儲已經收到的最大msgid
    • 對於單聊,群聊,匿名分別存儲(某人對應的id,某群對應的id).

image.png

思考

這兩種方式的優缺點?

  1. 方式二中,確認機制都是多一次http請求. 但是能夠保證及時淘汰數據
  2. 方式一中,確認機制是等到下一次拉取數據的時候進行確定, 不額外增加請求, 但是淘汰數據不及時.

技術點三之: 心跳策略

心跳功能: 維護TCP長連接,保證長連接穩定性, 對於移動網路, 僅僅只有這個功能嗎?

  1. 心跳其實有兩個作用
    • 運營商通過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 上呈現的地址就是這個地址池的公網地址
    • 心跳保證客戶端和服務端的連接保活功能,服務端以此來判斷客戶端是否還在線
    • 心跳還需要維持移動網路的GGSN
  2. 最常見的就是每隔固定時間(如4分半)發送心跳,但是這樣不夠智慧.
    • 4分半的原因就是綜合了各家移動運營商的NAT超時時間
    • 心跳時間太短,消耗流量/電量,增加伺服器壓力.
    • 心跳時間太長,可能會被因為運營商的策略淘汰NAT表中的對應項而被動斷開連接
  3. 智慧心跳策略
    • 為了保證收消息及時性的體驗,當app處於前台活躍狀態時,使用固定心跳。
    • app進入後台(或者前台關屏)時,先用幾次最小心跳維持長鏈接。然後進入後台自適應心跳計算。這樣做的目的是盡量選擇用戶不活躍的時間段,來減少心跳計算可能產生的消息不及時收取影響。
    • 大部分移動無線網路運營商都在鏈路一段時間沒有數據通訊時,會淘汰 NAT 表中的對應項,造成鏈路中斷。NAT超時是影響TCP連接壽命的一個重要因素(尤其是中國),所以客戶端自動測算NAT超時時間,來動態調整心跳間隔,是一個很重要的優化點。
    • 維護移動網GGSN(網關GPRS支援節點)
    • 參考微信的一套自適應心跳演算法:
  4. 精簡心跳包,保證一個心跳包大小在10位元組之內, 根據APP前後台狀態調整心跳包間隔 (主要是Android)

技術點四之: 斷線重連策略

掉線後,根據不同的狀態需要選擇不同的重連間隔。如果是本地網路出錯,並不需要定時去重連,這時只需要監聽網路狀態,等到網路恢復後重連即可。如果網路變化非常頻繁,特別是 App 處在後台運行時,對於重連也可以加上一定的頻率控制,在保證一定消息實時性的同時,避免造成過多的電量消耗。

  1. 斷線重連的最短間隔時間按單位秒(s)以4、8、16…(最大不超過30)數列執行,以避免頻繁的斷線重連,從而減輕伺服器負擔。當服務端收到正確的包時,此策略重置
  2. 有網路但連接失敗的情況下,按單位秒(s)以間隔時間為2、2、4、4、8、8、16、16…(最大不超過120)的數列不斷重試
  3. 為了防止雪崩效應的出現,我們在檢測到socket失效(伺服器異常),並不是立馬進行重連,而是讓客戶端隨機Sleep一段時間(或者上述其他策略)再去連接服務端,這樣就可以使不同的客戶端在服務端重啟的時候不會同時去連接,從而造成雪崩效應。

典型IM業務場景流程

  1. 用戶A發送消息給用戶B
    • A 通過帳號密碼獲取token.
    • A 拿著token進行login
    • 服務端快取用戶資訊並維持登錄狀態
    • A 打包數據發送給服務端
    • 服務端檢測A用戶是否風險用戶
    • 服務端對消息進行敏感詞檢查(這個重要)
    • 服務端生成msgid
    • 服務端進行好友檢測(A/B)
    • 服務端進行重複發送檢測
    • 服務端獲取B的連接資訊,並判斷在線狀態
    • 如果在線,直接發送給B,併入cache和db
    • 如果不在線,直接存儲.如果是ios,則進行apns.
    • 在線的B,收到消息後回應ack進行確認.
  2. 用戶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出來的考量和目的,到底有沒有必要?

  1. 拆分出來的目的:
    • 連接層更穩定
    • 減少重啟,方便Access服務升級
  2. 真的能夠起到這樣的效果么?
    • 拆分出來的connd server 還是有可能會需要重啟的, 這時候怎麼辦呢 ?關鍵性問題還是沒有解決
    • 加一層服務,是打算通過共享記憶體的方式,connd 只管理連接。access 更新升級的時候,用戶不會掉線。
    • 目前Access服務不重, 拆分出來真有必要嗎?
    • 真要拆分, 那也不是這麼拆分, 是在Oracle上做拆分, 類似微服務的那種概念
    • 穩定性不是這麼體現,原來 connd 的設計,更薄不承擔業務,而現在的 access 還是有一些業務邏輯,那麼它升級的可能性就比較高。
    • access 拆分,目的就是讓保持連接的那一層足夠薄,薄到怎麼改業務,它都不用升級程式碼(tcp 不會斷)。
    • 連接層更穩定 – – – 需要有硬性指標來判斷才能確定更穩定,因為Access的服務不重,目前也不是瓶頸點.
    • 減少重啟,方便Access服務升級 – – – 不能通過增加一層服務來實現重啟升級,需要有其他機制來確保服務端進行升級而不影響TCP長連接上的用戶
    • 增加一個服務,就多了一條鏈路, 就可能會導致服務鏈路過長,請求經過更多的服務,會導致服務更加不可用. 因為要保證每個服務的可用性都到99.999%(5個9)是很難的,增加一個服務,就會降低整個服務的可用性.
  3. 架構改進一定要有數據支撐, 要確實起到效果, 要有數據輸出才能證明這個改進是有效果的,要不然花了二個月時間做改進,結果沒有用,浪費人力和時間,還降低開發效率
    • 每個階段的架構可能都不一樣,根據當前階段的用戶量和熱度來決定

怎麼保證接入層服務重啟升級? 服務擴/縮容?

  1. 方案: 增加一條信令交互,服務端如果要重啟/縮容, 告知連接在此Access上的所有客戶端,服務端要升級了,客戶端需要重連其他節點
    • 這其實是屬於一種主動遷移的策略,這樣客戶端雖然還是有重連,比我們直接斷連接會好一些.
  2. 等確定當前Access節點上的所有客戶端都連接到其他節點後, 當前Access節點再進行重啟/下線/縮容.
  3. 怎麼擴容? 如果需要擴容,則增加新的節點後,通過etcd進行服務發現註冊.客戶端通過router server請求數據後,拉取到相關節點.
  4. 如果當前3個節點扛不住了,增加2個節點, 這個時候,要能夠馬上緩解當前3個節點壓力,需要怎麼做?
    • 服務端發送命令給當前節點上的客戶端,讓客戶端連接到新增節點上.
    • 服務端還需要確定是否有部分連接到其他節點了,然後再有相應的策略.
    • 按照之前的方式,客戶端重新登錄請求router server,然後再進行連接的話,這是不能夠馬上緩解壓力的,因為新增的節點後, 當前壓力還是在之前幾個節點
    • 所以, 服務端需要有更好的機制,來由服務端控制

怎麼防止攻擊

  1. 線上機器都有防火牆策略(包括硬體防火牆/軟體防火牆)
    • 硬體防火牆: 硬體防火牆設備,很貴,目前有採購,但是用的少
    • 軟體防火牆: 軟體層面上的如iptable, 設置iptable的防火牆策略
  2. TCP 通道層面上
    • 要能夠發送消息, 必須要先登錄
    • 要登錄, 必須有token,有秘鑰
    • 收發消息也可以設置頻率控制
    • 目前設置的是獨立ip建連速度超過100/s,則認為被攻擊了,封禁此ip
    • socket建連速度的頻率控制, 不能讓別人一直建立socket連接,要不然socket很容易就爆滿了,撐不住了
    • 收發消息頻率控制, 不能讓別人一直能夠發送消息,要不然整個服務就掛掉了

目前市面上的開源/通用協議的比較選型

  1. 為啥xmpp不適合,僅僅是因為xml數據量大嗎 ?
    • 目前也有方案是針對xmpp進行優化處理的. 因此流量大並不是主要缺點
    • 還有一點就是消息不可靠,它的請求及應答機制也是主要為穩定長連網路環境所設計,對於頻寬偏窄及長連不穩定的移動網路並不是特別優化
    • 因此設計成支援多終端狀態的XMPP在移動領域並不是擅長之地
  2. 為啥mqtt不適合? 為啥xxx項目沒有用mqtt ?
    • mqtt 適合推送,不適合IM, 需要業務層面上額外多做處理, 目前已經開始再用
    • xxx項目不用mqtt是歷史遺留問題,因為剛開始要迅速開展,迅速搭建架構實現,因此用來蘑菇街的teamtalk.
    • 如果後續選型的話, 如果沒有歷史遺留問題,那麼就會選擇使用mqtt
  3. 除了數據量大, 還要考慮協議的複雜度, 客戶端和服務端處理協議的複雜度?
    • 協議要考慮容易擴展, 方便後續新增欄位, 支援多平台
    • 要考慮客戶端和服務端的實現是否簡單
    • 編解碼的效率

跨機房, 多機房容災

  1. 服務需要能夠跨機房,尤其是有狀態的節點.
  2. 需要儲備多機房容災,防止整個機房掛掉.

剛討論說到接入層有哪些功能的:

  1. 維持TCP長連接,包括心跳/超時檢測
  2. 收包解包
  3. 防攻擊機制
  4. 等待接收消息回應(這個之前沒有說到,就是把消息發送給接收方後還需要接收方回應)

思考點(考核關鍵點)

  1. 消息為什麼可能會亂序? 怎麼保證消息不亂序?
    • 考慮離線
    • 考慮網路異常
  2. 對於離線消息,存儲方式/存儲結構要怎麼設計?
    • 考慮會有多個人發送消息
    • 考慮快取+db的方式
  3. 如何保證消息不丟,不重? 怎麼設計消息防丟失機制?
    • 考慮同一帳號可能會多終端登錄
    • 考慮弱網環境下,ACK也可能會丟失
  4. 對於長連接, 怎管理這些長連接?
    • 後端數據來了, 怎麼快速找到這個請求對應的連接呢
    • 考慮快速查找
  5. 接入層節點有多個,而且是有狀態的.通過什麼機制保證從節點1下發的請求,其對應的響應還是會回到節點1呢?
    • 或者說如果響應不回到節點1,而是回到節點2了會有什麼弊端?