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

14 KiB

HTTP/1.1

目录

  1. 前言
  2. 持久连接
  3. HTTP 管道
  4. 使用多个 TCP 连接
  5. 域名分区
  6. 度量和控制协议开销
  7. 连接和拼合
  8. 嵌入资源

前言

HTTP/1.1 引入了大量增强性能的重要特性,其中一些大家比较熟知的有:

  • 持久化连接以支持连接复用
  • 分块传输编码以支持流式响应
  • 请求管道以支持并行请求处理
  • 字节服务以支持基于范围的资源请求
  • 改进的更好的缓存机制

但是有些 HTTP/1.1 特性,比如请求管道,由于缺乏支持而流产,而其他协议限制,比如队首响应阻塞,则导致了更多问题。为此,Web 开发社区,创造和推行了很多自造的优化手段:域名分区、连接文件、拼合图标、嵌入代码,等等,不下十种。它们都是针对当前 HTTP/1.1 协议的局限性而采用的权宜之计。

持久连接

HTTP/1.1 的一个主要改进就是引入了持久 HTTP 连接。

每个 TCP 连接开始都有三次握手,要经历一次客户端与服务器间完整的往返。此后,会因为 HTTP 请求和响应的两次通信而至少引发另一次往返,最后,还要加上服务器处理时间,才能得到每次请求的总时间。

服务器处理时间无法预测,因为这个时间因资源和后端硬件而异。不过,这里的重点其实是由一个新 TCP 连接发送的 HTTP 请求所花的总时间,最少等于两次网络往返的时间:一次用于握手,一次用于请求和响应。这是所有非持久连接 HTTP 会话都要付出的固定时间成本。

实际上,这时候最简单的优化就是重用底层连接!添加对 HTTP 持久连接的支持,就可以避免第二次 TCP 连接时的三次握手、消除另一次 TCP 慢启动的往返,节约整整一次网络延迟。

HTTP/1.1 默认启用持久连接,如果只能使用 HTTP/1.0,则可以明确使用 Connection: Keep-Alive 首部声明使用持久连接。

HTTP 管道

持久 HTTP 可以让我们重用已有的连接来完成多次应用请求,但多次请求必须严格满足先进先出(FIFO)的队列顺序:发起请求,等待响应完成,再发送客户端队列中的下一个请求。HTTP 管道是一个很小但对上述工作流却非常重要的一次优化,管道可以让我们把 FIFO 队列从客户端(请求队列)迁移到服务器(响应队列)。

服务器处理完第一次请求之后,会发生一次完整的往返:先是响应回传,接着是第二次请求。在此期间,服务器空闲。如果服务器能在处理完第一次请求后,立即开始处理第二次请求呢?甚至,如果服务器可以并行或在多线程上或者多个工作进程,同时处理两个请求呢?

通过尽早分派请求,不被每次响应阻塞,可以再次消除额外的网络往返。这样,就从非持久连接状态下的每个请求两次往返,变成了整个请求队列只需要两次网络往返。

但是,HTTP/1.x 也有它的局限性,HTTP/1.x 只能严格串行的返回响应。特别是,HTTP/1.x 不允许一个连接上的多个响应数据交错到达(多路复用),因而一个响应必须完全返回后,下一个响应才会开始传输。

比如,服务器并行处理两个请求,一个是 HTML 请求,一个是 CSS 请求。服务器处理 HTML 请求比 CSS 请求耗时,即使 CSS 资源先准备就绪,服务器也会先发送 HTML 响应,然后再交互 CSS,这种情况通常被称为队首阻塞,并经常导致次优化交互:不能充分利用网络连接,造成服务器缓冲开销,最终导致无法预测的客户端延迟。假如第一个请求无限期挂起,或者要花很长时间才能处理完,怎么办呢?在 HTTP/1.1 中,所有后续的请求都将被阻塞,等待它完成。

实际中,由于不可能实现多路复用,HTTP 管道会导致 HTTP 服务器、代理和客户端出现很多微妙的、不见文档记载的问题:

  • 一个慢响应就会阻塞所有后续请求
  • 并行处理请求时,服务器必须缓冲管道中的响应,从而占用服务器资源,如果有个响应非常大,则很容易形成服务器的受攻击面
  • 响应失败可能终止 TCP 连接,从而强迫客户端重新发送对所有后续资源的请求,导致重复处理
  • 由于可能存在中间代理,因此检测管道兼容性,确保可靠性很重要
  • 如果中间代理不支持管道,那它可能会中断连接,也可能会把所有请求串行起来

由于存在这些以及其他类似的问题,而 HTTP/1.1 标准中也未对此作出说明,HTTP 管道技术的应用非常有限,虽然其优点毋庸置疑。今天,一些支持管道的浏览器,通常都将其作为一个高级配置选项,但大多数浏览器都会禁用它。换句话说,如果浏览器是 Web 应用的主要交互工具,那还是很难指望通过 HTTP 管道来提升性能。

使用多个 TCP 连接

由于 HTTP/1.x 不支持多路复用,浏览器可以不假思索的在客户端排队所有 HTTP 请求,然后通过一个持久连接,一个接一个的发送这些请求。然鹅,这种方式在实践中太慢。实际上,浏览器开发商没有别的办法,只能允许我们并行打开多个 TCP 会话。多少个?现实中,大多数现代浏览器,包括桌面和移动浏览器,都支持每个主机打开六个连接。

进一步讨论之前,有必要先想一想同时打开多个 TCP 连接意味着什么。当然,有正面的也有负面的。下面我们以每个主机打开最多六个独立连接为例:

  • 客户端可以并行分派最多六个请求
  • 服务端可以并行处理最多六个请求
  • 第一次往返可以发送的累积分组数量(TCP cwnd)增长为原来的六倍

在没有管道的情况下,最大的请求数与打开的连接数相同。相应的,TCP 拥塞窗口也要乘以打开的连接数量,从而允许客户端绕开由 TCP 慢启动规定的分组限制。这好像是一个方便的解决方案,我们再看看这样做的代价:

  • 更多的套接字会占用客户端、服务器以及代理的资源,包括内存缓冲区和 CPU 时钟周期
  • 并行 TCP 流之间竞争共享的带宽
  • 由于处理多个套接字,实现复杂性更高
  • 即使并行 TCP 流,应用的并行能力也受限制

实践中,CPU 和内存占用并非微不足道,由此会导致客户端和服务端的资源占用量上升、运维成本提高。类似的,由于客户端实现的复杂性提高,开发成本也会提高。最后,说到应用的并行性,这种方式提供的好处还是非常有限的。这不是一个长期的方案,了解这些之后,可以说今天之所以使用它,主要有三个原因:

  1. 作为绕过应用协议(HTTP)限制的一个权宜之计
  2. 作为绕过 TCP 中低起始阻塞窗口的一个权宜之计
  3. 作为让客户端绕过不能使用 TCP 窗口缩放的一个权宜之计

域名分区

根据 HTTP Archive 的统计,目前平均每个页面都包含 90 多个独立的资源,如果这些资源都来自同一个主机,那么仍然会导致明显的排队等待。既然每个主机最多 6 个 TCP 流,那么何必把自己只限制在一个主机上呢?我们不必只通过一个主机(例如 www.example.com)提供所有资源,而是可以手工将所有资源分散到多个子域名:{shard1.shardn}.example.com。由于主机名称不一样了,就可以突破浏览器的连接限制,实现更高的并行能力,域名分区使用的越多,并行能力就越强。

当然,天下没有免费的午餐,域名分区也不例外:每个新主机名都要求有一次额外的 DNS 查询,每多一个套接字就会多消耗两端的一些资源,而更糟糕的是,站点作者必须手工分离这些资源,并分别把它们托管到多个主机上。

实践中,域名分区经常会被滥用,导致几十个 TCP 流都得不到充分利用,其中很多永远也避免不了 TCP 慢启动,最坏的情况下还会降低性能。此外,如果使用的是 HTTPS,那么由于 TLS 握手导致额外的网络往返,会使上述代价更高。

域名分区是一种合理但又不完美的优化手段,所以实践中一定先从最小分区数目开始,然后逐个增加分区并度量分区后对应用的影响。

度量和控制协议开销

HTTP/1.0 增加了请求和响应首部,以便双方能够交换有关请求和响应的元信息。最终,HTTP/1.1 把这种格式变成了标准:客户端和服务端都可以轻松扩展首部,而且始终以纯文本形式发送,以保证与之前的 HTTP 保持兼容。

今天,每个浏览器发起的 HTTP 请求,都会携带额外 500-800 字节的 HTTP 元数据:用户代理字符串、很少改变的接收和传输首部、缓存指令,等等。有时候,500-800 字节都说少了,因为没有包含最大的一块:HTTP Cookie。现代应用经常通过 Cookie 进行会话管理,记录个性选项或者完成分析。综合到一起,所有这些未经压缩的 HTTP 元数据经常会给每个 HTTP 请求增加几千字节的协议开销。

连接和拼合

最快的请求是不用请求,减少请求次数总是最好的优化手段。可是,如果你无论如何也无法减少请求,那么对 HTTP/1.x 而言,可以考虑把多个资源捆绑打包到一块,通过一次网络请求获取:

  • 连接

    把多个 JavaScript 或 CSS 文件组合为一个文件。

  • 拼合

    把多张图片组合为一个更大的复合的图片。

对 JavaScript 和 CSS 来说,只要保持一定的顺序,就可以做到把多个文件连接起来而不影响代码的行为和执行。类似的,多张图片可以组合为一个 "图片精灵",然后使用 CSS 选择这张大图中的适当部分,显示在浏览器中。这两种技术都具备两方面的优点:

  • 减少协议开销

    通过把文件组合成一个资源,可以消除与文件相关的协议开销。如前所述,每个文件很容易招致 KB 级未压缩数据的开销。

  • 应用层管道

    说到传输的字节,这两种技术的效果都好像是启用了 HTTP 管道:来自多个响应的数据前后相继的连接在一起,消除了额外的网络延迟。实际上,就是把管道提高了一层,置入了应用中。

连接和拼合技术都属于以内容为中心的应用层优化,它们通过减少网络往返开销,可以获得明显的性能提升。可是,实现这些技术也要求额外的处理、部署和编码,因而也会给应用带来额外的复杂性。此外,把多个资源打包到一块,也可能给缓存带来负担,影响页面的执行速度。

要理解为什么这些技术会伤害性能,可以考虑一种并不少见的情况:一个包含十来个 JavaScript 和 CSS 文件的应用,在产品状态下把所有文件合并为一个 CSS 文件和一个 JavaScript 文件。

  • 相同类型的资源都位于一个 URL 下面
  • 资源包中可能包含当前页面不需要的内容
  • 对资源包中任何文件的更新,都要求重新下载整个资源包,导致较高的字节开销
  • JavaScript 和 CSS 只有在传输完成后才能被解析和执行,因而会拖慢应用的执行速度

实践中,大多数 Web 应用都不是只有一个页面,而是由多个视图构成,每个视图都有自己的资源,同时资源之间还有部分重叠:公用的 CSS、JavaScript 和图片。实际上,把所有资源都组合到一个文件经常会导致处理和加载不必要的字节。虽然可以把它看成一种预获取,但代价则是降低了初始启动的速度。

对很多应用来说,更新资源带来的问题更大,更新图片精灵或组合 JavaScript 文件中的某一处,可能就会导致重新传输几百 KB 数据。由于牺牲了模块化和缓存粒度,假如打包资源变动频率过高,特别是在资源包过大的情况下,很快就会得不偿失。

内存占用也会成为问题,对图片精灵来说,浏览器必须分析整个图片,即便实际上只显示其中的一小块,也要始终把整个图片都保存在内存中。浏览器是不会把不显示的部分从内存中剔除掉的!

最后,为什么执行速度还会受影响呢?我们知道,浏览器是以递增方式处理 HTML 的,而对于 JavaScript 和 CSS 的解析及执行,则要等到整个文件下载完毕。JavaScript 和 CSS 处理器都不允许递增式执行。

总之,连接和拼合是在 HTTP/1.x 协议限制的现实下可行的应用层优化。使用得当的话,这两种技术都可以带来明显的性能提升,代价则是增加应用的复杂度,以及导致缓存、更新、执行速度、甚至渲染页面的问题。应用这两种优化时,要注意度量结果。

嵌入资源

嵌入资源是另一种非常流行的优化方法,把资源嵌入文档可以减少请求的次数。比如,JavaScrpt 和 CSS 代码,通过适当的 script 和 style 块可以直接放在页面中,而图片甚至音频或 PDF 文件,都可以通过数据 URI 的方式嵌入页面中。

数据 URI 适合特别小的,理想情况下,最好是只用一次的资源。以嵌入方式放到页面中的资源,应该算是页面的一部分,不能被浏览器、CDN 或其他缓存代理作为单独的资源缓存。换句话说,如果在多个页面中都嵌入同样的资源,那么这个资源将会随着每个页面的加载而被加载,从而增大每个页面的总体大小。另外,如果嵌入资源被更新,那么所有以前出现过它的页面都将被宣告无效,而由客户端重新从服务器获取。

最后,虽然 CSS 和 JavaScript 等基于文本的资源很容易直接嵌入页面,也不会带来多余的开销,但非文本性资源则必须通过 base64 编码,而这会导致开销明显增大,编码后的资源大小比原大小增大 33%。

实践中,常见的一个经验规则是只考虑嵌入 1~2 KB 以下的资源,因为高于这个标准的资源经常会导致比它自身更高的 HTTP 开销。然而,如果嵌入的资源频繁更新,又会导致宿主文档的无效缓存率升高。如果你的应用要使用很小的、个别的文件,在考虑是否嵌入时,可以参照以下建议:

  1. 如果文件很小,而且只有个别页面使用,可以考虑嵌入
  2. 如果文件很小,但需要在多个页面中重用,应该考虑集中打包
  3. 如果小文件经常更新,就不要嵌入了
  4. 通过减少 HTTP cookie 的大小将协议开销最小化