完整的一次 HTTP 请求响应过程(一)

因特网无疑是人类有史以来最伟大的设计,它互联了全球数亿台计算机、通讯设备,即便位于地球两端的用户也可在顷刻间完成通讯。

可以说『协议』是支撑这么一个庞大而复杂的系统有条不紊运作的核心,而所谓『协议』就是通讯双方所必须遵守的规则,在这种规则下,不同的数据报可能被解析为不同的响应动作。

简而言之,『协议』就是指如果发送和接收方按照这个规则进行数据报文的发送,即可在基本的数据传输之上得到某些特殊的功能或服务,否则你的数据别人是不认识的。例如:遵循 TCP 协议的两端,可以在不可靠的网络传输中得到可靠的数据传输能力。

整个计算机网络是分层的,有七层模型,也有五层模型,个人觉得五层模型更利于理解。我们从上至下的介绍这五个层,它们分别是,应用层,运输层,网络层,数据链路层和物理层

应用层

『应用层』算是距离用户最近的一层了,主机上的一个个的进程就构成了『应用层』。比如你在你的浏览器地址栏输入了 「www.baidu.com」,你的浏览器在应用层会做哪些事情呢?

首先浏览器会使用 DNS 协议返回域名「www.baidu.com」所对应的 IP 地址,关于 DNS 我们待会详细介绍。

接着,应用层决定创建一个『TCP 套接字』,然后将这个请求动作封装成一个 Http 数据报并推入套接字中。

套接字分为两种类型,『TCP 套接字』和『UDP 套接字』,应用层同时可能会有几十个数据报的发出,而运输层也会收到所有的响应报文,那么它该如何区分这些报文到底是谁的响应报文呢?

而套接字就是用于区分各个应用层应用的,往往由端口号和 IP 地址进行标识,运输层只要查看响应报文的源端口号和 IP 地址就能够知道该将报文推送给哪个套接字了。

当一个应用层数据报被推动进套接字之后,应用层的所有工作也算是全部完成了,关于后续报文的去向,它已经不用管了。

这里还要说明一点的是,『TCP 套接字』和『UDP 套接字』两者本质上的区别在于,前者保证数据报可靠地到达目的地,但是必然耗时,而后者不保证数据报一定能到达目的地,但是速度快,这也是应用层协议在选择运输层协议的时候需要考虑的一点。

关于 TCP 和 UDP,我们后续还会继续说,下面我们看看域名解析协议 DNS 是如何运作的,它是如何将一个域名解析返回它的 IP 地址的。

DNS 原理

首先明确一点的是,DNS 是一个应用层协议,并且它选择的运输层协议是 UDP,所以你的域名解析过程一般会很快,但也会经常出现解析失败的情况,然而刷新一下又好了。

image

在 DNS 服务器上,域名和它所对应的 IP 地址存储为一条记录,而所有的记录都不可能只存储在一台服务器上,我相信无论多么强大的服务器都扛不住全球上亿次的并发量吧。

大致来说,有三种类型的 DNS 服务器,根 DNS 服务器,顶级域 DNS 服务器和权威 DNS 服务器。

其中,顶级域 DNS 服务器主要负责诸如 com、org、net、edu、gov 等顶级域名。

根 DNS 服务器存储了所有顶级域 DNS 服务器的 IP 地址,也就是说你可以通过根服务器找到顶级域服务器。例如:「www.baidu.com」,根服务器会返回所有维护 com 这个顶级域服务器的 IP 地址。

然后你任意选择其中一个顶级域服务器,请求该顶级域服务器,该顶级域服务器拿到域名后应当能够做出判断并给出负责当前域的权威服务器地址,以百度为例的话,顶级域服务器将返回所有负责 baidu 这个域的权威服务器地址。

于是你可以任意选择其中一个权威服务器地址,向它继续查询 「www.baidu.com」 的具体 IP 地址,最终权威服务器会返回给你具体的 IP 地址。

至此,我们简单描述了一个域名解析的大致过程,还有一些细节之处并未提及,我们等会会通过一个实例来完整的看一下,下面描述一个非常重要的概念。

整个 DNS 解析过程中,有一个非常核心的人物我们一直没介绍它,它就像主机的『助理』一样,帮助主机查询域名的 IP 地址。它叫做『本地 DNS 服务器』。

image

大家每次通过 DHCP 动态获取 IP 地址的时候,这一点后文会说。其实路由器不仅给你返回了 IP 地址,还会告诉你一个 DNS 服务器地址,这个就是你的本地 DNS 服务器地址,也就是说,你的所有域名解析请求只要告诉它就行了,它会帮你查并返回结果给你的。

除此之外,本地 DNS 服务器往往是具有缓存功能的,通常两天内的记录都会被缓存,所以大部分时候你是感觉不到域名解析过程的,因为往往就是从缓存里拿的,非常快。

下面我们看一个简单的案例:

网上找的一个图,自己画实在太费时间了,但足以说明问题,现在假设请求 「www.xx.com」 。

image

  • ①:主机向负责自己的本地 DNS 发送查询报文,如果本地服务器缓存中有,将直接返回结果
  • ②:本地服务器发现缓存中没有,于是从内置在内部的根服务器列表中选一个发送查询报文
  • ③:根服务器解析一下后缀名,告诉本地服务器负责 .com 的所有顶级服务器列表
  • ④:本地服务器选择一个顶级域服务器继续查询,.com 域服务器拿到域名后继续解析,返回负责 .xx 域的所有权威服务器列表
  • ⑥:本地服务器从返回的权威服务器之一再次发送查询报文,最终会从某一个权威服务器上得到具体的 IP 地址
  • ⑧:向主机返回结果

其实整个 DNS 报文的发送与响应过程都是要走我们的五层协议的,只是这里重点在于理解 DNS 协议本身,所以并未提及其他层的具体细节,这里的强调是提醒你 DNS 只是一个应用层协议。

运输层

运输层的任务就是将应用层推出套接字的所有数据报收集起来,并且按照应用层指定的运输层协议,TCP 或 UDP,重新封装应用层数据报,并推给网络层等待发送。

TCP 和 UDP 是运输层的两个协议,前者是基于连接的可靠传输协议,后者是无连接的不可靠传输协议,所以前者更适合于一些对数据完整性要求高的场合,后者则适合于那种可以允许数据丢失但对传输速率要求特别高的场景,例如:语音电话,视频等,丢一两个包最多卡顿一下,无伤大雅。

UDP

UDP 不同于 TCP 那样复杂,它既不保证数据可靠的传输到目的地,也不保证数据按序到达目的地,仅仅提供了简单的差错检验。报文格式如下:

image

其中,数据就是应用层推出来的数据,源端口号用于响应报文的交付,目的端口号用于向目的进程交付数据,校验和用于检查传输过程中数据是否受损,如果受损,UDP 将直接丢弃该报文。

TCP

TCP 要稍微复杂些,它是面向连接的,并且基于连接提供了可靠的数据传输服务,它的数据报文格式如下:

image

单纯的解释报文格式中各个字段的含义并没有太过实际的意义,你也很难理解了,在我们介绍 TCP 是如何『三次握手』,『四次挥手』以及『丢包重传』等动作时,不间断的会说明这些动作时如何使用报文中的相关字段的。

首先我们来看耳熟能详的『三次握手』,这基本上是 TCP 的代名词了,无论懂不懂具体原理的人,提到 TCP,基本上都是知道『三次握手』的。

而本身,TCP 的三次握手就是为了确保通讯双方能够稳定的建立连接并完成数据报文的请求与响应动作,至于为什么是三次握手而不是四次五次,这是一个哲学问题,这里就不做讨论了。

第一步:

客户端向服务端发送一份特殊的 TCP 报文,该报文并不包含应用层的数据,是一份特殊的报文,它的 TCP 首部中 SYN 字段值为 1 (参见上述报文格式)。

除此之外,客户端还会随机生成一个初始序号,填在报文的「序号」字段,代表当前报文的序号是这个,并且我后续的分组会基于这个序号递增。

然后该报文将会经网络层、链路层、物理层发送到服务端。

第二步:

如果分组丢失了,那么客户端会经过某个时间间隔再次尝试发送。

而如果分组准确的到达服务端了,服务端拆开 TCP 首部会看到,这是一个特殊的 SYN 握手报文,于是为此次连接分配缓存等资源。

接着服务端开始构建响应报文,SYN 是一个用于同步需要的字段,响应报文中依然会被置为 1,并且服务端也将随机生成一个初始序号放置的响应报文的序号字段中。

最后,服务端还会为响应报文中的确认字段赋值,这个值就是客户端发过来的那个序号值加一。

整体上的意思就是说,「我同意你的连接请求,我的初始序号为 xxx,你的初始序号我收到了,我等着你的下一个分组到来」

第三步:

客户端收到服务端的响应报文,于是分配客户端 TCP 连接所必须的缓存等资源,于是连接已经建立。

实际上从第三步开始,客户端就可以携带应用层数据向服务端交换报文了,以后的每份报文中,SYN 都为 0,因为它只是用于同步初始序号的,这一点需要明确。

总的来说,整个『握手』过程大致如下图所示:

image

下面我们看看拆除一条 TCP 连接的『四次挥手』是怎样的过程。

因为一条 TCP 连接会消耗大量的主机资源,不仅仅服务端需要分配各种缓存资源,客户端也同样需要分配相应资源。因为 TCP 是『全双工通信』,服务端和客户端两方其实是一样的,谁是客户谁是服务器是相对的。

强调这一点是为了说明,一条 TCP 连接不是只有客户端才能断开,服务端也同样可以主动断开连接,这一点需要清楚。

我们这里假设客户端主动发起断开连接的请求为例:

第一步:

客户端构建一份特殊的 TCP 报文,该报文首部字段 FIN 被置为 1,然后发送该报文。

第二步:

服务端收到该特殊的 FIN 报文,于是响应客户端一个 ACK 报文,告诉客户端,请求关闭的报文已经收到,我正在处理。

第三步:

服务端发送一个 FIN 报文,告诉客户端,我将要关闭连接了。

第四步:

客户端返回一个 ACK 响应报文,告诉服务端,我收到你刚才发的报文了,我已经确认,你可以关闭连接了。

当服务端收到客户端发送的 ACK 响应报文时,将释放服务端用于该 TCP 连接的所有资源,与此同时,客户端也会定时等待一定时间后完全释放自己用于该连接的相关资源。

用一张图更直观的描述一下:

image

结合着图与相关序号信息,我们再详细说说其中的一些细节。

首先,客户端发送一个特殊分组,该分组的序号为 u。发送完成之后,客户端进入 FIN-WAIT-1 这个状态,这个状态下,该 TCP 连接的客户端不再能发送数据报,但是是可以接受数据报的,它等待着服务端的响应报文。

接着,服务端收到客户端发送的终止连接报文请求,服务端构建响应报文,告诉客户端「序号 u+1 以前的分组我都收到了」,并且进入 CLOSE-WAIT 状态,这个状态持续时间很短。

服务端会紧接着发送它的 FIN 数据报,通知客户端我服务端即将关闭连接,并随即进入 LAST_ACK 状态等待客户端响应报文。

一旦客户端收到这个 FIN 报文,将返回确认报文并进入 TIME-WAIT 状态,等待 2MSL 时间间隔后完全释放客户端 TCP 连接所占用资源。

与此同时,当服务端收到客户端最后的确认报文,就将直接断开服务端连接并释放相关资源。

至于为什么最后客户端需要等 2MSL 时间长度再完全释放 TCP 相关资源呢?

那是因为 2MSL 是一份报文存在于网络中最长的时间,超过该时间到达的报文都将被丢弃,而如果客户端最后的确认报文于网络中丢失的话,服务端必将发起超时请求,重新发送第三次挥手动作,此时等待中的客户端就可随即重新发送一份确认请求。

这是为什么客户端等待一个最长报文传输时间的原因。有人可能好奇为什么前面的各次请求都没有做超时等待而只最后一次数据发送做了超时等待?

其实原因很简单,相信你也能想到,就是 TCP 自带计时能力,超过一定时间没有收到某个报文的确认报文,会自动重新发送,而这里如果不做等待而直接关闭连接,那么我如何知道服务端到底收到没我的确认报文呢。

通过等待一个最长周期,如果这个周期内没有收到服务端的报文请求,那么我们的确认报文必然是到达了服务端了的,否则重复发送一次即可。

至此,TCP 的『三次握手』和『四次挥手』我们已经简单描述完成了,下面我们看看 TCP 的一些其他特性,比如:可靠传输,拥塞控制等

首先我们来看 TCP 是如何实现可靠传输的,即如何解决网络传输中丢包的问题。

TCP 使用『回退 N 步』协议实现的可靠传输,准确来说,TCP 是在它的基础上进行了一部分优化。

image

『回退 N 步』协议也被称作『滑动窗口』协议,即最多允许发送方有 N 个「已发送但未被确认」的数据报文,如图所示,p1 到 p3 长度即为 N,这里的窗口指的就是 p1 到 p3 这个区间。

只有当发送端收到 p1 的确认报文后,整个窗口才能向前滑动,而实际上在没有收到 p1 的确认报文前,即便它后面的报文已经被接收,服务端也仅仅会缓存这些『非预期的报文』

直到服务端收到最小预期的那个报文后,从缓存中取出已经到达的后续报文,合并并向上交付,然后向发送端返回一个确认报文。

当发送端窗口从左往右已经连续多个报文被确认后,整个窗口将向前滑动多个单位长度。

下面我们看一个例子:

image

这是一个发送方的窗口,灰色表示已经被确认的报文,黄色表示已发送但未被确认的报文,绿色表示下一个待发送的报文,白色表示不可用的报文。

这是我们假设服务端已经收到 6、7 两份报文,但是它上一次向上交付给应用层的是 4 号报文,也就是说它在等 5 号报文,所以它暂时会将 6、7 两个报文缓存起来,等到 5 号报文来了一并交付给应用层。

现在 5 号报文由于超时被重传了,终于到达目的地了,如愿以偿,服务端向上交付 5、6、7 三份报文,并返回一份确认报文,ACK = 8,表示序号 8 以前的所有报文都收到了

当发送端收到这份确认报文后,5、6、7 变成灰色,窗口向前移动三个单位长度。

此外,我还想强调一个细节,TCP 是没有否定确认的,所以如果服务端连续响应的多份报文是对同一序号的确认,那很有可能该序号以后的某个报文丢失。

例如:如果服务端发送多个对分组 5 的 ACK 确认,那说明什么?说明目前我服务端完整的向上交付的序号是 5 号,后续的报文我没收到,你最好重新发一下别等待超时了。

这也是『快速重传』的核心原理。

那么 TCP 的可靠传输我们也基本介绍完了,下面我们看看如果网络拥塞的时候,TCP 是如何控制发送流量的呢?

TCP 认为:丢包即拥塞,需要降低发送效率,而每一次收到确认数据报即认为网络通畅,会增加发送效率。

TCP 的拥塞控制算法包含三个部分,慢启动、拥塞避免和快速恢复

慢启动的思想是,刚开始缓慢的发送,比如某个时间段内只发送一次数据报,当收到确认报文后,下一次同样的时间间隔内,将发送两倍速率的两份数据报,并以此类推。

所以,短时间内,一个 TCP 连接的发送方将以指数级增长,但一旦出现丢包,即收到冗余的 ACK 确认,或者对于一个包的确认 ACK 始终没收到而不得不启动一次超时重传,那么发送方认为「网络是拥塞的」。

于是将速率直接调成一,即一个往返时间段,只发送一个分组,并且设置一个变量 ssthresh 表述一个阈值的概念,这个值是上次丢包时发送方发送速率的一半。

之后的发送方的发送效率一样会以指数级增长,但是不同于第一次,这次一旦达到这个阈值,TCP 将进入『拥塞避免』模式,该模式下的发送效率将不再指数级增长,会谨慎的增长。

拥塞避免的思想是,每个往返时间段发送的所有数据报全部得到确认后,下一次就增加一个分组的发送,这样缓慢的增长效率是谨慎的。

那么一旦出现发送端超时丢包,注意这里是超时,将发送速率置为一并重新进入慢启动状态,阈值就是当前发送效率的一半。

而如果是服务端返回多个冗余 ACK 以明确你丢包,TCP 认为这不是严重的,对于这种情况,TCP 减半当前发送效率并进入快速恢复阶段。

快速恢复的基本思想是,收到几个冗余的 ACK 就增加几个分组的发送效率,就是说,你服务端不是没收到我的几个报文吗,这两次发送我提升速率迅速发给你。

当这期间出现了由发送端超时导致的丢包,同样的处理方式,初始化发送速率为一并减半当前发送效率作为阈值,进入慢启动阶段。

当然,如果这期间收到了对丢失报文的确认,那么将适当降低发送效率并进入拥塞避免状态。

这样,整个 TCP 最核心的几个思想都已经介绍完了,整个运输层基本上也算明了了。关于运输层,你应当有了一定的理解,我再总结一下。

运输层的任务就是从应用层的各个进程的套接字那取回来所有需要发送的数据,然后选择 TCP 或者 UDP 将数据封装并推给下面的网络层待发送。

未完,待续。。。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image

posted @ 2018-06-01 11:35  Single_Yam  阅读(11945)  评论(4编辑  收藏  举报