标题:探索http1.0到http3.0的发展史,详解http2.0
引言:可能几年后,http1.1也将退出历史舞台,那就必须先了解全新的http2.0,http3.0的特性
1、http1.0
1.1、特点
- 短链接性能拉垮,每次发送请求都必须重新建立TCP连接,每次响应结束后都会断开TCP连接,http客户端容易端口占用太多;
- 无host头域(请求头信息中无需host),http1.0时代认为每台服务器都绑定一个唯一的IP,所以请求消息中并没有传递服务器的主机名;
- 不允许断点续传,每次只能传输完整的对象,不能只传输一部分,对文件的上传下载极度不友好;
- 非管道化的缺陷,规定下一个请求必须在前一个请求响应到达之前才能发送,若前一个请求响应一直不到达,下一个请求就不发送;
- 由于请求排队的原因,http1.0的队头阻塞始终发生在客户端,如下所示:
1.2、测试
由于现在http1.0几乎已经被弃用了,只能通过更改源码中的http配置参数来实现,以node为例:
1.2.1、搭建一个httpServer
const http = require('http')
/**
* 构造http1.0
* @type {http.ServerResponse._storeHeader}
*/
const serverResponseStoreHeader = http.ServerResponse.prototype._storeHeader;
http.ServerResponse.prototype._storeHeader = function () {
this.httpVersion = '1.0';
arguments[0] = 'HTTP/1.0' + arguments[0].slice(8);
serverResponseStoreHeader.apply(this, arguments);
};
http.createServer(((req, res) => {
console.log(req.url)
res.end('Hello , this is http 1.0 version')
})).listen( 3000, err => {
if (err) {
return console.log('something bad happened', err)
}
console.log('server is listening on 3000')
});
1.2.2、搭建一个httpClient
let http = require('http');
/**
* 构造http1.0,删除头信息的host
* @type {http.ClientRequest._storeHeader}
*/
const clientRequestStoreHeader = http.ClientRequest.prototype._storeHeader;
http.ClientRequest.prototype._storeHeader = function () {
this.httpVersion = '1.0';
arguments[0] = arguments[0].slice(0, -3) + '0\r\n';
delete arguments[1];
clientRequestStoreHeader.apply(this, arguments);
};
let options = {
hostname: '127.0.0.1',
port: 3000,
path: '/ikejcwang/syaHello?method=sayHello',
method: 'POST'
};
let req = http.request(options, function (res) {
console.log('STATUS: ' + res.statusCode);
console.dir(res.headers);
res.on('data', function (chunk) {
console.log('BODY: ' + chunk);
});
});
req.on('error', function (e) {
console.log('problem with request_test: ' + e.message);
});
req.end('hello http1.0 version');
1.2.3、启动测试
先开启httpServer,打开Wireshark抓包,再启动httpClient,
抓包记录如下:
抓包请求&响应的报文如下:
POST /ebus/ikejcwang/syaHello?method=sayHello HTTP/1.0
Connection: close
Transfer-Encoding: chunked
4
haha
0
HTTP/1.0 200 OK
Date: Sun, 11 Sep 2022 09:37:13 GMT
Connection: close
Hello Its Your Node.js Server!
2、http1.1
2.1、特点
- 启用长连接,支持在一个TCP连接上传输多个http的请求与响应,减少了建立和关闭连接的消耗和延迟。通过头信息的
connection:keep-alive
来控制,默认开启,可以设置close
关闭关闭长连接; - 节省带宽,http1.1支持只传headers(不带任何body),可以通过相应状态码来决定后续传输的报文。http1.0每次都需要传输一个完整的对象,极大程度上浪费了带宽;
- host域,http1.1强制要求请求头信息中必须携带host域,否则会报错(400 Bad Request),随着虚拟主机技术的发展,一台物理机上可以有多个虚拟主机,共享一个IP地址,host域更好的适应了虚拟主机环境;
- 丰富缓存策略,在http1.0中主要使用headers里的If-Modified-Since,Expires来做为缓存判断的标准,http1.1则引入了更多的缓存控制策略例如Entity tag,If-Unmodified-Since,If-Match,If-None-Match等更多可供选择的缓存头来控制缓存策略。
- 相对新增了24个响应状态码;
- 引入管道化技术(pipeline),支持将多条请求放入队列,不必等待之前的请求是否有响应,后续的请求都可以陆续发送,在高延时网络条件下,可以降低网络回环时间;
- 队头阻塞发生在服务端,客户端可以允许发送多个请求,但http1.1规定,服务端的响应报文必须根据收到请求的顺序排队依次应答,造成问题是,如果第一个请求的处理逻辑复杂,执行周期长,生成响应自然慢,直接阻塞了后续请求的响应应答;如下所示:
2.1.1、队头阻塞解决方案
- 并发TCP连接,对于一个独立域名,是允许分配多个TCP长连接,将请求均分到TCP连接上,主动避开队头阻塞的产生,在RFC规范中规定客户端最多并发2个连接,实际情况不然,Chrome中是6个,说明浏览器一个域名采用6个TCP连接,并发HTTP请求;
- 域名分片,在一个域名下分出多个子域名,使其最终指向同一台服务器,其原理同上,还是为了增加并发;
2.2、测试
现在绝大多数应用使用的是http1.1,也默认的是长连接,此处在请求头中主动设置connection:close
来测试短链接
2.2.1、搭建一个httpServer
就是当下一个标准的httpServer:
const http = require('http');
http.createServer().on("request", (req, res) => {
console.log("接收请求")
console.log("url:"+req.url)
console.log("headers:"+JSON.stringify(req.headers))
let d = [];
req.addListener("data",chunk=>{
d.push(chunk)
})
req.addListener("end", ()=>{
console.log("接收完报文:")
console.log(d.toString())
})
res.statusCode = 200
res.end(JSON.stringify({name: 'ike'}));
}).listen(9000, "0.0.0.0");
2.2.2、搭建一个httpClient
let http = require('http');
let options = {
hostname: '127.0.0.1',
port: 9000,
path: '/ikejcwang/syaHello?method=sayHello',
method: 'POST',
headers: {
'connection': 'close' // 测试短链接
}
};
let req = http.request(options, function (res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
let body = []
res.addListener('data', function (chunk) {
body.push(chunk);
});
res.addListener('end', () => {
console.log('response body:' + body.toString());
})
});
req.on('error', function (e) {
console.log('problem with request_test: ' + e.message);
});
req.end('hello http1.1 version');
2.2.3、启动测试
先开启httpServer,打开Wireshark抓包,再启动httpClient,
抓包记录如下:标准的三次握手建立连接,http报文传输,四次挥手断开连接
请求响应报文如下:
POST /ikejcwang/syaHello?method=sayHello HTTP/1.1
connection: close
Host: 127.0.0.1:9000
Content-Length: 21
hello http1.1 versionHTTP/1.1 200 OK
Date: Mon, 12 Sep 2022 08:29:47 GMT
Connection: close
Content-Length: 14
{"name":"ike"}
3、http2.0
3.1、特点
3.1.1、二进制分帧
http1.x在应用层是以纯文本的方式通信,注定了每次请求&响应的数据包特别大,这是第一个影响其通信效率的原因。http2.0针对此做了改造,将所有的传输信息分割为更小的帧和消息,并对此采用二进制编码,所以http2.0的客户端和服务端都需要引进新的二进制编解码机制。
http2.0并没有改写http1.x之前的各种在应用层上的语义,只是用分帧的技术将原来的数据包重新封装了一下,比方说:http1.x的传输信息主要为headers+body,http2.0的传输信息就是headers帧和body帧;
3.1.2、帧
最小的传输单位,http2.0定义了帧的模板(跟协议模板类似),也有头部,头部标明了帧长度,类型,标志位……其中帧类型如下所示:
- DATA:用于传输http消息体;
- HEADERS:用于传输首部字段;
- SETTINGS:用于约定客户端和服务端的配置数据。比如设置初识的双向流量控制窗口大小;
- WINDOW_UPDATE:用于调整个别流或个别连接的流量
- PRIORITY: 用于指定或重新指定引用资源的优先级。
- RST_STREAM: 用于通知流的非正常终止。
- PUSH_ PROMISE: 服务端推送许可。
- PING: 用于计算往返时间,执行“ 活性” 检活。
- GOAWAY: 用于通知对端停止在当前连接中创建流。
标志位用于不同的帧类型定义特定的消息标志。比如DATA帧就可以使用End Stream: true
表示该条消息通信完毕。流标识位表示帧所属的流ID。优先值用于HEADERS帧,表示请求优先级。R表示保留位
3.1.2、消息
就是逻辑上HTTP的数据包(请求&响应)。一系列数据帧组成了一个完整的消息。比如一系列DATA帧和一个HEADERS帧组成了请求消息;
3.1.3、流
http1.x是一种半双工的通信协议,规定在某一时刻数据只能在一个方向上传递(要么是轻轻,要么是响应),这是第二个影响其通信效率的原因,http2.0定义了一种虚拟信道,可以承载双向数据传输,模拟全双工模式,被称作流。每个流有唯一整数标识符ID,为了防止两端流冲突,客户端发起的流具有奇数ID,服务器端发起的流具有偶数ID。
所有的流Stream都是建立在一个TCP连接之上的,每个数据流以消息的形式发送, 每条消息由多个帧组成,帧可以乱序发送,对端根据每个帧首部的标识符重新组装。如下所示:
3.1.4、多路复用
如上所述:基于流Stream的设计,http2.0可以在一个共享TCP连接的基础上,同时发送请求和响应。http消息被分解为独立的帧,每一帧都拥有着自己的标识符,保证交错发送出去后,对端能够根据流ID和首部将它们重新组合起来。
由于http1.x的队头阻塞问题一直存在,不管是http1.0的客户端队头阻塞,还是http1.1的服务端队头阻塞,都直接影响了网页&图片&流媒体……的渲染时间,这是第三个影响其通信效率的原因=
http 2.0建立一条TCP连接后,可以并行传输着多个数据流,客户端向服务端乱序发送stream1~n的一系列的DATA帧,双向通信的模式下,服务端已经在依次返回stream n的DATA帧了,单个TCP下做到极致传输,无需并发连接对于服务器的性能也有很大提升;
3.1.5、请求优先级
流可以带有一个31字节的优先级。当客户端明确指定优先级时,服务端可以根据这个优先级作为依据。
例如:客户端优先级设置为.css>.js>.jpg,服务端按优先级返回结果有利于高效利用底层连接,提高用户体验。 然而,也不能过分迷信请求优先级,仍然要注意以下问题:
- 服务端是否支持请求优先级
- 会否引起队首阻塞问题,比如高优先级的慢响应请求会阻塞其他资源的交互
3.1.6、服务端主动推送
HTTP 2.0增加了服务端推送功能,服务端可以根据客户端的请求,提前返回多个响应,推送额外的资源给客户端,主要应对html5页面的加载。例如:客户端请求stream 1(/page.html)。服务端在返回stream 1消息的同时推送了stream 2(/script.js)和stream 4(/style.css)……
PUSH_PROMISE帧是服务端向客户端有意推送资源的信号。
- 如果客户端不需要服务端Push,可在SETTINGS帧中设定服务端流的值为0,禁用此功能
- PUSH_PROMISE帧中只包含预推送资源的首部。如果客户端对PUSH_PROMISE帧没有意见,服务端在PUSH_PROMISE帧后发送响应的DATA帧开始推送资源。如果客户端已经缓存该资源,不需要再推送,可以选择拒绝PUSH_PROMISE帧。
- PUSH_PROMISE必须遵循请求-响应原则,只能借着对请求的响应推送资源。
3.1.7、headers压缩
http1.x每一次的请求&响应,都会携带header信息用于描述资源属性,有的header信息庞大,且来回反复传递。http2.0在客户端和服务端之间使用内存来管理“头部表”,通过头部表来跟踪和存储之前发送的键-值对,头部表在连接过程中始终存在,新增的键-值对会更新到表尾,因此,后续的通信中直接使用k-v的方式代替原来header中的明文,实现压缩的效果。
3.2、测试
3.2.1、搭建一个httpServer
引用http2的依赖包,其余跟http1一样。
const http2 = require("http2")
// 开启http2服务
http2.createServer().on('request', (request, response) => {
console.log("http2 request_test")
let d = []
request.addListener("data", chunk => {
d.push(chunk)
})
request.addListener("end", () => {
console.log(d.toString())
})
response.end('hello http2 server')
}).listen(8080, '0.0.0.0');
3.2.2、搭建一个httpClient
通过http2.connect
能够看出,http2的client应该是个类似全双工的通信模式,建立连接,写数据,接收数据
const http2 = require('http2')
const reqBody = JSON.stringify({'name': 'ikejcwang'})
const client = http2.connect('http://127.0.0.1:8080');
const req = client.request({
':method': 'POST',
':path': '/hello',
'content-type': 'application/json',
'content-length': Buffer.byteLength(reqBody),
});
let resBody = [];
req.on('response', (headers, flags) => {
console.dir(headers)
});
req.on('data', chunk => {
resBody.push(chunk);
});
req.on('end', () => {
console.log("resBody: " + resBody.toString())
// client需要主动关闭,否则一直是连接状态
// client.close()
});
req.end(reqBody)
3.2.3、启动测试
先开启httpServer,打开Wireshark抓包,再启动httpClient,
client的终端始终在连接态中,没有断开,印证了stream。
抓包记录如下:标准的三次握手建立连接,http2的多路复用,流式报文传输
一次完整请求&响应的数据报文如下:
.........PRI * HTTP/2.0
SM
...........'.......A...\..p.x....br.A.._..u.b
&=LtA..
.20.........{"name":"ikejcwang"}............................a..i~....Z...%...?p.S............hello http2 server.........
可以发现,headers被压缩简化,采用编码替代了。
3.3、传输层的瓶颈
启用http2.0之后,应用整体的性能必然提升,但是所有的流Stream都集中在传输层的一个TCP连接之上,然而TCP本身的可靠性机制带来的性能瓶颈必然无法避免,例如:TCP分组的队首阻塞问题,单个TCP 数据包丢失导致整个连接的阻塞无法逃避,此时所有http2的Stream都会受到影响。
4、http3.0
4.1、历史问题
4.1.1、tcp的缺点:
- tcp协议是有序的,存在对头阻塞的问题,如果有一个数据包丢失了,后面的数据包都需要排队等待,效率比较低
- tcp协议和TLS的握手是分开进行的,增加了握手的延时,对https不太友好;
- tcp协议是基于客户端IP,客户端端口,服务端IP,服务端端口,这四元组确定一个连接的,在有线网络中没有问题。但是在无线网络中,客户端的IP经常会发生变化,导致tcp连接经常性需要重连。很明显的i个案例:手机从流量数据切换到Wi-Fi,正在刷的视频会卡顿重载一下的。
4.2、变革
Google公司在http1.x,http2.0的发展历史中破旧迎新,做出决定:http3.0正式抛弃tcp,基于udp协议开发了一个能规避tcp缺陷,又能兼容udp优点的叫做quic协议,http3.0正是运行在quic之上。
4.3、特点
- 实现了并发无序的字节流式传输,解决了对头阻塞的问题;
- http3.0重新定义了tls的加密方式,降低了建立连接的延时;
- 兼容了udp的高效特性,满足了连接迁移不断开的功能,在无线网络切换IP时,无需重新建立连接;
4.4、测试
暂无测试案例,各类语言还有待封装实现。
5、结尾
tcp丢包后队头阻塞的解释:
阻塞发生在用户态,内核态的报文接收正常,这也抵消了一个疑问,为啥抓包抓不出来队头阻塞的现象。
在内核态并不会堵塞,后续报文会进入linux 的ooo队列(out-of-order),等待着丢失的报文。 一旦丢失的报文再次出现(重传机制),才开始响应用户态,用户就能正常接受到数据。
即,对于Linux内核来说一切收发报文都是正常的,不会堵塞。但是对于用户态确实是堵塞的,因为tcp的数据包是基于seq(序列号) 而丢包导致seq不是连续的,所以必然不能返回到用户态,用户态也就堵塞了。