IM伺服器:編寫一個健壯的伺服器程式需要考慮哪些問題

一隻會鏟史的貓發表於2021-12-24

如果是編寫一個伺服器demo,比較簡單,只要會socket程式設計就能實現一個簡單C/S程式,但如果是實現一個健壯可靠的伺服器則需要考慮很多問題。下面我們看看需要考慮哪些問題。

一、維持心跳

為何要維持心跳,TCP難道不是一個安全可靠的連線麼?正常情況下,C端和S端無論是誰掉線,對方都能感知到。從而進行後續處理,比如釋放維持的資源並通知業務層進行相應的業務處理。

如果TCP通道非常繁忙,C端和S端都能通過正常的業務通訊感知到對方的存在與否。但如果TCP通道長時間無資料往來,這種感知就無法主動獲取到,這時就需要通過心跳包來進行檢測。 看看下面的情況:

1.1、突然死亡

客戶端突然斷電、當機等,這種情況下對方都來不及跟你道別就駕鶴西遊了,只留下伺服器擱那傻等。

1.2、突然失聯

比如:網線突然脫落或防火牆強行關閉TCP通道。防火牆為何會關閉TCP通道呢
防火牆認為C端和S端長時間沒通訊,可能感情破裂了,因此繼續維持兩者之間的聯絡毫無意義,所以單方面宣佈兩者離婚,強制執行,立即生效。
上面是我猜的,實際情況是防火牆出於對伺服器的愛,防火牆時刻監視著所有連線到伺服器上的TCP通道,如果有長期佔著茅坑不拉屎的連線,防火牆就會認為該連線是惡意的,是在對伺服器耍流氓,因此有必要立即斷開連線。
此時C端和S端雖然都活著,但兩者之間已經陰陽兩地,不可能在碰面了。
上述情況下,如果不進行心跳檢測,伺服器長期執行後,可能存在大量的“殭屍”連線,從而過多的佔用系統資源。對於業務層來說如果不及時處理這些“殭屍” 可能造成業務處理的混亂。

二、處理超時

為何要處理超時? 我們通常理解的超時處理,大部分是基於套接字(socket)這層,超時有可能是網路擁塞導致,也有可能是上述的突然死亡突然失聯導致。
如果send或recv長期無法完成,則有可能是TCP通道失效或對方已不在服務區,因此伺服器端有必要主動進行關閉操作。對於超時,你可以粗暴的直接關閉連線,也可以在嘗試N次傳送或接收都超時後進行關閉

對於這種socket超時,我們只需要通過setsockopt函式在網路層進行超時設定。對於阻塞套接字而言,這種方式是可行的,但對於非同步模型,這種方式則無法採用,比如IOCP模型。在IOCP模型下,所有投遞的讀、寫操作都需要業務層進行超時判斷。

上面的超時大家都比較清楚,其實超時處理最重要的作用是防止惡意連線,從而增強伺服器的健壯性。
以HTTP協議為例,伺服器需要讀取HTTP請求頭,這個請求頭會以兩個連續的回車換行(\r\n)來標記結束。
伺服器只有讀取完請求頭後才能進行下一步的解析和業務處理工作。如果請求方在傳送一半請求頭後,遲遲不傳送結束標記,就會導致伺服器傻等,因為伺服器會認為一次完成會話(HTTP Sesstion)並沒有結束。
或者,對方在content-length欄位中指明長度為100位元組,卻只給伺服器傳送了99位元組後跑路。如果沒有超時,伺服器會一直痴痴的等著這最後一個位元組的到來。
因此有必要在超時後進行會話關閉,否則這種惡意連線會很輕鬆的耗盡伺服器有限的連線資源。

因此處理超時,不僅能解決網路層的意外問題,也能有效解決業務層的耍流氓行為。 當然超時也可能導致誤傷,但相較於整體安全而言,這點誤傷是可以理解的,大不了重聯,重新培養感情。

三、實現定時器

這個好理解,上面的心跳檢測,需要定時器來週期性的發起(如果你的超時判斷不是依賴socket自帶實現機制,即通過setsockopt函式設定KEEP_ALIVE引數來實現的話)。ngnix、redis、libuv(nodejs使用的底層庫)等伺服器都有自己的定時器實現邏輯,設計一個好的定時器有助於減少不必要的資源浪費
定時器可以幫伺服器維持心跳檢測,同時也能幫伺服器做一些自身維護方面的工作,比如定期檢查記憶體、CPU使用情況,定期同步(儲存)資料等。
此外,處理超時也需要定時器來進行檢測,對於IOCP模型,無法通過setsockopt函式來設定套接字層的超時,只能通過業務層來自己實現,也就是對於每個發出的IO請求(讀寫操作)記錄時間,並在IO請求完畢後更新時間。定時器要週期性的檢查所有IO請求是否完成,或者是否超時。比如投遞一個寫操作,如果長時間沒有寫完畢,則需要進行超時處理。

對於上萬連線,該如何設計自己的定時器? 如果對每個連線socket(TCP連線)都啟動一個定時器進行超時或心跳檢測,則定時器本身就會消耗大量的系統資源,顯然這種方式是不明智的。
如果只啟動一個定時器,去檢測成千上萬連線,則需要考慮如何在CPU空閒或IO空閒時的去做這些事。比如當伺服器準備向某個TCP通道傳送心跳包時,該通道正在進行正常業務會話,此時心跳包可能會干擾正常的業務資料。比如CPU很繁忙的時候,如何讓你的定時器進行錯峰檢測。
也就是說,你的定時器要根據你的伺服器業務特點親自實現,並融合到整體的IO排程中。

四、有罪推論

這個和現實中的無罪推論相反。伺服器設計上,一定要假設所有請求可能都是非法的,要做有罪推論。 我們不能想當然的認為每個連線請求都會按照標準的協議與伺服器通訊。

大部分協議都是通過特定的結束標記(\r\n)來表示一次完整的請求或資料響應的完成。比如HTTP、FTP、TELNET、POP3、SMTP等協議。上古時期,早期作業系統UNIX(或DOS),使用者操作介面就是控制檯,控制檯的輸入輸出方式就決定了使用者只能通過敲擊鍵盤將協議命令輸入到網路,這也就導致了回車換行"\r\n"會作為一次命令結束的標識。 比如HTTP協議,與主機建立連線後,輸入"GET / HTTP/1.1\r\n"即可獲取網站的主頁。

還是以HTTP協議為例,HTTP請求頭是以兩個回車換行(\r\n\r\n)來標記結束。如果對方一直髮送資料,而不傳送結束標記該如何處理? 假設我們開闢一個4K(4096)位元組的緩衝區用於接收HTTP請求頭,對方傳送的請求頭超過4K怎麼辦,當然你可以remalloc記憶體繼續接收,但如果是惡意請求呢?比如對方一直髮送資料,直到把你的伺服器記憶體消耗殆盡。這時候就需要我們設定一個閥值,超過該值時要立即斷開連線。
這種方式可以理解為對講機模式,一句話講完後必須要帶上一句over,屬於後付費 。對方在你沒有傳送over之前無法知道最終資料有多長。這種後付費方式容易讓對方吃霸王餐,比如吃完之後沒說over(沒付錢)就跑了。。。。

還有一種協議不是以“over”標記符來表示請求的完整性。而是通過請求頭中的“長度欄位”來表示後續資料的大小。這種方式可以理解為報文方式。 屬於預付費 ,就是一開始就告訴對方自己要傳送資料的大小,或者告訴對方自己有多少錢,可以消費多少,讓對方提前準備好緩衝區。

這種方式下會有一個固定大小的報文頭,報文頭的欄位有嚴格的定義,用於指示後續資料的實際情況或者意義。
後付費能吃霸王餐,預付費也是可以的,也就是資料長度可能是假的,長度欄位雖然是1000個位元組,但最後給你2000個怎麼辦?或者只給你500個怎麼辦?

以websocket協議為例,雖然websocket協議是基於HTTP協議,但這僅限於建立會話階段。一旦會話建議,websocket就會通過固定格式的報文來進行資料交流。這種情況下我們要嚴格檢驗報文的格式,比如長度是否合法。
此外,對於所有recv來說,一次接收的資料不一定是你想要的結果,不是緩衝區開闢了多大,對方就一次性發給你多大。極端情況下,對方可以一個位元組一個位元組的傳送資料,這時候你就要進行資料的封裝和實時校驗。

上述情況都會涉及到記憶體的分配和訪問,一旦處理不當就可能造成系統資源耗盡或這伺服器的直接coredown。

五、使用記憶體池

從上面我們可以看到,記憶體的分配和銷燬是頻繁發生的事,伺服器長期執行就會導致記憶體碎片的產生。我的這篇文章對此有詳細的說明。
【超值分享】為何寫伺服器程式需要自己管理記憶體,從改造std::string字串操作說起

寫累了,到此為止吧,考慮的問題還有很多,比如你的上層業務是IO密集型還是CPU密集型,這就會對你程式架構產生影響,比如是否考慮使用執行緒池?這就是為何redis採用單執行緒,nginix採用多執行緒的原因之一。

相關文章