[HTTP] HTTP 協議 Response Header 之 Content-Length、Transfer-Encoding與Content-Encoding

千千寰宇發表於2024-07-30

0 引言

  • 在近期專案一場景中,一 Web API (響應內容:7MB - 40MB、資料項:5W-20W條)的網路傳輸耗時較大,短則 5s,長則高達25s,前端渲染又需要耗時 9s-60s。
  • 在這個場景中,前端的問題暫且不表。那麼針對後端的問題,個人認為還是有較大的最佳化空間:
    • 1)啟用HTTP 內容壓縮策略 【最重要】
    • 2)調整資料結構(參考JDBC,fields:list<string> + values:list<list<object>>,分2個獨立的資料集,資料集內的object的屬性值保證有序,減少了object內各欄位名稱的反覆描述,以此降低網路頻寬) 【次之】

  • 此處,主要探討分析、實施HTTP 內容壓縮策略中重點需要關注的 HTTP Response Header:

    • Content-Length :(如果啟用壓縮,壓縮後的)內容長度
    • Transfer-Encoding : 傳輸編碼
    • Content-Encoding : 內容編碼
  • 正式講之前,想講講結論、效果:

  • 響應內容 7119kb --> 633kb,縮減網路頻寬約 90%
  • 響應耗時:6.12s --> 300ms,提升響應耗時約 95 %

1 概述篇

1.1 Transfer-Encoding

  • Transfer-Encoding,是一個 HTTP 頭部欄位,字面意思是「傳輸編碼」。
  • Transfer-Encoding 則是用來改變報文格式,它不但不會減少實體內容傳輸大小,甚至還會使傳輸變大,那它的作用是什麼呢?本文接下來主要就是講這個。我們先記住一點,Content-Encoding 和 Transfer-Encoding 二者是相輔相成的,對於一個 HTTP 報文,很可能同時進行了內容編碼和傳輸編碼。

1.2 Content-Encoding

  • 實際上,HTTP 協議中還有另外一個頭部與編碼有關:Content-Encoding(內容編碼)
  • Content-Encoding 通常用於對實體內容進行壓縮編碼,目的是最佳化傳輸。例如,用 gzip 壓縮文字檔案,能大幅減小體積。
  • 內容編碼通常是選擇性的,例如 jpg / png 這類檔案一般不開啟,因為圖片格式已經是高度壓縮過的,再壓一遍沒什麼效果不說還浪費 CPU。
Content-Encoding
Transfer-Encoding 值
描述
gzip (推薦) 表明實體採用 GNU zip 編碼
compress 表明實體採用 Unix 的檔案壓縮程式
deflate 表明實體是用 zlib 的格式壓縮的
br (推薦) 指示響應資料採用Brotli壓縮編碼。
示例:Transfer-Encoding: br / Content-Encoding: br
identity 表明沒有對實體進行編碼。當沒有 Content-Encoding 首部時,就預設為這種情況

HTTP 定義了一些標準的內容編碼型別,並允許用擴充套件編碼的形式增添更多的編碼。
由網際網路號碼分配機構(IANA)對各種編碼進行標準化,它給每個內容編碼演算法分配了唯一的代號。
Content-Encoding 首部就用這些標準化的代號來說明編碼時使用的演算法。

gzip、compress 以及 deflate 編碼都是無失真壓縮演算法,用於減少傳輸報文的大小,不 會導致資訊損失。
這些演算法中,gzip 通常是效率最高的,使用最為廣泛。

1.3 Accept-Encoding : 客戶端宣告可接受的編碼

  • Accept-Encoding 欄位包含用逗號分隔的支援編碼的列表,下面是一些例子:
Accept-Encoding: compress, gzip
Accept-Encoding:
Accept-Encoding: *
Accept-Encoding: compress;q=0.5, gzip;q=1.0
Accept-Encoding: gzip;q=1.0, identity; q=0.5, *;q=0
  • 客戶端可以給每種編碼附帶 Q(質量)值引數來說明編碼的優先順序。Q 值的範圍從 0.0 到 1.0,0.0 說明客戶端不想接受所說明的編碼,1.0 則表明最希望使用的編碼。

1.4 Persistent Connection(持久連線)

  • 暫時把 Transfer-Encoding 放一邊,我們來看 HTTP 協議中另外一個重要概念:Persistent Connection持久連線,通俗說法長連線)。
  • 我們知道 HTTP 執行在 TCP 連線之上,自然也有著跟 TCP 一樣的三次握手慢啟動等特性,為了儘可能的提高 HTTP 效能,使用持久連線就顯得尤為重要了。為此,HTTP 協議引入了相應的機制。
    • HTTP/1.0持久連線機制是後來才引入的,透過 Connection: keep-alive 這個頭部來實現,服務端客戶端都可以使用它告訴對方在傳送完資料之後不需要斷開 TCP 連線,以備後用
    • HTTP/1.1 則規定所有連線都必須是持久的,除非顯式地在頭部加上 Connection: close
      • 所以,實際上,HTTP/1.1Connection 這個頭部欄位已經沒有 keep-alive 這個取值了,但由於歷史原因,很多 Web Server 和瀏覽器,還是保留著給 HTTP/1.1 長連線傳送 Connection: keep-alive 的習慣。

1.5 Content-Length : 告訴瀏覽器(編碼後)響應實體的長度

沒有 Content-Length 時 : pending

  • 瀏覽器重用已經開啟的空閒持久連線,可以避開緩慢的三次握手,還可以避免遇上 TCP 慢啟動的擁塞適應階段,聽起來十分美妙。為了深入研究持久連線的特性,我決定用 Node 寫一個最簡單的 Web Server 用於測試,Node 提供了 http 模組用於快速建立 HTTP Web Server,但我需要更多的控制,所以用 net 模組建立了一個 TCP Server:
require('net')
	.createServer(function(sock) { 
		sock.on('data', function(data) { 
			sock.write('HTTP/1.1 200 OK\r\n'); 
			sock.write('\r\n'); 
			sock.write('hello world!'); 
			sock.destroy(); 
		}); 
	}).listen(9090, '127.0.0.1');

啟動服務後,在瀏覽器裡訪問 127.0.0.1:9090,正確輸出了指定內容,一切正常。去掉 sock.destroy() 這一行,讓它變成持久連線,重啟服務後再訪問一下。這次的結果就有點奇怪了:遲遲看不到輸出,透過 Network 檢視請求狀態,一直是 pending

這是因為,對於非持久連線,瀏覽器可以透過連線是否關閉來界定請求或響應實體的邊界;而對於持久連線,這種方法顯然不奏效。上例中,儘管我已經傳送完所有資料,但瀏覽器並不知道這一點,它無法得知這個開啟的連線上是否還會有新資料進來,只能傻傻地等了。

引入 Content-Length 後 :如實給瀏覽器反饋響應內容實體的長度

要解決上面這個問題,最容易想到的辦法就是計算內容實體長度,並透過頭部告訴對方。這就要用到 Content-Length 了,改造一下上面的例子:

require('net')
	.createServer(function(sock) { 
		sock.on('data', function(data) { 
			sock.write('HTTP/1.1 200 OK\r\n'); 
			sock.write('Content-Length: 12\r\n');
			sock.write('\r\n'); 
			sock.write('hello world!'); 
			sock.destroy(); 
		}); 
	}).listen(9090, '127.0.0.1');

可以看到,這次傳送完資料、並沒有關閉 TCP 連線,但瀏覽器能正常輸出內容、並結束請求,因為瀏覽器可以透過 Content-Length 的長度資訊,判斷出響應實體已結束。那如果 Content-Length 和實體實際長度不一致會怎樣?有興趣的同學可以自己試試,通常如果 Content-Length 比實際長度短,會造成內容被截斷;如果比實體內容長,會造成 pending

由於 Content-Length 欄位必須真實反映實體長度,但實際應用中,有些時候實體長度並沒那麼好獲得,例如實體來自於網路檔案,或者由動態程式生成。這時候要想準確獲取長度,只能開一個足夠大的 buffer,等內容全部生成好再計算。但這樣做一方面需要更大的記憶體開銷,另一方面也可能會讓客戶端等更久。

1.6 Transfer-Encoding: chunked(傳輸編碼 = 分塊傳輸) :不依賴頭部的長度資訊,也能知道實體的邊界

TTFB (Time To First Byte)

  • 我們在做 WEB 效能最佳化時,有一個重要的指標叫 TTFBTime To First Byte),它代表的是從客戶端發出請求到收到響應的第一個位元組所花費的時間
  • 大部分瀏覽器自帶的 Network 皮膚都可以看到這個指標(如 Chrome - Network - a request - Timing - Waiting for server response),越短的 TTFB 意味著使用者可以越早看到頁面內容,體驗越好。可想而知,服務端為了計算響應實體長度而快取所有內容,跟更短的 TTFB 理念背道而馳。
  • 但在 HTTP 報文中,實體一定要在頭部之後,順序不能顛倒,為此我們需要一個新的機制:不依賴頭部的長度資訊,也能知道實體的邊界

Transfer-Encoding : 不依賴頭部的長度資訊,也能知道實體的邊界

  • 本文主角終於再次出現了,Transfer-Encoding 正是用來解決上面這個問題的。歷史上 Transfer-Encoding 可以有多種取值,為此還引入了一個名為 TE 的頭部用來協商採用何種傳輸編碼。但是最新的 HTTP 規範裡,只定義了一種傳輸編碼:分塊編碼(chunked)。

  • 分塊編碼相當簡單,在頭部加入 Transfer-Encoding: chunked 之後,就代表這個報文采用了分塊編碼。這時,報文中的響應實體需要改為用一系列分塊來傳輸。每個分塊包含十六進位制的長度值和資料,長度值獨佔一行,長度不包括它結尾的 CRLF(\r\n),也不包括分塊資料結尾的 CRLF。最後一個分塊長度值必須為 0,對應的分塊資料沒有內容,表示實體結束

  • 按照這個格式改造下之前的程式碼:

require('net').createServer(function(sock) {
    sock.on('data', function(data) {
        sock.write('HTTP/1.1 200 OK\r\n');
        sock.write('Transfer-Encoding: chunked\r\n');
        sock.write('\r\n');

        sock.write('b\r\n');
        sock.write('01234567890\r\n');

        sock.write('5\r\n');
        sock.write('12345\r\n');

        sock.write('0\r\n');
        sock.write('\r\n');
    });
}).listen(9090, '127.0.0.1');

上面這個例子中,我在響應頭中表明接下來的實體會採用分塊編碼,然後輸出了 11 位元組的分塊,接著又輸出了 5 位元組的分塊,最後用一個 0 長度的分塊表明資料已經傳完了。用瀏覽器訪問這個服務,可以得到正確結果。可以看到,透過這種簡單的分塊策略,很好的解決了前面提出的問題。

前面說過 Content-EncodingTransfer-Encoding 二者經常會結合來用,其實就是針對 Transfer-Encoding 的分塊再進行 Content-Encoding

下面是用 telnet 請求測試頁面得到的響應,就對分塊內容進行了 gzip 編碼:

> telnet 106.187.88.156 80

GET /test.php HTTP/1.1
Host: qgy18.qgy18.com
Accept-Encoding: gzip

HTTP/1.1 200 OK
Server: nginx
Date: Sun, 03 May 2015 17:25:23 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Content-Encoding: gzip

1f
�H���W(�/�I�J

0

用 HTTP 抓包神器 Fiddler 也可以看到類似結果,有興趣的同學可以自己試一下。

Transfer-Encoding: chunked 與 Content-Length

  • Transfer-Encoding: chunkedContent-Length 同為頭部欄位,它們不會同時出現在頭部中
  • 當使用分塊傳輸時,頭部將出現 Transfer-Encoding: chunked,而不再包含Content-Length欄位,即使強行設定該欄位,也會被忽略。

在HTTP中,我們通常依賴 HttpCode/HttpStatus 來判斷一個 HTTP 請求是否成功,如:

HTTP: Status 200 – 成功,伺服器成功返回網頁
HTTP: Status 304 – 成功,網頁未修改
HTTP: Status 404 – 失敗,請求的網頁不存在
HTTP: Status 503 – 失敗,服務不可用

… …

延伸:開發人員的程式發起HTTP請求時,判斷 HTTP 請求是否成功場景 (可選讀章節)

但開發人員有時候也會有令人意外的想象力。我們的一部分開發人員決定使用 Content-Length 來判斷 HTTP 請求是否成功,當 Content-Length 的值小於等於0或者為 162 時,認為請求失敗。

Content-Length 的值小於等於0時認為http請求失敗還好理解,因為開發人員錯誤地以為 HTTP 響應頭中一定會包含 Content-Length 欄位。

為什麼當 Content-Length 的值為162時,也認為請求失敗呢。這是因為公司伺服器的404頁面的長度恰好是162。驚不驚喜,意不意外!

2 Web Server 配置篇

  • 啟用壓縮等配置

Nginx

gzip on;
gzip_min_length 1k;

gzip_buffers 4 16k;

#gzip_http_version 1.0;

gzip_comp_level 2;

gzip_types text/xml text/plain text/css text/js application/javascript application/json;

gzip_vary on;

gzip_disable "MSIE [1-6]\.";
  • 當在 Nginx 的配置檔案 nginx.conflocation等位置配置以上內容時,Nginx 伺服器將對指定的檔案型別開啟壓縮 (gzip)以最佳化傳輸,減少傳輸量。

  • 分塊傳輸可以將壓縮物件分為多個部分,在這種情況下,資源整個進行壓縮,壓縮的輸出分塊傳輸。在壓縮的情形中,分塊傳輸有利於一邊進行壓縮一邊傳送資料而不是先完成壓縮過程,得知壓縮後資料的大小之後再進行傳輸,從而使得使用者能夠更快地接受到資料,TTFB 指標更好。

  • 對於開啟了 gzip 的傳輸,報文的頭部將增加 Content-Encoding: gzip 來標記傳輸內容的編碼方式。同時,Nginx 伺服器預設就會對壓縮內容進行分塊傳輸,而無須顯式開啟chunked_transfer_encoding

  • Nginx 中如何關閉分塊傳輸呢,在 Nginx 配置檔案 location 段中加一行“chunked_transfer_encoding off;”即可。

location / {
    chunked_transfer_encoding       off;
}

SpringBoot(Embed Tomcat)

  • SpringBoot 預設是不開啟 gzip 壓縮的,需要我們手動開啟,在配置檔案中新增兩行
server: 
  compression: 
    enabled: true 
    mime-types: application/json,application/xml,text/html,text/plain,text/css,application/x-javascript
  • 注意:上面配置中的mime-types,在 spring2.0+的版本中,預設值如下,所以一般我們不需要特意新增這個配置
// org.springframework.boot.web.server.Compression#mimeTypes
/**
 * Comma-separated list of MIME types that should be compressed.
 */
private String[] mimeTypes = new String[] { "text/html", "text/xml", "text/plain",
		"text/css", "text/javascript", "application/javascript", "application/json",
		"application/xml" };
  • 雖然加上了上面的配置,開啟了 gzip 壓縮,但是需要注意並不是說所有的介面都會使用 gzip 壓縮,預設情況下,僅會壓縮 2048 位元組以上的內容
  • 如果我們需要修改這個值,透過修改配置即可
server:
  compression:
    min-response-size: 1024

Tomcat

  • Tomcat5.0以上。 修改%TOMCAT_HOME%/conf/server.xml
<Connector port="8080"
  protocol="HTTP/1.1"
  connectionTimeout="20000"
  redirectPort="8443"
  compression="on"
  compressionMinSize="2048"
  noCompressionUserAgents="gozilla, traviata"
  compressableMimeType="text/html,text/xml,text/javascript,
application/javascript,text/css,text/plain,text/json"/>
  • compression="on" 開啟壓縮。可選值:"on"開啟,"off"關閉,"force"任何情況都開啟。
  • compressionMinSize="2048"大於2KB的檔案才進行壓縮。用於指定壓縮的最小資料大小,單位B,預設2048B。注意此值的大小,如果配置不合理,產生的後果是小檔案壓縮後反而變大了,達不到預想的效果。
  • `noCompressionUserAgents="gozilla, traviata",對於這兩種瀏覽器,不進行壓縮(我也不知道這兩種瀏覽器是啥,百度上沒找到),其值為正規表示式,匹配的UA將不會被壓縮,預設空。
  • compressableMimeType="text/html,text/xml,application/javascript,text/css,text/plain,text/json" 會被壓縮的MIME型別列表,多個逗號隔,表明支援html、xml、js、css、json等檔案格式的壓縮(plain為無格式的,但對於具體是什麼,我比較概念模糊)。compressableMimeType很重要,它用來告知tomcat要對哪一種檔案進行壓縮,如果型別指定錯誤了,肯定是無法壓縮的。那麼,如何知道要壓縮的檔案型別呢?可以透過以下這種方法找到。

X 參考文獻

  • http accept-encoding詳解 HTTP協議-壓縮(gzip,deflate) - CSDN 【推薦】
  • HTTP 協議中的 Transfer-Encoding - imququ.com 【推薦】
  • HTTP 協議中的 Transfer-Encoding - 部落格園
  • 瀏覽器控制檯Network皮膚介紹 - CSDN 【推薦】
    • Chrome-開發者工具-Network-(a request)-Size(響應頭大小 + 響應資料)
  • HTTP Transfer-Encoding介紹 - CSDN
  • SpringBoot系列教程Web篇之開啟GZIP資料壓縮 - 部落格園 【推薦】
  • Content-Encoding:內容編碼 - CSDN
  • Content-Encoding:br 是一種什麼編碼格式? - 51CTO 【推薦】
  • Tomcat啟用GZIP壓縮,提升web效能 - 部落格園 【推薦】
    • 檢測網站: http://seo.chinaz.com/?host=iitshare.com
  • 對HTTP請求介面資源下載時間過長的問題分析 - 部落格園 【推薦】
  • 懷疑1:滑動窗持續收縮,導致後面接收效率急劇下降
  • 懷疑2:擁塞視窗cwnd,會不會是傳送端因為亂序或超時導致伺服器當前鏈路的cwnd下降而主動降速
  • 懷疑3:資料包亂序,或丟包
  • 懷疑4(結論):客戶端的的問題(Web 前端)。

問題請求在瀏覽器除首頁的其他場景、或著使用其他客戶端直接請求下載速度都是正常的,出問題的那次請求又是預載入的請求(同時,還會有好幾個請求會被一起傳送),所以乍一看總會覺得是網路方面的問題,當然這個上文中的內容已經證明了,完全不是網路的問題

相關文章