HTTP2协议虽然大幅提升了HTTP/1.1的性能,然而,基于TCP实现的HTTP2遗留下3个问题:
HTTP3协议解决了这些问题:
HTTP3在保持HTTP1语义不变的情况下,更改了编码格式,这由2个原因所致:
首先,是为了减少编码长度。下图中HTTP1协议的编码使用了ASCII码,用空格、冒号以及\r\n作为分隔符,编码效率很低:
HTTP2与HTTP3采用二进制、静态表、动态表与Huffman算法对HTTP Header编码,不只提供了高压缩率,还加快了发送端编码、接收端解码的速度。
其次,由于HTTP1协议不支持多路复用,这样高并发只能通过多开一些TCP连接实现。然而,通过TCP实现高并发有3个弊端:
HTTP2协议基于TCP有序字节流实现,因此应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题。如下面的动态图片所示,服务器返回的绿色响应由5个TCP报文组成,而黄色响应由4个TCP报文组成,当第2个黄色报文丢失后,即使客户端接收到完整的5个绿色报文,但TCP层不会允许应用进程的read函数读取到最后5个报文,并发成了一纸空谈。
当网络繁忙时,丢包概率会很高,多路复用受到了很大限制。因此, HTTP3采用UDP作为传输层协议,重新实现了无序连接,并在此基础上通过有序的QUIC Stream提供了多路复用 ,如下图所示:
最早这一实验性协议由Google推出,并命名为gQUIC,因此,IETF草案中仍然保留了QUIC概念,用来描述HTTP3协议的传输层和表示层。HTTP3协议规范由以下5个部分组成:
对于当下的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中完成的,如下图所示:
这3层Header实现的功能各不相同:
为了进一步提升网络传输效率,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加密过。当然,Packet Header只是描述了最基本的连接信息,其上的Stream层、HTTP消息也是被加密保护的:
解决队头阻塞的方案,就是允许微观上有序发出的Packet报文,在接收端无序到达后也可以应用于并发请求中。
在Packet Header之上的QUIC Frame Header,定义了有序字节流Stream,而且Stream之间可以实现真正的并发。HTTP3的Stream,借鉴了HTTP2中的部分概念,所以在讨论QUIC Frame Header格式之前,我们先来看看HTTP2中的Stream长成什么样子:
每个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概念对照理解:
前4个字节的Frame Type字段描述的类型不同,接下来的编码也不相同,下表是各类Frame的16进制Type值:
在上表中,我们只要分析0x08-0x0f这8种STREAM类型的Frame,就能弄明白Stream流的实现原理,自然也就清楚队头阻塞是怎样解决的了。Stream Frame用于传递HTTP消息,它的格式如下所示:
可见,Stream Frame头部的3个字段,完成了多路复用、有序字节流以及报文段层面的二进制分隔功能,包括:
你可能会奇怪,为什么会有8种Stream Frame呢?这是因为0x08-0x0f 这8种类型其实是由3个二进制位组成,它们实现了以下3种 标志位的组合:
Stream数据中并不会直接存放HTTP消息,因为HTTP3还需要实现服务器推送、权重优先级设定、流量控制等功能,所以Stream Data中首先存放了HTTP3 Frame:
其中,Length指明了HTTP消息的长度,而Type字段(请注意,低2位有特殊用途,在QPACK章节中会详细介绍)包含了以下类型:
总结一下,QUIC Stream Frame定义了有序字节流,且多个Stream间的传输没有时序性要求,这样,HTTP消息基于QUIC Stream就实现了真正的多路复用,队头阻塞问题自然就被解决掉了。
与HTTP2中的HPACK编码方式相似,HTTP3中的QPACK也采用了静态表、动态表及Huffman编码:
先来看静态表的变化。在上图中,GET方法映射为数字2,这是通过客户端、服务器协议实现层的硬编码完成的。在HTTP2中,共有61个静态表项:
而在QPACK中,则上升为98个静态表项,比如Nginx上的ngx_htt_v3_static_table数组所示:
你也可以从这里找到完整的HTTP3静态表。对于Huffman以及整数的编码,QPACK与HPACK并无多大不同,但动态表编解码方式差距很大。
所谓动态表,就是将未包含在静态表中的Header项,在其首次出现时加入动态表,这样后续传输时仅用1个数字表示,大大提升了编码效率。因此,动态表是天然具备时序性的,如果首次出现的请求出现了丢包,后续请求解码HPACK头部时,一定会被阻塞!
QPACK是如何解决队头阻塞问题的呢?事实上,QPACK将动态表的编码、解码独立在单向Stream中传输,仅当单向Stream中的动态表编码成功后,接收端才能解码双向Stream上HTTP消息里的动态表索引。
单向指只有一端可以发送消息,双向则指两端都可以发送消息。还记得上一小节的QUIC Stream Frame头部吗?其中的Stream ID别有玄机,除了标识Stream外,它的低2位还可以表达以下组合:
因此,当Stream ID是0、4、8、12时,这就是客户端发起的双向Stream(HTTP3不支持服务器发起双向Stream),它用于传输HTTP请求与响应。单向Stream有很多用途,所以它在数据前又多出一个Stream Type字段:
由于HTTP3的STREAM之间是乱序传输的,因此,若先发送的编码Stream后到达,双向Stream中的QPACK头部就无法解码,此时传输HTTP消息的双向Stream就会进入Block阻塞状态(两端可以通过控制帧定义阻塞Stream的处理方式)。
基于四元组定义连接并不适用于下一代IoT网络,HTTP3创造出Connection ID概念实现了连接迁移,通过融合传输层、表示层,既缩短了握手时长,也加密了传输层中的绝大部分字段,提升了网络安全性。
HTTP3在Packet层保障了连接的可靠性,在QUIC Frame层实现了有序字节流,在HTTP3 Frame层实现了HTTP语义,这彻底解开了队头阻塞问题,真正实现了应用层的多路复用。
QPACK使用独立的单向Stream分别传输动态表编码、解码信息,这样乱序、并发传输HTTP消息的Stream既不会出现队头阻塞,也能基于时序性大幅压缩HTTP Header的体积。
本文转载自:深入剖析HTTP3协议