TCP Socket服務器編程

經過一年時間的沉澱 再次回首 TCP Socket服務器編程

------------------

前言

------------------

開發了這麼多年,發現最困難的程序開發就是通訊系統。

 

其他大部分系統,例如CRM/CMS/權限框架/MIS之類的,無論怎麼複雜,基本上都能夠本地代碼本地調試,性能也不太重要。(也許這個就是.net的企業級開發的戰略吧)

 

可是來到通訊系統,一切變得困難複雜。原因實在太多了,如:

  • 性能永遠是第一位:有時候一個if判斷都要考慮性能,畢竟要損耗一個CPU指令,而在通訊系統服務器,每秒鐘都產生上百萬級別的通訊量,這樣一個if就浪費了1個毫秒了。
  • 系統環境極其惡劣:所有我們可以想象的惡意攻擊、異常輸入等都要考慮;
  • 網絡說斷就斷:在socket環境下,客戶端可以以各種理由斷開鏈接,而且服務器根本不會知道,連一個流水作業的業務邏輯都無法保證正常執行,因此需要設計各種輔助的協議、架構去監督。
  • 各種網絡鏈接問題:例如代理、防火牆等等。。。

經過了1年的跌跌撞撞,我總算收穫了點有用的經驗,本文先從設計角度介紹一些我在Socket編程中的經驗,下一篇在放出源代碼。

 

------------------

現有的Socket編程資源

------------------

1. 首選推薦開源的XMPP框架,也就是Google的Gtalk的開源版本。裏面的架構寫的非常漂亮。特點就是:簡潔、清晰。

 

2. 其次推薦LumaQQ.net,這套框架本身寫的一般般,但是騰訊的服務器非常的猛,這樣必然導致客戶端也要比較猛。通過學習這套框架,能夠了解騰訊的IM傳輸協議設計,而且他們的協議是TCP/UDP結合,一舉兩得。

 

3. 最後就是DotMsn。這個寫的實在很一般般,而且也主要針對了MSN的協議特點。是能夠學習到一點點的框架知識的,不過要有所鑑別。

 

------------------

Socket的選擇

------------------

在Java,到了Java5終於出現了異步編程,NIO,於是各種所謂的框架冒了出來,例如MINA, xsocket等等;而在.NET,微軟一早就爲我們準備好了完善的Socket模型。主要包括:同步Socket、異步Socket;我還聽說了.net 3.x之後,異步的Socket內置了完成端口。綜合各種模型的性能,我總結如下:

 

1. 如果是短鏈接,使用同步socket。例如http服務器、轉接服務器等等。

 

2. 如果是長鏈接,使用異步socket。例如通訊系統(QQ / Fetion)、webgame等。

 

3. .net的異步socket的連接數性能在 7500/s(每秒併發7500個socket鏈接)。而聽說完成端口在1.5w所有。但是我到目前還沒有正式見過所謂的完成端口,不知道到底有多牛逼。

 

4. 我聽說了java的NIO性能在5000/s所有,我們項目內部也進行了鏈接測試,在4000~5000比較穩定,當然如果代碼調優之後,能提高一點點。

 

------------------

TCP Socket協議定義

------------------

本文從這裏開始,主要介紹TCP的socket編程。

新手們(例如當初的我),第一次寫socket,總是以爲在發送方壓入一個"Helloworld",接收方收到了這個字符串,就“精通”了Socket編程了。而實際上,這種編程根本不可能用在現實項目,因爲:

 

1. socket在傳輸過程中,helloworld有可能被拆分了,分段到達客戶端),例如 hello   +   world,一個分段就是一個包(Package),這個就是分包問題

 

2. socket在傳輸過成功,不同時間發送的數據包有可能被合併,同時到達了客戶端,這個就是黏包問題。例如發送方發送了hello+world,而接收方可能一次就接受了helloworld.

 

3. socket會自動在每個包後面補n個 0x0 byte,分割包。具體怎麼去補,這個我就沒有深入瞭解。

 

4. 不同的數據類型轉化爲byte的長度是不同的,例如int轉爲byte是4位(int32),這樣我們在製作socket協議的時候要特別小心了。具體可以使用以下代碼去測試:

代碼

 

 

儘管socket環境如此惡劣,但是TCP的鏈接也至少保證了:

  • 包發送順序在傳輸過程中是不會改變的,例如發送方發送 H E L L,那麼接收方一定也是順序收到H E L L,這個是TCP協議承諾的,因此這點成爲我們解決分包、黏包問題的關鍵。
  • 如果發送方發送的是helloworld, 傳輸過程中分割成爲hello+world,那麼TCP保證了在hello與world之間沒有其他的byte。但是不能保證helloworld和下一個命令之間沒有其他的byte。

 

因此,如果我們要使用socket編程,就一定要編寫自己的協議。目前業界主要採取的協議定義方式是:包頭+包體長度+包體。具體如下:

 

1. 一般包頭使用一個int定義,例如int = 173173173;作用是區分每一個有效的數據包,因此我們的服務器可以通過這個int去切割、合併包,組裝出完整的傳輸協議。有人使用回車字符去分割包體,例如常見的SMTP/POP協議,這種做法在特定的協議是沒有問題的,可是如果我們傳輸的信息內容自帶了回車字符串,那麼就糟糕了。所以在設計協議的時候要特別小心。

 

2. 包體長度使用一個int定義,這個長度表示包體所佔的比特流長度,用於服務器正確讀取並分割出包。

 

3. 包體就是自定義的一些協議內容,例如是對像序列化的內容(現有的系統已經很常見了,使用對象序列化、反序列化能夠極大簡化開發流程,等版本穩定後再轉入手工壓入byte操作)。

 

一個實際編寫的例子:比如我要傳輸2個整型 int = 1, int = 2,那麼實際傳輸的數據包如下:

   173173173               8                  1         2

|------包頭------|----包體長度----|--------包體--------|

這個數據包就是4個整型,總長度 = 4*4  = 16。

 

說說我走的彎路:

我曾經偷懶,使用特殊結束符去分割包體,這樣傳輸的數據包就不需要指名長度了。可是後來高人告訴我,如果使用特殊結束符去判斷包,性能會損失很大,因爲我們每次讀取一個byte,都要做一次if判斷,這個性能損失是非常嚴重的。所以最終還是走主流,使用以上的結構體。

 

 

------------------

Socket接收的邏輯概述

------------------

針對了我們的數據包設計+socket的傳輸特點,我們的接收邏輯主要是:

1. 尋找包頭。這個包頭就是一個int整型。但是寫代碼的時候要非常注意,一個int實際上佔據了4個byte,而可悲的是這4個byte在傳輸過程中也可能被socket 分割了,因此讀取判斷的邏輯是:

  • 判斷剩餘長度是否大於4
  • 讀取一個int,判斷是否包頭,如果是就跳出循環。
  • 如果不是包頭,則倒退3個byte,回到第一點。
  • 如果讀取完畢也沒有找到,則有可能包頭被分割了,因此當前已讀信息壓入接收緩存,等待下一個包到達後合併判斷。

2. 讀取包體長度。由於長度也是一個int,因此判斷的時候也要小心,同上。

3. 讀取包體,由於已知包體長度,因此讀取包體就變得非常簡單了,只要一直讀取到長度未知,剩餘的又回到第一條尋找包頭。

 

這個邏輯不要小看,就這點東西忙了我1天時間。而非常奇怪的是,我發現c#寫的socket,似乎沒有我說的這麼複雜邏輯。大家可以看看LumaQQ.net / DotMsn等,他們的socket接收代碼都非常簡單。我猜想:要麼是.net的socket進行了優化,不會對int之類的進行分割傳輸;要麼就是作者偷懶,隨便寫點代碼開源糊弄一下。

 

------------------

Socket服務器參數概述

------------------

我在開篇也說了,Socket服務器的環境是非常糟糕了,最糟糕的就是客戶端斷線之後服務器沒有收到通知。 因爲socket斷線這個也是個信息,也要從客戶端傳遞到我們socket服務器。有可能網絡阻塞了,導致服務器連斷開的通知都沒有收到。

因此,我們寫socket服務器,就要面對2個環境:

1. 服務器在處理業務邏輯中的任何時候都會收到Exception, 任何時候都會因爲鏈接中斷而斷開。

2. 服務器接收到的客戶端請求可以是任意字符串,因此在處理業務邏輯的時候,必須對各種可能的輸入都判斷,防止惡意攻擊。

 

針對以上幾點,我們的服務器設計必須包含以下參數:

1. 客戶端鏈接時間記錄:主要判斷客戶端空連接情況,防止連接數被惡意佔用。

2. 客戶端請求頻率記錄:要防止客戶端頻繁發送請求導致服務器負荷過重。

3. 客戶端錯誤記錄:一次錯誤可能導致服務器產生一次exception,而這個性能損耗是非常嚴重的,因此要嚴格監控客戶端的發送協議錯誤情況。

4. 客戶端發送信息長度記錄:有可能客戶端惡意發送非常長的信息,導致服務器處理內存爆滿,直接導致宕機。

 

5. 客戶端短時間暴漲:有可能在短時間內,客戶端突然發送海量數據,直接導致服務器宕機。因此我們必須有對服務器負荷進行監控,一旦發現負荷過重,直接對請求的socket返回處理失敗,例如我們常見的“404”。

 

6. 服務器短時間發送信息激增:有可能在服務器內部處理邏輯中,突然產生了海量的數據需要發送,例如遊戲中的“羣發”;因此必須對發送進行隊列緩存,然後進行合併發送,減輕socket的負荷。

 

 

------------------

後記

------------------

本文從架構設計分析了一個socket服務器的設計要點。如果您有其他見解,歡迎留言與討論。

------------------------------------------
發佈了95 篇原創文章 · 獲贊 7 · 訪問量 17萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章