Skip to content

HTTP/1.1

特点

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

持久化连接

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

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

img

通过单独的 TCP 连接取得 HTML 和 CSS 文件。

服务器处理速度越快,固定延迟对每个网络请求总时间的影响就越大!要验证这一点,可以改一改前面例子中的往返时间和服务器处理时间。

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

在我们两个请求的例子中,总共只节约了一次往返时间。但是,更常见的情况是一次 TCP 连接要发送N 次 HTTP 请求,这时:

  • 没有持久连接,每次请求都会导致两次往返延迟;
  • 有持久连接,只有第一次请求会导致两次往返延迟,后续请求只会导致一次往返延迟。

在启用持久连接的情况下,N 次请求节省的总延迟时间就是(N-1)×RTT。还记得吗,前面说过,在当代 Web 应用中,N 的平均值是 90,而且还在继续增加。因此,依靠持久连接节约的时间,很快就可以用秒来衡量了!这充分说明持久化 HTTP 是每个 Web 应用的关键优化手段。

客户端和服务器上的连接重用

  • 好消息是,只要服务器愿意配合,所有现代浏览器都会尝试使用持久化 HTTP 连接。可以检查一下自己的应用和代理服务器配置,确保使用持久连接。为保证最好的结果,请使用 HTTP 1.1,因为它默认启用持久连接。如果只能使用 HTTP 1.0,则可以明确使用 Connection: Keep-Alive 首部声明使用持久连接。
  • 此外,还要注意 HTTP 库和框架的默认行为,因为很多库和框架经常会默认使用非持久连接,这种做法多数源于它们提供“更简单 API”的理念。只要使用原始HTTP 连接,一定记得重用它们:重用连接的性能提升非常巨大!

HTTP管道

持久 HTTP 可以让我们重用已有的连接来完成多次应用请求,但多次请求必须严格满足先进先出(FIFO)的队列顺序:发送请求,等待响应完成,再发送客户端队列中的下一个请求。HTTP 管道是一个很小但对上述工作流却非常重要的一次优化。管道可以让我们把 FIFO 队列从客户端(请求队列)迁移到服务器(响应队列)。要理解这样做的好处,我们再看一看前图。首先,服务器处理完第一次请求后,会发生了一次完整的往返:先是响应回传,接着是第二次请求。在此期间服务器空闲。如果服务器能在处理完第一次请求后,立即开始处理第二次请求呢?甚至,如果服务器可以并行或在多线程上或者使用多个工作进程,同时处理两个请求呢?

img

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

HTTP 1.1 管道的好处,主要就是消除了发送请求和响应的等待时间。这种 并行处理请求的能力对提升应用性能的帮助非常之大。

现在我们暂停一会,回顾一下在性能优化方面的收获。一开始,每个请求要用两个TCP 连接(,总延迟为 284 ms。在使用持久连接后,避免了一次握手往返,总延迟减少为 228 ms。最后,通过使用 HTTP 管道,又减少了两次请求之间的一次往返,总延迟减少为 172 ms。这样,从 284 ms 到 172 ms,这 40% 的性能提升完全拜简单的协议优化所赐。

img

使用 HTTP 管道发送请求,服务器端按 FIFO 处理队列

而且,这 40% 的性能提升还不是固定不变的。这个数字与我们选择的网络延迟和两个请求的例子有关。希望读者自己能够尝试一些不同的情况,比如延迟更高、请求更多的情况。尝试之后,你会惊讶于性能提升效果比这里还要高得多。事实上,网络延迟越高,请求越多,节省的时间就越多。我觉得大家很有必要自己动手验证一下这个结果。因此,越是大型应用,网络优化的影响越大。

不过,这还不算完。眼光敏锐的读者可能已经发现了,我们可以在服务器上并行处理请求。理论上讲,没有障碍可以阻止服务器同时处理管道中的请求,从而再减少20 ms 的延迟。

可惜的是,当我们想要采取这个优化措施时,发现了 HTTP 1.x 协议的一些局限性。HTTP 1.x 只能严格串行地返回响应。特别是,HTTP 1.x 不允许一个连接上的多个响应数据交错到达(多路复用),因而一个响应必须完全返回后,下一个响应才会开始传输。为说明这一点,我们可以看看服务器并行处理请求的情况(图 11-4)。

img

图 11-4 演示了如下几个方面:

  • HTML 和 CSS 请求同时到达,但先处理的是 HTML 请求;
  • 服务器并行处理两个请求,其中处理 HTML 用时 40 ms,处理 CSS 用时 20 ms;
  • CSS 请求先处理完成,但被缓冲起来以等候发送 HTML 响应;
  • 发送完 HTML 响应后,再发送服务器缓冲中的 CSS 响应。

HTTP队头拥塞

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

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

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

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

在浏览器外部使用 HTTP 管道

在完全忽略 HTTP 管道的优点之前,有必要提醒一下大家,如果你对客户端和服务器拥有完全控制的权限,那么还是可以使用它的,并且效果非常好。事实上,苹果 iTunes 那个案例就说明了问题,参见本章开头的“让 iTunes 用户感受到 3 倍以上的性能增强”。如此巨大的性能提升,就来自启用持久 HTTP 连接,以及在服务器和 iTunes 客户端内启用 HTTP 管道。要在你自己的应用中启用管道,要注意如下事项:

  • 确保 HTTP 客户端支持管道;
  • 确保 HTTP 服务器支持管道;
  • 应用必须处理中断的连接并恢复;
  • 应用必须处理中断请求的幂等问题;
  • 应用必须保护自身不受出问题的代理的影响。

实践中部署 HTTP 管道的最佳途径,就是在客户端和服务器间使用安全通道(HTTPS)。这样,就能可靠地避免那些不理解或不支持管道的中间代理的干扰。

使用多个TCP连接

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

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

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

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

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

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

(1) 作为绕过应用协议(HTTP)限制的一个权宜之计;

(2) 作为绕过 TCP 中低起始拥塞窗口的一个权宜之计;

(3) 作为让客户端绕过不能使用 TCP 窗口缩放的一个权宜之计(参见 2.3 节“带宽延迟积”)。

后两个针对 TCP 的问题(窗口缩放和 cwnd)最好是通过升级到最新的 OS 内核来解决,参见 2.5 节“针对 TCP 的优化建议”。cwnd 值最近又提高到了 10 个分组,而所有最新的平台都能可靠地支持 TCP 窗口缩放。这当然是好消息。但坏消息是,没有更好办法绕开 HTTP 1.x 的多路复用问题。

只要必须支持 HTTP 1.x 客户端,就不得不想办法应对多 TCP 流的问题。而这又会带来一个明显的问题:为什么浏览器要规定每个主机 6 个连接呢?恐怕有读者也猜到了,这个数字是多方平衡的结果:这个数字越大,客户端和服务器的资源占用越多,但随之也会带来更高的请求并行能力。每个主机 6 个连接只不过是大家都觉得比较安全的一个数字。对某些站点而言,这个数字已经足够了,但对其他站点来说,可能还满足不了需求。

消耗客户端和服务器资源

限制每个主机最多 6 个连接,可以让浏览器检测出无意(或有意)的 DoS(Denial of Service)攻击。如果没有这个限制,客户端有可能消耗掉服务器的所有资源。讽刺的是,同样的安全检测在某些浏览器上却会招致反向攻击:如果客户端超过了最大连接数,那么所有后来的客户端请求都将被阻塞。大家可以做个试验,在一个主机上同时打开 6 个并行下载,然后再打开第 7 个下载请求,这个请求会挂起,直到前面的请求完成才会执行。

用足客户端连接的限制似乎是一个可以接受的安全问题,但对于需要实时交付数据的应用而言,这样做越来越容易造成部署上的问题。比如 WebSocket、Server Sent Event 和挂起 XHR,这些会话都会占用整整一个 TCP 流,而不管有无数据传输——记住,没有多路复用一说!实际上,如果你不注意,那很可能自己对自己的应用施加 DoS 攻击。

域名分区

HTTP/1.x协议的一项空白强迫浏览器开发商引入并维护着连接池,每个主机最多 6 个 TCP 流。好的一方面是对这些连接的管理工作都由浏览器来处理。作为应用开发者,你根本不必修改自己的应用。不好的一方面呢,就是 6 个并行的连接对你的应用来说可能仍然不够用。

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

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

image-20230609215247265

实践中,把多个域名(如 shard1.example.com、shard2.example.com)解析到同一个 IP 地址是很常见的做法。所有分区都通过 CNAME DNS 记录指向同一个服务器,而浏览器连接限制针对的是主机名,不是 IP 地址。另外,每个分区也可以指向一个 CDN 或其他可以访问到的服务器怎么计算最优的分区数目呢?这个问题不好回答,因为没有简单的方程式。答案取决于页面中资源的数量(每个页面都可能不一样),以及客户端连接的可用带宽和延迟(因客户端而异)。实际上,我们能做的,就是在调查的基础上做出预测,然后使用固定数量的分区。幸运的话,多这么一点复杂性,还是能给大多数用户带来好处的。实践中,域名分区经常会被滥用,导致几十个 TCP 流都得不到充分利用,其中很多永远也避免不了 TCP 慢启动,最坏的情况下还会降低性能。此外,如果使用的是HTTPS,那么由于 TLS 握手导致的额外网络往返,会使得上述代价更高。

此时,请大家注意如下几条:

  • 首先,把 TCP 利用好
  • 浏览器会自动为你打开 6 个连接;
  • 资源的数量、大小和响应时间都会影响最优的分区数目;
  • 客户端延迟和带宽会影响最优的分区数目;
  • 域名分区会因为额外的 DNS 查询和 TCP 慢启动而影响性能。

注:把 TCP 利用好,性能检查清单

  • 把服务器内核升级到最新版本(Linux:3.2+);
  • 确保 cwnd 大小为 10;
  • 禁用空闲后的慢启动;
  • 确保启动窗口缩放;
  • 减少传输冗余数据;
  • 压缩要传输的数据;
  • 把服务器放到离用户近的地方以减少往返时间;(CND)
  • 尽最大可能重用已经建立的 TCP 连接。

域名分区是一种合理但又不完美的优化手段。请大家一定先从最小分区数目(不分区)开始,然后逐个增加分区并度量分区后对应用的影响。现实当中,真正因同时打开十几个连接而提升性能的站点并不多,如果你最终使用了很多分区,那么你会发现减少资源数量或者将它们合并为更少的请求,反而能带来更大的好处。

DNS 查询和 TCP 慢启动导致的额外消耗对高延迟客户端的影响最大。换句话说,移动(3G、4G)客户端经常是受过度域名分区影响最大的!

度量和控制协议开销

HTTP 0.9 当初就是一个简单的只有一行的 ASCII 请求,用于取得一个超文本文档,这样导致的开销是最小的。HTTP 1.0 增加了请求和响应首部,以便双方能够交换有关请求和响应的元信息。最终,HTTP 1.1 把这种格式变成了标准:服务器和客户端都可以轻松扩展首部,而且始终以纯文本形式发送,以保证与之前 HTTP版本的兼容。

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

RFC 2616(HTTP 1.1)没有对 HTTP 首部的大小规定任何限制。然而,实际中,很多服务器和代理都会将其限制在 8 KB 或 16 KB 之内。

HTTP 首部的增多对它本身不是坏事,因为大多数首部都有其特定用途。然而,由于所有 HTTP 首部都以纯文本形式发送(不会经过任何压缩),这就会给每个请求附加较高的额外负荷,而这在某些应用中可能造成严重的性能问题。举个例子,API 驱动的 Web 应用越来越多,这些应用需要频繁地以序列化消息(如 JSON)的形式通信。在这些应用中,额外的 HTTP 开销经常会超过实际传输的数据静荷一个

数量级:

img

image-20230609215154346

➊ HTTP 请求首部:218 字节

➋ 应用静荷 15 字节({"msg":"hello"})

➌ 服务器的 204 响应:134 字节

在前面的例子中,寥寥 15 个字符的 JSON 消息被 352 字节的 HTTP 首部包裹着,全部以纯文本形式发送——协议字节开销占 96%,而且这还是没有 cookie 的最好情况。减少要传输的首部数据(高度重复且未压缩),可以节省相当于一次往返的延迟时间,显著提升很多 Web 应用的性能。

连接与拼合

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

  • 连接

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

  • 拼合

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

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

  • • 减少协议开销

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

  • • 应用层管道

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

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

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

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

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

对很多应用来说,更新资源带来的问题更大。更新图片精灵或组合 JavaScript 文件中的某一处,可能就会导致重新传输几百 KB 数据。由于牺牲了模块化和缓存粒度,假如打包资源变动频率过高,特别是在资源包过大的情况下,很快就会得不偿失。如果你的应用真到了这种境地,那么可以考虑把“稳定的核心”,比如框架和库,转移到独立的包中。

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

CSS 和 JavaScript 文件大小与执行性能

CSS 文件越大,浏览器在构建 CSSOM 前经历的阻塞时间就越长,从而推迟首次绘制页面的时间。类似地,JavaScript 文件越大,对执行速度的影响同样越大;小文件倒是能实现“递增式”执行。

打包文件到底多大合适呢?可惜的是,没有理想的大小。然而,谷歌 PageSpeed团队的测试表明,30~50 KB(压缩后)是每个 JavaScript 文件大小的合适范围:既大到了能够减少小文件带来的网络延迟,还能确保递增及分层式的执行。具体的结果可能会由于应用类型和脚本数量而有所不同。

嵌入资源

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

HTML
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAA
 AAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw=="
 alt="1x1 transparent (GIF) pixel" />

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

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

前端知识体系 · wcrane