更多内容关注微信公众号:fullstack888
HTTPS通过引入 SSL/TLS 使 HTTP 在网络通信过程中的安全达到了“极致”。但是这么多年以来 HTTP 本身在性能方面的提升却在原地踏步。对于整体的数据传输并没有提出更好的改进方案。甚至在 HTTP/1.x 我们还只能依赖 keep-alive 这种“长连接”技术。
Google 对 HTTP 的性能不满而率先“起义” — SPDY 协议,并在自家的 Chrome 浏览器中大获成功,从此开始倒逼 HTTP 协议的变革。
为什么不是 HTTP/2.0?
先来解答一个疑惑,细心的朋友肯定会发现这次怎么不像之前的 “1.0”、“1.1” 那样叫 “2.0” 呢?
对此 HTTP/2 工作组也给出了解释,他们认为以前 “1.0”、“1.1” 的版本管理方式造成了很多混乱和误解,让使用人员在实际应用过程中难以区分它们之间的差异,所以从这次决定 HTTP 协议将不再使用小版本号(minor version),只使用大版本号(marjor version),故从今往后的 HTTP 版本不会再有 HTTP/2.0、2.1,只会有 “HTTP/2”、“HTTP/3” ......
这也决定了 HTTP 在未来的发展中不会再有“零碎敲打”的小改良,HTTP 工作组在今后发布的每一个 HTTP 版本,都必须与上一个版本有本质上的不同。同样这种“跃进程度”对于使用者来说也更容易区分了。
兼容 HTTP/1
HTTP/1.x 的设计初衷主要是实现要简单,然而这种实现简单却是以牺牲应用性能为代价的。而这也是 HTTP/2 要致力于解决的核心问题,所以 HTTP/2 的唯一目标就是改进性能。
由于 HTTPS 在安全方面已经做的非常好了,所以 HTTP/2 直接沿用即可,不过它还是有一些要求的,这个我们后面会讲到。
但是由于 HTTP/1 庞大且沉重的历史包袱,所以协议的修改必须小心谨慎,兼容性是首要考虑的目标,否则就会破坏到互联网上无数现有的资产,这方面 TLS 1.3 已经有了先例(为了兼容 TLS 1.2 不得不进行“伪装”)。
那么,HTTP/2 是怎么做的呢?
因为必须要保持功能上的兼容,HTTP/2 不会改动 HTTP 的语义部分:HTTP 方法、状态码、URI 及头部字段等,这些核心概念都保留不变。即基于 HTTP 的上层应用也不需要做任何修改,可以无缝转换到 HTTP/2。
特别要说的是,HTTP/2 没有在 URI 中引入新的协议名,仍然用 “http” 表示明文协议,用 “https” 表示加密协议。这是一个了不起的决定,这样客户端或服务器可以自动升降级协议,免去了选择的麻烦,让用户在上网的时候都意识不到协议的切换,实现平滑过渡。
在 “语义” 保持基本不变的情况下,HTTP/2 在“语法”层做了“天翻地覆”地改造,完全变更了原有 HTTP 报文的传输方式。
优化了哪些问题
前面我们有说到,HTTP/2 的唯一目标是改良性能,因此 HTTP/2 不会对之前的版本再做出任何妥协,这次要“大刀阔斧”的进行改良,下面我们就一起来看下 HTTP/2 主要优化了哪些内容?
“大头儿子”问题
如今,每个客户端发起的 HTTP 请求,至少会携带几百甚至上千字节的头部数据,用于描述传输的资源及其属性。在 HTTP/1.x 中这些元数据都是以纯文本形式发送的,通常会给每个请求增加 500 ~ 800 字节的额外开销。
而且由于报文中一般会携带 “User Agent”、“Cookie”、“Accept” 和 “Server” 等许多固定的头字段,此时情况将会变得更糟,下面我们以 cookie 为例。
Cookie
HTTP 本身是一种无状态的协议(就是说服务器不必保存每次请求的客户端信息),但是在增加了 cookie 之后使得 HTTP 具有了会话管理的能力,然而过渡依赖这些状态信息,实现会话管理和个性化等功能时,可能会附加几百甚至上千字节。这么多的元数据跟随请求传递,必然会给应用带来明显的性能损失。
HTTP 并没有限制 cookie 的大小,但实践中大多数浏览器会将其限制在一个固定范围之内,一般为 4KB。
要知道此时我们的 body 却经常只有几十字节(比如 GET 请求、204/301/304 响应),可能更要命的是,成千上万的请求响应报文里有很多字段值都是重复的,非常浪费。
此时请求头成了不折不扣的“大头儿子”,而 cookie 则是这个“大头儿子”中的重头。
注意这里并不是说 cookie 是万恶之源,但是它的确会带来性能瓶颈,对此我们应该合理的利用并消除这些不必要的请求字节。
头部压缩
所以,HTTP/2 把“头部压缩” 作为性能改进的一个重点,优化的方式大家也肯定想的到,还是“压缩”,不过压缩并非这次的终点。
HTTP/2 并没有使用传统的压缩算法,而是开发了专门的 “HPACK” 算法,在客户端和服务器两端建立“字典”,用索引号表示重复的字符串,采用哈夫曼编码来压缩整数和字符串,整体压缩率可以达到 50% ~ 90%。
HTTP/2 是从 SPDY 发展而来,SPDY 早期采用 zlib 和自定义字典压缩所有 HTTP 首部,可以有效减少 85% ~ 88% 的首部开销,从而显著减少加载页面的时间。由于后期出现了针对该算法的安全攻击,于是 zlib 压缩算法被撤销。
下面我们看下由 Google 性能专家 Ilya Grigorik 在 Velocity 2015. SC 会议中分享的 HTTP/2 中有关 HPACK 的头部压缩。
首先 HTTP/2 的头部压缩需要客户端和服务器同时支持,双方:
维护一份相同的静态字典(Static Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;
维护一份相同的动态字典(Dynamic Table),可以动态地添加内容;
支持基于静态哈夫曼表的哈夫曼编码(Huffman Coding)。
静态字典的作用有两个,一个是对于完全匹配的头部键值对,例如: method:GET,可以直接使用一个字符表示,另一个是对于头部名称可以匹配的键值对,例如 cookie : xxxxxx,可以将名称使用一个字符表示。
使用字典可以极大地提升压缩效果,其中静态字典首次请求就可以使用。对于静态、动态字段中不存在的内容再使用哈夫曼编码来减小体积。
另外,HTTP/2 对原来 HTTP 协议的请求行例如 Method、Path、Status 等,在 HTTP/2 中被拆解成键值对放入头部,此时也可以享受到字典和哈夫曼压缩。另外 HTTP/2 中所有头部 key 必须小写。
利用 HTTP/2 协议本身带来的性能优化,虽然这部分元数据经过了压缩,那是不是意味着从此“高枕无忧”了呢?答案是否定的,我们依然不能忽视它们存在所带来的开销。
二进制格式
相信大家早已习惯 HTTP/1 的纯文本形式的报文了,它的优点是“一目了然”,用最简单的工具就可以开发调试,非常方便。
对人类友好的往往对计算机并不那么“友好”,这次 HTTP/2 没有再“妥协”,而是向以二进制为基础的下层 TCP/IP 协议“靠拢”,全面采用二进制格式。
主要是纯文本会有多义性,比如大小写、空白字符、回车换行、多字勺子等,程序在处理时必须用复杂的状态机,这不仅导致效率低、还麻烦。而二进制里只有 “0” 和 “1”,可以严格规定对错,解析起来也没有歧义,实现简单,体积小、速度快。
二进制分帧层
以二进制格式为基础, HTTP/2 决定改变延续了十多年的现状,而这也是 HTTP/2 性能增强的核心,新增的二进制分帧层定义了如何封装 HTTP 消息并在客户端与服务器之间传输。
HTTP/2 在保持原有“语义”不变的前提下,在 Socket 接口与应用可见的高层 HTTP API 之间增加了一层二进制分帧层,保证原来的不受到影响。不同的是数据传输期间的编码方式发生了变化。HTTP/2 将所有传输的信息分割为更小的消息和帧,并对他们采用二进制格式的编码。
因此以二进制格式为基础的 HTTP/2 开始了重大改革,把 TCP 协议的部分特性挪到了应用层,把原来固有的 “Header+Body” 的消息拆解成数个小段的二进制“帧”(Frame),用 “HEADERS” 帧存放头数据、“DATA” 帧存放实体数据。
这个好像与 “Chunked” 分块编码有点类似,都属于“化整为零”的思路。但是 HTTP/2 数据分帧之后 “Header + Body” 的报文结构就完全消失了,协议看到的只是一个个的数据“碎片”。
虚拟的“流”
消息的“碎片”到达目的地后应该怎么组装起来呢?
HTTP/2 为此定义了一个“流”(Stream)的概念,它是二进制帧的双向传输序列,同一个消息往返的帧会被分配一个唯一的流 ID。你可以把它想象成是一个虚拟的“数据流”,在里面流动的是一串有先后顺序的数据帧,这些数据帧按照次序组装起来就是 HTTP/1 里的请求报文和响应报文了。
这个流实际是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上用 “流”同时发送多个“碎片化”消息,这就是常说的“多路复用”(Multiplexing)— 多个往返通信都复用一个 TCP 连接来处理。
HTTP/2 的多路复用与 keep-alive 有本质上的差异,多路复用多个请求没有先后顺序,而 keep-alive 多个请求必须排队,这就是所说的 HTTP 队首阻塞。
为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的”流“,实现了优先级和流量控制,这些特性也和 TCP 协议非常相似。
解决队首阻塞
在 HTTP 1.x 中,如果客户端想发送多个并行的请求,那么必须使用多个 TCP 连接。这种模型只能保证每个连接每次只交付一个响应(多个响应必须排队)。更糟糕的是,这种模型会导致 HTTP 的队首阻塞,导致底层的 TCP 连接空闲使效率变得低下。
HTTP/1.1 的管道使请求队列(FIFO)从客户端(请求队列)迁移到服务器(响应队列),消除了发送请求和响应的等待时间,不过这种仅并行处理请求的能力并不能解决 HTTP 本质上的队首阻塞(其实是把多个HTTP请求放到一个TCP连接中一一发送,而在发送过程中不需要等待服务器对前一个请求的响应;只不过,客户端还是要按照发送请求的顺序来接收响应)。
而 HTTP/2 的流是虚拟的,消息是一些有序的“帧”序列。在“连接”层面,消息是乱序收发的“帧”。多个请求/响应之间没有了顺序关系,不需要排队等待,也就不会再出现 “HTTP” 队首阻塞的问题,降低了延迟,大幅度提高了 TCP 通道的利用率。
总的来说,HTTP/2 的二进制分帧机制解决了 HTTP 1.x 中存在队首阻塞的问题,也消除了并行处理和发送请求及响应时需要依赖多个 TCP 连接的方案。
Server Push
HTTP/2 还在一定程度上调整了传统的”请求 - 应答“工作模式,服务器不再是完全被动地响应请求,也可以新建”流“主动向客户端发送消息。比如,在浏览器刚请求 HTML 的时候就提前把可能用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为“服务器推送”(Server Push,也叫 Cache Push)。
强化安全
由于 HTTPS 在安全方面已经做的很好了,已经成为互联网安全的趋势。而且主流的浏览器 Chrome、Firefox 等都公开宣布只支持加密的 HTTP/2。也就是说目前在互联网上能见到的 HTTP/2 都是用了 “https” 的协议名,即跑在 TLS 上面。
不过有一点要说的是,HTTP/2 是在 2015 年开始制定标准,而当时的互联网还是基于 TLS 1.2,此时出现了很多 SSL/TLS 的漏洞和弱点。而最新的 TLS 1.3 还在定制中。所以 HTTP/2 废除了那些弱密码套件,比如 DES、RC4、CBC、SHA-1 等都不能在 HTTP/2 中使用。所以加密版本的 HTTP/2 在安全方面属于做了强化,要求下层的安全协议必须是 TLS 1.2 以上(相当于 TLS 1.25 版本),还要支持前向安全和 SNI。
为了区分“加密”和“明文”这两个版本,HTTP/2 协议定义了两个字符串标识符:“h2” 表示加密的 HTTP/2,“h2c” 表示明文的 HTTP/2,多出的那个字母 “c” 表示的是 “clear text”。
协议栈
下面我们简要对比下 HTTP/1、HTTPS 和 HTTP/2 的协议栈,如下 HTTP/2 是建立在 “HPack” “Stream” “TLS 1.2” 基础之上的,比 HTTP/1、HTTPS 更加复杂。
虽然 HTTP/2 的底层实现很复杂,但它在“语义”层面兼容了 HTTP/2,但其特性与 HTTP/2 完全不同(与 SPDY 相似)。
另外 HTTP/2 的多路复用本质上使用的是同一条 TCP 连接,改进性能的同时也解决了之前的队首阻塞问题。如果所有域名的请求都集中在同一条连接上,在网络拥塞的时候容易出现 TCP 队首阻塞的问题。关于这部分感兴趣的朋友可以继续学习 H3 的相关内容。
最后
总的来说 HTTP/2 的目的就是通过支持请求与响应的多路复用来减少延迟,把很多以前我们针对 HTTP/1.x 想出来的“歪招儿”一笔勾销,通过压缩 HTTP 首部字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。
实践及应用
虽然 H2 十分强大,不过后端在做支持时还是需要额外的改造,这个时候可以考虑在统一接入层做改造,在接入层将数据转换到 HTTP/1.1 再转发到对应域名的服务器。
这样所有的服务都不用做任何改造便可以享受到 H2 带来的优化。另一个是同一条 H2 连接只支持同一个域名,对于客户端网络框架来说,无论是 OkHttp 还是 Chromium 对 H2 的连接,同一个域名只会保留一条。在一些需要访问第三方请求,特别是文件下载或者视频播放等场景可能会遇到单连限速的问题。这个时候我们可以通过修改网络库实现,也可以简单的禁用 HTTT/2 协议来解决。
- END -
往期回顾
◆基于 Prometheus、InfluxDB 与 Grafana 打造监控平台
详情架构群的同学,加微信: jiagou6688 ,备注:Java