You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

216 lines
20 KiB
Markdown

2 years ago
# 加餐7深入剖析HTTP/3协议
你好我是陶辉又见面了。结课并不意味着结束有好的内容我依然会分享给你。今天这节加餐整理自今年8月3号我在[Nginx中文社区](https://www.nginx-cn.net/explore)与QCon共同组织的[QCon公开课](https://www.infoq.cn/video/VPK3Zu0xrv6U8727ZSXB?utm_source=in_album&utm_medium=video)中分享的部分内容主要介绍HTTP/3协议规范、应用场景及实现原理。欢迎一起交流探讨
自2017年起HTTP/3协议已发布了29个Draft推出在即Chrome、Nginx等软件都在跟进实现最新的草案。那它带来了哪些变革呢我们结合HTTP/2协议看一下。
2015年HTTP/2协议正式推出后已经有接近一半的互联网站点在使用它
[![](https://static001.geekbang.org/resource/image/0c/01/0c0277835b0yy731b11d68d44de00601.jpg "图片来自https://w3techs.com/technologies/details/ce-http2")](https://w3techs.com/technologies/details/ce-http2)
HTTP/2协议虽然大幅提升了HTTP/1.1的性能然而基于TCP实现的HTTP/2遗留下3个问题
* 有序字节流引出的**队头阻塞(**[**Head-of-line blocking**](https://en.wikipedia.org/wiki/Head-of-line_blocking)****使得HTTP/2的多路复用能力大打折扣
* **TCP与TLS叠加了握手时延**建链时长还有1倍的下降空间
* 基于TCP四元组确定一个连接这种诞生于有线网络的设计并不适合移动状态下的无线网络这意味着**IP地址的频繁变动会导致TCP连接、TLS会话反复握手**,成本高昂。
而HTTP/3协议恰恰是解决了这些问题
* HTTP/3基于UDP协议重新定义了连接在QUIC层实现了无序、并发字节流的传输解决了队头阻塞问题包括基于QPACK解决了动态表的队头阻塞
* HTTP/3重新定义了TLS协议加密QUIC头部的方式既提高了网络攻击成本又降低了建立连接的速度仅需1个RTT就可以同时完成建链与密钥协商
* HTTP/3 将Packet、QUIC Frame、HTTP/3 Frame分离实现了连接迁移功能降低了5G环境下高速移动设备的连接维护成本。
接下来我们就会从HTTP/3协议的概念讲起从连接迁移的实现上学习HTTP/3的报文格式再围绕着队头阻塞问题来分析多路复用与QPACK动态表的实现。虽然正式的RFC规范还未推出但最近的草案Change只有微小的变化所以现在学习HTTP/3正当其时这将是下一代互联网最重要的基础设施。
## HTTP/3协议到底是什么
就像HTTP/2协议一样HTTP/3并没有改变HTTP/1的语义。那什么是HTTP语义呢在我看来它包括以下3个点
* 请求只能由客户端发起,而服务器针对每个请求返回一个响应;
* 请求与响应都由Header、Body可选组成其中请求必须含有URL和方法而响应必须含有响应码
* Header中各Name对应的含义保持不变。
HTTP/3在保持HTTP/1语义不变的情况下更改了编码格式这由2个原因所致
首先是为了减少编码长度。下图中HTTP/1协议的编码使用了ASCII码用空格、冒号以及 \\r\\n作为分隔符编码效率很低。
![](https://static001.geekbang.org/resource/image/f8/91/f8c65f3f1c405f2c87db1db1c421f891.jpg)
HTTP/2与HTTP/3采用二进制、静态表、动态表与Huffman算法对HTTP Header编码不只提供了高压缩率还加快了发送端编码、接收端解码的速度。
其次由于HTTP/1协议不支持多路复用这样高并发只能通过多开一些TCP连接实现。然而通过TCP实现高并发有3个弊端
* 实现成本高。TCP是由操作系统内核实现的如果通过多线程实现并发并发线程数不能太多否则线程间切换成本会以指数级上升如果通过异步、非阻塞socket实现并发开发效率又太低
* 每个TCP连接与TLS会话都叠加了2-3个RTT的建链成本
* TCP连接有一个防止出现拥塞的慢启动流程它会对每个TCP连接都产生减速效果。
因此HTTP/2与HTTP/3都在应用层实现了多路复用功能
[![](https://static001.geekbang.org/resource/image/90/e0/90f7cc7fed1a46b691303559cde3bce0.jpg "图片来自https://blog.cloudflare.com/http3-the-past-present-and-future/")](https://blog.cloudflare.com/http3-the-past-present-and-future/)
HTTP/2协议基于TCP有序字节流实现因此**应用层的多路复用并不能做到无序地并发,在丢包场景下会出现队头阻塞问题**。如下面的动态图片所示服务器返回的绿色响应由5个TCP报文组成而黄色响应由4个TCP报文组成当第2个黄色报文丢失后即使客户端接收到完整的5个绿色报文但TCP层不允许应用进程的read函数读取到最后5个报文并发也成了一纸空谈。
![](https://static001.geekbang.org/resource/image/12/46/12473f12db5359904526d1878bc3c046.gif)
当网络繁忙时,丢包概率会很高,多路复用受到了很大限制。因此,**HTTP/3采用UDP作为传输层协议重新实现了无序连接并在此基础上通过有序的QUIC Stream提供了多路复用**,如下图所示:
[![](https://static001.geekbang.org/resource/image/35/d6/35c3183d5a210bc4865869d9581c93d6.png "图片来自https://blog.cloudflare.com/http3-the-past-present-and-future/")](https://blog.cloudflare.com/http3-the-past-present-and-future/)
最早这一实验性协议由Google推出并命名为gQUIC因此IETF草案中仍然保留了QUIC概念用来描述HTTP/3协议的传输层和表示层。HTTP/3协议规范由以下5个部分组成
* QUIC层由[https://tools.ietf.org/html/draft-ietf-quic-transport-29](https://tools.ietf.org/html/draft-ietf-quic-transport-29) 描述,它定义了连接、报文的可靠传输、有序字节流的实现;
* TLS协议会将QUIC层的部分报文头部暴露在明文中方便代理服务器进行路由。[https://tools.ietf.org/html/draft-ietf-quic-tls-29](https://tools.ietf.org/html/draft-ietf-quic-tls-29) 规范定义了QUIC与TLS的结合方式
* 丢包检测、RTO重传定时器预估等功能由[https://tools.ietf.org/html/draft-ietf-quic-recovery-29](https://tools.ietf.org/html/draft-ietf-quic-recovery-29) 定义,目前拥塞控制使用了类似[TCP New RENO](https://tools.ietf.org/html/rfc6582) 的算法,未来有可能更换为基于带宽检测的算法(例如[BBR](https://github.com/google/bbr)
* 基于以上3个规范[https://tools.ietf.org/html/draft-ietf-quic-http-29](https://tools.ietf.org/html/draft-ietf-quic-http-29H) 定义了HTTP语义的实现包括服务器推送、请求响应的传输等
* 在HTTP/2中由HPACK规范定义HTTP头部的压缩算法。由于HPACK动态表的更新具有时序性无法满足HTTP/3的要求。在HTTP/3中QPACK定义HTTP头部的编码[https://tools.ietf.org/html/draft-ietf-quic-qpack-16](https://tools.ietf.org/html/draft-ietf-quic-qpack-16)。注意以上规范的最新草案都到了29而QPACK相对简单它目前更新到16。
自1991年诞生的HTTP/0.9协议已不再使用但1996推出的HTTP/1.0、1999年推出的HTTP/1.1、2015年推出的HTTP/2协议仍然共存于互联网中HTTP/1.0在企业内网中还在广为使用例如Nginx与上游的默认协议还是1.0版本即将面世的HTTP/3协议的加入将会进一步增加协议适配的复杂度。接下来我们将深入HTTP/3协议的细节。
## 连接迁移功能是怎样实现的?
对于当下的HTTP/1和HTTP/2协议传输请求前需要先完成耗时1个RTT的TCP三次握手、耗时1个RTT的TLS握手TLS1.3**由于它们分属内核实现的传输层、openssl库实现的表示层所以难以合并在一起**,如下图所示:
[![](https://static001.geekbang.org/resource/image/79/a2/79a1d38d2e39c93e600f38ae3a9a04a2.jpg "图片来自https://blog.cloudflare.com/http3-the-past-present-and-future/")](https://blog.cloudflare.com/http3-the-past-present-and-future/)
在IoT时代移动设备接入的网络会频繁变动从而导致设备IP地址改变。**对于通过四元组源IP、源端口、目的IP、目的端口定位连接的TCP协议来说这意味着连接需要断开重连所以上述2个RTT的建链时延、TCP慢启动都需要重新来过。**而HTTP/3的QUIC层实现了连接迁移功能允许移动设备更换IP地址后只要仍保有上下文信息比如连接ID、TLS密钥等就可以复用原连接。
在UDP报文头部与HTTP消息之间共有3层头部定义连接且实现了Connection Migration主要是在Packet Header中完成的如下图所示
![](https://static001.geekbang.org/resource/image/ab/7c/ab3283383013b707d1420b6b4cb8517c.png)
这3层Header实现的功能各不相同
* Packet Header实现了可靠的连接。当UDP报文丢失后通过Packet Header中的Packet Number实现报文重传。连接也是通过其中的Connection ID字段定义的
* QUIC Frame Header在无序的Packet报文中基于QUIC Stream概念实现了有序的字节流这允许HTTP消息可以像在TCP连接上一样传输
* HTTP/3 Frame Header定义了HTTP Header、Body的格式以及服务器推送、QPACK编解码流等功能。
为了进一步提升网络传输效率Packet Header又可以细分为两种
* Long Packet Header用于首次建立连接
* Short Packet Header用于日常传输数据。
其中Long Packet Header的格式如下图所示
![](https://static001.geekbang.org/resource/image/5e/b4/5ecc19ba9106179cd3443eefc1d6b8b4.png)
建立连接时连接是由服务器通过Source Connection ID字段分配的这样后续传输时双方只需要固定住Destination Connection ID就可以在客户端IP地址、端口变化后绕过UDP四元组与TCP四元组相同实现连接迁移功能。下图是Short Packet Header头部的格式这里就不再需要传输Source Connection ID字段了
![](https://static001.geekbang.org/resource/image/f4/26/f41634797dfafeyy4535c3e94ea5f226.png)
上图中的Packet Number是每个报文独一无二的序号基于它可以实现丢失报文的精准重发。如果你通过抓包观察Packet Header会发现Packet Number被TLS层加密保护了这是为了防范各类网络攻击的一种设计。下图给出了Packet Header中被加密保护的字段
![](https://static001.geekbang.org/resource/image/5a/93/5a77916ce399148d0c2d951df7c26c93.png)
其中显示为EEncrypt的字段表示被TLS加密过。当然Packet Header只是描述了最基本的连接信息其上的Stream层、HTTP消息也是被加密保护的
![](https://static001.geekbang.org/resource/image/9e/af/9edabebb331bb46c6e2335eda20c68af.png)
现在我们已经对HTTP/3协议的格式有了基本的了解接下来我们通过队头阻塞问题看看Packet之上的QUIC Frame、HTTP/3 Frame帧格式。
## Stream多路复用时的队头阻塞是怎样解决的
其实解决队头阻塞的方案就是允许微观上有序发出的Packet报文在接收端无序到达后也可以应用于并发请求中。比如上文的动态图中如果丢失的黄色报文对其后发出的绿色报文不造成影响队头阻塞问题自然就得到了解决
![](https://static001.geekbang.org/resource/image/f6/f4/f6dc5b11f8a240b4283dcb8de5b9a0f4.gif)
在Packet Header之上的QUIC Frame Header定义了有序字节流Stream而且Stream之间可以实现真正的并发。HTTP/3的Stream借鉴了HTTP/2中的部分概念所以在讨论QUIC Frame Header格式之前我们先来看看HTTP/2中的Stream长什么样子
[![](https://static001.geekbang.org/resource/image/ff/4e/ff629c78ac1880939e5eabb85ab53f4e.png "图片来自https://developers.google.com/web/fundamentals/performance/http2")](https://developers.google.com/web/fundamentals/performance/http2)
每个Stream就像HTTP/1中的TCP连接它保证了承载的HEADERS frame存放HTTP Header、DATA frame存放HTTP Body是有序到达的多个Stream之间可以并行传输。在HTTP/3中上图中的HTTP/2 frame会被拆解为两层我们先来看底层的QUIC Frame。
一个Packet报文中可以存放多个QUIC Frame当然所有Frame的长度之和不能大于PMTUDPath Maximum Transmission Unit Discovery这是大于1200字节的值你可以把它与IP路由中的MTU概念对照理解
![](https://static001.geekbang.org/resource/image/3d/47/3df65a7bb095777f1f8a7fede1a06147.png)
每一个Frame都有明确的类型
![](https://static001.geekbang.org/resource/image/0e/5d/0e27cd850c0f5b854fd3385cab05755d.png)
前4个字节的Frame Type字段描述的类型不同接下来的编码也不相同下表是各类Frame的16进制Type值
![](https://static001.geekbang.org/resource/image/45/85/45b0dfcd5c59a9c8be1c5c9e6f998085.jpg)
在上表中我们只要分析0x08-0x0f这8种STREAM类型的Frame就能弄明白Stream流的实现原理自然也就清楚队头阻塞是怎样解决的了。Stream Frame用于传递HTTP消息它的格式如下所示
![](https://static001.geekbang.org/resource/image/10/4c/10874d334349349559835yy4d4c92b4c.png)
可见Stream Frame头部的3个字段完成了多路复用、有序字节流以及报文段层面的二进制分隔功能包括
* Stream ID定义了一个有序字节流。当HTTP Body非常大需要跨越多个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与HTTP/2协议Frame帧中的FIN标志位相同。
Stream数据中并不会直接存放HTTP消息因为HTTP/3还需要实现服务器推送、权重优先级设定、流量控制等功能所以Stream Data中首先存放了HTTP/3 Frame
![](https://static001.geekbang.org/resource/image/12/61/12b117914de00014f90d1yyf12875861.png)
其中Length指明了HTTP消息的长度而Type字段请注意低2位有特殊用途在QPACK一节中会详细介绍包含了以下类型
* 0x00DATA帧用于传输HTTP Body包体
* 0x01HEADERS帧通过QPACK 编码传输HTTP Header头部
* 0x03CANCEL\_PUSH控制帧用于取消1次服务器推送消息通常客户端在收到PUSH\_PROMISE帧后通过它告知服务器不需要这次推送
* 0x04SETTINGS控制帧设置各类通讯参数
* 0x05PUSH\_PROMISE帧用于服务器推送HTTP Body前先将HTTP Header头部发给客户端流程与HTTP/2相似
* 0x07GOAWAY控制帧用于关闭连接注意不是关闭Stream
* 0x0dMAX\_PUSH\_ID客户端用来限制服务器推送消息数量的控制帧。
总结一下QUIC Stream Frame定义了有序字节流且多个Stream间的传输没有时序性要求。这样HTTP消息基于QUIC Stream就实现了真正的多路复用队头阻塞问题自然就被解决掉了。
## QPACK编码是如何解决队头阻塞问题的
最后我们再看下HTTP Header头部的编码方式它需要面对另一种队头阻塞问题。
与HTTP/2中的HPACK编码方式相似HTTP/3中的QPACK也采用了静态表、动态表及Huffman编码
[![](https://static001.geekbang.org/resource/image/af/94/af869abf09yy1d6d3b0fa5879e300194.jpg "图片来自https://www.oreilly.com/content/http2-a-new-excerpt/")](https://www.oreilly.com/content/http2-a-new-excerpt/)
先来看静态表的变化。在上图中GET方法映射为数字2这是通过客户端、服务器协议实现层的硬编码完成的。在HTTP/2中共有61个静态表项
![](https://static001.geekbang.org/resource/image/8c/21/8cd69a7baf5a02d84e69fe6946d5ab21.jpg)
而在QPACK中则上升为98个静态表项比如Nginx上的ngx\_http\_v3\_static\_table数组所示
![](https://static001.geekbang.org/resource/image/0c/10/0c9c5ab8c342eb6545b00cee8f6b4010.png)
你也可以从[这里](https://tools.ietf.org/html/draft-ietf-quic-qpack-14#appendix-A)找到完整的HTTP/3静态表。对于Huffman以及整数的编码QPACK与HPACK并无多大不同但动态表编解码方式差距很大。
所谓动态表就是将未包含在静态表中的Header项在其首次出现时加入动态表这样后续传输时仅用1个数字表示大大提升了编码效率。因此动态表是天然具备时序性的如果首次出现的请求出现了丢包后续请求解码HPACK头部时一定会被阻塞
QPACK是如何解决队头阻塞问题的呢事实上QPACK将动态表的编码、解码独立在单向Stream中传输仅当单向Stream中的动态表编码成功后接收端才能解码双向Stream上HTTP消息里的动态表索引。
这里我们又引入了单向Stream和双向Stream概念不要头疼它其实很简单。单向指只有一端可以发送消息双向则指两端都可以发送消息。还记得上一小节的QUIC Stream Frame头部吗其中的Stream ID别有玄机除了标识Stream外它的低2位还可以表达以下组合
![](https://static001.geekbang.org/resource/image/ae/0a/ae1dbf30467e8a7684d337701f055c0a.png)
因此当Stream ID是0、4、8、12时这就是客户端发起的双向StreamHTTP/3不支持服务器发起双向Stream它用于传输HTTP请求与响应。单向Stream有很多用途所以它在数据前又多出一个Stream Type字段
![](https://static001.geekbang.org/resource/image/b0/52/b07521a11e65a24cd4b93553127bfc52.png)
Stream Type有以下取值
* 0x00控制Stream传递各类Stream控制消息
* 0x01服务器推送消息
* 0x02用于编码QPACK动态表比如面对不属于静态表的HTTP请求头部客户端可以通过这个Stream发送动态表编码
* 0x03用于通知编码端QPACK动态表的更新结果。
由于HTTP/3的Stream之间是乱序传输的因此若先发送的编码Stream后到达双向Stream中的QPACK头部就无法解码此时传输HTTP消息的双向Stream就会进入Block阻塞状态两端可以通过控制帧定义阻塞Stream的处理方式
## 小结
最后对这一讲的内容做个小结。
基于四元组定义连接并不适用于下一代IoT网络HTTP/3创造出Connection ID概念实现了连接迁移通过融合传输层、表示层既缩短了握手时长也加密了传输层中的绝大部分字段提升了网络安全性。
HTTP/3在Packet层保障了连接的可靠性在QUIC Frame层实现了有序字节流在HTTP/3 Frame层实现了HTTP语义这彻底解开了队头阻塞问题真正实现了应用层的多路复用。
QPACK使用独立的单向Stream分别传输动态表编码、解码信息这样乱序、并发传输HTTP消息的Stream既不会出现队头阻塞也能基于时序性大幅压缩HTTP Header的体积。
如果你觉得这一讲对你理解HTTP/3协议有所帮助也欢迎把它分享给你的朋友。