---
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 的大小将协议开销最小化