第四章、连接管理
1 TCP连接
几乎所有的HTTP通信都是有TCP/IP连接承载的。TCP/IP是一种可靠的连接,其传输的数据不会丢失、受损。
TCP 连接是通过 4 个值来识别的:
< 源IP地址、源端口号、目的IP地址、目的端口号>
这 4 个值一起唯一地定义了一条连接。两条不同的 TCP 连接不能拥有 4 个完全相同的地址组件值(但不同连接的部分组件可以拥有相同的值)。
这里我们先看一条HTTP请求的连接过程,如下:
其中第四步的连接一般就是通过TCP/IP连接,然后在其连接的基础上进行报文发送。
1.1 TCP的数据传输
TCP为HTTP提供了一条可靠的传输管道,从TCP管道的一端写入数据,TCP管道的另一端就会以原有顺序有序的读出数据。
上面仅仅提到了TCP的传输是有序的,具体是怎么传输数据的呢?TCP的数据是通过IP数据报的小数据块来发送的。也就是说,HTTP需要传送报文的时候,先先打开一条TCP连接,然后以流的形式将数据给TCP,TCP收到数据流之后,会对其进行分块,将每一块数据封装进IP分组当中,然后通过英特网进行传输。一般来说,我们只会看到HTTP报文的传输,中间TCP/IP对数据的处理,对HTTP的开发人员来说是透明的。整个数据分块传输如下图所示:
TCP是通过端口号来保持所有这些连接持续不断地运行
上述提到的IP分组后,每个分组中一般包括以下几项数据:
- 一个 IP 分组首部(通常为 20 字节):包含源和目标的IP地址、长度等信息
- 一个 TCP 段首部(通常为 20 字节):如TCP的端口号,用于数据完整性检查和校验等信息
- 一个 TCP 数据块(0 个或多个字节):也就是真正需要传输的数据了
1.2 TCP套接字编程
一般操作系统都会以API接口的形式提供一条TCP编程的工具,我们利用这套API就可以进行TCP编程了。该API只会暴露出需要的接口给我们使用,其中数据分组、传输的细节被隐藏起来了。下面我们看一个例子和一些伪代码来理解TCP传输的过程:
时间长短取决于服务器距离的远近、服务器的负载情况,以及因特网的拥塞程度
2 TCP性能问题
2.1 HTTP的时延问题
从上图可以看到,其实我们一次HTTP请求响应过程中,真正处理HTTP请求的时间相对于整个TCP连接,报文传输等的时间来说,占比是非常小的。所以HTTP的时延主要因素就在于TCP的时延,通过上图可以将HTTP时延原因总结为如下几条:
- 第一个就是通过DNS来解析URI中的IP地址和端口的时间;
- 第二个需要关心的就是建立TCP连接的时间,TCP连接为了提供可靠连接,在连接的过程中会有多次握手的过程,也是非常消耗时间的
- 剩下的就是我们HTTP请求报文的处理和最后将相应报文回传给客户端的时间。
TCP网络时延的大小取决于硬件速度、网络和服务器的负载,请求和响应报文的尺寸,以及客户端和服务器端之间的距离
2.2 性能优化
2.2.1 IP地址和端口解析
HTTP时延中的一个原因就是DNS解析IP地址和端口的问题,该问题目前大部分客户端的处理方式就是建立起一个缓存机制,当我们需要从URI中解析出IP地址和端口的时候,先从缓存中获取,本地缓存中有的话,就不需再通过DNS进行解析了。这样就节约了这部分的时间
2.2.2 TCP连接的握手时延
一个新的TCP连接建立的时候,会在发送真正的报文数据之前,进行几次握手,用于保证其传输的可靠性。一般TCP连接握手分为以下几步:
a)客户端要建立新的连接的时候,先向服务器端发送一个特殊的分组数据,包含一个SYN的标记,说明这是一个连接请求
b)服务端收到该请求后,对收到的参数进行一些计算,向客户端返回一个分组数据,包含SYN和ACK标记,表明服务器已经接受该请求
c)客户收到服务端的消息后,再发送一条确认信息,告诉服务器已经确认连接成功。
而上诉三次握手都是需要花费时间的,如果我们发送的是一条数据量非常大的报文,那么这个握手的时间可能不会有太大问题,但是如果我们本身发送的数据量非常小,使得整个HTTP请求的时间中大部分都浪费在了握手的时间上,再设想一下,如果我们后面发送的大部分请求都是这种销量数据类型的请求,那如果每次都是去重新建立TCP连接,将是非常低效率的做法。为了解决该问题,我们可以重用连接,这样我们一次建立TCP连接,发送多次报文数据,就可以达到提高性能的目的。怎么实现重用连接,后续会有介绍。
2.2.3 延迟确认问题
每个TCP段都有一个序列号和数据完整性校验和
TCP为了保证数据传输的可靠性,除了在建立连接的三次握手机制外,还会通过在接收到数据后回复给发送者一个确认消息。表明自己已经收到了正确的数据。但是如果每次我们都专门为了回复确认消息而单独发送数据,那就很浪费网络资源,因为确认消息是非常小的,可能还没有一条分组数据的TCP标记信息大。所以TCP为了节约资源,允许将确认消息捎带在发往同一个方向的数据分组中(如此次数据分组是从服务器发往客户端的,那就可以捎带服务器给客户端的确认消息)。但是要知道,不是每次都刚好有发往同方向的数据分组的,这个时候,在一个特定的窗口时间(通常延迟100~200ms)后,都没有合适的数据分组需要发送,那就需要单独将该确认消息发出。假设我们客户端和服务器端进行很多次通信,而每次通信都恰巧没有合适的数据分组可以捎带确认消息,那么每次都需要延迟100~200ms进行确认。当次数很多的时候,延迟的影响也会变的很大。为了解决该问题,我们可以在合适的时候禁用延迟确认机制,当然是在合适的时候,有些时候用该机制是可以
2.2.4 TCP慢启动
TCP 数据传输的性能还取决于 TCP 连接的使用期(age)。TCP 连接会随着时间进行自我“调谐”,起初会限制连接的最大速度,如果数据成功传输,会随着时间的推移提高传输的速度。这种调谐被称为 TCP 慢启动(slow start)。用于防止因特网的突然过载和拥塞
这种机制造成的一个问题就是在前期TCP传输数据的效率是非常低下的。为了解决该问题,也可以通过重用连接的方式,达到不要每次都重新启动TCP连接而经历慢启动。
即:HTTP事务有很大数据量要发送时候,是不能一次将所有分组都发送出去的。必须发送一组,等待确认后再能获取发送另外两个分组的权限,然后发送,以此类推。---------打开拥塞窗口
2.2.5 Nagle算法与TCP_NODELAY
Nagle算法也是为了解决资源浪费的问题。试想如果我们有大量的非常小的数据传输的情况,每次传输除了我们自己的数据外,还需要至少40个字节用于存放TCP标记和首部。最终导致的结果就是浪费了大量的资源在传输这些标记和首部上面。Nagle算法会在每次TCP发送数据之前进行判断,如果该次数据不足以填满TCP的数据分组的全尺寸(一般为1500字节),就会将其放入缓存中,待凑足了数据再一次性进行发送,这样也会造成HTTP时延问题。解决办法就是在需要的时候,通过配置TCP_NODELAY禁用Nagle算法。
2.2.6 TIME_WAIT累积和端口耗尽
当某个 TCP 端点关闭 TCP 连接时,会在内存中维护一个小的控制块,用来记录最近所关闭连接的 IP 地址和端口号。这类信息只会维持一小段时间(称为2MSL)。一般情况2MSL不会出现什么问题。但是考虑极端情况,需要用一台客户端一直快速重复连接到一台服务器(服务器只监听80端口),根据之前介绍,我们知道一条TCP构建要数如下:
<source-IP-address, source-port, destination-IP-address, destination-port>
如果这4个元素都相同,就视同为同一个连接,所以想要不同的连接,就需要修改其中的某个或某几个值。在该种情况下,就只有source-port该参数是可变的,其他几个已经固定死了。这个时候,由于source-port的资源也是有限的,一般为60000个。如果假设2MSL=120秒,那么一秒内最大连接数就只有 60000/120=500个。那这可能出现要么就达不到快速的要求,要么最终会导致端口耗尽的情况。这里提出这个问题,主要是让大家防止出现那种大量连接都处于打开的情况,从而导致端口耗尽。一般想要解决这个问题,可以考虑设置虚拟IP地址,从而可以实现更多TCP连接的组合。
3 HTTP连接的处理
3.1 Connection首部
HTTP允许客户端和最终服务器中间存在一串中间实体(如代理、高速缓存等)。一般的首部从客户端发出后,都会一步一步的传递到最终服务器,但有时候我们希望某个首部,只出现在其中某一步,这个时候,Connection首部就有用途了,该字段中有一个由逗号分隔的连接标签列表,这些标签为此连接指定了一些不会传播到其他连接中去的选项。该首部一般可以承载三种不同的标签:
- HTTP 首部字段名,列出了只与此连接有关的首部;
- 任意标签值,用于描述此连接的非标准选项;
- 值 close,说明操作完成之后需关闭这条持久连接。
- 比如我们有如下请求:设立收到Connection标签的中间实体,在转发请求的时候,就不应该将Meter首部进行转发,应当将其删除后在转发请求。
3.2 串行事务导致的时延
如果我们对于所有的请求管理都是进行简单的串行处理,那前面提到的TCP性能中的时延影响将会不断增加。如:我们请求的一个html页面,其中包含3张图片。这个时候,我们就会至少有4次请求,一次请求整个html文件,另外还有3次去请求图片。这个时候,如果只是简单的串行处理,整个加载的时常如下图所示:
从图中可以看到,浪费了非常多的时间进行等待连接。其实整个加载过程,完全可以分为两步即可,即第一次先获取html文件,然后获得这3张需要加载图片的URL,然后完全可以同时加载这3张图片,这样时间就会节约很多。同时加载3张图片就是我们下面要介绍的并行连接。
4 并行连接
如上面提到的例子,简单的进行串行处理,HTTP请求会显得非常缓慢,效率很低。所以HTTP是支持并行连接的——HTTP客户端可以同时打开多条连接,同时处理多个事务。
以前面提到的加载一个含有3张图片的html稳定,如果我们用并行连接,那么最终的耗时情况会如下图所示:
可以看到,这里比前面的串行连接节约了非常多的时间,而且这样处理还有个好处是,在你带宽足够的情况,并行处理相比较串行处理可以更充分的使用带宽。
一般情况下并行连接相较于串行连接会更快,但并不是绝对的,试想下面一种情况:我们的带宽本就不足,就只有24kbps,然后我们上述的那个例子中,每个请求几乎都会占满整个带宽,这个时候,即使使用并行连接,但是由于带宽资源不足,每个事务都会去竞争带宽资源,这个时候竞争也会产生一些额外的开销,最终反而会使得整个加载整体上比串行更慢。
并行连接可以提高复合页面的传输速度,以下为并行连接的缺陷:
- 每个事物都会打开/关闭一条新的连接,会耗费时间和宽带
- 由于TCP慢启动特性的存在,每条新连接的性能都会有所降低
- 可打开的并行连接数量实际上是有限的
5 持久连接
我们的客户端其实经常会连接同一个站点,比如:一个html页面中的大部分内嵌图片等资源一般都位于同一个服务器,比如我们在电子商务网站上览商品的时候,我们中途打开的很多连接也是位于同一个服务器。这种现象——即:初始化了对某服务器 HTTP 请求的应用程序很可能会在不久的将来对那台服务器发起更多的请求(比如,获取在线图片)。被称为站点局部性(site locality)。
为了由于上述情况下,每次都去重新创建新的链接导致时延和性能低下,HTTP/1.1中允许 HTTP 设备在事务处理结束之后将 TCP 连接保持在打开状态,以便为未来的 HTTP 请求重用现存的连接。这种连接就被成为持久连接。
持久连接降低了时延和连接建立的开销,将连接保持在已调谐状态,而且减少了打开连接的潜在数量。但是,管理持久连接时要特别小心,不然就会累积出大量的空闲连接,耗费本地以及远程客户端和服务器上的资源。
5.1 HTTP/1.0+ keep-alive连接
实现 HTTP/1.0 keep-alive 连接的客户端可以通过包含 Connection: Keep-Alive 首部请求将一条连接保持在打开状态。如果服务器愿意为下一条请求将连接保持在打开状态,就在响应中包含相同的首部。如果响应中没有 Connection: Keep-Alive 首部,客户端就认为服务器不支持 keep-alive,会在发回响应报文之后关闭连接。一个带keep-alive连接的请求过程如下:
5.1.2 keep-alive选项
上面的内容介绍了如何创建一个keep-alive连接。但是实际使用中不仅仅打开一条keep-alive连接就可以了。还要对其进行一些操作,比如:规定多久可以关闭该条连接(总不能一直开着,否则就太浪费资源了)。这个时候,我们可以通过在keep-alive首部中,通过","分隔选项对keep-alive进行控制,常用选项如下:
- 参数 timeout 是在 Keep-Alive 响应首部发送的。它估计了服务器希望将连接保持在活跃状态的时间。这并不是一个承诺值,即可能在还未达到该时间服务器就已经关闭连接了。
- 参数 max 是在 Keep-Alive 响应首部发送的。它估计了服务器还希望为多少个事务保持此连接的活跃状态。同样,这也不是一个承诺值。
- Keep-Alive 首部还可支持任意未经处理的属性,这些属性主要用于诊断和调试。语法为 name [=value]。
如下面的首部:
Connection: Keep-Alive Keep-Alive: max=5, timeout=120
说明服务器最多还会为另外 5 个事务保持连接的打开状态,或者将打开状态保持到连接空闲了 2 分钟之后。
5.1.3 keep-alive连接的规则和限制
使用 keep-alive 连接时有一些限制和规则,这里总结如下:
- 在 HTTP/1.0 中,keep-alive 并不是默认使用的。客户端必须发送一个 Connection: Keep-Alive 请求首部来激活 keep-alive 连接。
- Connection: Keep-Alive 首部必须随所有希望保持持久连接的报文一起发送。如果客户端没有发送 Connection: Keep-Alive 首部,服务器就会在那条请求之后关闭连接。
- 通过检测响应中是否包含Connection: Keep-Alive响应首部,客户端可以判断服务器是否会在发出响应之后关闭连接。
- 只有在无需检测到连接的关闭即可确定报文实体主体部分长度的情况下,才能将连接保持在打开状态——也就是说实体的主体部分必须有正确的 Content-Length,有多部件媒体类型,或者用分块传输编码的方式进行了编码。在一条 keep-alive 信道中回送错误的 Content-Length 是很糟糕的事,这样的话,事务处理的另一端就无法精确地检测出一条报文的结束和另一条报文的开始了。
- 代理和网关必须执行 Connection 首部的规则。代理或网关必须在将报文转发出去或将其高速缓存之前,删除在 Connection 首部中命名的所有首部字段以及 Connection 首部自身。
- 严格来说,不应该与无法确定是否支持 Connection 首部的代理服务器建立 keep-alive 连接,以防止出现下面要介绍的哑代理问题。在实际应用中不是总能做到这一点的。
- 从技术上来讲,应该忽略所有来自 HTTP/1.0 设备的 Connection 首部字段(包括 Connection: Keep-Alive),因为它们可能是由比较老的代理服务器误转发的。但实际上,尽管可能会有在老代理上挂起的危险,有些客户端和服务器还是会违反这条规则。
- 除非重复发送请求会产生其他一些副作用,否则如果在客户端收到完整的响应之前连接就关闭了,客户端就一定要做好重试请求的准备。
5.1.4 keep-alive和哑代理
1. Connection首部和盲中继
试想下面这种情况:我们在连接过程中使用了代理,而该代理不理解“Connection”首部,仅仅是简单的对其进行转发,如下图所示:
从图中可以看到,中间的代理,并没有对Connection: keep-alive进行任何处理,仅仅是简单的转发。在第一次请求完成后,服务器和客户端都会以为对方支持keep-alive连接,所以不会对连接进行关闭。第一次请求没问题,但是如果客户端接着往同一服务器发送了第二天请求,因为代理是不支持keep-alive的,即其虽然目前和客户度、服务端都保持着连接的状态,但是由于其不支持keep-alive特性,它不会处理同一条连接上的的该次请求,这个时候整个请求就会被阻塞在这里。
2. 代理和逐跳首部
为了避免上述情况的产生,所以代理必须要能正确处理“Connection”首部,不能对其进行盲目转发。除了此处的keep-alive外,还有几个不能作为 Connection 首部值列出,也不能被代理转发或作为缓存响应使用的首部。其中包括 Proxy-Authenticate、Proxy-Connection、Transfer-Encoding 和 Upgrade。
5.1.5 插入Proxy-Connection
另外一种做法是使用Proxy-Connection首部。如果代理是盲中继,它会将无意义的 Proxy-Connection 首部转发给 Web 服务器,服务器会忽略此首部,不会带来任何问题。但如果代理是个聪明的代理(能够理解持久连接的握手动作),就用一个 Connection 首部取代无意义的 Proxy-Connection 首部,然后将其发送给服务器,以收到预期的效果。
下图描述了怎么通过Proxy-Connection避免产生盲中继带来的问题的:
但是该种解决方法,并不能彻底解除问题,其只适用于客户端和服务端中间只有一个代理的情况。试想如果存在2个代理的情景(第一个可以正常处理keep-alive,第二个是盲转发):第一个是可以处理Proxy-Connection,所以其会将替换掉,会向下一级发送一个“Connection: keep-alive”首部,第二个代理不知道怎么处理,进行盲转发,这时又会回到最初的问题。
5.2 HTTP/1.1持久连接
到了HTTP/1.1,用一种名为持久连接(persistent connection)的改进型设计取代了keep-alive。持久连接的目的与 keep-alive 连接的目的相同,但工作机制更优一些。
persistent connection和keep-alive最大的一个区别是,其默认是打开的状态,除非显式的添加Connection: close首部,表示请求完成后立即关闭连接,否则客户度和服务器之间的连接就会维持在打开状态。当然,客户端和服务器也可以选择随时关闭连接,即使没有Connection: close首部。
我们在日常使用当中,需要注意一下几点:
- 只要发送了Connection: close首部后,就意味着会关闭连接,将来就不能在该连接上再发其他请求;
- 如果客户端,明确知道当前请求完成后,不会在该连接上继续发送请求,就应该显示发送Connection: close首部,以节约资源
- 持久连接能够有效保持的前提是,当前连接所有的报文都必须有正确的、自定义的报文长度——即实体部分的长度都和相应的 Content-Length 一致
- 所有的代理应该能正确处理持久连接,而且持久连接只能作用于一跳之间,当我们在实现代理的时候,要特别注意这一点
- 要做好应对连接的对方随时关闭连接的可能
- 客户端应该要有重试机制,一般只要重复请求不会带来其他影响的情况下,如果在没有收到完整响应的情况下,连接关闭了,客户端应当能自动重新尝试发起请求
- 一般情况下,客户端最多会维持2条到指定服务器的持久连接,防止服务器过载。所以在有N个用户需要访问服务器的情况,代理一般就需要维护2N条到服务器或者父代理的连接。
5.3 管道化连接
在HTTP/1.1中,在持久连接的选择上,可以使用请求管道,其也是针对keep-alive的一个性能优化点。在一个连接上,如果有多个无依赖性的请求,我们可以将多条请求连续放入请求队列中,而无需等待前一条请求响应后再发起请求。这样就大大缩减了网络时延。但是在使用的时候需要注意一下几点:
- 使用管道,必须保证是在持久化连接的前提下,如果不能确保持久化连接,则不能使用管道
- 响应的顺序必须和请求的顺序相同,在请求队列中并没有对每个请求做标识,所以必须按照请求的顺序进行响应,以便匹配每个请求和响应。
- 客户端必须要做好连接被随时关闭的准备,且在幂等请求的情况下,应该具有自动重试机制——如发了10条GET请求,但是只有5条响应成功后连接被关闭了,则客户端应该尝试重新发起剩下的5个请求。
- 管道不能用于非幂等的请求操作。如:想向服务器提交一条新的name=victor的表单数据。通过POST请求,服务器收到请求后会在数据库插入一条新的数据,如果连接的关闭是在服务器已经处理完请求,但是没有响应的情况,重试机制会再次发起POST请求,就会让服务器在数据库插入两条重复的数据。所以非幂等操作不能自动进行重试。
6 连接的关闭
虽然我们在上述内容中多次提到了客户度和服务端可以随时关闭连接。但是我们在实际处理的时候,除非是因为某些突发性故障导致不得不在中途关闭连接,否则我们应当尽量在每次请求完成后再尝试关闭连接。但是我们的应用程序也要做好应对突发故障导致请求中途连接关闭情况。
每条HTTP响应都应该有精确的Content-Length首部,用以描述响应主体的尺寸。
当我们收到一条随连接关闭的HTTP报文,而且传输的实体长度与 Content-Length 并不匹配(或没有 Content-Length)时,接收端应该对该条内容正确性产生质疑。特别是代理,在遇到该情况的时候,既不能对这条报文做缓存,也不能对其尝试做“校正”。应当直接转发给它的下游。
在关闭连接的另外一个注意点就是上述已经说过的重试机制一定是针对幂等请求的。非幂等请求不应该自动尝试重新请求,针对非幂等请求,实际操作中,大部分客户端会谈出提示框由用户选择是否重新发起请求。
如果一个事务,不管是执行一次还是多次,得到的结果都相同,这个事务就是幂等的
根据连接关闭的方向我们可以将关闭分为半关闭和完全关闭。如果套接字调用close()会将TCP连接的输入和输出信道都关闭,称为完全关闭,如果套接字调用shutdown()单独关闭输入或输出信道,则称为半关闭。如下图所示:
在关闭连接的过程中,我们特别注意重置错误。一般来说,关闭输出端是非常安全的,输出端关闭后,接收端在接收到最后一条数据后会收到通知,就会知道连接关闭了。但是针对输入端来说,就需要谨慎了。如果另一端向你已经关闭的输入信道发送数据,这个时候就会产生一个重置错误。这个时候操作系统一般会清除该条连接的缓存数据,就会产生严重的问题。试想一下:你刚刚发送了10条请求,然后也正常接收到了响应后接收端关闭了输入信道,但是你目前只处理了其中5条,还有5条的响应还存在缓存区。恰好这个时候你还有一条请求需要发送,发送之后由于对方已经关闭输入信道,就会收到一条重置错误,这个时候操作系统就会将缓存清空,剩下的还未处理的5条响应数据也就没了。
由上所述,正确的关闭连接也是非常重要的,实际操作需要注意一下几点:
- 首先应该关闭它们的输出信道,然后等待连接另一端的对等实体关闭它的输出信道。当两端都告诉对方它们不会再发送任何数据(比如关闭输出信道)之后,连接就会被完全关闭,而不会有重置的危险。
- 在无法确保对方是否实现半关闭的情况下,应当周期性地检查其输入信道的状态(查找数据,或流的末尾)。如果在一定的时间区间内对端没有关闭输入信道,应用程序可以强制关闭连接,以节省资源。