[HTTP] HTTP 协议 Response Header 之 Content-Length、Transfer-Encoding与Content-Encoding
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.1
中
Connection
这个头部字段已经没有 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 性能优化
时,有一个重要的指标叫
TTFB
(
Time 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-Encoding
和
Transfer-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: chunked
与
Content-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.conf
的
location
等位置配置以上内容时,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请求接口资源下载时间过长的问题分析 - 博客园
【推荐】
- 怀疑1:滑动窗持续收缩,导致后面接收效率急剧下降
- 怀疑2:拥塞窗口cwnd,会不会是发送端因为乱序或超时导致服务器当前链路的cwnd下降而主动降速
- 怀疑3:数据包乱序,或丢包
- 怀疑4(结论):客户端的的问题(Web 前端)。
问题请求在浏览器除首页的其他场景、或着使用其他客户端直接请求下载速度都是正常的,出问题的那次请求又是预加载的请求(同时,还会有好几个请求会被一起发送),所以乍一看总会觉得是网络方面的问题,当然这个上文中的内容已经证明了,完全不是网络的问题