网络-软件架构设计

概述

网络协议有很多种,但对互联网来说,用的最多的就是HTTP协议。HTTP主要有1.0、1.1、2三个版本,在HTTP之上有HTTPS。
1996年,HTTP1.0协议规范RFC 1945发布;
1999年,HTTP1.1协议规范RFC 2616发布。
2015年,HTTP/2协议规范RFC 7540/7541发布。
HTTP/2还比较新,目前远没有达到普及的程度。在过去的近20年间,主流的协议一直是http1.1。接下来将对HTTP协议的发展脉络进行梳理。

HTTP1.0

HTTP1.0的问题

HTTP协议的基本特点是“一来一回”。什么意思呢?客户端发起一个TCP连接,在连接上面发一个HTTP Request到服务器,服务器返回一个HTTP Response,然后连接关闭。每来一个请求,就要开一个连接,请求完了,连接关闭。
这样的协议有两个问题:

  1. 性能问题。连接的建立、关闭都是耗时操作。对应一个网页来说除了网页本身的HTML请求,页面里面的JS、CSS、img资源,都是一个个的HTTP请求。现在的互联网上的页面,一个页面上有几十个资源文件是很常见的事。每来一个请求就开一个TCP连接时非常耗时的。虽然可以同时开多个连接,并发的发送请求,但连接数毕竟是有限的。
  2. 服务器推送问题。不支持“一来多回”,服务器无法在客户端没有请求的情况下主动向客户端推送消息。但很多的应用恰恰都需要服务器在某些事情完成后主动通知客户端。
    针对这两个问题,来看HTTP在发展过程中是怎么解决的。

Keep-Alive机制与Content-Length属性

为了解决上面提及的第一个问题,HTTP1.0设计了一个Keep-Alive机制来实现TCP连接的复用。具体来说,就是客户端在HTTP请求的头部加上一个字段Connection:Keep-Alive。服务器收到带有这样字段的请求,在处理完请求之后不会关闭连接,同时在HTTP的Response里面也和加上该字段,然后等待客户端在该连接上发送下一个请求。
当然,这会给服务器带来一个问题:连接数有限。如果每个连接都不关闭的话,一段时间之后,服务器的连接数就耗光了。因此,服务器会有一个Keep-Alive timeout参数,过一段时间之后,如果该链接上上没有新的请求进来,则连接就会关闭。
连接复用之后又产生了一个新问题:以前一个连接就只发送一个请求,返回一个响应,服务器处理完毕,把连接关闭,这个时候客户端就知道连接的请求处理结束了。但现在,即使一个请求处理完了,连接也不关闭,那么客户端怎么知道连接处理结束了呢?或者说,客户端怎么知道接收回来的数据包是完整的呢?
答案是在HTTP Response的头部,返回一个Content-Length:xxx的字段,这个字段可以告诉客户端HTTP Response的Body共有多少个字节,客户端接收到这么多字节之后,就知道响应成功接收完毕。

HTTP 1.1

连接复用与Chunk机制

从上面的分析可以看出,连接复用非常有必要,所以到了HTTP 1.1之后,就把连接复用变成了一个默认属性。即使不加Connection:Keep-Alive属性,服务器也会在请求处理完毕之后不关闭连接。除非在请求头部显示地加上Connection:Close属性,服务器才会在请求处理完毕之后主动关闭连接。
在HTTP 1.0里面可以利用Content-Length字段,让客户端判断一个请求的响应成功是否接收完毕。但Content-Length有个问题,如果服务器返回的数据时动态语言生成的内容,则要计算Content-Length,这点对服务器来说比较困难。即使能够计算,也需要服务器在内存中渲染出整个页面,然后计算长度,非常耗时。
为此,在HTTP1.1中引用了Chunk机制(Http Streaming)。具体来说,就是在响应的头部加上Transfer-Encoding:chunked属性,其目的是告诉客户端,响应的Body是分成一块块的,块与块之间有分隔符,所有块的结尾也有一个特殊标记。这样,即使没有Content-Length字段,也能方便客户判断出响应的末尾。
下面显示了一个简单的具体Chunk机制的HTTP响应,头部没有Content-Length字段,而是Transfer-Encoding:chunked字段。该响应包含4个chunk,数字25(16机制)表示第一个chunk的字节数,1C(16进制)表示第二个chunk的字节数…最后的数字0表示整个响应的末尾。

HTTP/1.1 200 OK
Content-Type:text/plain
Transfer-Encoding:chunked
25
This is the data in the first chunk
1C
and this is the second one
3
con
8
sequence
0

Pipeline与Head-of-line Blocking问题

有了“连接复用”之后,减少了建立连接、关闭连接的开销。但还存在一个问题,在同一个连接上,请求是串行的,客户端发送一个请求,收到响应,然后发送下一个请求,再收到响应。这种串行的方式导致并发度不够。
为此,HTTP1.1引入了Pipeline机制。在同一个TCP连接上面,可以在一个请求发出去之后、响应没有回来之前,就可以发送下一个、再下一个,这样就提高了在同一个TCP连接上面的处理请求的效率。如下图所示,展示了在同一个TCP连接上面,串行和Pipeline的对比。
在这里插入图片描述
从上图可以明显看出,Pipeline提高了请求的处理效率。但Pipeline有个致命问题,就是Head-of-Line Blocking翻译成中文叫做“对头阻塞”。什么意思呢?
客户端发送的请求顺序是1,2,3,虽然服务器是并发处理的,但客户端端接收响应的顺序必须是1,2,3,如此才能把响应和请求成功匹对,跟队列一样,先进先出。一旦队列头部请求1发生延迟,客户端迟迟收不到请求1的响应,则请求2、请求3响应也会被阻塞。如果请求2、请求3不和请求3在一个TCP连接上面,而是在其他的TCP连接上面发出去的话,说不定早返回了,现在因为请求1处理的慢,也影响了请求2、请求3。
也正因为如此,为了避免Pipeline带来的副作用,很多浏览器默认把Pipeline关闭了。

HTTP/2出现之前的性能提升方法

HTTP/2

TCP协议是先进先出的协议。
报文段在一个TCP连接上,如果按照seq=1,seq=2,seq=3的顺序发送到服务器,服务器端必定是按照seq=1,seq=2,seq=3的顺序接收报文段。如果过程中seq=1的数据包延迟或者丢包,服务端会一直等待seq=1的包。
因为报文有seq和ack序号,比如服务端发送一个包ack=1,那么客户端发送的包seq=1必须先到服务端,之后的seq=2的包才能被服务端接收,这就是TCP协议的报文先进先出规则,这注意是为了放置丢包等问题。

SSL/TLS

在介绍HTTPS之前,需要先深入探讨SSL/TLS,因为HTTPS是构建在这个基础上的。

背景

SSL/TLS的历史几乎互联网历史一样长:SSL(Secure Socket Layer)的中文名词为安全套接层,TLS(Transport Layer Security)的中文名词为传输层安全协议。
1994年,网景(NetScape)公司设计了SSL1.0;
1995年,网景公司发布SSL2.0,但很快发现存在严重漏洞;
1996年,SSL3.0问世,得到大规模应用;
1999年,互联网标准化组织IETF对SSL进行标准化,发布了TLS1.0;
2006年和2008年,TLS进行了两次升级,分别为TLS1.1和TLS1.2。
所以,TLS1.0相当于SSL3.1;TLS1.1、TLS1.2相当于SSL3.2、SSL3.3。在应用层里,习惯将两者并称为SSL/TLS。
如下图,SSL/TLS处在TCP层的上面,它不仅可以支撑HTTP协议,也能支撑FTP、IMAP等其他各种应用层的协议。
在这里插入图片描述
接下来从最基础的对称加密讲起,一步步分析SSL/TLS背后的原理和协议本身。

对称加密的问题

对称加密的想法很简单,如下图所示。客户端和服务器知道同一个密钥,客户端给服务器发消息,客户端用此密钥加密,服务器用此密钥解密;反过来,服务器给客户端发消息时,是相反的过程。
在这里插入图片描述
这种加密方式在互联网上有两个问题:

  • 密钥如何传输?
    密钥A的传输也需要另外一个密钥B,密钥B的传输有需要密钥C…,如此循环,无解。
  • 如何存储密钥?
    对应浏览器的网页来说,都是明文的,肯定存储不了密钥;对应Android/iOS客户端,即使能把密钥藏在安装包的某个位置,也很容易被破解。
    当然,这两个问题其实是一个问题。因为如果解决了密钥的传输问题,就可以在建立好连接之后获取到密钥,然后只存在内存中,当连接断开之后,密钥在内存中就被销毁了,也就解决了存储的问题。

双向非对称加密

如下图所示,客户端为字节准备一对公私钥(PubA,PriA),服务器为自己准备一对公私钥(PubB,PriB)。公私钥有关关键特性:公钥PubA是通过私钥PriA计算出来的,但反过来无法通过PubA计算出PriA,不能根据PubA推算出PriA!对于公私钥之间的数学关系,此处就不再展开讨论。
在这里插入图片描述
客户端、服务器把自己的公钥公开出去,自己保留私钥。这样一来客户端就知道了服务器的公钥,服务器也知道了客户端的公钥。
当客户端给服务器发送信息时,就用自己的私钥PriA签名,再用服务器的公钥PubB加密。所谓的“签名”相当于自己盖了一个章,或者说签了一个字,证明这个信息是客户端发送的,客户端不能抵赖;用服务器的公钥PubB加密,意味着只有服务器B可以用自己的私钥PriB解密。即使这个信息被C截获了,C没有B的私钥,也无法解密这个信息。
服务器收到信息后,先用自己的私钥PriB解密,再用客户端的公钥验签(证明信息是客户端发出的)。反向过程同理:服务器给客户端发送信息时,先用自己的私钥PriB签名,然后用PubA加密;客户端收到服务器的信息后,先用自己的私钥PriA解密,再用服务器的公钥PubB验签。
在这个过程中,存在着签名和验签与加密和解密两个过程。

  1. 签名和验签:私钥签名,公钥验签,目的是防止数据被篡改。如果第三方截取到信息之后篡改,则接收方验签肯定过不了。同时也防止抵赖,既然没人可篡改,只能是发送方自己发出的。
  2. 加密和解密:公钥加密,私钥解密。目的是防止信息被第三方拦截和偷听。第三方即便能截获到信息,但如果没有私钥,也解密不了。
    在双向非对称加密中,客户端需要提前知道服务器的公钥,服务器需要指定客户端的公钥。和对此加密一样,同样面临公钥如何传输的问题。
    在继续探讨之前,先看一下单向非对称加密。

单向非对称加密

在互联网上,网站对外是完全公开的,网站的提供者没有办法去验证每个客户端的合法性;只有客户端可以验证网站的合法性。比如用户访问百度或者淘宝网站,需要验证所访问的是不是真的百度或者淘宝,防止被钓鱼。
在这种情况下,客户端并不需要公钥和私钥对,只有服务器有一对公钥和私钥。如下图所示,客户端没有公钥和私钥对,只有服务器有。服务器把公钥给到客户端,客户端给服务器发送消息时,使用公钥加密,然后服务器私钥解密。反过来,服务器给客户端发送的消息,采用明文发送。
在这里插入图片描述
当然,对于安全性要求很高的场景,比如银行的个人网银,不仅客户端要验证服务器的合法性,服务器也要验证每个 访问的客户端的合法性。对于这种场景,往往会给客户端发一个U盘,里面装的就是客户端一方的公钥和私钥对,用的就是上面的双向非对称加密。
对于单向非对称加密,只有客户端到服务器的单向传输时加密的,服务器的返回是明文的,这怎么能保证安全呢?接着往下看。
假设PubB的传输过程是安全的,客户端知道了服务器的公钥。客户端就可以利用加密通道给服务器发送一个对称加密的密钥。如下图所示:
在这里插入图片描述
客户端对服务器说:“Hi,我们的对称加密密钥是xxx,接下来就用这个密钥通信。”这句话是通过Pub加密的,所以只有服务器能用自己的PriB解密。然后服务器回复一句明文:“好的,我知道了”。虽然是明文,但没有任何密钥信息在里面,所以采用明文发送也没关系。接下来,双方就可以基于对称加密的密钥进行通信了,这个密钥在内存里面,不会落地存储,所以也不存在被盗的问题,而这就是SSL/TLS的原型。

中间人攻击

通过上面的分析可以发现,我们并不需要双向的非对称加密,而用单向的非对称加密就能达到传输的目的。
但无论是双向还是单向,都存在着公钥如何安全传输的问题。下面就以一个典型的“中间人攻击”的案例为例,来看一下这个问题是如何被解决的。
如下图所示,本来客户端和服务器要交换公钥,各自把自己的公钥发给对方。但被中间人劫持了,劫持过程如下:
在这里插入图片描述
客户端本来是要把自己的公钥发给服务器:“Hi,我是客户端1,我的公钥是PubA。”
被中间人C劫持之后,C用自己的公钥替换客户端的公钥,然后发给服务器:“Hi,我是客户端1,我的公钥是PubC。”
反过来,服务器本来是要把自己的公钥发送给客户端“Hi,我是服务器,我的公钥是PubB。”
被C劫持之后,C用自己的公钥替换服务器的公钥,然后发给客户端:“Hi,这是服务器,我的公钥是PubC。”
最终结果是:客户端和服务器都以为自己是在和对方通信,但其实他们都是在和中间人C通信!接下来,客户端发给服务器的信息,会用PubC加密,C当然可以解密这个信息;同样服务器发给客户端的信息也会被PubC加密,C也可以加密。
这个问题为什么会出现呢?是因为公钥的传输过程是不安全的。客户端和服务器在网络上,互相没见过对方,又没有根据,怎么知道收到的公钥就是对方发出的,而不是被中间人篡改过的呢?
需要像个办法证明服务器收到的公钥,的确就是客户端发出的,中间没有人可以篡改这个公钥,反过来也一样。这就是接下来要讲的数字证书。

数字证书与证书认证中心

如下图所示,引入一个中间机构CA。当服务器把公钥发给客户端时,不是直接发送公钥,而是发送公钥对应的证书。那么证书是怎么来的呢?
从组织上来说,CA类似显示中的“公证处”,从技术上讲,就是一个服务器。服务器先把自己的公钥PubB发给CA,CA给服务器颁发一个数字证书(Certificate),这个证书相当于服务器的身份证。之后,服务器把证书给客户端,客户端可以验证证书是否为服务器下发的。
在这里插入图片描述
反过来也同理,如下图所示,客户端用自己的公钥PubA通过CA换取一个证书,相当于客户端的身份证,客户端把这个证书发给服务器,服务器就能验证整个证书是否为客户端下发的。
在这里插入图片描述
当然,对于通常的互联网应用,只需要客户端验证服务器,不需要服务器验证客户端。
具体的验证过程是如何操作的呢?
CA有一对公钥和私钥,私钥只有CA知道,公钥在网络上,谁都可以知道。服务器把个人信息 + 服务器的公钥发给CA,CA用自己的私钥为服务器生成一个数字证书。通俗地讲,服务器把自己的公钥发给CA,让CA加盖公章,之后别人就不能再伪造公钥了。如果被中间人伪造了,客户端拿着CA的公钥验证这个证书,验证将无法通过。

根证书与CA信任链

但有会出现类似鸡生蛋、蛋生鸡的问题,如果让客户端、服务器都信任CA,但CA是个假的怎么办?CA的公钥如何被安全地在网络上传输?假如是一个假的CA在中间,客户端和服务器都和假的CA通信,互相还以为和真正的CA通信。
CA面临和客户端、服务器同样的问题:客户端和服务器需要证明公钥的确是由自己发出去的,不是被伪造的;CA同样需要证明,自动的公钥是由自己发出去的,不是被伪造的。
答案是给CA颁发证书!CA的证书谁来颁发呢?CA的上一级CA。最终形成下图所示的证书信用链。关于证书信任链,有两点说明:
在这里插入图片描述

证书信任链的验证过程

客户端要验证服务器的合法性,需要拿着服务器的证书C3,到CA2处去验证(C3是CA2颁发的,验证方法是拿着CA2的公钥,去验证证书C3的有效性);
客户端要验证CA2的合法性,需要拿着CA2的证书C2,到CA1处去验证(C2是CA1颁发的);
客户端要验证CA1的合法性,需要拿着CA1的证书C1,到CA0处去验证(C1是CA0颁发的);
而CA0呢,只能无条件信任。怎么做到无条件信任呢?Root CA机构都是一些世界上公认的机构,在用户的操作系统、浏览器发布的时候,里面就已经嵌入了这些机构的Root证书。你信任这个操作系统,信任这个浏览器,也就信任了这些Root证书。

证书信任链的颁发过程

颁发过程与验证过程刚好是逆向的,上一级CA给下一级CA颁发证书。从根CA(CA0)开始,CA0给CA1颁发证书,CA1给CA2颁发证书,CA2给应用服务器颁发证书。
最终,证书成为网络上每个通信实体的“身份证”,在网络上传输的都是证书,而不再是原始的那个公钥。把这套体系标准化之后,就是在网络安全领域经常见到的一个词,PKI(public key infrastructure)。
想一想在现实生活中的例子:

  • 你出生在一个小镇上,怎么证明你是你呢?
  • 镇派出所给你发个身份证,证明你是你;
  • 镇派出所为什么可以被信任呢?因为经过了县公安局授权;
  • 县公安局为什么可以被信任呢?因为经过了市公安局授权;

  • 一级级回溯,最后,信任的根是什么?根是国家最高机构!

SSL/TLS协议:四次握手

到此为止,我们理解了对称加密、非对称加密、证书、根证书等概念后,再来看看SSL/TLS协议就很简单了,如下图所示:
在这里插入图片描述
在建立TCP连接之后、数据发送之前,SSL/TLS协议通过四次握手、两个来回,协商出客户端和服务器之间的对称加密密钥。第一个来回,是公钥的传输与验证过程(通过数字证书);第二个来回基于第一个来回得到的公钥,协商出对称加密的密钥。接下来,就是正常的应用层数据包的发送操作了。
当然,为了协商出对称加密的密钥,SSL/TLS协议引入了几个随机数,具体细节不在展开,这里主要讨论SSL/TLS的核心思路。

HTTPS

理解了SSL/TLS,再来看HTTPS就很简单了,HTTPS = HTTP + SSL/TLS。整个HTTPS的传输过程大致可以分层三个阶段,如下图所示。
在这里插入图片描述

  1. TCP连接的建立。
  2. SSL/TLS四次握手协商出对称加密的密钥。
  3. 基于密钥,在TCP连接上对所有的HTTP Request/Response进行加密和解密。
    其中阶段1和阶段2只在连接建立时做1次,之后只要连接不关闭,每个请求只需要经过阶段3,因此相比HTTP,性能没有太大损失。
    最后,分析一下HTTP/2和HTTPS的关系:HTTP/2主要是解决性能问题,HTTPS主要解决安全问题。从理论上讲,两者没有必然的关系,HTTP/2可以不依赖于HTTPS;反过来也如此。把两者同时放在整个网络分层体系中,如下图所示:
    在这里插入图片描述
    中间两层都是可选项。去掉中间的两层,变为HTTP1.1;只去掉HTTP/2一层,变为HTTPS;两层都加上,变为HTTP/2+HTTPS。
    但在实践层面,目前主流的浏览器都要求如果要支持HTTP/2则必须先支持HTTPS,这也是因为整个互联网都在推动HTTPS的普及。

TCP/UDP

可靠与不可靠

说到TCP/UDP,众所周知,UDP是不可靠的,而TCP是可靠的。什么是不可靠的呢?
客户端发送了数据包1,2,3,这三个数据包经过互联网的传输,到了服务器端,接收到的是3和1。其中发生了两件事情:

  • 丢包:数据包2丢失了。
  • 时序错乱:客户端先发的数据包1,后发的是数据包3;服务器却先收到了数据包3,后收到的是数据包1。
    为什么会丢包呢?网络中有成千上万个路由节点,节点发生故障是很正常的事情;为什么会时序错乱呢?三个数据包被发送到网络上,走的是不同的网络链路,谁快谁慢是不确定的,所以后发的可能先到。从这个意义上讲,“不可靠”是常态!
    那TCP是如何做到“可靠”的呢?先看一下TCP的可靠是什么意思。
    客户端连续发送数据包1、2、3,经过互联网的传输,服务器收到的也是数据包1、2、3。这种“可靠”传输具有三重语义:
  • 数据包不丢。
  • 数据包不重。比如服务器不会收到两次数据包2,有且只会收到一次。
  • 时序不乱。
    这相当于在客户端和服务器之间建立了一个“可靠”的管道,数据包按顺序经过这个管道,一个包不丢,一个包也不重。
    但数据包1、2、3在网络上走的是不同的路由链路,数据包即可能丢失,到达的先后顺序也不一定正确。那么,客户端和服务器之间通过什么机制可以保证“可靠”地传输这三个语义呢?或者说,如何在一个“不可靠”的网络上实现一个“可靠”的网络通道?而这正是TCP的核心所在:

解决不丢问题:ACK + 重发

网络丢包是一定会出现的,对上层应用来说,如何确保不丢呢?只有一个办法,重发!服务器每次收到一个包,就要对客户端进行确认,反馈给客户端已经收到了数据包;如果客户端在超时时间内没有收到ACK,则重发数据。
当客户端发送数据包1后,服务器确认,收到数据包1了;当客户端发送数据包2后,服务器确认,收到数据包2…这样每个数据包都要一一确认,效率太低了。怎么办呢?客户端对发送的每个数据包编一个号,编号由小到大单调递增,基于编号就能就能进行顺序的确认。
比如服务器几乎同时收到数据包1、2、3,它只用回复客户端(ACK=3),意思是所有小于或等于3的数据都已经收到了;又过了一会,服务器收到了数据包4、5、6,它只用回复客户端(ACK=6),意思是说所有小于或等于6的数据包都收到了。

解决不重的问题

因为只要超过了约定时间,客户端还没有收到服务器的确认,客户端就会重发。但可能此时服务器的ACK已经在网络上了,只是还没达到客户端而已。如果客户端重发,则服务器会收到重复消息,就需要判重。
如何判重呢?和已经收到的数据包逐一比对,核实是否有重复?这显然不显示。其实解决方法很简单,就是顺序ACK。服务器给客户端回复ACK = 6,意思是所有大于或等于6的数据包全部收到了,之后凡是再收到这个范围的数据包,则判定为重复的包,服务器收到后丢弃即可。

解决时序错乱问题

假设服务器收到了数据包1、2、3,回复客户端(ACK=3),之后收到了数据包5、6、7,而数据包4迟迟没有收到,这个时候怎么办呢?
服务器会把数据包5、6、7暂时存放,直到数据包4的到来,再给客户端回复ACK=7;如果数据包不来,服务器的ACK进度会一直停在那(保存ACK=3),等到客户端超时,会把数据包4、5、6、7全部重新发送,这样服务器收到了数据包4,回复ACK=7,同时数据包5、6、7重复了,通过上面的判重的办法,丢弃掉数据包5、6、7。
总之,服务器虽然接收数据包是并发的,但数据包的ACK是按照编号从小到大逐一确认的,所以数据包的时序是有保证的。
最终,通过消息顺序编号 + 客户端重复 + 服务器顺序ACK,实现了客户端到服务器的数据包不重、不漏、时序不乱;反过来,从服务器到客户端的数据包的发送与接收用相同的原理来实现,从而实现TCP的全双工。
TCP的这种思想可以说朴素而深刻,分布式系统中消息中间件的消息不重,不漏的实现机制和它有异曲同工之妙。

TCP的“假”连接(状态机)

可以看到,在物理层面,数据包1、2、3走的是不同的网络链路,在客户端和服务器之间并不存在一条可靠的“物理管道”。只是在逻辑层面,通过一定的机制,让TCP之上的应用层就像在客户端和服务器之间架起了一个“可靠的连接”。但实际上这个连接是假的,是通过数据包的不重、不漏、时序不乱的机制,给上层应用造成的一个假象。
具体来说,每条连接用(客户端IP,客户端Port,服务器IP,服务器Port)4元组唯一确定,在代码中是一个个的Socket。其中有一个关键问题要解决,既然“连接”是假的,在物理层面不存在;但在逻辑层面连接是存在,每条连接都需要经历建立阶段、正常数据传输阶段、关闭阶段,要完整地维护在这三个阶段过程中连接的每种可能的状态。
如下图所示,每条连接都是一个“状态机”,客户端和服务器都需要针对这条连接维护不同的状态变迁,在不同的状态下执行相应的操作。下图呈现了一个TCP连接在三个阶段经历的11中状态变迁。
在这里插入图片描述
在这里插入图片描述
从上向下看,整张图可以分层三个阶段;对于建立连接来说,都是客户端发起的,所有客户端是主动方,服务器是被动方;但对于关闭连接,既可以由客户端发起关闭,也可以由服务器发起关闭,主动发起的一方就是主动方,另外一方就是被动方。不过通常来说,都是由客户端发起连接、关闭连接,服务器一直处于被动一方。
首先,客户端和服务器都处于CLOSED状态;连接建立好之后,双方都处于ESTABLISHED状态,开始传输数据;最后连接关闭,双方再次回到CLOSED状态的。为什么开始是处于CLOSED状态,而没有一个INIT(初始)状态呢?因为连接是复用的,每个连接由4元组唯一标识,关闭之后,后面又会开启,所以没有必要引入“初始”状态。
接下来,将详细探讨每个阶段的状态转移过程。

三次握手(网络2将军问题)

下图展示了TCP建立连接的三次握手过程,以及对应的客户端和服务器的状态。这里首先有两点要说明:
在这里插入图片描述

  • 图中的ACK的意思和之前所讲的稍微有些差异:前文中的ACK=7,表示告诉对方编号小于或等于7的包都收到了;这里ACK=x+1,表示小于或等于x的包都收到了,接下来要接收x+1。所以,虽然意思相同,但换了一种说法。
  • seq=x表示发出去的包的编号是x。因为TCP是全双工的,通信双方一方面要发送自己的编号的包,一方面要确认对方的包,为了优化传输,会把两个包合在一起传输,所以就有了同一个包里,同时包含seq=y,ack=x+1。表示当前这个包是发出去的第y个包,同时也是对对方的第x个包的确认(接下来要接收x+1)。
    从图中可以看出,客户端的状态转移过程是CLOSED->SYN_SENT->ESTABLISHED;服务器的状态转移过程是CLOSED->LISTEN->SYN_RCVD->ESTABLISHED。
    那为什么是三次握手呢?看起来好像两次就够了,我们来分析一下。
    客户端:“Hi,服务器,我想建立一个连接。”
    服务器:“好的,可以。”
    但问题是,服务器知道“好的,可以”这句话它发出去了,但是客户端是否收到,服务器是不确定的。所以两次不够,需要改成三次,同样的问题,客户端没法确认最后一次ACK(第三次)对方是否收到了。
    这就是经典的网络的2将军问题:无论是两次,还是三次,还是四次…永远都不知道最后发出去的那个数据包对方是否收到了。想要指定最后一次是否收到,只有让对方回复一个ACK,但是回复的这个ACK是否收到,只能让对方为这个ACK再回复一个ACK,如此循环往复,问题无解。
    网络的2将军问题非常关键,在网络通信中几乎是无处不在的,在应用层也存在同样的问题:
    客户端给服务器发送了一个HTTP请求,或者说客户端向DB写入一条记录,然后超时了没有得到响应,请问服务器是写入成功了,还是没有成功呢?答案是不确定的。
    场景1:该请求服务器根本没有收到,发送时网络有问题。
    场景2:该请求服务器收到了,服务器写入成功了,但回复给客户端时,网络有问题。
    场景3:网络没有问题,服务器接收到了请求,写入成功了,但回复给客户端时,服务器宕机了。
    无论哪种场景,客户端看到的结果是一样的:它发出的数据没有得到响应。对于客户端来说,只有一个办法,就是再重试,直到服务器返回成功,客户端才能确认请求被成功处理了。
    无论是两次握手,还是三次握手、四次握手,都绕不开网络的2将军问题,那为什么是三次呢?
    因为三次握手恰好可以保证客户端和服务器对自己的发送、接收能力做了一次确认。第一次,客户端给服务器发了seq=x,无法得到对方是否收到;第二次,对方回复了seq=y,ack=x+1。这时客户端知道自己的发送和接收能力没有问题,但服务器只知道自己的接收能力没问题;第三次,客户端发送了ack=y+1,服务器收到后知道自己第二次发的ACK对方收到了,发送能力也没有问题。

四次挥手

相比于建立连接的三次握手,关闭连接的四次挥手根据复杂。如下图所示:
在这里插入图片描述
假设客户端主动发起关闭连接,客户端的状态转移过程为:ESTABLISHED->FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED;服务器的状态转移过程为:ESTABLISHED->CLOSE_WAIT->LAST_WAIT->CLOSED。
为什么是四次握手呢?因为TCP是全双工的,可以处于Half-Close状态。
第一次:客户端对服务器说:“Hi,服务器,我要关了。”
第二次:服务器回复:“好的,你的信息我收到了。”
第三次:服务器回复:“Hi,客户端我也要关了。”
第四次:客户端回复:“好的,你的信息我也收到了。”
如果只发生第一次和第二次,意味着该连接处于Half-close状态,此时客户端处于FIN_WAIT_2状态,服务器处于CLOSE_WAIT状态,客户端通往服务器的通道关闭了,但服务器通往客户端的通道还未关闭。等第三次、第四次发生,连接才会处于完全的CLOSED状态。
还有一种场景,客户端和服务器同时主动发起了关闭,双方都会处于FIN_WAIT_1状态,此时又收到了对方的ACK,这是双方都会切到CLOSED状态,CLOSING状态,之后一起进入TIME_WAIT状态,经过一段时间后进入CLOSED状态。
在这里插入图片描述
这里有一个问题:关了就关了,为何不能直接进入CLOSED状态,而要做一个TIME_WAIT状态,非要等待一段时间之后,才能进入CLOSED状态呢?有两个原因:

  1. 所谓的“连接”是假的,物理层面没有连接。这意味着当双方都进入CLOSED状态后,仍可能有数据包还在网络上“闲逛”,此时如果收到了这些闲逛的数据包,丢掉即可,但问题是连接可能重开。
    一个连接是由(客户端IP、客户端Port、服务器IP、服务器Port)4元组唯一标识的,连接关闭之后在重开,应该是一个新的连接,但用4元组无法区分出新连接和老连接。这会导致,之前闲逛的数据包在新连接打开后被当做新的数据包,这样一来,老连接上的数据包会“串”到新连接上面,这是不能接受的。怎么解决这个问题呢?
    在整个TCP/IP网络上,定义了一个值叫做MSL(Maximum Segment Lifetime),任何一个IP数据包在网络上逗留的最长时间是MSL,这个值默认是120s。意味着一个数据包必须最多在MSL时间内,从源点传输到目的地,如果超出了这个时间,中间的路由节点就会把该数据包丢弃。
    有了这个限定之后,一个连接保持在TIME_WAIT状态,在等待2*MSL的时间进入CLOSED状态,就好完全避免旧的连接上面存在闲逛的数据包串到新的连接上。为什么是2倍的MSL的时间呢?这涉及到下面这个原因。
  2. 因为网络的2将军问题,4次握手第四次发送的数据包,服务器是否收到是不确定的。服务器采取的方法是无法收到第四次的情况下重新发送第三次的数据包,客户端重新收到第三次数据包,再次发送第四次的数据包。第四次数据包的传输时间+服务器重新发送发送第四次数据包的时间,最长是两个MSL,所以要让客户端在TIME_WAIT状态等待2MSL的时间。
    还有一个问题:客户端处于TIME_WAIT状态,要等待2
    MSL时间进入CLOSED;但服务器收到第四次的ACK之后,立即进入了CLOSED状态。为什么不让服务器也进入TIME_WAIT状态呢?原因是没有必要。任何一个连接都是一个4元组,同时关联了客户端和服务器,客户端处于TIME_WAIT状态后,意味着这个连接要到2MSL时间之后才能重新启用(也就是在2MSL时间里,即使创建连接该端口的连接也是创建不了的,因为有一端还没有进入CLOSED状态),服务器端即使想立马使用也无法实现。
    通过分析发现,一个连接并不是想关就能立刻关闭的,关闭后还要等2*MSL时间才能重开。这就会造成一个问题,如果频繁地创建连接(同一个ip可以使用不同的端口创建连接),最后可能导致大量的连接处于TIME_WAIT状态。最终耗光所有的连接资源(比如服务器的端口资源)。为了避免出现这种问题,可以采取如下措施:
  • 不要让服务器主动关闭连接。这样服务器的连接就不会处于TIME_WAIT状态。
  • 客户端做连接池,复用连接,而不要频繁地创建和关闭,这其实也是HTTP1.1和HTTP/2采用的思路。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

融极

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值