如今互联网已经与我们密不可分了,购物、金融、社交、娱乐等都依赖于互联网,其中主要依赖的几项技术就包含HTTP(HyperText Transfer Protocol, 超文本传输协议)。本文核心就是介绍HTTP发展历史以及HTTP/2协议!
HTTP发展历史
HTTP/0.9
HTTP协议的第一个规范是1991年发布的0.9版本,此规范文档不足700个单词,其中规范中指出了通过TCP/IP (或类似的面向连接的服务)与服务器和端口建立连接!规范申明错误类型以可读文本显示HTML语法,以及请求是幂等的!
目前已经没有网站和浏览器支持 http/0.9 协议了!
请求格式:只有GET方法,报文如下:
1 | GET /doc/index.heml |
响应内容:只有HTML文本,所以无法传输其他类型文件!
1 | <HTML> |
HTTP/1.0
在1991-1995年,人们由于不满足HTTP/0.9,于是搞了一些新扩展,但是这些新拓展并没有被引入到标准中。直到1996年11月,为了解决这些问题,一份新文档(RFC 1945)被发表出来,用以描述如何操作实践这些新扩展功能。
主要新增内容如下:
- 引入了 POST、HEAD方法
- 请求与响应都引入了 HTTP 头/首部(Header),为此也支持了其他传输类型(图片等)
- 引入响应状态码
1 | ➜ ~ curl --http1.0 www.baidu.com -v |
HTTP/1.1
HTTP/1.0 多种不同的实现方式在实际运用中显得有些混乱,自1995年开始,即HTTP/1.0文档发布的下一年,就开始修订HTTP的第一个标准化版本。于是在1997年初,HTTP1.1 标准发布。
主要新增内容如下:(下列内容部分也在HTTP/1.0中有支持,所以和HTTP/1.1统称为HTTP/1.X)
- 连接复用 (引入 Keep-Alive Header)
- 增加管线化(管道化)技术,允许在第一个应答被完全发送之前就发送第二个请求,以降低通信延迟。
- 支持响应分块(chunked编码)
- 引入额外的缓存控制机制
- 引入内容协商机制,包括语言,编码,类型等,并允许客户端和服务器之间约定以最合适的内容进行交换
- 凭借
Host
头,能够使不同域名配置在同一个IP地址的服务器上,并且强制要求客户端请求必须携带Host。例如Nginx这种反向代理工具,可能根据Host进行反向代理,也就是一台服务器承载多个域名! - 支持包含GET、POST、PUT、PATCH、DELETE、HEAD、CONNECT、OPTIONS、TRACE 请求方法
1 | ➜ ~ curl --http1.1 www.baidu.com -v |
由于HTTP协议的可扩展性 – 创建新的头部和方法是很容易的 – 即使 HTTP/1.1 协议进行过两次修订,RFC 2616 发布于 1999 年 6 月,而另外两个文档 RFC 7230-RFC 7235 发布于 2014 年 6 月(在 HTTP/2 发布之前)。HTTP/1.1 协议已经稳定使用超过 15 年了。
1. HTTP/1.1 修订记录
HTTP/1.1 协议发布于1997年的1月份,后面经过三次修订!最后一次修订时间是2014年6月份!具体修订内容可以点击下面链接进行查看!HTTP1.1的岁数和我差不多!
- 首发 1997年 rfc2068
- 第一次修订 1999年 rfc2616
- 第二次修订 2014年 rfc7230 (想要详细了解HTTP协议编码可以看这个)
- 第三次修订 2014年 rfc7235
2. 服务端优化手段
1. keep-alive
引入keep-alive 可以有效的降低了TCP建立连接的开销(包含TCP握手和TCP慢启动,其实TCP一开始窗口挺大的,默认14KB),其次就是有效的降低了系统开销,如TCP连接数等!
在HTTP/1.0中默认是关闭开启 keep-alive
的,必须在请求头部添加Connection: keep-alive
才可以;但是在HTTP/1.1中默认是开启keep-alive
,需要在响应头加入Connection: close
才可以关闭!下图是两者的差异:
但是也会存在很多问题,原来是请求->响应直接关闭连接很容易拆包,但是出现了连接复用,那么如何拆包呢?HTTP/1.x中如果我们开启了keep-alive
必须在请求头和响应头中加入content-length
来标识请求体/响应体的大小!(chunked传输编码除外)
- 测试
1 | HTTP/1.1,下图为HTTP/1.1的抓包图 |
- 关于如何实现HTTP的 keep-alive,一般server端设置读超时即可,client端维护就比较麻烦,可以阅读一些源码看一下即可,其次就是用的连接池!
- 目前字节内部的TLB做代理的时候维护单个请求连接的时间过长,导致连接不会被销毁,也就是经常会出现后端服务(up stream)出现大量的 TCP
ESTABLISHED
,其主要原因也是因为client侧不断开连接,server侧一般也不会直接断开,这也就是为什么后面HTTP/2做了自己的keep-alive机制了! - keep-alive 另外还是额外的两个配置,一个是 timeout 一个是max,例如
Keep-Alive: timeout=5, max=1000
, 具体可以看: https://tools.ietf.org/id/draft-thomson-hybi-http-timeout-01.html !可以参考Nginx的实现! - 如果响应头里申明
Connection=close
,则会关闭连接!
2. 请求体压缩
一般压缩是根据 Content-Encoding
进行区分Body体使用哪种压缩方式,请求的时候会携带Accept-Encoding
来标识支持的压缩方式! 常见就是 gzip
、tr
、deflate
压缩,字节内部也有一些压缩率比较高的压缩算法,比如 ttzip
基于zstd优化后的!
1 | GET /api/test HTTP/1.1 |
3. Chunked 编码(分块传输)
分块编码是HTTP/1.1新增的传输报文格式,响应会携带Transfer-Encoding: chunked
,其次响应体是不会有content-length
的,因为chunked编码长度部分记录在一个 16进制编码单独的文本行上,然后再写响应内容,如果仍然有内容继续重复操作即可!终止符是 0\r\n\r\n
!下图是chunked编码的抓包图,其中12
其实表示0x12
也就是为十进制的18
!
使用场景: 其中分块编码可以节约用户首屏加载时间,先加载一部分渲染!实际业务中可以做一些 实时进度条展示、日志展示、流式拉取、大文件传输等!比如抖音的首屏预测方案就是基于chunked编码实现的,具体可以看文章尾部的参考文章!
注意1: 假如你使用Go语言默认HTTP Server,当响应体大于2KB的时候就会默认升级为 chunked 编码!你也可以手动添加响应header Transfer-Encoding: chunked
!
注意2:对于L7 Proxy来说,chunked编码处理不得当很可能造成业务的OOM,所以可以参考一些chunked包转发和抓取的优化手段,尽可能的降低内存拷贝次数和维护的buffer大小!
上面第二、第三节讲完了,可以发现会存在两个Header, Content-Encoding
和 Transfer-Encoding
, 这里可以根据句面意思,可以知道 Content-Encoding
是表示内容编码格式,Transfer-Encoding
表示传输格式! 两者是可以同时使用的!
3. 前端(浏览器)优化手段
1. 合并资源
合并资源这个我理解大部分都或多多少的了解过,因为请求3个资源的耗时一般都会大于合并成一个资源的请求耗时!例如前端也有很多静态资源(js/css)的打包工具,比如webpack、gulp 等资源合并工具!其次一些小的Image可以使用精灵图!
这个东西有利也有弊端,比如我原来20个文件,合并成了一个,我改动一个,那么全部资源都是需要重新reload,浏览器缓存的效果就丢失了,不过这个目前也有解决方案!
2. 域名分片
浏览器其实有同域名请求的最大并发数限制,例如主流的浏览器Chrome 其实会对于同域名下最大并发数限制在6个!
域名分片就是讲一个域名划分为多个,比如 www.google.com, www.gstatic.com, 这样就可以很好的解决浏览器的限制!
3. 管道化
下图是使用管道化和未使用的差别,可以发现他可以解决http/1.x 请求阻塞,但是并不能解决响应阻塞的问题!这个主要还是由于服务端HTTP请求处理是串行的!注意只有支持幂等的请求且开启keep-alive才会支持管道化!其次这个技术主要在浏览器侧实现!但是由于keep-alive持久连接实现上并不可靠,所以管道化也需要支持重试等,所以这也就是为什么只允许幂等请求了!
4. 长连接
这个放在这里,感觉也不合理,主要是目前也存在这种场景,比如在线编辑、在线聊天等一些在线软件对于实时性要求比较高,轮训的话也不太好,因为HTTP建连开销比较大,其次对于服务器压力也比较大(比如无效的请求,类似于空转),所以在这条路上诞生了很多的技术,这里我们这里就简单介绍下WebSocket!
WebSocket是HTML5开始提供的一种在单个TCP连接上进行全双工通讯的协议,位于OSI模型的应用层。WebSocket协议在2008年诞生,于2011年由IETF标准化为 RFC 6455 ,后由 RFC 7936 补充规范。
注意:WebSocket 和 HTTP同为应用层协议!WebSocket是利用HTTP协议进握手的!
4. 注意点
1. 网关方向
上诉我们讲的可能是偏向于业务方面一些技巧,对于网关/LB来说,往往在HTTP链路中担任着一个代理角色,那么代理就会涉及到一个问题,我要代理哪些东西,哪些东西不可代理!
- Hop-by-hop headers (点到点)
具体可以见 https://www.rfc-editor.org/rfc/rfc7230.txt, 大概描述了逐级跳的时候一个 header 是不能传递的!
1 | Connection |
上诉这些header 主要是和协议有关,其中不少网关直接不支持上诉功能代理,
- 比如 Transfer-Encoding=chunked,很多业务使用chunked编码进行分段传输,来实现单向长链接,但是可能经过网关后会remove掉这个传输协议,因此使用 chunked很可能在网络传输某个节点丢失了frc-4.1.1 . 所以很多业务会自定义 chunked编码,向字节内部就开发了一个Next-Chunked编码解决传输过程中 chunked 分段编码丢失问题.(本质上就是在payload中进行分段)
- Connection、Keep-Alive 等都是一些连接复用优化策略,本质上上下游并不可信,对于连接.
- 其次还有一些Header,比如Upgrade用作协议切换,也是不可以进行透传的。
- End-to-End (端到端)
就是很常见的业务header
- 正向代理
主要是代理客户端(Client)请求,例如VPN、抓包工具、匿名访问、提高访问速度 等
是”代理服务器”代理了”客户端”,去和”目标服务器”进行交互,目标服务器是不知道真正的客户端是谁的
- 反向代理
主要是代理服务端(Server)端请求,例如LB
是”代理服务器”代理了”目标服务器”,去和”客户端”进行交互,客户端是不知道真正的目标服务器是谁的
参考: https://oxylabs.cn/blog/reverse-proxy-vs-forward-proxy
2. form-data 编码 (body篡改)
这里我申明一个名词(透明代理),在实际业务中往往会存在一个问题,就是客户端+服务端传输的时候会进行一个 body/header加密+checksum来防止被篡改(checksum往往会使用加密防止中途被篡改),这种case非常场景.
那么这里就有一个问题了,form-data 在很多应用中会很容易出现丢失header信息和乱序.具体关于 form-data编码可以看 rfc2056
所以透明代理往往是不会修改整体的http请求报文,例如正向代理中的抓包,就充当了一个透明代理,它并不会修改你的流量,其实就是不会修改应用层流量(这里主要指的是代理抓包,仅作为4层代理,其实也不是病不能称为4层)
1 | POST /api/v1/test/codec?ce=gzip&te=chunked HTTP/1.1 |
乱序后 可能会出现:
1 | POST /api/v1/test/codec?ce=gzip&te=chunked HTTP/1.1 |
3. 流式传输
- 有些应用使用 长轮训 ,代理层会读超时
- 有些应用使用 分段传输,代理层会读取全部数据后再传输 (为了安全限制读取的最大size,除非支持转发chunked编码)
4. 总结
根据上诉描述,所以我们发现做一款优秀的HTTP代理工具,并不是简简单单的一件事情,需要很深入的了解HTTP协议的细节,以及尽可能的满足业务需求,所以优秀的HTTP代理工具,比如apache,nginx等都是不断的迭代和满足业务现状!所以现在一般都是 通用型代理工具 + 偏向于特定业务场景的代理工具!
HTTPS
这个并不是HTTP协议,只是在HTTP发展路上遇到的问题,随着互联网发展,越来越多的人注意到数据安全,比如用户敏感信息需要加密,一些恶意软件的攻击,以及恶意拦截等!所以HTTPS就诞生了!这里我们只是引入而已,不会深入讲解!HTTPS(Hypertext Transfer Protocol Secure) 是 HTTP 协议外面包裹了一层 TLS/SSL!
SSL(Secure Sockets Layer,安全套接字层),它是由网景公司(Netscape)设计的主要用于Web的安全传输协议,目的是为网络通信提供机密性、认证性及数据完整性保障。如今,SSL已经成为互联网保密通信的工业标准。SSL最初的几个版本(SSL 1.0、SSL2.0、SSL 3.0)由网景公司设计和维护,从3.1版本开始,SSL协议由因特网工程任务小组(IETF)正式接管,并更名为TLS(Transport Layer Security),发展至今已有TLS 1.0、TLS1.1、TLS1.2、TLS1.3 这几个版本。
如TLS名字所说,SSL/TLS协议仅保障传输层安全。同时,由于协议自身特性(数字证书机制),SSL/TLS不能被用于保护多跳(multi-hop)端到端通信,而只能保护点到点通信。
SSL/TLS发展历程如下图:
协议 | 使用情况 |
---|---|
SSLv3.0以下 | 有安全问题,且已被废弃,不建议使用 |
TLSv1.0/v1.1 | 过渡版本,不建议使用 |
TLSv1.2 | 目前绝大多数都在使用 |
TLSv1.3 | 最新的更快更安全的协议(变更最大的一次协议,可以实现1RTT) |
其次TLS加解密会非常的消耗CPU,其次加密也会额外消耗带宽,不过主要开销都在TLS握手这块,因为现在主流CPU都会对常见加密算法做硬件加速,所以传输过程中数据包这块开销不大!目前一般内网都会卸载掉TLS!其次TLS握手过程也会很长,主要包含证书认证和确认加密算法!其中TLS发展过程中也经历了不断的迭代,发展方向主要是为了更加安全、效率更高!下图是现在主流TLSv1.2的握手流程,相比于裸TCP,会多两倍的时间开销!
注: TLS握手中很多流程是可选的,上图并不完全正确,其次就是还会分为全完握手和简化握手!
对于TLS来说也是一个协议规范,具体我们在使用的过程中需要在应用程序中设置或者需要程序来支持!常见开源实现主要有 OpenSSL(主流)、JSSE(Java版实现)、NSS(浏览器中广泛使用),下图是些常见软件以及它们所使用的SSL/TLS开源实现的情况!对于安全(TLS/SSL)来说也是不断在进步中,同时也有漏洞不断挖出!
HTTP/2 & HTTP/3
随着近20年来互联网的高速发展,页面愈加复杂,有些甚至演变成了独立的应用,一个页面会加载大量的资源,增进交互的脚本大小也增加了许多,更多的数据通过HTTP请求被传输。HTTP/1.1链接需要请求以正确的顺序发送,理论上可以用一些并行的链接,带来的成本和复杂性堪忧。比如,HTTP管线化(pipelining)就成为了Web开发的负担。
在2009年到2015年,谷歌通过实践了一个实验性的SPDY协议(2011年Google的全部服务就添加了SPDY),证明了一个在客户端和服务器端交换数据的另类方式。其收集了浏览器和服务器端的开发者的焦点问题。明确了响应数量的增加和解决复杂的数据传输,SPDY成为了HTTP/2协议的基础。在2015年5月HTTP/2正式标准化后,取得了极大的成功!目前来看SPDY已经完全被HTTP/2所取代!然后Google其实早起已经对于TCP做了优化,叫做QUIC,所以就考虑把HTTP运行在QUIC上,于是诞生了后面的HTTP/3!
HTTP/2 相比HTTP/1.1 比较最大的特点就是多路复用,有点类似于我们web server由HTTP服务升级为RPC服务,带来的提升!从浏览器视角看的话,下图是169张图渲染一张图浏览器加载的耗时,结果是HTTP/2 为1.53s, HTTP1.1为 2.47s!
HTTP/2 介绍
主要特性
- 二进制协议 (frame帧)
- 多路复用 (stream 流)
- 流量控制
- 数据流优先级
- 首部压缩(HPACK)
- 服务端推送 (Push)
虽然有这么多特性,对于HTTP的语义来说,其实并没有太大的变更!
理解流
对于HTTP/1.x协议来说一个连接上的请求、响应是串行的处理,但是HTTP/2引入流的概念,把请求、响应的过程抽象成流,一个连接上可以有多个流,把数据包抽象成帧,帧在建立的连接上传输!
一个流的生命周期只是一次请求、响应,不存在复用(即另外一个请求不能复用之前的流,关闭即销毁),具体可以看流的生成周期!
流的生命周期
- 下图是一个状态机,对于一个普通的请求、响应来说,一般经历有四个阶段
- 空闲(idle): 这里可以理解为初始化一个stream后的状态,这个状态可以理解为很短!
- 开启(open) : 当客户端开始写header的时候(包含服务端读header),会流转到这个状态! 这里也就是
- 半关闭状态(half closed): 客户端写完数据,发送
END_STREAM
flags时(写完请求时)就会变成这个状态,此时客户端的流不能再写数据!对于服务端而言就是收到客户端发送的END_STREAM
flags,就处于此状态! - 关闭(closed): 对于客户端而言,收到服务端响应发来的
END_STREAM
会变为此状态;对于服务器而言发送END_STREAM
也会变为此状态!
- 只要客户端、服务端发送或者接收到
RST_STREAM 帧
时就会直接变为closed
状态! - 对于服务端推送来说我们后文会讲到!
- GRPC双向流(stream msg)实现原理其实也就是一个普通的请求、响应模型,根据上面四个阶段也能大概了解它是如何实现的!即客户端发完请求并没有直接发送
END_STREAM
,而是一直处于open状态,且服务端也是!只有客户端/服务端主动关闭才可以关闭stream,或者双方通过自定义协议约定才可以!
1 | +--------+ |
如何建立HTTP/2连接
首先建立连接需要客户端和服务端都支持HTTP/2协议,才可以使用HTTP/2!
使用HTTPS协商 (h2)
例如下面这个curl 请求其实是模拟了一个使用HTTPS发送HTTP/2 协议的请求
1 | ➜ ~ curl --http2 https://www.toutiao.com/ -v -o s/dev/null |
上面我们只需要核心关注
1 | ## ALPN客户端支持列表 |
这里其实利用了TLS的 ALPN (Application-Layer Protocol Negotiation 应用层协议协商) 扩展字段以及识别 HTTP/2 over TLS !其中HTTP/2 就是 ALPN 最佳实践!
其中原理就是ALNP给Client hello 和 Server hello 消息加了个拓展功能,客户端可以来申明我支持的应用层协议(嗨,我支持h2和http/1,你用哪个都行),服务端可以用它来确认在HTTPS协商后的应用层协议(好吧,我们用h2吧)!
更多ALPN可以参考文章: https://datatracker.ietf.org/doc/html/rfc7301#section-3 !
另外其实在 ALPN 协议之前,有一个NPN(Next Protocol Negotiation 下一代协议协商)其实作用和ALPN一样,但是呢过程不一样,其中我这里直接拍照吧,就是它分为三步,选择权利在客户端,其中选择的协议是加密的!所以这么一看其实NPN更加安全可靠!但是想一想增加这几步有必要吗?其实没有必要,所以ALPN成为了规范!
本图片引用自 <HTTP/2 in Action>, 有兴趣可以看一下!
注意这里有一种情况就是假如服务器不支持HTTP2但是客户端支持,那么根据上面流程选择HTTP/1.1就可以了,例如下面这个图,最后选择的是HTTP/1.1
。
但是还有一种情况就是如果 server hello
中不返回ALNP,比如举个例子服务端TLS版本过低不支持ANLP,所以此时目前大部分浏览器做法就是默认降级成HTTP/1.1!具体可以看:https://stackoverflow.com/questions/47758705/http-2-h2-with-no-alpn-support-in-server!
注意: 上面讲诉的过程是基于TLS/1.2的,其中1.3会有部分差异!其次就是h2建立要求最低TLS/v1.2版本!
使用 h2c 协商
我们知道HTTP协议是可以做协议协商的,那么h2c的建立流程和这个大同小异!目前h2c的主要使用场景就是后端服务希望HTTP/2来带来性能提升,而且大部分场景也不需要tls加密,所以h2c就诞生了!
注意h2c 有两种实现,一种是基于HTTP/1.1协议升级,一种就是h2的流程但是移除了tls!
1. 方式一(HTTP/1.1升级)
目前主流浏览器都不支持h2c,所以这个基本上没办法测试,如果想要测试可以看一下我写的测试用例: h2c_example
- 浏览器发起请求
1 | GET / HTTP/1.1 |
- 如果服务端支持则会响应如下报文,如果不支持正常返回即可!其中下面可以看到会携带 HTTP/2首帧!这里后面会讲解!
1 | HTTP/1.1 101 Switching Protocols |
- 关于
HTTP2-Settings
需要了解HTTP/2涉及到的基本概念,一个核心的就是SETTTINGS 帧
, 必须在建连的时候发送,这个header也就是SETTTINGS 帧
的内容,理解这个就很简单了,它本质上就是把SETTTINGS 帧
base64编码了一下!用于发送客户端建立的初始化设置! [ HTTP/2 connection ...
后续流程是客户端会先发送连接前奏、但是不需要发送SETTINGS帧
了,其他就和h2流程差不多!- 这里我们不能抓包了,所以直接看我写的测试用例输出吧
1 | ➜ h2c_client git:(master) ✗ go run main.go |
2. 方式二(h2c)
这种是grpc(without tls)采用的方式,就是我们已经知道对面是HTTP/2服务器了,所以不需要使用h2c
那种通过 http/1.1
升级为http/2
这个流程!它的过程和h2
的过程一模一样! 具体可以自行抓包查看!
帧介绍
下图是一个HTTP2 over https(h2) 的整个流程,可以看到HTTP2 主要包含有几个特殊标识
- Magic
- Settings
- Windows_Update
- Headers
- Data
那么我们下面会依次介绍!我们把这些都成为帧,这些帧都有着不同的作用!
如果你用 nghttp2, 可以执行以下命令: nghttp -vo https://www.toutiao.com | more
连接前奏
根据上图可以看到在 h2 或者 h2c 升级完成,下面紧接着就跟着一个 连接前奏
(客户端向服务端发送的),这个内容是固定的,主要是有24个字节组成,16进制标识法如下,展示成字符串就是 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
!
1 | 0000 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a PRI * HTTP/2.0.. |
注意: 具体这么设计的原因也是有的,比如向一个不支持HTTP2的服务器(HTTP/1.x)接收到这个报文,是可以正常解析为HTTP请求报文的,如果不支持就直接返回异常不支持了!
帧格式介绍
连接前奏
紧跟着就是 SETTINGS 帧
了, 下图就是一个HTTP SETTINGS 帧
的报文。在这里我们先补充体下帧的报文格式,也方便后续的理解!这也就是为什么HTTP/2是二进制协议了,同时也能避免HTTP/1.x协议通过纯文本等进行协议分帧的尴尬了!
帧报文如下:
1 | 帧格式, 头部固定9字节, 所以 9+ bytes: |
Length
: 固定为3字节,所以帧最大长度为1 << 24 -1
, 注意这个长度=len(frame_content)
Type
: 即帧的类型,主要分为以下几种,例如SETTINGS帧
就是0x04
,其中这个类型目前仍然在不断拓展中!常见的就是这9种!
1 | +---------------+------+--------------+ |
Flags
: 即标志位,这个不太方便理解,你可以理解为 帧的特殊标记!例如SETTINGS帧
会有一个ACK
Falgs! 多个falgs通过 bitmap 进行设置!R
: 这个是保留位(reserved bit),占用1bit,必须是0Stream Identifier
: 即流ID,占用31bit,为无符号整数!顾名思义和RPC的seq id 很像!这里注意客户端发起的stream_id必须为奇数,服务端发起的为偶数!例如上面那个case,客户端发起的stream_id=13
!Frame Payload
即 payload,允许为空!
SETTINGS 帧
在建立请求过程中,Magic帧
后面就会立马跟一个SETTINGS 帧
!如下图,这个帧主要作用就是为了初始化连接的配置信息!
其中SETTINGS 帧
就是一对对KV组成,如下图,多个KV的话,顺序写即可!
1 | +-------------------------------+ |
- Identifier:标识符,主要分为以下几类,可以理解为key
1 | SETTINGS_HEADER_TABLE_SIZE (0x1): 设置HPACK中动态表的大小,默认是4096个字节 |
- Value: 值,四个字节,都是int值
注意:
- 如果一个端需要发起设置,那么它需要标记
falgs Ack=0
, 然后携带上配置,另外一端接收后会响应一个falgs Ack=1
,且payload
为空! 例如下面流程:
1 | # 收到settings帧 |
SETTINGS 帧
的stream_id
固定为0 !
WINDOW_UPDATE 帧 (流量控制)
WINDOW_UPDATE 帧
主要是用于流量控制(Flow Control),这个名词在TCP也有!不过 HTTP/1.x 并未包含流控机制,依靠 TCP 的流控也工作得很好,那为什么 HTTP/2 需要添加呢?原因很明显,因为 HTTP/2 引入了 stream
(流) 和 multiplexing
(多路复用) ,想让众多 stream
协同工作,就需要一种控制机制,防止某个流阻塞其他流!
具体原理如下:sender发送数据会降低窗口大小,需要receiver方通知sender来恢复窗口,这就需要WINDOW_UPDATE 帧
了! 其中流控算法官方并没有提出实现规范!注意 sender 指的数据发送方(可以是server、也可以是client)。 整体总结就是:接收者来提供控制!
这里理解比较抽象,比如现在假如一个连接有多个流,流A和流B…xxx,假如此时客户端压力比较大(比如处理不过来消息或者机器负载过高),客户端检测到了,那么可以通过流量控制去实现!
具体HTTP/2如何实现流控的,主要是由于HTTP/2 的流控分为了两类,stream flow-control window(sfw) 和 connection flow-control window (cfw), 其中每个stream会维护自己的sfw,所有的stream共用一个cfw !发送多少包窗口就减少多少,cfw和sfw都会减少!那么增加呢是单独增加!要么是cfw、要么是sfw!其次cfw、sfw只针对于DATA帧的内容!
例如:GRPC中在当接收的包的总大小(自己做的receive flow-control)大于cfw/sfw大小的1/4的时候,就会发送window_update
帧,然后重置receive size,或者发送PING包的时候也会携带发送window_update
帧!具体逻辑可以看: sender逻辑 、 server 端receiver逻辑 、 client receiver逻辑,有兴趣的可以看一下!目前HTTP/2应用比较广泛的应该是GRPC、Nginx(L7)、浏览器!
WINDOW_UPDATE帧
协议包比较简单,主要就是传输Window Size Increment
,这个值是增加cfw
or sfw
的大小! 如果调整 sfw
则需要传递 stream_id
!
1 | +-+-------------------------------------------------------------+ |
说到这里,可能还得知道,初始化窗口: 当一个连接初始化的时候,sfw和cfw都是65535,如果需要调整sfw 的初始化窗口大小则需要需要发送Setting帧
(这个是针对所有stream),如果需要调整cfw大小(所以初始化时候cfw一定是65535)需要发送window_update帧
!
HEADERS 帧
一个HTTP/2 请求从 HEADERS 帧
开始发送的!例如这个是一个HTTP/2 Headers 帧的抓包图:
其中header的编码比较复杂,后续我们会讲解HPACK,所以这里只要知道有这个header帧主要包含哪些内容即可!
1 | +---------------+ |
- Pad Length: 表示尾部Padding的长度,为可选字段(注意标记?的为可选字段)
- E:1bit,表示当前流是否排他的 (与
PRIORITY 帧有关
) - Stream Dependency: 表示依赖于哪个流 (与
PRIORITY 帧有关
) - Weight:流的权重 (与
PRIORITY 帧有关
) - Header Block Fragment: 请求的首部,使用HPACK编码
- Padding:补齐内容,用0填充
例如报文如下:
1 | 0000 00 00 1f 01 05 00 00 00 01 82 84 87 41 8b f1 e3 ............A... |
00 00 1f 01 05 00 00 00 01
: 前9个字段为Frame的头部,payload长度为31,为Headers帧,flag为5,stream_id=1- 其中后面31个字节就是头部的header内容了, 这里采用了 HPACK 编码!所以需要了解下HPACK编码,这里也能看出来下文是Header的内容,一共是100个字节,但是使用HPACK编码后只占用了31个字节! 整整节约了70%的编码!
1 | :method: GET |
这里我们要解码者31个字节需要直接跳转到 HPACK那一节吧!
注意:
- HTTP/2 和 HTTP/1.x 协议有些header是不兼容的,比如
Keep-Alive
和Connection
等在HTTP/2中直接没有! - HTTP的header是支持
header: v1;v2
表示多个 ,同时也支持header: k1
,header:k2
,在HTTP/2中在编码的时候会使用后者!同时header name推荐全部小写(看完HPACK就会理解)!
DATA 帧
data帧对应着 HTTP的Body,如果没有响应体的话是不需要传输的!
1 | +---------------+ |
Pad Length
: 表示padding长度, 只有存在PADDED flags
才有这个值!Data
: 真正的数据Padding
: 表示padding数据,并没有强制的padding算法,也就是你发出的数据可以不padding!
这里 DATA 帧
会有两个标识,一个是 END_STREAM
、一个是 PADDED
PRIORITY帧 (请求优先级)
这个帧可以定义请求(stream)的优先级,但是这个优先级只是客户端发来的提议,服务端可以不去支持!下面就是报文格式:
1 | +-+-------------------------------------------------------------+ |
- E:是否独占(排他),独占依赖的流(形象点就是我独占它,原来依赖它的人全部得依赖我)
- Stream Dependency:流依赖
- Weight:流权重,这个应该是最好理解的,权重越大,优先级越高!
这里吐槽一句:使用HTTP/1.x
,浏览器可以完全控制资源加载顺序!HTTP/2
让这些事情变得更好也更复杂了!但是么还是值得学习一下的!
具体实现就设计到算法了,因为既要要求性能高,同时也要求准确性!关于算法可以看 RFC7540-5.3,实现的话可以看下NGINX或者熟悉语言、框架的SDK!下面我就就单介绍一下!
- 简单的依赖,比图 B和C依赖于A,此时插入个D也依赖于A那么就是下面这个效果!注意D的位置可以任意!
1 | A A |
- 独占情况,还是上面的关系,假如D独占A,那么就会变为下面这个图
1 | A |
- 权重,主要是解决具有相同父流的流应该根据权重比例分配资源!比如这个图假如A加载完成后,那么B应该优先处理,才会处理C!
1 | A |
- 动态调整,例如下图初始化是图一,假如此时A依赖于D,那么D直接替换A的位置(这里看着操作是D放到了和A一样的父亲下面,变成了
图intermediate
),然后A的子依赖关系不变,挪动一下,就变成了图non-exclusive
!假如A独占D了,就变成了图exclusive
!
1 | x x x x |
其他帧
PING帧 ,主要是用来维持一个空闲连接的,以及可以计算一次RTT时间!
GOAWAY帧 ,主要是用来关闭连接的,同时允许携带发送一个错误码!
RST_STREAM 帧,主要是用来关闭流的,同时允许携带发送一个错误码!
CONTINUATION 帧,主要是解决当
HEADER帧
超出了限制MAX_FRAME_SIZE
的大小来获取完整的HTTP首部的问题,所以需要发送CONTINUATION 帧
,同时CONTINUATION帧
也需要在MAX_FRAME_SIZE
大小之内,所以可能跟随多个CONTINUATION 帧
! 它有个END_HEADERS
标记来标记是否结束!所以假如出现这个case那么Header帧
就需要标记END_HEADERS=False
了! 其实PUSH_PROMISE帧
不够大(这个后面会讲到),后面也得用CONTINUATION 帧
!
HPACK 编码
我们知道HTTP/1.x其实可以对Body部分进行压缩,但是头部部分是无法压缩的!所以HTTP/2中引入了HPACK 主要是用来压缩HTTP的头部的,主要是采用静态表+动态表+压缩算法组成!然后就是HTTP/2中把请求行/响应行部分移到了全部转移到了Header部分!对照关系是:
1 | method -> :method |
静态表
静态表(Static Table),一共61对,应该很好理解,比如 method=GET
,那么会把它转换成一个数字,例如:authority=www.google.com
会转换一个数字 + 字符串(不一定)!
1 | +-------+-----------------------------+---------------+ |
注意:这里虽然没有:method
且 Header Value
为空的这种情况,其实实际处理的时候:method=Delete
这种实际上 :method
会编码为3,如果没有则取key相同最大的index!
动态表
动态表(Header Table),顾名思义就是动态生成表,然后最终形式上和上面静态表差不多!它的生命周期是一个连接上(注意一个连接可以有多个请求/响应)!
由于动态表时动态生成的,那么对于单机维护的连接数过多,此时动态表会很占内存,假如此时我们不进行内存限制,很容易被攻击导致内存OOM,那么所以动态表会有一个大小限制,可以通过 SETTINGS 帧
下发HEADER_TABLE_SIZE=4096
, 单位是字节,来实现动态表内存限制!那么一个Header的Key和Value的大小是多少了?计算公式是 len(key)+len(value)+32
, 为什么加32了,是因为大部分语言,存储string会额外消耗16字节用来存储数组指针和数组长度!
那么假如动态表在我们写header的时候,发现大小超出了 HEADER_TABLE_SIZE
怎么办了,具体算法就是个FIFO模型,队列大小确定,所以大概流程就是下面这个:
1 | // 请求1 |
编码
在HTTP/2中编码采用的算法主要是 varint编码 和 霍夫曼编码 (Huffman Coding 也叫做哈夫曼编码)
概念:
- varint 编码是数字压缩算法中常见的一种,主要是采用变长来存储数字,比如虽然值定义的是8字节,但是比如数字
1
可以用1个字节进行编码,主要原理就是利用msb (the Most Significant Bit 最高有效位)
! - 霍夫曼编码是文本压缩算法中常见的一种,是基于字符出现的次数为权重(墒编码)进行的构建字典,进而构建霍夫曼树,然后根据霍夫曼树来确定字符的二进制编码。但是如果字符不重复,且字符还很多,导致霍夫曼树很深,那么霍夫曼编码的意义就不大了!
注意:
- HTTP/2 中采用的 varint 编码并不是标准的,因为他需要根据低位第一个字节标记falg, 所以解决思路就是减去第一个字节定义的最大值(区间是 1 到 1<<7-1),剩余的值用的varint编码,具体可以参考: https://datatracker.ietf.org/doc/html/rfc7541#section-5.1, 伪代码如下:
1 | 伪代码 |
- HTTP/2 中采用的 霍夫曼编码也不是按照标准的,它采用的是静态表,也就是它省去了动态生成霍夫曼树的过程(省略计算过程和Map过程),这个树就是个静态的!具体可以参考: https://datatracker.ietf.org/doc/html/rfc7541#section-5.2 !
1. Indexed Header Field Representation
采用这边编码情况是: header 的 key和value 都在中,然后可以拿到index(动态表index=静态表index+偏移量),比如method: GET
1 | +---+---+---+---+---+---+---+---+ |
1
:标识符号,varint编码后第一个字节的高位8bit标识符是1index (7+)
: 表示index值,采用(7+)varint
算法
2.1 Literal Header Field with Incremental Indexing - indexed Name
表示: name 在表中,value 不在表中,且需要添加到表中,比如 Host: www.google.com
1 | 0 1 2 3 4 5 6 7 |
01
+Index (6+)
,为index编码,主要是为了区分上面Indexed Header Field Representation
的caseH
: 为Value String
是否采用霍夫曼编码编码,H=1
表示采用霍夫曼编码Value Length(7+)
: 表示value string的长度,具体算法就是(7+)varint
算法Value String
:霍夫曼编码 or 原值
2.2 Literal Header Field with Incremental Indexing - New Name
表示: name 不在表中,value也不在表中,但是需要添加到表中!例如自定义header X-Host: www.google.com
,且我们允许 name 和 value 添加到表中!
1 | 0 1 2 3 4 5 6 7 |
这里我就不过多讲解了,通过上面的两个讲解,应该大家都有所了解!这个也就是第一个字节一定是 0x40
3.1 Literal Header Field without (Never) Indexing - Indexed Name
和上面的Literal Header Field with Incremental Indexing - indexed Name
的区别在于,这个不需要被添加到表中!
其次就是 Literal Header Field without Indexing
和 Literal Header Field Never Indexing
主要区别在于首字节,前者是 0000xxxx
,后者是0001xxxx
!
1 | 0 1 2 3 4 5 6 7 |
3.2 Literal Header Field without(Never) Indexing - New Name
和上面的Literal Header Field with Incremental Indexing - indexed Name
的区别在于,这个不需要被添加到表中!
1 | 0 1 2 3 4 5 6 7 |
4. Dynamic Table Size Update
我们知道 SETTINGS帧
的 SETTINGS_HEADER_TABLE_SIZE
也是设置 tables size大小的,那么这个作用是什么?它的含义就是动态变更 size,但是这个max size
必须小于或者等于 SETTINGS_HEADER_TABLE_SIZE
!
1 | 0 1 2 3 4 5 6 7 |
5. 总结
注意关于 Literal Header Field without Indexing
和 Literal Header Field Never Indexing
的区别在于哪里了?官方的解释差别就一句话 Intermediaries MUST use the same representation for encoding this header field., 意思就是假如我们请求中间有个HTTP/2代理(例如 nginx),那么如果是Never Indexed
那么代理也必须原封不动的转发 !
但是目前看Nginx并不支持后端服务(up stream)是HTTP/2协议,只支持GRPC(注意GRPC处理模块可以处理一些通用的HTTP/2协议的请求,但是支持度并不好)!具体可以参考 https://trac.nginx.org/nginx/ticket/923 !
服务器推送 PUSH_PROMISE 帧
首先要明白,HTTP/2为什么会有服务端推送,比如我们一个普通的加载网页的流程,会返回网页,然后再起请求一些静态资源,所以浏览器的整个流程是下面这个流程如下图左侧!但是如果有服务端推送的话后续加载n次资源就会减少n次RTT的时间,如下图右侧!那么HTTP/2是如何实现服务端推送的呢?
首先在讲下面我们需要先说明一个问题:如果实现上面的功能,我们需要告诉浏览器要推送哪些资源,不然我们先返回html浏览器就渲染html直接发起请求加载静态文件了,那么我们推送就白做了,所以这里需要知道是哪个流发起的加载html的请求,然后通过这个流告诉浏览器我们要下发给你哪些资源在返回HTML之前!
1 | 服务器推送从源服务器的 Link 标头的 rel=preload 参数提取 URI 引用,然后将这些额外 URI 提供给客户端 |
PUSH_PROMISE
帧是服务器发起的请求,所以这里的stream_id是偶数!
1 | +---------------+ |
- R: 保留位
- Promised Stream ID: 这个就是上面讲到的,其实就是Push stream id(承诺要发送的stream ID)
- Header Block Fragment: 请求header,其实就是把推送的请求体发送过去了!
注意:由于推送会涉及到幂等、安全等问题,所以一般就是推送静态资源!
服务器推送的整体流程还是很简单,就是响应HTML之前发送 PUSH_PROMISE 帧
,这个帧会携带push资源的请求内容!然后返回HTML,然后再用Promised Stream ID
写响应关闭流即可!
服务端推送相比于HTTP/2不推送,前端页面整体的提升率大约在8%左右,具体可以看相关测评 ,同时开启后也会存在一定浪费带宽的问题,那就是假如浏览器有缓存不就不用返回了!目前主流的架构都是将静态资源基本放在CDN上,所以如果需要使用服务端推送,需要确定CDN厂商是否支持!
下图来自于又拍云关于DNS 服务端推送相关的页面,具体可以点击链接查看:
HTTP/3 介绍
通过学习上面我们发现HTTP/2其实做了很多的优化,在整个传输层,不仅降低了数据包的大小,同时流和多路复用降低了连接上的开销,提高了页面的加载速度!但是这种优化也是有弊端的!现在移动互联网比较普及,大部分流量都来自于移动设备,而移动设备最大的特点就是网络问题!而HTTP/2依赖于TCP,在网络不稳定的情况下,TCP的流控会导致HTTP/2变慢,如果发生丢包,就会导致HoL (Head of Line Blocking 头部阻塞), 导致一个连接上所有的流都需要等待!
因此作为HTTP/2前身SPDY的发明者Google,毕竟走的路比较长,所以也提出了比较好的解决思路,那就是把TCP替换掉,随后就诞生了QUIC!QUIC ( Quick UDP Internet Connection) 是 Google 研发的一种基于 UDP 协议的低时延互联网传输协议。在2018年IETF会议中(主要原因还是TLS v1.3同年正式发布),HTTP-over-QUIC协议被重命名为HTTP/3,并成为 HTTP 协议的第三个正式版本,大概的关系就是: 运行在 QUIC 之上的 HTTP 协议被称为 HTTP/3!
**QUIC 是传输层协议(4层协议)**,其中有两个版本一个是gQUIC(Google QUIC)和iQUIC(IETF QUIC),注意两者差别!有兴趣的可以看下QUIC 介绍,QUIC[RFC 8999-9002],以及 TLS1.3[RFC 8446],下图是一个大概的一个模型图:
由于本文前面篇幅有点长了,而且从HTTP发展历史也可以看得出来会越来越复杂,所以如果有兴趣的想深入了解的可以查阅相关资料,上面已经提供了很多了,下面就列出来了QUIC的几大特点:
- 降低了建连时间(注意a为TLS完全握手,b为TLS简化握手,可以实现0RTT 握手)
根据第三方数据使用QUIC 0RTT率大约在55%左右,具体可以点击文章查看详情!
- 优化了流量控制算法
- 优化了拥塞控制算法(其实和TCP用的是一样的都是Cubic算法)
- 用户态协议,不需要升级or修改内核,升级迭代容易,
虽然TCP有许多新特性,但是抵挡不住不敢随意升级内核哇!
目前外部对于QUIC的实现也比较多,比如字节的TTQUIC,快手的KQUIC, 以及微软的 msquic 。但是目前来看QUIC也有很多问题,主要问题还是UDP带来的问题,因为不同运营商会对UDP进行了一定的限制,其次就是多一层用户态协议的开销也是有一定的性能损耗(CPU、Mem的开销),最后就是目前人们对于TCP优化做的太多了!不过相信这些问题都是可以用时间去解决的,相信未来QUIC发展的更好!