HTTP1
HTTP1.0
浏览器与服务器只保持短暂的连接,浏览器的每次请求都需要与服务器建立一个
TCP连接(TCP连接的新建成本很高,因为需要客户端和服务器三次握手),服务器完成请求处理后立即断开TCP连接,服务器不跟踪每个客户也不记录过去的请求
通过添加头信息——非标准的Connection字段Connection: keep-alive 来暂时解决
HTTP1.1
- 持久连接
引入了持久连接,即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive(对于同一个域名,大多数浏览器允许同时建立6个持久连接) - 管道机制
在同一个TCP连接里面,客户端可以同时发送多个请求 - 分块传输编码
服务端没产生一块数据,就发送一块,采用”流模式”而取代”缓存模式” - 新增请求方式
PUT DELETE OPTIONS TRACE CONNECT
虽然允许复用TCP连接,但是同一个TCP连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个请求,才会接着处理下一个请求。如果前面的处理特别慢,后面就会有许多请求排队等着。这将导致“队头堵塞”
避免方式:一是减少请求数,二是同时多开持久连接
HTTP2
超文本传输协议 HTTP 的 2.0 版本的协议在 2015 年的时候已经发布了。相比于前面的 HTTP 1.1 版本。它多出了下面三个主要的新特性
- 在建立连接后,可以
多路复用 - 在建立连接后,一次的请求与被响应,视为
流 - 数据传输分为
二进制帧片段
二进制帧
在 TCP 协议中,数据的传输单位是数据报。数据分成两大部分。头部(header) 和 实际数据部分(body)
在 HTTP 2.0 中,它把数据报的两大部分分成了 header frame 和 data frame。也就是头部帧和数据体帧。
帧的传输最终在流中进行,流中的帧,头部(header)帧 和 data 帧可以分为多个片段帧,例如data帧即是可以 data = data_1 + data_2 + ... + data_n
此外地,如果基于二进制帧整体来划分,除了报文的帧分类。还有其它一些辅助帧类型,例如:SETTINGS、PING、GOWAY、WINDOW_UPDATE等控制帧
流
流代表了一个完整的请求-响应数据交互过程。它具有如下几个特点:
双向性:同一个流内,可同时发送和接受数据。有序性:流中被传输的数据就是二进制帧。帧在流上的被发送与被接收都是按照顺序进行的。并行性:流中的二进制帧都是被并行传输的,无需按顺序等待。但却不会引起数据混乱,因为每个帧都有顺序标号。它们最终会被按照顺序标号来合并。流的创建:流可以被客户端或服务器单方面建立, 使用或共享。流的关闭:流也可以被任意一方关闭
帧是流中的数据单位。一个数据报的header 帧可以分成多个 header 帧,data 帧可以分成多个data 帧
多路复用
HTTP 2.0 的多路复用其实是 HTTP 1.1 中长链接的升级版本
在 HTTP 1.1 中,一次链接成功后,只要该链接还没断开,那么 client 端可以在这么一个链接中有序地发起多个请求,并以此获得每个请求对应的响应数据。它的缺点是,一次请求与响应的交互必须要等待前面的请求交互完成,否则后面的只能等待,这个就是线头阻塞
在 HTTP 2.0 中,一次链接成功后,只要链接还没断开,那么 client 端就可以在一个链接中并发地发起多个请求,每个请求及该请求的响应不需要等待其他的请求,某个请求任务耗时严重,不会影响到其它连接的正常执行

HTTP3
HTTP2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP2遗留下3个问题:
- 有序字节流引出的
队头阻塞(Head-of-line blocking),使得HTTP2的多路复用能力大打折扣 TCP与TLS叠加了握手时延,建链时长还有1倍的下降空间- 基于
TCP四元组确定一个连接,这种诞生于有线网络的设计,并不适合移动状态下的无线网络,这意味着IP地址的频繁变动会导致TCP连接、TLS会话反复握手,成本高昂
HTTP3协议解决了这些问题:
HTTP3基于UDP协议重新定义了连接,在QUIC层实现了无序、并发字节流的传输,解决了队头阻塞问题(包括基于QPACK解决了动态表的队头阻塞)HTTP3重新定义了TLS协议加密QUIC头部的方式,既提高了网络攻击成本,又降低了建立连接的速度(仅需1个RTT就可以同时完成建链与密钥协商)HTTP3将Packet、QUIC Frame、HTTP3 Frame分离,实现了连接迁移功能,降低了5G环境下高速移动设备的连接维护成本
HTTP3协议到底是什么?
就像HTTP2协议一样,HTTP3并没有改变HTTP1的语义。那什么是HTTP语义呢?在我看来,它包括以下3个点:
- 请求只能由客户端发起,而服务器针对每个请求返回一个响应
- 请求与响应都由
Header、Body(可选)组成,其中请求必须含有URL和方法,而响应必须含有响应码 Header中各Name对应的含义保持不变
HTTP3在保持HTTP1语义不变的情况下,更改了编码格式,这由2个原因所致:
首先,是为了减少编码长度。HTTP1协议的编码使用了ASCII码,用空格、冒号以及\r\n作为分隔符,编码效率很低
HTTP2与HTTP3采用二进制、静态表、动态表与Huffman算法对HTTP Header编码,不只提供了高压缩率,还加快了发送端编码、接收端解码的速度
其次,由于HTTP1协议不支持多路复用,这样高并发只能通过多开一些TCP连接实现。然而,通过TCP实现高并发有3个弊端:
- 实现成本高。
TCP是由操作系统内核实现的,如果通过多线程实现并发,并发线程数不能太多,否则线程间切换成本会以指数级上升;如果通过异步、非阻塞socket实现并发,开发效率又太低 - 每个
TCP连接与TLS会话都叠加了2-3个RTT的建链成本 TCP连接有一个防止出现拥塞的慢启动流程,它会对每个TCP连接都产生减速效果
因此,HTTP2与HTTP3都在应用层实现了多路复用功能
HTTP2协议基于TCP有序字节流实现,因此应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题
当网络繁忙时,丢包概率会很高,多路复用受到了很大限制。因此,HTTP3采用UDP作为传输层协议,重新实现了无序连接,并在此基础上通过有序的QUIC Stream提供了多路复用

连接迁移功能是怎样实现的?
对于当下的HTTP1和HTTP2协议,传输请求前需要先完成耗时1个RTT的TCP三次握手、耗时1个RTT的TLS握手(TLS1.3),由于它们分属内核实现的传输层、openssl库实现的表示层,所以难以合并在一起,如下图所示:

在IoT时代,移动设备接入的网络会频繁变动,从而导致设备IP地址改变。对于通过四元组(源IP、源端口、目的IP、目的端口)定位连接的TCP协议来说,这意味着连接需要断开重连,所以上述2个RTT的建链时延、TCP慢启动都需要重新来过。
而HTTP3的QUIC层实现了连接迁移功能,允许移动设备更换IP地址后,只要仍保有上下文信息(比如连接ID、TLS密钥等),就可以复用原连接
在UDP报文头部与HTTP消息之间,共有3层头部,定义连接且实现了Connection Migration主要是在Packet Header中完成的

Packet Header实现了可靠的连接。当UDP报文丢失后,通过Packet Header中的Packet Number实现报文重传。连接也是通过其中的Connection ID字段定义的;QUIC Frame Header在无序的Packet报文中,基于QUIC Stream概念实现了有序的字节流,这允许HTTP消息可以像在TCP连接上一样传输;HTTP3 Frame Header定义了HTTP Header、Body的格式,以及服务器推送、QPACK编解码流等功能
为了进一步提升网络传输效率,Packet Header又可以细分为两种:
Long Packet Header用于首次建立连接;Short Packet Header用于建立连接后传输数据
Long Packet Header的格式如下图所示

建立连接时,连接是由服务器通过Source Connection ID字段分配的,这样,后续传输时,双方只需要固定住Destination Connection ID,就可以在客户端IP地址、端口变化后,绕过UDP四元组(与TCP四元组相同),实现连接迁移功能
Short Packet Header头部的格式,这里就不再需要传输Source Connection ID字段了

Packet Number是每个报文独一无二的序号,基于它可以实现丢失报文的精准重发。如果你通过抓包观察Packet Header,会发现Packet Number被TLS层加密保护了,这是为了防范各类网络攻击的一种设计
Packet Header中被加密保护的字段:

显示为E(Encrypt)的字段表示被TLS加密过
Stream多路复用时的队头阻塞是怎样解决的?
允许微观上有序发出的Packet报文,在接收端无序到达后也可以应用于并发请求中
在Packet Header之上的QUIC Frame Header,定义了有序字节流Stream,而且Stream之间可以实现真正的并发。HTTP3的Stream,借鉴了HTTP2中的部分概念
每个Stream就像HTTP1中的TCP连接,它保证了承载的HEADERS frame(存放HTTP Header)、DATA frame(存放HTTP Body)是有序到达的,多个Stream之间可以并行传输。
在HTTP3中,HTTP2 frame会被拆解为两层,我们先来看底层的QUIC Frame
一个Packet报文中可以存放多个QUIC Frame,当然所有Frame的长度之和不能大于PMTUD(Path Maximum Transmission Unit Discovery,这是大于1200字节的值),你可以把它与IP路由中的MTU概念对照理解

每一个Frame都有明确的类型:

前4个字节的Frame Type字段描述的类型不同,接下来的编码也不相同,下表是各类Frame的16进制Type值
| 数值 | 名称 |
|---|---|
| 0x00 | PADDING |
| 0x01 | PING |
| 0x02 – 0x03 | ACK |
| 0x04 | RESET_STREAM |
| 0x05 | STOP_SENDING |
| 0x06 | CRYPTO |
| 0x07 | NEW_TOKEN |
| 0x08 – 0x0f | STREAM |
| 0x10 | MAX_DATA |
| 0x11 | MAX_STREAM_DATA |
| 0x12-0x13 | MAX_STREAMS |
| 0x14 | DATA_BLOCKED |
| 0x15 | STREAM_DATA_BLOCKED |
| 0x16-0x17 | STREAM_BLOCKED |
| 0x18 | NEW_CONNECTION_ID |
| 0x19 | RETRY_CONNECTION_ID |
| 0x1a | PATH_CHALLENGE |
| 0x1b | PATH_RESPONSE |
| 0x1c-0x1d | CONNECTION_CLOSE |
| 0x1e | HANDSHAKE_DONE |
分析0x08-0x0f这8种STREAM类型的Frame,就能弄明白Stream流的实现原理,自然也就清楚队头阻塞是怎样解决的了
Stream Frame用于传递HTTP消息,它的格式如下所示:

Stream ID标识了一个有序字节流。当HTTPBody非常大,需要跨越多个Packet时,只要在每个Stream Frame中含有同样的Stream ID,就可以传输任意长度的消息。多个并发传输的HTTP消息,通过不同的Stream ID加以区别- 消息序列化后的“有序”特性,是通过
Offset字段完成的,它类似于TCP协议中的Sequence序号,用于实现Stream内多个Frame间的累计确认功能 Length指明了Frame数据的长度
为什么会有8种Stream Frame呢?这是因为0x08-0x0f 这8种类型其实是由3个二进制位组成,它们实现了以下3 标志位的组合:
- 第1位表示是否含有
Offset,当它为0时,表示这是Stream中的起始Frame,这也是上图中Offset是可选字段的原因; - 第2位表示是否含有
Length字段; - 第3位
Fin,表示这是Stream中最后1个Frame,与HTTP2协议Frame帧中的FIN标志位相同
Stream数据中并不会直接存放HTTP消息,因为HTTP3还需要实现服务器推送、权重优先级设定、流量控制等功能,所以Stream Data中首先存放了HTTP3 Frame

Length指明了HTTP消息的长度,而Type字段(请注意,低2位有特殊用途)包含了以下类型:
| 数值 | 名称 | 说明 |
|---|---|---|
| 0x00 | DATA | 用于传输HTTP Body包体; |
| 0x01 | HEADERS | 通过QPACK 编码,传输HTTP Header头部; |
| 0x03 | CANCEL_PUSH | 控制帧,用于取消1次服务器推送消息,通常客户端在收到PUSH_PROMISE帧后,通过它告知服务器不需要这次推送; |
| 0x04 | SETTINGS | 控制帧,设置各类通讯参数; |
| 0x05 | PUSH_PROMISE | 用于服务器推送HTTP Body前,先将HTTP Header头部发给客户端,流程与HTTP2相似; |
| 0x07 | GOAWAY | 控制帧,用于关闭连接(注意,不是关闭Stream); |
| 0x0d | MAX_PUSH_ID | 客户端用来限制服务器推送消息数量的控制帧 |
QUIC Stream Frame定义了有序字节流,且多个Stream间的传输没有时序性要求,这样,HTTP消息基于QUIC Stream就实现了真正的多路复用,队头阻塞问题自然就被解决掉了
QPACK编码是如何解决队头阻塞问题的?
与HTTP2中的HPACK编码方式相似,HTTP3中的QPACK也采用了静态表、动态表及Huffman编码:

在上图中,GET方法映射为数字2,这是通过客户端、服务器协议实现层的硬编码完成的。在HTTP2中,共有61个静态表项:

在QPACK中,则上升为98个静态表项,比如Nginx上的ngx_htt_v3_static_table数组所示:

对于Huffman以及整数的编码,QPACK与HPACK并无多大不同,但动态表编解码方式差距很大
所谓动态表,就是将未包含在静态表中的Header项,在其首次出现时加入动态表,这样后续传输时仅用1个数字表示,大大提升了编码效率。
因此,动态表是天然具备时序性的,如果首次出现的请求出现了丢包,后续请求解码HPACK头部时,一定会被阻塞
QPACK将动态表的编码、解码独立在单向Stream中传输,仅当单向Stream中的动态表编码成功后,接收端才能解码双向Stream上HTTP消息里的动态表索引
单向指只有一端可以发送消息,双向则指两端都可以发送消息。其中的Stream ID别有玄机,除了标识Stream外,它的低2位还可以表达以下组合:

当Stream ID是0、4、8、12时,这就是客户端发起的双向Stream(HTTP3不支持服务器发起双向Stream),它用于传输HTTP请求与响应。单向Stream有很多用途,所以它在数据前又多出一个Stream Type字段:

Stream Type有以下取值:
0x00:控制Stream,传递各类Stream控制消息;0x01:服务器推送消息;0x02:用于编码QPACK动态表,比如面对不属于静态表的HTTP请求头部,客户端可以通过这个Stream发送动态表编码;0x03:用于通知编码端QPACK动态表的更新结果
由于HTTP3的STREAM之间是乱序传输的,因此,若先发送的编码Stream后到达,双向Stream中的QPACK头部就无法解码,此时传输HTTP消息的双向Stream就会进入Block阻塞状态(两端可以通过控制帧定义阻塞Stream的处理方式)