IM伺服器:開發一個高併發的IM伺服器難在哪

一隻會鏟史的貓發表於2021-11-02

IM伺服器要實現的最基本功能就是訊息的轉發。——好像是一句廢話!

這就意味著IM伺服器要為每個登入使用者建立一個與該使用者資訊相關的記憶體上下文,為方便描述我們在這裡稱之為:user_context。user_context中一般包含這些基本資訊:使用者id、暱稱、peer端的ip和埠,以及最重要的用於通訊的socket。

使用者連線上線時,需要malloc一個user_context塊,用於儲存上述資訊,使用者斷開連線時,需要free這個user_context塊。

IM伺服器要隨時維護這張user_context列表,這張表我們在這裡稱之為:list_user_context。這張表非常重要,im伺服器要根據這張表進行訊息的轉發。如果100個使用者登入,list_user_context表中就有100個元素,10萬個使用者就有10萬個元素,使用者間聊天時,IM伺服器就需要反覆查詢list_user_context,從而確定轉發的訊息要傳送到哪個使用者的機器上。

舉個例子:使用者A發訊息給使用者B,基本流程如下:
1、A將訊息傳送給IM伺服器;
2、IM伺服器解析訊息,獲取該訊息的接收人為B;
3、IM伺服器查詢list_user_context表,找到B的user_context(裡面有B的連線通道socket);
4、IM伺服器將A的訊息轉發給B;

正常流程都沒有問題,我們說下特殊的情況(注意不是異常情況):

【特殊情況一】

A在傳送訊息給B時,B突然退出客戶端程式。
此時IM伺服器接收到2個來自IO層的事件:
事件1:A發給B的聊天資料
事件2:B的掉線通知

這兩個事件會觸發IM伺服器進行如下兩個操作,
操作1:查詢list_user_context表,找到B的user_context(一個指向該記憶體的指標),並準備轉發A的訊息。
操作2:查詢list_user_context表,找到B的user_context,從表中移除並準備釋放指向該記憶體的上下文。

這兩個操作可能是在不同的執行緒中執行,實際上在IOCP這種完全非同步的模型下,這種可能的機率非常大。這時候B的user_context所在的記憶體區就是“臨界區”,操作不當就會導致訪問“野指標”,從而導致整個IM伺服器掛掉。當然你可以給list_user_context表加把鎖,加鎖可以減少訪問野指標的機率但還是無法完全避免這種情況的發生。

如果IM伺服器先執行釋放操作,也就是“操作2”,則是安全的,當“操作1”執行時,由於查詢不到B的user_context,就會認為B已離線,並放棄傳送操作。但如果“操作1”先執行,IM伺服器首先獲得了指向B的user_context指標,剛準備傳送資料時,CPU的時間片切換到了“操作2”上,並把B的user_context釋放,之後,CPU時間片又切換到“操作1”上,此時im server會訪問之前 查到的B的user_context記憶體區,這時訪問異常,伺服器程式崩潰。這種機率看似很小,但在高併發且聊天繁忙時,還是會發生。注意這種情況不是異常情況,而是在真實的業務場景中會實實在在並且經常發生的情況。

當然,你可以將鎖的範圍擴大,也就是從“臨界區”資料訪問擴大到操作層面上,也就是將整個傳送操作和釋放操作進行加鎖,從而確保CPU在時間片切換時仍能保證讀、寫、刪除等操作的原子性。這種方式雖然安全了,但顯然會讓你的伺服器從底層IOCP的完全非同步,退化為一個業務層面上的完全同步。
如果1萬人同時聊天的話,其結果將是災難性的。如果是群聊的話,就會更加複雜,如果A所在的群有100人,這就意味著IM伺服器要將訊息轉發給群中的其他99人。這99人可能會在此時發生各種情況,比如某些人突然退出或者突然退群。

【特殊情況二】

先說一下IM伺服器和WEB伺服器在設計上的最大不同。理解這一點,就能體會到IM伺服器設計上的複雜性。 WEB伺服器,也就是基於HTTP協議的伺服器,其業務可以抽象為:請求應答式服務, 即客戶傳送請求,伺服器響應請求,一問一答。即使是用POST命令上傳檔案也是基於請求應答式,只不過傳送請求的資料特別長而已。伺服器在沒有收到請求時,不會主動傳送資料給客戶端,這點非常重要,也就是說同一時間要麼只有一個讀操作,要麼只有一個寫操作。

“請求應答式”業務,如果放在IO層看就是讀寫同步,伺服器從IO中讀完請求後開始向IO中寫響應。實際上大部分應用協議都是基於請求應答式,比如:Telnet、FTP、POP3、SMTP。。。,這種方式在業務層面處理起來比較簡單。

另一種業務模式,就是:“非請求應答式”,比如IM,讀寫之間沒有聯絡,讀寫操作可能同時存在。

A在給B傳送訊息的時候,可能會同時收到B發來的訊息,甚至還有其它人的訊息,這時A要時刻保持“讀”監聽狀態,同時也會進行“寫”操作。對於IM伺服器來說,既要保持對A的“讀”監聽(用於接收A發來的訊息),也可能要對A進行“寫”操作 (轉發其他人發給A的訊息)。

假設A和100人同時進行聊天,就意味著IM伺服器可能要不停的對A的IO進行“寫”操作。即使A不傳送訊息,IM伺服器也要保持對A的“讀”監聽。如果此時,A退出聊天客戶端程式,而此時尚有98條訊息正在準備傳送A。那些存在於記憶體中的98條訊息,該如何釋放?
伺服器捕獲到A離線,開始準備釋放A的user_context,此時伺服器正在向A轉發群聊中來自不同使用者的訊息給A(上述98條訊息),這時一旦處理不當就會導致記憶體訪問異常,從而造成伺服器崩潰。
當然這種情況下,你也可以通過加鎖來解決,但遇到的問題和上述 【特殊情況一】 一樣,你可能要鎖的不是一個資料臨界區,而是一個完整的操作,從而確保操作的原子性,避免記憶體訪問異常。但過多的加鎖會導致IM伺服器效能大打折扣。

如果是IM伺服器叢集則會更加複雜,不同的使用者會登入在不同的IM伺服器上,A可能在伺服器1上,B可能在伺服器2上。A給B轉發訊息時,可能B已從伺服器2上離線。如果支援群聊的話,就更加複雜了。

舉一個極端的例子:
假設存在一個100臺IM伺服器的叢集,你有99個好友,你和他們分別登入在上述100臺伺服器上,也就是說大家彼此登入在不同的IM伺服器上。此時你要和每一位好友聊天就需要知道每位好友當前登入在哪臺IM伺服器上,你給每個好友傳送的訊息都需要進行伺服器間的轉發。如果你和他們分別建立N個群組的話,則每臺IM伺服器都要知道每個人所在的群組,從而進行群組訊息的轉發。

總結一下,IM伺服器的業務複雜就在於:使用者間會頻繁的進行互動。回到文章開頭的那句廢話:IM伺服器要實現的最基本功能就是訊息的轉發。正是由於訊息的轉發,才會導致臨界區的存在,因為某一時刻線上的使用者,可能在你給他傳送訊息時,已經下線。
IM伺服器編寫的難度和複雜性就源於這句廢話。 因為你編寫不是一個簡單的demo,而是要處理和解決所有可能的意外和異常情況,從而讓你的伺服器健壯、可靠和穩定。

最後多說幾句:

單機併發量越高,需要的叢集機器就越少,成本就越低,整個系統的複雜度也會降低。假設需要開發一個支援千萬級線上聊天的IM伺服器。
如果單機支援1萬,則需要1000臺IM伺服器,如果單機支援10萬,則只需要100臺。顯性成本就是需要多購買900臺伺服器,如果1臺伺服器價格1萬,則要多付出900萬。隱性成本就是每臺伺服器每年的電費或機房託管費,假設每臺每年成本為1千元,則每年要多付出90萬。此外,伺服器叢集越多,複雜度越高,開發成本越高,運維成本也就越高。所以要儘量採用好的IO模型開發伺服器端,比如linux下的epoll,windows下的iocp,從而提升單機的併發量。

相關文章