【架構思考】IM架構

本文將總結關於如何構建一個IM架構相關的知識。

1. 將【接入服務】與【業務處理服務】獨立拆分

理由有二,是任務分工不同,接入服務負責建立並保持與客戶端的連接、消息的編解碼、協議解析等一些IM前臺服務(也可以叫做網關),是最接近用戶的服務,而且要在流量高峯期進行快速的性能擴展;

而業務處理服務則是整個IM架構的核心,經常會隨着業務需求不斷變化而進行頻繁的版本迭代,服務升級就意味着需要重啓,如果將其與接入服務和在一起,這勢必會導致已連接的客戶端出現不穩定,斷連重連的情況,甚至會導致消息推送不及時消息發送失敗等嚴重問題,帶來非常不好的用戶體驗。

是拆分後有助於提高業務開發效率,業務開發的開發者就不用關心連接層的事情,可以專注業務處理。

是向高內聚,低耦合的架構靠攏,這會對以後做系統擴展有很大的幫助,比如有另外一個完全不同的業務也需要進行消息推送,但只是推送,那就可以直接對接現有的接入服務,負責原來業務處理服務開發的同事就不用關心這塊了,這樣一來, 也將不同的業務隔離開了。

2. IM系統的特性

實時性,如果我和別人聊天,他的消息我1分鐘後才收到,那還聊個啥。

保證消息實時性有幾種方案,在後續探討

可靠性,這是即時消息服務應用於各種社交、互動領域的基本條件。可靠性在這裏分兩點,一是不丟消息,而是消息不重複。

對於這兩點,需要特別的處理才能實現完整的可靠性,後續探討

一致性,指的是同一條消息,在多人、多終端都需要保證消息順序展示的一致性。比如我先後在羣裏發了傻瓜、叫你兩條消息,有些人先看到的是叫你,這就有問題了。不同終端也是一樣的道理。這裏也需要通過特別的處理才能實現一致性,後續探討

安全性,即時消息已經被廣泛應用於各種私密社交場景,所以用戶對系統的隱私保護需求也就更高。從系統使用安全性的角度看,IM系統首先需要保證數據傳輸安全,其次是數據存儲安全,最後是消息內容安全

省電省流量,作爲一個優化,如果你的IM客戶端也包括移動端,那顯然,我們的IM系統考慮這方面的優化對用戶體驗無疑是錦上添花,所謂做就做到極致,後續探討

待續。

3. IM系統幾個必要的功能

3.1 消息未讀數

      功能很簡單,只要用戶沒有讀這條消息,就算作一條未讀消息。未讀消息的作用,一是直接的告訴用戶【哪個聯繫人發了幾條消息】,用戶很可能會根據未讀數來決定先讀取誰的消息。二是在APP的通知欄權限被限制以防止頻繁干擾用戶後*或者*用戶剛登錄/聯網的情況下,就需要APP圖標上的消息總未讀數來提醒用戶一共有多少條未讀消息。

      未讀數變更:當用戶點擊進入與聯繫人的聊天會話後,總消息未讀數減一(假設這個聯繫人只有一條未讀消息),單個聊天會話未讀數減一。

4. 存儲方面

4.1 對消息內容表/索引表進行分庫分表存儲,索引表以用戶ID爲主鍵進行哈希,保證用戶的所有會話記錄都在一張表上。

5. 如何保證消息投遞的可靠性:ACK機制

5.1 先看看常見的服務器中轉路由的IM架構下,消息如何流轉的

 這裏分兩部分:

          1. 第一部分是1/2/3這三個環節都可能出錯導致消息丟失,解決辦法是發送方的超時重發+IM端去重機制(發送方生成msgid)

          2. 第二部分是4這個環節可能出現問題,如IM存完消息就宕機,或者網絡原因未到達,或接收端處理失敗等。解決辦法是參考TCP的ACK機制實現一套業務層的ACK機制。

如何實現業務層的ACK機制

如圖,服務端推送消息時,攜帶一個SID(seq-id)表示此消息的唯一序列ID,消息推出後,立即將消息添加到“待ACK消息列表”,接收方若成功收到並處理消息就給服務端發一個ACK消息(攜帶這條消息的SID),然後服務端從“待ACK消息列表”中清除這條消息,此時纔算這條消息推送成功。

當然,這其中還有一些細節(重傳+):

        1. 當服務端發送的消息因爲某些原因導致B遲遲收不到,這個時候必須進行重傳。服務端在維護“待ACK消息列表”的同時,也會維護一個超時計時器,當某條消息在一定時間內沒有收到接收方的ACK,那麼就會取出這條消息進行重傳。

        2. 由發送方的ACK消息本身丟失的原因導致服務端重傳的情況,這時接收方就需要在本次會話中緩存收到的消息SID,在處理接收到的消息時根據SID進行去重。

最壞的情況:IM服務端推出消息後就宕機了,這個時候消息又因爲某些原因導致了接收方沒有收到,一旦宕機,對接收端來說,本次會話就停止了(本地的緩存可能清空)。那麼當IM恢復時,肯定會對這條消息進行重傳,但是IM恢復時接收端不一定在線,那麼就會將“待ACK消息列表”中的這個接收方的消息全部清空(不可能一直保持在緩存中,而是在磁盤中)。當下一次與接收方的會話開啓時,如何獲取那些“丟失“的消息呢?解決方法是消息的完整性檢查

如何實現消息的完整性檢查(時間戳):

        服務端發送消息時再帶一個timestamp,接收方接收消息成功時在本地緩存時也存下這個時間戳,那麼當IM宕機再恢復後,接收方帶上本地的上次最後一條消息的時間戳向服務器拉取消息,IM就會將這個大於這個時間戳的消息全部推送(大量的話需分頁)給接收方;若不帶時間戳,服務器在pop“待ACK消息列表”的同時需要記錄A用戶與B用戶的最後一條消息時間戳last_msg_ts,方便A用戶更換設備上線不帶時間戳時,把last_msg_ts用來計算未讀消息數,並推給用戶,大量消息仍然以分頁展示。

注意點:當IM是集羣/分佈式部署時,需要同步全局時鐘,否則會出現消息重複拉取/漏掉的問題,也可以使用全局自增序列來替換時間戳。

6. 如何保證消息不亂序?

6.1 即時通訊系統中,消息收發一致性是最需要保證的功能之一,如果亂序,在人看來就是語無倫次,說話令人懊惱。

6.2 如果是在後端服務器是完全單線程的場景中,那麼保證消息順序或許不是難題,但是這種模式部署的TPS和效率太低。實際應用中,我們需要多進程部署。那麼在多進程部署場景中,要保證消息時序一致性就找到一個“時序可比較性”,有了這個基準,才能將多條消息串起來變成有序的。

6.3 如何找到時序基準

     首先要排除的就是發送方的本地時間,因爲不同發送方的時間多半是不同步的(還可能不同時區),我們無法保證。

然後來考慮服務器本身的時間是否可以作爲這個時序基準,當服務器集羣部署時,即使使用NTP同步各服務器的時間仍然會存在誤差,而且當規模變大的時候,連NTP同步這個機制都不會可靠。

既然本地化的時鐘都不能作爲時序基準,那麼是不是可以專門設定一臺時鐘服務器或者序號服務器,所以的IM節點收發消息時都向這臺服務器獲取時間或序號,這樣就不存在同步問題,而且全局序號生成器有多中方案可用,如redis的incr命令,db的自增id,snowflake算法,時間相關的分佈式序號生成服務都可以。

6.4 解決“時序基準”的可用性問題

      當系統處於高併發訪問場景下,這個“時序基準”服務要如何維持高可用性呢?

      1. 首先是集羣或分佈式部署,如redis集羣。或者採用snowflake算法這樣的分佈式“序號生成器”。

      2. 其次是從業務層上考慮,對於羣聊和單聊這種場景,沒必要保證全局的跨羣的絕對消息時序一致性,只需要保證每個羣的消息時序一致性就可以了。這樣一來,就可以給每個羣分一個“ID生成器”,通過哈希規則把壓力分散到多個服務實例上,就可以大大降低全局共用一個“ID生成器”的併發壓力了,這樣即使ID生成服務是集羣部署,造成的誤差也可以忽略。

     微信的聊天和朋友圈的消息時序就是通過一個遞增的版本號服務來實現的,不過版本號是每個用戶獨立的。

6.5 其他誤差

     現在消息在服務端本地已經保證了時序性,但是這就能保證消息一定按照正確的順序顯示在用戶的手機上嗎?明顯是不一定的,有幾個原因。

      1. IM服務器是集羣化部署的,不同機器的性能和網絡質量略有差異,即使兩條消息同時離開服務器,先離開的消息有可能因爲網絡問題後到達用戶終端。

      所以需要對消息進行本地整流,因爲在有些場景中,需要IM服務器保證絕對的時序性。比如用戶取關一個公衆號,取關操作下達時,發送方先後生成兩個消息:先是發送“XXX已取關”的消息給公衆號,然後是真正的取關操作消息(雖然實際不會這麼實現)。如果第二條消息先到達服務器被處理,那麼“發送取關”的消息就會因爲已取關無法發送給對方。

這裏的解決方案可以是調整實現方式,比如用戶只需發送1條取關消息,觸發消息由服務端來操作;但是推送消息時也存在時序錯亂問題,要解決就需要進行接收端整流

當攜帶不同序號的消息到達接收端後,可能會出現順序錯亂,業界常見的實現方式比較簡單:

         1. 下推消息時,將序號隨消息一起推給接收方

         2. 接收方收到消息時進行判定,若當前消息序號大於前一條消息序號,則直接追加。

         3. 若當前消息序號小於前一條消息序號,則向上逐一比對,將消息插入序號剛好大於的那條消息後面。

另外需要注意,只有離線推送消息時(聊天記錄)一般纔會需要整流,在線聊天消息做整流會很影響實時性。

7. 保證消息的安全性

     三個維度來保證消息安全。

     7.1 消息傳輸安全。“訪問入口安全”和“傳輸鏈路安全”是基於互聯網的即時消息場景下的重要防範點。針對“訪問入口安全”可以通過 HttpDNS 來解決路由器被惡意篡改和運營商的 LocalDNS 問題;而 TLS 傳輸層加密協議是保證消息傳輸過程中不被截獲、篡改、僞造的常用手段。

     7.2 消息存儲安全。針對賬號密碼的存儲安全可通過單向散列算法和加鹽機制來保證安全;對於追求極致安全性的場景中,可採用E2EE即端到端加密的方式來提供傳輸保護,會話雙方啓動會話時會通過非對稱加密算法各自生成本地祕鑰對並進行公鑰交換,私鑰不在網絡上傳輸,消息由接收方公鑰加密,接收方再拿自己私鑰解密。IM服務端僅進行數據的流轉,亦無法查看明文。

再甚者就是消息完全不經過IM服務器,P2P的聊天,國外的telegram就支持這種,所以各種國內違法的東西盛行,採用這種方式是絕對的聊天自由了,因爲消息對開發商來說已經不可控了,國內肯定是不行的。

     7.3 消息內容安全。可以依託敏感詞庫,圖片/視頻/語音識別服務,市面上已經有很多成熟的廠商提供這些服務;還可以加上一些“聯動懲罰處置”進行風險識別後的閉環。

8. 分佈式鎖和原子性:確定消息未讀數

     8.1 即時通訊系統中的最重要的幾個特性是實時性、可靠性、一致性、安全性,除此之外,還有一個對用戶非常重要的功能就是未讀數提醒。如果我在手機桌面看到微信圖標顯示10條未讀,進去只有1條,我一定會投訴微信app嚴重bug,對於強迫症患者來說,這個功能甚爲重要。

     8.2 未讀數提醒爲兩方面,會話未讀和總未讀,前者指的是和某個聯繫人/羣組的消息未讀數,後者就是前者的總和,如何來保證這兩個數據的準確性顯得尤爲關鍵。

     8.3 成熟的方案中對於會話未讀和總未讀兩個數據時單獨維護的,雖然後者可以通過計算前者的和來得到,但是可能會因爲超時未取到部分會話的未讀導致出現一致性問題,而且多次獲取累加的操作在性能上易出現瓶頸。

     如果單獨維護,就要解決高併發場景中兩者的一致性問題,即會話未讀的和要==總未讀,在下面的例舉場景中就會發生數據不一致的問題:

    A給B發消息之前,B的A會話未讀==總未讀==0,A發送消息後,到達IM服務器時,爲B執行A會話未讀+1,然後執行B總未讀+1;若第二個操作失敗了,最終結果是:B不知道有新消息,從而漏掉查看這條消息。

    同樣的,第一步操作失敗造成的現象是用戶看到總未讀提示,點進去卻發現沒有一個未讀會話。

   原因分析:都是因爲會話未讀和總未讀的兩次連續變更不是原子性的,從而導致各種問題。

   解決:IM服務通常都是分佈式部署的,這裏可以選擇的方案:

     1. 分佈式鎖,如依賴於DB的唯一性(插入一條固定記錄成功則獲得鎖否則重試),redis的setNX等

     1. redis的事務功能,但是它的watch機制實際是一種樂觀鎖策略,在高併發場景中失敗率高,有一定的效率問題。更優的方案是Lua腳本。

     2. 原子化的腳本。redis支持嵌入Lua腳本來原子化的執行多條語句,這裏實現兩個操作的原子性的連續變更不在話下。還能實現更復雜的功能,比如有的未讀數不希望一致存在干擾用戶,如用戶7天未查看則清除未讀。這種業務邏輯可以很方便的使用lua腳本實現“讀時判斷過期並清楚”。

9. 智能心跳機制:解決網絡的不穩定性

在IM系統中,消息傳輸是很頻繁也很普通的事情,通常會選擇在服務端和客戶端維護一條基於TCP的長連接通道來傳輸消息,長連接的好處是:

        1. 節省了多次交互的TCP3次握手時間 

        2. 節省了部分header開銷,每次HTTP請求都會攜帶完整的頭部,如認證信息。

        3. 讓IM系統具有消息通信的強實時性(很重要)

對於大部分IM場景中,通信一方處於弱網絡環境中時,長連接此時就不可用,但IM服務無法感知到其不可用。比如路由器掉線,拔掉網線,wifi異常斷開,這些情況下客戶端和IM服務端都無法實時感知。這會造成IM服務在內存中維護的一部分連接可能都是無效的連接,導致了資源浪費。而心跳機制可以讓客戶端和服務端及時知道連接通道的失效,做及時的資源清理;心跳的另一個好處就是:支持客戶端及時斷線重連,當連接不可用時,客戶端執行及時的斷線重連是很必要的;最後一個作用是:連接保活。在用戶網絡和中間網絡都正常的情況下,長連接仍然有可能被殺死,原因如下:
         由於IPV4資源有限,移動運營商的上網卡實際上只是分配了一個其內網的IP,訪問互聯網時,運營商網關通過一個外網IP+端口 到 內網IP+端口 的雙向映射表來讓上網卡能夠上網,懂網絡的人應該知道這是用的NAT技術;這不是關鍵,關鍵是運營商爲了節省資源和降低其網關壓力,對於一段時間沒有數據收發的連接會把他們從NAT映射表中清除,這個操作也不會被手機端和IM服務端感知,所以就更需要心跳機制了。

業界常用的三種心跳方法:

       A. TCP keepalive

           作爲OS的TCP/IP協議棧實現的一部分,對於本機的TCP連接,會在連接空閒時按預定的頻次自動發送不帶body的傳輸層報文,來探測對方是否存活,OS默認關閉這個特性,需要應用層開啓。Linux系統上默認是心跳週期2hour,失敗後重試9次,超時時間75s,都可以修改。

           這種方法的優勢在於,傳輸層的報文開銷比應用層更小,可以提高發送頻率。

           劣勢就是,配置好了如果要改,就必須重啓IM服務,靈活性差。還有就是傳輸層的正常並不代表應用層是可用的,IM服務的代碼死鎖、阻塞都會導致連接不可用,通過傳輸層的保活機制是無法發現的,此時就需要應用層的心跳才能發現問題。

      B. 應用層心跳

           通過在客戶端應用程序中定時發送心跳來保活。這種方式可以彌補TCP保活機制的缺陷,小缺點就是開銷稍大,但基本可以忽略。還有一個優勢就是可以根據實際網絡情況和消息通信情況靈活的控制下一次心跳的間隔,這可以節省更多流量。實際案例:WhatApps 的應用層心跳間隔有 30 秒和 1 分鐘,微信的應用層心跳間隔大部分情況是 4 分半鐘,目前微博長連接採用的是 2 分鐘的心跳間隔。

           不同的IM客戶端發送心跳的間隔也是不一樣的,因爲他們網絡和消息通信情況的各不相同。大概策略是當客戶端空閒時纔會發送頻次更高的心跳包。

            常見的客戶端心跳機制:當連接空閒時間超過X,發送心跳包,連續N次收不到服務端迴應判定連接無效。

            常見的服務端心跳機制:當連接空閒時間超過X,判定連接無效,斷開並清理資源。

            另外,當連接無效時,應用層心跳無法區分到底是傳輸層還是應用層的問題,結合TCP的keepalive可以方便定位故障源。

      C. 智能心跳(極致優化)

           國內移動網絡場景下,各地方運營商的NAT超時時間的差異性也很大,從幾分鐘到幾小時不等。採用應用層固定頻率的心跳固然簡單,但對於設備CPU,電量,流量資源無法做到最大程度節約。爲了做到極致優化,部分廠商會採用智能心跳方案。來平衡”NAT超時“和"設備資源節省"。所謂智能,就是讓心跳能夠根據網絡環境來自動調整,通過不斷自動調整心跳間隔的方式,逐步逼近NAT超時臨街點。(在超時的邊緣瘋狂試探)

           這種方案雖然是好的,但是也增加了業務複雜度,帶來的節省效果也有限,因爲流量貴,電量不夠用的時代已經過去了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章