凤凰架构:构建可靠的大型分布式系统
上QQ阅读APP看书,第一时间看更新

4.3.1 连接数优化

我们知道HTTP(特指HTTP/3以前)是以TCP为传输层的应用层协议,但HTTP over TCP这种搭配只能说是TCP在当今网络中统治性地位所造就的结果,而不能说它们两者的配合就是合适的。回想一下你上网时平均在每个页面停留的时间,以及每个页面中包含的资源(HTML、JS、CSS、图片等)数量,可以总结出HTTP传输对象的主要特征是数量多、时间短、资源小、切换快。另一方面,TCP协议要求必须在三次握手完成之后才能开始数据传输,这是一个可能以高达“百毫秒”为计时尺度的事件;另外,TCP还有慢启动的特性,使得刚刚建立连接时的传输速度是最低的,后面再逐步加速直至稳定。由于TCP协议本身是面向长时间、大数据传输来设计的,在长时间尺度下,它建立连接的高昂成本才不至于成为瓶颈,它的稳定性和可靠性的优势才能展现出来。因此,可以说HTTP over TCP这种搭配在目标特征上确实是有矛盾的,以至于HTTP/1.x时代,大量短而小的TCP连接导致了网络性能的瓶颈。为了缓解HTTP与TCP之间的矛盾,聪明的程序员们一方面致力于减少发出的请求数量,另一方面也致力于增加客户端到服务端的连接数量,这就是上面Yslow规则中“Minimize HTTP Requests”与“Split Components Across Domains”两条优化措施的根本依据所在。

通过前端开发人员的各种Tricks,的确能减少消耗TCP连接数量,这是有数据统计作为支撑的。图4-2和图4-3展示了HTTP Archive对最近五年数百万个URL地址采样得出的结论:在页面平均请求没有改变的情况下(桌面端下降3.8%,移动端上升1.4%),TCP连接正在持续且幅度较大地下降(桌面端下降36.4%,移动端下降28.6%)。

图4-2 HTTP平均请求数量,70余个,没有明显变化

但是开发人员节省TCP连接的优化措施并非只有好处,它们也带来了诸多不良的副作用。

·如果你用雪碧图将多张图片合并,意味着任何场景下哪怕只用到其中一张小图,也必须完整加载整张大图片;任何场景下哪怕对一张小图要进行修改,都会导致整个缓存失效,类似地,样式、脚本等其他文件的合并也会存在同样的问题。

·如果你使用了媒体内嵌,除了要承受Base64编码导致传输容量膨胀1/3的代价外(Base64以8位表示6位数据),也将无法有效利用缓存。

·如果你合并了异步请求,这就会导致所有请求的返回时间都受最慢的那个请求的拖累,导致整体响应速度下降。

·如果你把图片放到不同子域下面,将会导致更大的DNS解析负担,而且浏览器对两个不同子域下的同一图片必须持有两份缓存,也使得缓存效率下降。

由此可见,一旦在技术根基上出现问题,依赖使用者通过各种Tricks去解决,无论如何都难以摆脱“两害相权取其轻”的权衡困境,否则这就不是Tricks而是一种标准的设计模式了。

图4-3 TCP连接数量,约15个,有明显下降趋势

在另一方面,HTTP的设计者们并不是没有尝试过在协议层面去解决连接成本过高的问题,即使HTTP协议的最初版本(指HTTP/1.0,忽略非正式的HTTP/0.9版本)就已经支持了[1]连接复用技术,即今天大家所熟知的持久连接(Persistent Connection),也称为连接Keep-Alive机制。持久连接的原理是让客户端对同一个域名长期持有一个或多个不会用完即断的TCP连接。典型做法是在客户端维护一个FIFO队列,在每次取完数据[2]之后一段时间内先不自动断开连接,以便在获取下一个资源时直接复用,避免创建TCP连接的成本。

但是,连接复用技术依然是不完美的,最明显的副作用是“队首阻塞”(Head-of-Line Blocking)问题。请设想以下场景:浏览器有10个资源需要从服务器中获取,此时它将10个资源放入队列,入列顺序只能按照浏览器遇见这些资源的先后顺序来决定。但如果这10个资源中的第1个就让服务器陷入长时间运算状态会怎样呢?当它的请求被发送到服务端之后,服务端开始计算,而运算结果出来之前TCP连接中并没有任何数据返回,此时后面9个资源都必须阻塞等待。因为服务端虽然可以并行处理另外9个请求(譬如第1个是复杂运算请求,消耗CPU资源,第2个是数据库访问,消耗数据库资源,第3个是访问某张图片,消耗磁盘I/O资源,这就很适合并行),但问题是处理结果无法及时返回客户端,服务端不能因为哪个请求先完成就返回哪个,更不可能将所有要返回的资源混杂到一起交叉传输,原因是只使用一个TCP连接来传输多个资源的话,如果顺序乱了,客户端就很难区分哪个数据包归属哪个资源了。

2014年,IETF发布的RFC 7230中提出了名为“HTTP管道”(HTTP Pipelining)的复用技术,试图在HTTP服务器中也建立类似客户端的FIFO队列,让客户端一次将所有要请求的资源名单全部发给服务端,由服务端来安排返回顺序,管理传输队列。无论队列维护在服务端还是客户端,其实都无法完全避免队首阻塞的问题,但由于服务端能够较为准确地评估资源消耗情况,进而能够更紧凑地安排资源传输,保证队列中两项工作之间尽量减少空隙,甚至做到并行化传输,从而提升链路传输的效率。可是,由于HTTP管道需要多方共同支持,协调起来相当复杂,推广得并不算成功。

队首阻塞问题一直持续到第二代的HTTP协议,即HTTP/2发布后才算是被比较完美地解决。在HTTP/1.x中,HTTP请求就是传输过程中最小粒度的信息单位了,所以如果将多个请求切碎,再混杂在一块传输,客户端势必难以分辨、重组出有效信息。而在HTTP/2中,帧(Frame)才是最小粒度的信息单位,它可以用来描述各种数据,譬如请求的Headers、Body,或者用来做控制标识,譬如打开流、关闭流。这里说的流(Stream)是一个逻辑上的数据通道概念,每个帧都附带一个流ID以标识这个帧属于哪个流。这样,在同一个TCP连接中传输的多个数据帧就可以根据流ID轻易区分开来,在客户端毫不费力地将不同流中的数据重组出不同HTTP请求和响应报文来。这项设计是HTTP/2的最重要的技术特征一,被称为HTTP/2多路复用(HTTP/2 Multiplexing)技术,如图4-4所示。

有了多路复用的支持,HTTP/2就可以对每个域名只维持一个TCP连接(One Connection Per Origin)并以任意顺序传输任意数量的资源了,这样既减轻了服务器的连接压力,也不需要开发者去考虑域名分片这种事情来突破浏览器对每个域名最多6个的连接数限制。更重要的是,没有TCP连接数的压力,就无须刻意压缩HTTP请求,所有通过合并、内联文件(无论是图片、样式、脚本)以减少请求数的需求都不再成立,甚至会被当作徒增副作用的反模式。

说这是反模式,也许还有一些前端开发人员会不同意,认为HTTP请求少一些总是好的,减少请求数量,最起码也减少了传输中耗费的Header。这里必须先承认一个事实,在HTTP传输中的Header占传输成本的比重是相当大的,对于许多小资源,甚至可能出现Header的容量比Body还要大,以至于在HTTP/2中必须专门考虑如何进行Header压缩的问题。但是,以下几个因素决定了通过合并资源文件减少请求数,对节省Header成本并没有太大帮助。

·Header的传输成本在Ajax(尤其是只返回少量数据的请求)请求中是比重很大的开销,但在图片、样式、脚本这些静态资源的请求中,通常并不占主要地位。

·在HTTP/2中Header压缩的原理是基于字典编码的信息复用。简而言之,同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以HTTP/2是单域名单连接的机制,合并资源和域名分片反而不利于提升Header压缩效果。

·与HTTP/1.x相比,HTTP/2本身变得更适合传输小资源,譬如传输1000张10KB的小图,HTTP/2肯定要比HTTP/1.x快,但传输10张1000KB的大图,则大概率HTTP/1.x会更快些。这是TCP连接数量(相当于多点下载)的影响,但更多是由TCP协议可靠传输机制导致的,一个错误的TCP包会导致所有的流都必须等待这个包重传成功,这是HTTP/3要解决的问题。因此,把小文件合并成大文件,在HTTP/2下是毫无益处的。

图4-4 HTTP/2的多路复用

[1] 连接复用技术在HTTP/1.0中并没有默认开启,是从HTTP/1.1开始变为默认开启的。

[2] 如何在不断开连接的情况下判断数据已取完将会放到稍后的4.3.2节去讨论。