静态资源递送优化

HTTP/2 诞生于 8 年前 Google 一封 SPDY 协议白皮书。5 年前,RFC7540RFC7541 的发布正式确立了 HTTP/2。5 年过去了,支持 HTTP/2 的浏览器的市场占有率达到了 96.88%,HTTP/3 已经从 QUIC 中诞生,然而回过头来看看国内各大网站,HTTP/2 仍然没有发挥它应该有的作用。不过,HTTP/2 究竟能做什么呢?

HTTP 协议和优化资源递送

不论前端再怎么发展,HTML、CSS、JS 三剑客是网站至关重要的元素。头部的 CSS、关键的 JS 迟迟不加载将会直接导致 FCP(首次内容绘制)、FID(首次输入延迟)不断被拉长,糟糕的体验只会让访客直接关闭页面。

为了让用户从距离更近的节点获得资源,几乎所有的外链资源甚至 HTML 本身都会通过 CDN 分发;gzip 乃至 brotli 压缩能够让传输文本文件所需的流量减少到原始大小的至少四分之一;Nginx 可以轻松应付百万并发请求。再除去 DNS 查询时间和 HTTP/1.1 所依赖的 TCP 三次握手带来的影响,最终决定用户能否更快获得所需资源的就是 HTTP 协议。

HTTP/1.1 时代常用的优化方法

HTTP/1.1 的规范是 RFC2616,规定了对于同一个域名只允许同时存在 2 个连接。由于这个规定看起来就不合理、以至于后来所有的浏览器实现都无视了这一限制,最终在 RFC7230 中将这一限制去除。但是浏览器为了保证公平,每个域名下最多也只允许同时存在 6 个连接。

如果要同步下载数十甚至数百个文件(这不稀奇,下次打开 GitHub 时可以用 DevTools 看一下,HTTP 请求数量不会低于 50),压榨浏览器最常用的方法就是域名散列。GitHub 的 avatar{0..3}.githubusercontent.com、Bilibili 的 i{0..3}.hdslb.com、京东的 img{1..30}.360buyimg.com 都是在做这件事情。但是域名散列又会带来如下的问题:

  • 建立 HTTP 连接的开销是巨大的 —— 每一个域名都需要经过一次 DNS 查询、TCP 三次握手、TLS 还要一次。
  • 每一个 HTTP/1.1 连接都要包含相同的信息比如 User-Agent,而对于非 Cookie-Free 域名还要在请求头中包含 Cookie,这造成了流量浪费。
  • 更多的并发连接和 Keep-Alive 长连接造成客户端和服务端的性能负担。
  • 如果同一个资源在不同页面下被散列在不同的域名下,那么就没法有效利用 HTTP 缓存。

正因为建立 HTTP 连接的开销巨大,因此除了散列域名、还需要合并请求:图片可以被合并成雪碧图、媒体文件(图片和音频)base64 后可以用 Data URI 存起来、多个 CSS 和 JS 可以合并、而关键的 CSS 和 JS 会被内联在页面头部。但是:

  • 对于雪碧图,为了显示一张小图不得不加载完整的文件,因此已经逐渐被 Data URI、Icon Font、SVG 替代。
  • 二进制文件 Base64 编码后,体积会至少变大三分之一。
  • 合并后的 CSS 和 JS 体积巨大,从之前多个文件加载一个解析一个、到全部加载后再一起解析,最终导致 FCP 和 FID 的大幅增加。 而且,其中任一文件的变动都会导致整个文件的 HTTP 缓存失效。
  • HTML 文件几乎不会被缓存或缓存的 TTL 很短,将关键的 CSS 和 JS 内联在 HTML 上会导致流量的浪费。

固然我们可以为文件添加夸张的响应头(比如 Cache-Control: public, immutable, max-age=1145141919810),但是众所周知千万不要相信 HTTP 缓存的有效性:用户只要 Ctrl + F5 就会把你的 HTTP 缓存变成 304,而「您的上网垃圾高达 3.4 GB!建议您立刻清理」则直接让你的 HTTP 缓存全部泡汤。

为了最大化利用缓存,将 JS 和 CSS 存进 localStorage 成为了几乎唯一可行的方案。在众多方案(比如饿了么的 bowl.js、摩拜单车的 betty.js)中,最完善的实现是奇虎 360 的燕尾服脚手架和微信文章的 Moon 框架,实现了完善的资源版本管理和高度整合的打包工作流;而最激进的实现是美团红包的 lsloader,支持资源合并加载、再在本地拆分存储,以及字符级别的缓存更新粒度。


HTTP/0.9 只用了一行协议就启动了 www,如今 HTTP 成为了应用最广泛、采用最多的互联网应用层协议。但是 HTTP/0.9-1.x 简单的实现随着互联网的发展逐渐暴露除了一系列问题:连接并发、请求头重复、TCP 利用率低。Web 前端的开发者提出了一系列奇技淫巧来试图解决这些问题,HTTP/2 则要在协议层面直接解决问题。

但是,HTTP/2 仍然只是 HTTP 的扩展,请求方法、状态码、请求头、URL 这些核心概念并未发生改变。在 HTTP 语义的基础上优化 HTTP 协议,HTTP/2 提出了三个概念 ——

HTTP/2 的设计

HTTP/0.9-1.x 的语义是明文的,以换行符作为分隔。一个 HTTP/1.1 的请求可能是这样的:

  1. POST /upload HTTP/1.1
  2. Host: www.skk.moe
  3. Content-Type: application/json
  4. Content-Length: 19
  5. {"sukka":"foxtail"}

如果不改变上面这个请求的关键语义(请求方式、URL、请求头、状态码),怎么组织这些信息就成为了优化的关键。相比 HTTP/1.1,HTTP/2 提出了将传输的信息编码为二进制。在这基础上,HTTP/2 提出了三个概念:

  • 帧:HTTP/2 通信的最小单位,承载了特定类型的数据
  • 数据流:已经建立的一个 TCP 连接、可以承载任意大小和数量的双向的字节流
  • 消息:一个逻辑上的 HTTP 消息,例如请求和响应。每条消息对应一系列帧

通过改变 HTTP 语义的编码方式,HTTP/2 得以进行一系列优化,比如:

HTTP/2 多路并发和响应复用

HTTP/2 和 Server Push - 图1

如上图所示,服务端在持续向客户端发送编号为 1 的数据流时,编号为 3 的数据流承担了一个新的请求(可以看到标识响应头的 HEADERS 帧和数据的 DATA 帧)插入了数据流 1。与此同时从客户端也在向服务端发送的编号为 5 的数据流。图源 Google Web Fundamentals。

由于 HTTP/1.1 时代的交付模型,一对请求和响应同时只能使用一个 TCP 连接。而 HTTP/2 的二进制编码和帧的设计,可以将 HTTP 信息分解成互不依赖的帧、同时交错发送,收到消息的一端再将帧进行组装。 因此,HTTP/2 得以:

  • 在一个数据流(一个 TCP 连接)上同时发送多个请求和响应
  • 同时将多个请求和响应的帧 交错 并行发送(注意并不等价于数个请求同时发送)
  • 消除新建 TCP 连接的巨大开销

现在,一个 TCP 连接就可以承担数百个请求和响应。这些请求有着相似的响应头,因此,HTTP/2 新增一个优化措施 ——

HTTP/2 头部压缩

HTTP/1.1 时代,消息本体已经用 gzip 进行了压缩、或者二进制文件(如 woff 字体等)也内置了压缩格式。但是,响应头、状态码并没有经过任何压缩、直接使用明文传输,对于不会经常变动的如 User-Agent、Cookie、状态码等每次请求会造成数百字节流量的浪费。在 HTTP/1.1 时代可以用 Cookie-Free 域名、请求合并来回避了这一问题。

RFC7540制定了 HTTP/2 的规范,而 RFC7541 专门制定了 HTTP/2 头部压缩的格式 HPACK。HPACK 格式的关键在于两点:

  • 使用静态霍夫曼码表编码,减少了传输的数据的大小
  • 客户端和服务端各自维护一组静态和动态的字典,对请求头和响应头进行索引,在请求间共享索引和映射

HTTP/2 和 Server Push - 图2

如上图所示,第二个请求中和第一个请求中相同的响应头字段被复用。图源 Google Web Fundamentals。

HPACK 默认的静态字典供包括 61 个字段,囊括了请求方式、路径、常见响应头(Referer、Accept、Age、Cache-Control、Content-Length、Content-Encoding、ETag 等)、状态码等,完整字典可以在 RFC7541 中查询。

为了进一步优化,客户端和服务端各自维护一个动态字典,随时更新,以对后续请求的头部进行压缩。即使不能进入动态字典复用的字段,静态霍夫曼码表也可以对其压缩、减少传输所需的流量。需要注意的是,动态字典仅在一个数据流(也就是一个 TCP 连接)中有效,客户端和服务端要为每个连接创建和维护各自一份动态字典。


HTTP/2 的连接复用、多路并发、头部压缩彻底颠覆了 HTTP/1.1 时代的优化手段。在同一个 TCP 连接(同一个数据流)上传输的帧越多,动态字典积累越完整,头部压缩效果越好,节省的流量越多。因此,在 HTTP/2 时代,网站不应该合并请求、不应该通过散列域名增加 TCP 连接数。

浏览器一般会针对一下两种情况使用同一个 TCP 连接:

  • 同一个域名
  • 不同的域名,但是解析到同一个 IP 且使用相同的 SSL 证书

上述第二点很容易被忽略。实际上如果注意一下你会发现,Google阿里百度 使用的都是 OV SSL 证书,你会发现他们使用的都是 OV SSL、包含数十个 WildCard 域名,目的就是为了将尽可能多的域名包含在同一个 SSL 中,当这些域名解析到同一个 IP 时浏览器可以复用一个 TCP 连接。

遗憾的是,大部分网站的静态资源分发的 CDN 仍然没有启用 HTTP/2、大部分网站仍然在使用过时的域名散列方案。

HTTP/2 Server Push

优化资源加载最有效的方式是:尽可能加载少的资源;如果这个资源之后才用得到,那么就不要一开始就加载它。关键资源、关键渲染路径、关键请求链的概念诞生已久,异步加载资源的概念可谓是老生常谈:懒加载图片、视频、iframe,乃至懒加载 CSS、JS、DOM,懒执行函数。但是,关键资源递送的思路却依然没有多少改变。

过去常见关键样式或关键媒体资源内联在 HTML 中,虽然客户端只请求了 HTML,但是这些资源随 HTML 文件一起到达用户手中、避免了浏览器在解析 DOM 后再开始加载资源。但是这部分关键资源却不能够被有效的缓存起来。于是,HTTP/2 提出了 Server Push,相比内联的方法有额外的性能收益:

  • 相比内联在 HTML 中、跟随 HTML 的缓存 TTL,这部分响应可被浏览器缓存起来
  • 成功缓存以后,其他页面可以不再请求这一文件
  • 客户端可以选择拒绝接收服务端对这一资源的 Push

需要注意的是,启用 Server Push 以后一定会存在流量浪费,因为服务端在接收到请求后一定会将额外的资源一并响应给客户端。如果客户端本地已有 HTTP 缓存,可以在接收到 Push 的帧后发送 RST_STREAM 帧阻止服务端发送后续的帧,但是头部的几个帧已经发送了,这是无可避免的。

但是,如果没有 Server Push,浏览器加载关键资源就会受到许多条件的限制(TCP 头部阻塞、浏览器解析 DOM 找出外链资源的耗时、浏览器等待服务端返回请求的外链资源的耗时),因此 Server Push 的核心就是用带宽和流量换取延时。以我的博客为例,我为 blog.skk.moe 的 CSS 启用了 Server Push 以后,DOMContentLoaded 触发计时平均减少了 180ms,这是一个很惊人的数字了。

HTTP/3 Server Push

HTTP/2 推出 Server Push 后备受争议 —— 虽然 Server Push 可以节省关键资源的 RTT,但是流量的浪费也不可忽视。HTTP/3 在设计时对 Server Push 进行了严格的限制。客户端在请求时会携带一个是否允许服务端推送的帧,只有客户端允许服务端推送时才会进行 Server Push;服务端不会立刻推送资源,而是先发送一个 PUSH_PROMISE 创建帧;一个新的帧 CANCEL_PUSH 被制定出来,用于让客户端阻止服务端推送。


一篇文章、三四千字,写完了五年前 HTTP/2 带来的所有性能机遇,还给 HTTP/3 开了一个头。这给了我一个想法,也许之后可以出个 HTTP/3 的专题。

本文作者 : Sukka
本文采用 CC BY-NC-SA 4.0 许可协议。转载和引用时请注意遵守协议、注明出处!
本文链接 : https://blog.skk.moe/post/http2-server-push/