Http權威指南筆記(四)——連接管理

本篇主要包括如下幾個方面的內容:

  • HTTP 是如何使用 TCP 連接的;
  • TCP 連接的時延、瓶頸以及存在的障礙;
  • HTTP 的優化,包括並行連接、keep-alive(持久連接)和管道化連接;
  • 管理連接時應該以及不應該做的事情。

1 TCP連接

幾乎所有的HTTP通信都是有TCP/IP連接承載的。TCP/IP是一種可靠的連接,其傳輸的數據不會丟失、受損。
TCP 連接是通過 4 個值來識別的:

< 源IP 地址、源端口號、目的IP 地址、目的端口號>

這 4 個值一起唯一地定義了一條連接。兩條不同的 TCP 連接不能擁有 4 個完全相同的地址組件值(但不同連接的部分組件可以擁有相同的值)。
這裏我們先看一條HTTP請求的連接過程,如下:
HTTP連接
其中第四步的連接一般就是通過TCP/IP連接,然後在其連接的基礎上進行報文發送。

1.1 TCP的數據傳輸

TCP爲HTTP提供了一條可靠的傳輸管道,從TCP管道的一端寫入數據,TCP管道的另一端就會以原有順序有序的讀出數據。
上面僅僅提到了TCP的傳輸是有序的,具體是怎麼傳輸數據的呢?TCP的數據是通過IP數據報的小數據塊來發送的。也就是說,HTTP需要傳送報文的時候,先先打開一條TCP連接,然後以流的形式將數據給TCP,TCP收到數據流之後,會對其進行分塊,將每一塊數據封裝進IP分組當中,然後通過英特網進行傳輸。一般來說,我們只會看到HTTP報文的傳輸,中間TCP/IP對數據的處理,對HTTP的開發人員來說是透明的。整個數據分塊傳輸如下圖所示:
TCP/IP分塊傳輸
上述提到的IP分組後,每個分組中一般包括以下幾項數據:

  • 一個 IP 分組首部(通常爲 20 字節):包含源和目標的IP地址、長度等信息
    一個 TCP 段首部(通常爲 20 字節):如TCP的端口號,用於數據完整性檢查和校驗等信息
    一個 TCP 數據塊(0 個或多個字節):也就是真正需要傳輸的數據了

1.2 TCP套接字編程

一般操作系統都會以API接口的形式提供一條TCP編程的工具,我們利用這套API就可以進行TCP編程了。該API只會暴露出需要的接口給我們使用,其中數據分組、傳輸的細節被隱藏起來了。下面我們看一個例子和一些僞代碼來理解TCP傳輸的過程:
TCP僞代碼

2 TCP性能問題

2.1 HTTP的時延問題

這裏先看一下一次HTTP請求過程:
HTTP請求
從上圖可以看到,其實我們一次HTTP請求響應過程中,真正處理HTTP請求的時間相對於整個TCP連接,報文傳輸等的時間來說,佔比是非常小的。所以HTTP的時延主要因素就在於TCP的時延,通過上圖可以將HTTP時延原因總結爲如下幾條:

  1. 第一個就是通過DNS來解析URI中的IP地址和端口的時間;
  2. 第二個需要關心的就是建立TCP連接的時間,TCP連接爲了提供可靠連接,在連接的過程中會有多次握手的過程,也是非常消耗時間的
  3. 剩下的就是我們HTTP請求報文的處理和最後將相應報文回傳給客戶端的時間。

2.2 性能優化

上一小節總結了HTTP請求的時延產生,本節對一些常見的性能問題進行一些詳細分析和說明,然後再次基礎上,提出相應的解決辦法。

2.2.1 IP地址和端口解析

上面我們提到了,HTTP時延中的一個原因就是DNS解析IP地址和端口的問題,該問題目前大部分客戶端的處理方式就是建立起一個緩存機制,當我們需要從URI中解析出IP地址和端口的時候,先從緩存中獲取,本地緩存中有的話,就不需再通過DNS進行解析了。這樣就節約了這部分的時間

2.2.2 TCP連接的握手時延

前面提到,一個新的TCP連接建立的時候,會在發送真正的報文數據之前,進行幾次握手,用於保證其傳輸的可靠性。一般TCP連接握手分爲以下幾步:

  1. 客戶端要建立新的連接的時候,先向服務器端發送一個特殊的分組數據,包含一個SYN的標記,說明這是一個連接請求
  2. 服務端收到該請求後,對收到的參數進行一些計算,向客戶端返回一個分組數據,包含SYN和ACK標記,表明服務器已經接受該請求
  3. 客戶收到服務端的消息後,再發送一條確認信息,告訴服務器已經確認連接成功。
    而上訴三次握手都是需要花費時間的,如果我們發送的是一條數據量非常大的報文,那麼這個握手的時間可能不會有太大問題,但是如果我們本身發送的數據量非常小,使得整個HTTP請求的時間中大部分都浪費在了握手的時間上,再設想一下,如果我們後面發送的大部分請求都是這種銷量數據類型的請求,那如果每次都是去重新建立TCP連接,將是非常低效率的做法。爲了解決該問題,我們可以重用連接,這樣我們一次建立TCP連接,發送多次報文數據,就可以達到提高性能的目的。怎麼實現重用連接,後續會有介紹。

2.2.3 延遲確認問題

TCP爲了保證數據傳輸的可靠性,除了在建立連接的三次握手機制外,還會通過在接收到數據後回覆給發送者一個確認消息。表明自己已經收到了正確的數據。但是如果每次我們都專門爲了回覆確認消息而單獨發送數據,那就很浪費網絡資源,因爲確認消息是非常小的,可能還沒有一條分組數據的TCP標記信息大。所以TCP爲了節約資源,允許將確認消息捎帶在發往同一個方向的數據分組中(如此次數據分組是從服務器發往客戶端的,那就可以捎帶服務器給客戶端的確認消息)。但是要知道,不是每次都剛好有發往同方向的數據分組的,這個時候,在延遲100~200ms後,都沒有合適的數據分組需要發送,那就需要單獨將該確認消息發出。假設我們客戶端和服務器端進行很多次通信,而每次通信都恰巧沒有合適的數據分組可以捎帶確認消息,那麼每次都需要延遲100~200ms進行確認。當次數很多的時候,延遲的影響也會變的很大。爲了解決該問題,我們可以在合適的時候禁用延遲確認機制,當然是在合適的時候,有些時候用該機制是可以達到解決資源浪費的目的。

2.2.4 TCP慢啓動

TCP 數據傳輸的性能還取決於 TCP 連接的使用期(age)。TCP 連接會隨着時間進行自我“調諧”,起初會限制連接的最大速度,如果數據成功傳輸,會隨着時間的推移提高傳輸的速度。這種調諧被稱爲 TCP 慢啓動(slow start)。
這種機製造成的一個問題就是在前期TCP傳輸數據的效率是非常低下的。爲了解決該問題,也可以通過重用連接的方式,達到不要每次都重新啓動TCP連接而經歷慢啓動。

2.2.5 Nagle算法與TCP_NODELAY

Nagle算法也是爲了解決資源浪費的問題。試想如果我們有大量的非常小的數據傳輸的情況,每次傳輸除了我們自己的數據外,還需要至少40個字節用於存放TCP標記和首部。最終導致的結果就是浪費了大量的資源在傳輸這些標記和首部上面。Nagle算法會在每次TCP發送數據之前進行判斷,如果該次數據不足以填滿TCP的數據分組的全尺寸(一般爲1500字節),就會將其放入緩存中,待湊足了數據再一次性進行發送,這樣也會造成HTTP時延問題。解決辦法就是在需要的時候,通過配置TCP_NODELAY禁用Nagle算法。

2.2.6 TIME_WAIT累積和端口耗盡

當某個 TCP 端點關閉 TCP 連接時,會在內存中維護一個小的控制塊,用來記錄最近所關閉連接的 IP 地址和端口號。這類信息只會維持一小段時間(稱爲2MSL)。一般情況2MSL不會出現什麼問題。但是考慮極端情況,需要用一臺客戶端一直快速重複連接到一臺服務器(服務器只監聽80端口),根據之前介紹,我們知道一條TCP構建要數如下:

<source-IP-address, source-port, destination-IP-address, destination-port>

如果這4個元素都相同,就視同爲同一個連接,所以想要不同的連接,就需要修改其中的某個或某幾個值。在該種情況下,就只有source-port該參數是可變的,其他幾個已經固定死了。這個時候,由於source-port的資源也是有限的,一般爲60000個。如果假設2MSL=120秒,那麼一秒內最大連接數就只有 60000/120=500個。那這可能出現要麼就達不到快速的要求,要麼最終會導致端口耗盡的情況。這裏提出這個問題,主要是讓大家防止出現那種大量連接都處於打開的情況,從而導致端口耗盡。一般想要解決這個問題,可以考慮設置虛擬IP地址,從而可以實現更多TCP連接的組合。

3 HTTP連接的處理

前面介紹了TCP連接的有關知識,這裏我們回到HTTP,繼續介紹HTTP連接相關知識和技術。

3.1 Connection首部

HTTP允許客戶端和最終服務器中間存在一串中間實體(如代理、高速緩存等)。一般的首部從客戶端發出後,都會一步一步的傳遞到最終服務器,但有時候我們希望某個首部,只出現在其中某一步,這個時候,Connection首部就有用途了,該字段中有一個由逗號分隔的連接標籤列表,這些標籤爲此連接指定了一些不會傳播到其他連接中去的選項。該首部一般可以承載三種不同的標籤:

  • HTTP 首部字段名,列出了只與此連接有關的首部;
  • 任意標籤值,用於描述此連接的非標準選項;
  • 值 close,說明操作完成之後需關閉這條持久連接。
    比如我們有如下請求:
    設立收到Connection標籤的中間實體,在轉發請求的時候,就不應該將Meter首部進行轉發,應當將其刪除後在轉發請求。

3.2 串行事務導致的時延

如果我們對於所有的請求管理都是進行簡單的串行處理,那前面提到的TCP性能中的時延影響將會不斷增加。如:我們請求的一個html頁面,其中包含3張圖片。這個時候,我們就會至少有4次請求,一次請求整個html文件,另外還有3次去請求圖片。這個時候,如果只是簡單的串行處理,整個加載的時常如下圖所示:
HTTP串行連接
從圖中可以看到,浪費了非常多的時間進行等待連接。其實整個加載過程,完全可以分爲兩步即可,即第一次先獲取html文件,然後獲得這3張需要加載圖片的URL,然後完全可以同時加載這3張圖片,這樣時間就會節約很多。同時加載3張圖片就是我們下面要介紹的並行連接。

4 並行連接

如上面提到的例子,簡單的進行串行處理,HTTP請求會顯得非常緩慢,效率很低。所以HTTP是支持並行連接的——HTTP客戶端可以同時打開多條連接,同時處理多個事務。
以前面提到的加載一個含有3張圖片的html穩定,如果我們用並行連接,那麼最終的耗時情況會如下圖所示:
HTTP並行連接
可以看到,這裏比前面的串行連接節約了非常多的時間,而且這樣處理還有個好處是,在你帶寬足夠的情況,並行處理相比較串行處理可以更充分的使用帶寬。
一般情況下並行連接相較於串行連接會更快,但並不是絕對的,試想下面一種情況:我們的帶寬本就不足,就只有24kbps,然後我們上述的那個例子中,每個請求幾乎都會佔滿整個帶寬,這個時候,即使使用並行連接,但是由於帶寬資源不足,每個事務都會去競爭帶寬資源,這個時候競爭也會產生一些額外的開銷,最終反而會使得整個加載整體上比串行更慢。

5 持久連接

我們的客戶端其實經常會連接同一個站點,比如:一個html頁面中的大部分內嵌圖片等資源一般都位於同一個服務器,比如我們在電子商務網站上覽商品的時候,我們中途打開的很多連接也是位於同一個服務器。這種現象——即:初始化了對某服務器 HTTP 請求的應用程序很可能會在不久的將來對那臺服務器發起更多的請求(比如,獲取在線圖片)。被稱爲站點局部性(site locality)
爲了由於上述情況下,每次都去重新創建新的鏈接導致時延和性能低下,HTTP/1.1中允許 HTTP 設備在事務處理結束之後將 TCP 連接保持在打開狀態,以便爲未來的 HTTP 請求重用現存的連接。這種連接就被成爲持久連接
持久連接降低了時延和連接建立的開銷,將連接保持在已調諧狀態,而且減少了打開連接的潛在數量。但是,管理持久連接時要特別小心,不然就會累積出大量的空閒連接,耗費本地以及遠程客戶端和服務器上的資源。

5.1 HTTP/1.0+ keep-alive連接

大約從 1996 年開始,很多 HTTP/1.0 瀏覽器和服務器都進行了擴展,以支持一種被稱爲 keep-alive 連接的早期實驗型持久連接。雖然HTTP/1.1規範中已經有更好的選擇,但是現在有些客戶度和服務器仍然在使用keep-alive連接,所以還是有必要了解一下。

5.1.1 keep-alive操作

實現 HTTP/1.0 keep-alive 連接的客戶端可以通過包含 Connection: Keep-Alive 首部請求將一條連接保持在打開狀態。如果服務器願意爲下一條請求將連接保持在打開狀態,就在響應中包含相同的首部。如果響應中沒有 Connection: Keep-Alive 首部,客戶端就認爲服務器不支持 keep-alive,會在發回響應報文之後關閉連接。一個帶keep-alive連接的請求過程如下:

5.1.2 keep-alive選項

上面的內容介紹瞭如何創建一個keep-alive連接。但是實際使用中不僅僅打開一條keep-alive連接就可以了。還要對其進行一些操作,比如:規定多久可以關閉該條連接(總不能一直開着,否則就太浪費資源了)。這個時候,我們可以通過在keep-alive首部中,通過","分隔選項對keep-alive進行控制,常用選項如下:

  • 參數 timeout 是在 Keep-Alive 響應首部發送的。它估計了服務器希望將連接保持在活躍狀態的時間。這並不是一個承諾值,即可能在還未達到該時間服務器就已經關閉連接了。
  • 參數 max 是在 Keep-Alive 響應首部發送的。它估計了服務器還希望爲多少個事務保持此連接的活躍狀態。同樣,這也不是一個承諾值。
  • Keep-Alive 首部還可支持任意未經處理的屬性,這些屬性主要用於診斷和調試。語法爲 name [=value]。

如下面的首部:

Connection: Keep-Alive
Keep-Alive: max=5, timeout=120

說明服務器最多還會爲另外 5 個事務保持連接的打開狀態,或者將打開狀態保持到連接空閒了 2 分鐘之後。

5.1.3 keep-alive連接的規則和限制

使用 keep-alive 連接時有一些限制和規則,這裏總結如下:

  • 在 HTTP/1.0 中,keep-alive 並不是默認使用的。客戶端必須發送一個 Connection: Keep-Alive 請求首部來激活 keep-alive 連接。
  • Connection: Keep-Alive 首部必須隨所有希望保持持久連接的報文一起發送。如果客戶端沒有發送 Connection: Keep-Alive 首部,服務器就會在那條請求之後關閉連接。
  • 通過檢測響應中是否包含Connection: Keep-Alive響應首部,客戶端可以判斷服務器是否會在發出響應之後關閉連接。
  • 只有在無需檢測到連接的關閉即可確定報文實體主體部分長度的情況下,才能將連接保持在打開狀態——也就是說實體的主體部分必須有正確的 Content-Length,有多部件媒體類型,或者用分塊傳輸編碼的方式進行了編碼。在一條 keep-alive 信道中回送錯誤的 Content-Length 是很糟糕的事,這樣的話,事務處理的另一端就無法精確地檢測出一條報文的結束和另一條報文的開始了。
  • 代理和網關必須執行 Connection 首部的規則。代理或網關必須在將報文轉發出去或將其高速緩存之前,刪除在 Connection 首部中命名的所有首部字段以及 Connection 首部自身。
  • 嚴格來說,不應該與無法確定是否支持 Connection 首部的代理服務器建立 keep-alive 連接,以防止出現下面要介紹的啞代理問題。在實際應用中不是總能做到這一點的。
  • 從技術上來講,應該忽略所有來自 HTTP/1.0 設備的 Connection 首部字段(包括 Connection: Keep-Alive),因爲它們可能是由比較老的代理服務器誤轉發的。但實際上,儘管可能會有在老代理上掛起的危險,有些客戶端和服務器還是會違反這條規則。
  • 除非重複發送請求會產生其他一些副作用,否則如果在客戶端收到完整的響應之前連接就關閉了,客戶端就一定要做好重試請求的準備。

5.1.4 keep-alive和啞代理

通過上面的介紹,我們知道keep-alive是屬於“Connection”首部的內容,前面已經介紹了“Connection”首部的特性。結合其特性,我們在使用keep-alive中不注意的話會產生一些問題。

1. Connection首部和盲中繼

試想下面這種情況:我們在連接過程中使用了代理,而該代理不理解“Connection”首部,僅僅是簡單的對其進行轉發,如下圖所示:
HTTP啞代理
從圖中可以看到,中間的代理,並沒有對Connection: keep-alive進行任何處理,僅僅是簡單的轉發。在第一次請求完成後,服務器和客戶端都會以爲對方支持keep-alive連接,所以不會對連接進行關閉。第一次請求沒問題,但是如果客戶端接着往同一服務器發送了第二天請求,因爲代理是不支持keep-alive的,即其雖然目前和客戶度、服務端都保持着連接的狀態,但是由於其不支持keep-alive特性,它不會處理同一條連接上的的該次請求,這個時候整個請求就會被阻塞在這裏。

2. 代理和逐跳首部

爲了避免上述情況的產生,所以代理必須要能正確處理“Connection”首部,不能對其進行盲目轉發。除了此處的keep-alive外,還有幾個不能作爲 Connection 首部值列出,也不能被代理轉發或作爲緩存響應使用的首部。其中包括 Proxy-Authenticate、Proxy-Connection、Transfer-Encoding 和 Upgrade。

5.1.5 插入Proxy-Connection

這裏爲了避免上述盲中繼產生的問題,另外一種做法是使用Proxy-Connection首部。如果代理是盲中繼,它會將無意義的 Proxy-Connection 首部轉發給 Web 服務器,服務器會忽略此首部,不會帶來任何問題。但如果代理是個聰明的代理(能夠理解持久連接的握手動作),就用一個 Connection 首部取代無意義的 Proxy-Connection 首部,然後將其發送給服務器,以收到預期的效果。
下圖描述了怎麼通過Proxy-Connection避免產生盲中繼帶來的問題的:
Proxy-Connection
但是該種解決方法,並不能徹底解除問題,其只適用於客戶端和服務端中間只有一個代理的情況。試想如果存在2個代理的情景(第一個可以正常處理keep-alive,第二個是盲轉發):第一個是可以處理Proxy-Connection,所以其會將替換掉,會向下一級發送一個“Connection: keep-alive”首部,第二個代理不知道怎麼處理,進行盲轉發,這時又會回到最初的問題。

5.2 HTTP/1.1持久連接

前面小節介紹的keep-alive並不是官方規範所的內容,是許多瀏覽器和服務器廠商自己擴展的。到了HTTP/1.1,用一種名爲持久連接(persistent connection)的改進型設計取代了keep-alive。持久連接的目的與 keep-alive 連接的目的相同,但工作機制更優一些。
persistent connection和keep-alive最大的一個區別是,其默認是打開的狀態,除非顯式的添加Connection: close首部,表示請求完成後立即關閉連接,否則客戶度和服務器之間的連接就會維持在打開狀態。當然,客戶端和服務器也可以選擇隨時關閉連接,即使沒有Connection: close首部。
我們在日常使用當中,需要注意一下幾點:

  • 只要發送了Connection: close首部後,就意味着會關閉連接,將來就不能在該連接上再發其他請求;
  • 如果客戶端,明確知道當前請求完成後,不會在該連接上繼續發送請求,就應該顯示發送Connection: close首部,以節約資源
  • 持久連接能夠有效保持的前提是,當前連接所有的報文都必須有正確的、自定義的報文長度——即實體部分的長度都和相應的 Content-Length 一致
  • 所有的代理應該能正確處理持久連接,而且持久連接只能作用於一跳之間,當我們在實現代理的時候,要特別注意這一點
  • 要做好應對連接的對方隨時關閉連接的可能
  • 客戶端應該要有重試機制,一般只要重複請求不會帶來其他影響的情況下,如果在沒有收到完整響應的情況下,連接關閉了,客戶端應當能自動重新嘗試發起請求
  • 一般情況下,客戶端最多會維持2條到指定服務器的持久連接,防止服務器過載。所以在有N個用戶需要訪問服務器的情況,代理一般就需要維護2N條到服務器或者父代理的連接。

5.3 管道化連接

在HTTP/1.1中,在持久連接的選擇上,可以使用請求管道,其也是針對keep-alive的一個性能優化點。在一個連接上,如果有多個無依賴性的請求,我們可以將多條請求連續放入請求隊列中,而無需等待前一條請求響應後再發起請求。這樣就大大縮減了網絡時延。但是在使用的時候需要注意一下幾點:

  • 使用管道,必須保證是在持久化連接的前提下,如果不能確保持久化連接,則不能使用管道
  • 響應的順序必須和請求的順序相同,在請求隊列中並沒有對每個請求做標識,所以必須按照請求的順序進行響應,以便匹配每個請求和響應。
  • 客戶端必須要做好連接被隨時關閉的準備,且在冪等請求的情況下,應該具有自動重試機制——如發了10條GET請求,但是隻有5條響應成功後連接被關閉了,則客戶端應該嘗試重新發起剩下的5個請求。
  • 管道不能用於非冪等的請求操作。如:想向服務器提交一條新的name=victor的表單數據。通過POST請求,服務器收到請求後會在數據庫插入一條新的數據,如果連接的關閉是在服務器已經處理完請求,但是沒有響應的情況,重試機制會再次發起POST請求,就會讓服務器在數據庫插入兩條重複的數據。所以非冪等操作不能自動進行重試。

6 連接的關閉

雖然我們在上述內容中多次提到了客戶度和服務端可以隨時關閉連接。但是我們在實際處理的時候,除非是因爲某些突發性故障導致不得不在中途關閉連接,否則我們應當儘量在每次請求完成後再嘗試關閉連接。但是我們的應用程序也要做好應對突發故障導致請求中途連接關閉情況。
當我們收到一條隨連接關閉的HTTP報文,而且傳輸的實體長度與 Content-Length 並不匹配(或沒有 Content-Length)時,接收端應該對該條內容正確性產生質疑。特別是代理,在遇到該情況的時候,既不能對這條報文做緩存,也不能對其嘗試做“校正”。應當直接轉發給它的下游。
在關閉連接的另外一個注意點就是上述已經說過的重試機制一定是針對冪等請求的。非冪等請求不應該自動嘗試重新請求,針對非冪等請求,實際操作中,大部分客戶端會談出提示框由用戶選擇是否重新發起請求。
根據連接關閉的方向我們可以將關閉分爲半關閉和完全關閉。如果應用程序的輸出端和輸入端都關閉了,稱爲完全關閉,如果只有其中一個端關閉了,則稱爲半關閉。如下圖所示:
HTTP半關閉
在關閉連接的過程中,我們特別注意重置錯誤。一般來說,關閉輸出端是非常安全的,輸出端關閉後,接收端在接收到最後一條數據後會收到通知,就會知道連接關閉了。但是針對輸入端來說,就需要謹慎了。如果另一端向你已經關閉的輸入信道發送數據,這個時候就會產生一個重置錯誤。這個時候操作系統一般會清除該條連接的緩存數據,就會產生嚴重的問題。試想一下:你剛剛發送了10條請求,然後也正常接收到了響應後接收端關閉了輸入信道,但是你目前只處理了其中5條,還有5條的響應還存在緩存區。恰好這個時候你還有一條請求需要發送,發送之後由於對方已經關閉輸入信道,就會收到一條重置錯誤,這個時候操作系統就會將緩存清空,剩下的還未處理的5條響應數據也就沒了。
由上所述,正確的關閉連接也是非常重要的,實際操作需要注意一下幾點:

  • 首先應該關閉它們的輸出信道,然後等待連接另一端的對等實體關閉它的輸出信道。當兩端都告訴對方它們不會再發送任何數據(比如關閉輸出信道)之後,連接就會被完全關閉,而不會有重置的危險。
  • 在無法確保對方是否實現半關閉的情況下,應當週期性地檢查其輸入信道的狀態(查找數據,或流的末尾)。如果在一定的時間區間內對端沒有關閉輸入信道,應用程序可以強制關閉連接,以節省資源。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章