前言
打开浏览器从输入网址到网页展现背后到底发生了什么?经历了一个怎么样的过程?如图:
具体分为以下几个过程:
- 检查缓存,有的话检查是否过期,没过期则跳过步骤3
- DNS查询
- TCP连接(三握手)
- 发送HTTP请求
- 服务器处理并响应请求
- 浏览器接收响应并解析渲染
- TCP断开连接(四挥手),不同的HTTP断开时机不同
一、检查缓存
如图:
- 浏览器会先检查是否在浏览器缓存中,没有则调用系统库函数进行查询。
- 操作系统也有自己的 DNS 缓存,但在这之前,会先检查域名是否存在本地的 Hosts 文件里,没有则向 DNS 服务器发送查询请求。
- 路由器也有 DNS 缓存。
- ISP 是互联网服务提供商(Internet Service Provider)的简称,有专门的 DNS 服务器应对 DNS 查询请求。也就是客户端电脑上设置的首选 DNS 服务器,它们也会有缓存。
如果ISP DNS服务器还找不到的话,就会向根服务器发出请求,也就是DNS查询
二、DNS查询
查询过程如图:
DNS 服务器先问根域名服务器.com 域名服务器的 IP 地址,然后再问.xxx 域名服务器,依次类推
- 递归方式:一路查下去中间不返回,得到最终结果才返回信息(浏览器到本地DNS服务器的过程)
- 迭代方式,就是本地DNS服务器到根域名服务器查询的方式。
浏览器通过向 DNS 服务器发送域名,DNS 服务器查询到与域名相对应的 IP 地址,然后返回给浏览器,浏览器再将 IP 地址打在协议上,同时请求参数也会在协议搭载,然后一并发送给对应的服务器。
#在前端提升网站性能方法中与 DNS 有关的一般有两点:减少 DNS 请求次数和 DNS 预获取(DNS Prefetch)
DNS 作为互联网的基础协议,其解析的速度似乎很容易被网站优化人员忽视。大多数新浏览器已经针对 DNS 解析进行了优化,典型的一次 DNS 解析需要耗费 20-120 毫秒,减少 DNS 解析时间和次数是个很好的优化方式。DNS Prefetching 是让具有此属性的域名不需要用户点击链接就在后台解析,而域名解析和内容载入是串行的网络操作,所以这个方式能 减少用户的等待时间,提升用户体验 。
默认情况下浏览器会对页面中和当前域名(正在浏览网页的域名)不在同一个域的域名进行预获取,并且缓存结果,这就是隐式的 DNS Prefetch。如果想对页面中没有出现的域进行预获取,那么就要使用显示的 DNS Prefetch 了。
目前大多数浏览器已经支持此属性,支持版本如下:
- – Safari: 5+
- – Chrome: All
- – Firefox: 3.5+
- – Opera: Unknown
- – IE: 9+ (called “Pre-resolution” on blogs.msdn.com)
其中 Chrome 和 Firefox 3.5+ 内置了 DNS Prefetching 技术并对DNS预解析做了相应优化设置。所以即使不设置此属性,Chrome 和 Firefox 3.5+ 也能自动在后台进行预解析 。
目前很多大型站点也应用了这一优化,例如:淘宝、支付宝、网易等
DNS Prefetch 应该尽量的放在网页的前面,推荐放在 <meta charset="UTF-8"> 后面。具体使用方法如下:
<meta http-equiv="x-dns-prefetch-control" content="on">
<link rel="dns-prefetch" href="//www.xxx.com">
<link rel="dns-prefetch" href="//api.xxx.xxx.com">
<link rel="dns-prefetch" href="//img.xxx.xxx.com">
需要注意的是,虽然使用 DNS Prefetch 能够加快页面的解析速度,但是也不能滥用,因为有开发者指出 禁用DNS 预读取能节省每月100亿的DNS查询 。
如果需要禁止隐式的 DNS Prefetch,可以使用以下的标签:
<meta http-equiv="x-dns-prefetch-control" content="off">
三、TCP连接(三报文握手)
过程如图:
状态变化:
- 客户端和服务端都为closed状态,表示没有连接
- 客户端发送连接请求,状态变为发送(SYN-sent)状态;同时服务端也变为监听(Listen)状态
- 服务端在接收到客户端的请求时,服务端切换为回复(SYN-recvd)状态
- 客户端在接收到服务端的响应时,客户端切换为稳定连接(Estab-lished)状态,同时发送第二次数据包
- 服务端在接收客户端的第二次数据时,服务端切换为稳定连接(Estab-lished)状态
- 这时通信双方建立稳定连接,可以正常通信数据
不同的字段到底表示什么意思?
- seq序号(sequence number):占32位字节,用来标识从TCP源端向目的端发送的字节流,发起方发送数据时对此进行标记
- ack确认序号(acknowledgement number):占32位字节,只有ACK标志位为1时,确认序号字段才有效,ack = seq + 1
- 标志位(Flags):共有6个,即URG、ACK、PSH、RST、SYN、FIN。具体含义:
- URG:紧急指针(urgent pointer)有效。
- ACK:确认序号有效。(为了与确认序号ack区分开,我们用大写表示)
- PSH:接收方应该尽快将这个报文交给应用层。
- RST:重置连接。
- SYN:发起一个新连接。
- FIN:释放一个连接。
其中 seq 序号、ack 序号:用于确认数据是否准确,是否正常通信。而标志位:用于确认/更改连接状态。
了解完字段含义后,我们可以将对话翻译为如图:
3.1、为什么TCP是三次握手而不能是两次?
如图:
- 浏览器第一次发送的连接请求由于某些原因未能及时到达服务器
- 超过一段时间后,浏览器没有及时收到回复,从而触发超时重传
- 第二次连接请求正常发送并正常传输数据
- 当数据传输完毕后TCP断开连接
- 这时第一次的请求到达服务器,服务器认为浏览器发送新的请求并改为回复(SYN-recvd)状态
- 浏览器收到答复后,自己没有发送请求,所以将回复丢弃
- 此时服务器一直是半连接状态,造成了资源浪费
SYN泛洪攻击便是让服务器大量保持这种半连接状态,导致服务器死机。
3.2、利用三握手机制缺陷的攻击:SYN泛洪攻击
介绍SYN泛洪攻击前,我们先了解一下DoS攻击和DDoS攻击,这两个攻击答题相同,前者的意思是:拒绝服务攻击;后者的意思是:分布式拒绝服务攻击。
-
DoS(拒绝服务):使目标系统或网络资源无法为合法用户提供正常服务。其目标并不是非法获取数据,而是迫使服务中断,降低业务可用性。最常见的DOS攻击有计算机网络带宽攻击和连通性的攻击。
-
DDoS(分布式拒绝服务);这个的攻击借助于客户/服务器技术,控制多个计算机同时向目标发动攻击,从而成倍的提高就裁决服务攻击的威力。通过大规模协调的恶意节点网络,同时向目标发送海量请求,瞬间淹没目标的带宽或资源。
简单地说,DDoS的攻击威力要大于DoS的攻击威力,DDoS主要是发动群体攻击。
SYN攻击主要利用TCP三握手机制的缺陷,攻击者发送TCP SYN(连接请求),而当这个服务器返回ACK以后,攻击者不进行确认,那这个连接就处在了一个挂起的状态,也就是半连接的意思,那么服务器收不到再确认的一个消息,还会重复发送ACK给攻击者。这样一来就会更加浪费服务器的资源。攻击者对服务器发送非法大量的这种TCP连接,由于每一个都没法完成握手的机制,所以它就会消耗服务器的内存最后可能导致服务器死机,就无法正常工作了。更进一步说,如果这些半连接的握手请求是恶意程序发出,并且持续不断,那么就会导致服务端较长时间内丧失服务功能——这样就形成了DoS攻击。这种攻击方式就称为SYN泛洪攻击。
如何防御SYN泛洪攻击?
最常用的一个手段就是优化主机系统设置。比如降低SYN timeout时间,使得主机尽快释放半连接的占用或者采用SYN cookie设置,如果短时间内收到了某个IP的重复SYN请求,我们就认为受到了攻击。我们合理的采用防火墙设置等外部网络也可以进行拦截。
四、发送HTTP请求
TCP三次握手后,开始发送HTTP请求,请求报文由请求行、请求头、请求体三个部分组成,如下图:
请求发出后,为了方便传输,会对数据进行分割(以报文段为单位),并标记编号,方便服务器接收时能够准确地还原报文信息。
五、服务器处理请求并响应数据
大致流程如图:
处理请求
接受 TCP 报文后,会对连接进行处理,对 HTTP 协议进行解析(请求方法、域名、路径等),并且进行一些验证:
- 验证是否配置虚拟主机
- 验证虚拟主机是否接受此方法
- 验证该用户可以使用该方法(根据 IP 地址、身份信息等)
重定向
假如服务器配置了 HTTP 重定向,就会返回一个 301 永久重定向响应,浏览器就会根据响应,重新发送 HTTP 请求(重新执行上面的过程)。
URL 重写
然后会查看 URL 重写规则,如果请求的文件是真实存在的,比如图片、html、css、js文件等,则会直接把这个文件返回。
否则服务器会按照规则把请求重写到 一个 REST 风格的 URL 上。
然后根据动态语言的脚本,来决定调用什么类型的动态文件解释器来处理这个请求。
六、浏览器获取响应
响应报文由状态行、HTTP响应头、响应体,如图:
(1) 响应行包含:协议版本,状态码,状态码描述
状态码规则如下:
- 1xx:指示信息--表示请求已接收,继续处理。
- 2xx:成功--表示请求已被成功接收、理解、接受。
- 3xx:重定向--要完成请求必须进行更进一步的操作。
- 4xx:客户端错误--请求有语法错误或请求无法实现。
- 5xx:服务器端错误--服务器未能实现合法的请求。
(2) 响应头部包含响应报文的附加信息,由 名/值 对组成
(3) 响应主体包含回车符、换行符和响应返回数据,并不是所有响应报文都有响应数据
七、浏览器渲染页面
浏览器渲染主要有以下步骤:
- 解析收到的HTML文档,根据文档定义构建一棵 DOM 树,DOM 树是由 DOM 元素及属性节点组成的。(在读取 HTML 文档,构建 DOM 树的过程中,若遇到 script 标签,则 DOM 树的构建会暂停,直至脚本执行完毕。)
- 对 CSS 进行解析,生成 CSSOM 规则树。(解析 CSS 规则树时 js 执行将暂停,直至 CSS 规则树就绪。解析 CSS 规则树时 js 执行将暂停,直至 CSS 规则树就绪。)
- 根据 DOM 树和 CSSOM 规则树构建渲染树。渲染树的节点被称为渲染对象,渲染对象是一个包含有颜色和大小等属性的矩形,渲染对象和 DOM 元素相对应,但这种对应关系不是一对一的,不可见的 DOM 元素不会被插入渲染树。还有一些 DOM元素对应几个可见对象,它们一般是一些具有复杂结构的元素,无法用一个矩形来描述。
- 当渲染对象被创建并添加到树中,它们并没有位置和大小,所以当浏览器生成渲染树以后,就会根据渲染树来进行布局(回流:布局完成后,如果某个部分发生了变化影响了布局,那就需要倒回去重新生成渲染树)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。
- 布局阶段结束后是绘制阶段,遍历渲染树并调用渲染对象的 paint 方法将它们的内容显示在屏幕上,绘制使用 UI 基础组件。
大致过程如图:
注意: 这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。
如何对浏览器渲染进行优化?
(1)对于JavaScript:JavaScript加载既会阻塞 HTML 的解析,也会阻塞 CSS 的解析。因此我们可以对JavaScript的加载方式进行改变,来进行优化:
(1)尽量将 JavaScript 文件放在 body 的最后
(2) body中间尽量不要写<script>标签
(3)<script>标签的引入资源方式有三种,有一种就是我们常用的直接引入,还有两种就是使用 async 属性和 defer 属性来异步引入,两者都是去异步加载外部的JS文件,不会阻塞DOM的解析(尽量使用异步加载)。三者的区别如下:
- script 立即停止页面渲染去加载资源文件,当资源加载完毕后立即执行js代码,js代码执行完毕后继续渲染页面;
- async 是在下载完成之后,立即异步加载,加载好后立即执行,多个带async属性的标签,不能保证加载的顺序;
- defer 是在下载完成之后,立即异步加载。加载好后,如果 DOM 树还没构建好,则先等 DOM 树解析好再执行;如果DOM树已经准备好,则立即执行。多个带defer属性的标签,按照顺序执行。
(2)对于CSS:使用CSS有三种方式:使用link、@import、内联样式,其中link和@import都是导入外部样式。它们之间的区别:
- link:浏览器会派发一个新等线程( HTTP 线程)去加载资源文件,与此同时 GUI 渲染线程会继续向下渲染代码
- @import:GUI 渲染线程会暂时停止渲染,去服务器加载资源文件,资源文件没有返回之前不会继续渲染(阻碍浏览器渲染)
- style:GUI 直接渲染
外部样式如果长时间没有加载完毕,浏览器为了用户体验,会使用浏览器会默认样式,确保首次渲染的速度。所以 CSS 一般写在 headr 中,让浏览器尽快发送请求去获取css样式。
所以,在开发过程中,导入外部样式使用 link,而不用 @import。如果 css 少,尽可能采用内嵌样式,直接写在 style 标签中。
(3)针对DOM树、CSSOM树:
可以通过以下几种方式来减少渲染的时间:
- HTML 文件的代码层级尽量不要太深
- 使用语义化的标签,来避免不标准语义化的特殊处理
- 减少 CSS 代码的层级,因为选择器是从左向右进行解析的
(4)减少回流与重绘:
- 操作 DOM 时,尽量在低层级的 DOM 节点进行操作
- 不要使用 table 布局, 一个小的改动可能会使整个 table 进行重新布局
- 使用 CSS 的表达式
- 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
- 使用 absolute 或者 fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
- 避免频繁操作 DOM,可以创建一个文档片段 documentFragment,在它上面应用所有 DOM 操作,最后再把它添加到文档中
- 移动元素可使用 transform 属性,使用定位会引起页面回流与重绘,使用 transform 属性不仅不会引起回流重绘,还会启动 GPU 加速。
- 将 DOM 的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制。
浏览器针对页面的回流与重绘,进行了自身的优化 —— 渲染队列
浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。
将多个读操作(或者写操作)放在一起,就会等所有的读操作进入队列之后执行,这样,原本应该是触发多次回流,变成了只触发一次回流。
八、TCP断开连接(四挥手)
在数据传输结束后,TCP就会断开连接(四挥手),不同的HTTP协议断开时机是不一样的:
- HTTP/0.9:每一个HTTP请求都要TCP连接和断开
- HTTP/1.0:一部分支持持久化连接(如果服务端不主动切断连接,连接就不会断开)
- HTTP/1.1:持久化连接为默认连接
- HTTP/2.0:多路复用,超出空闲时间断开(一般为30s,30s内没有收到HTTP请求就会断开连接)
如图:
刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。四次挥手的过程如下:
- 第一次挥手: 客户端会发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT-1 状态。
即发出连接释放报文段(FIN = 1,序号 seq = X),并停止再发送数据,主动关闭 TCP 连接,进入 FIN_WAIT-1(终止等待1)状态,等待服务端的确认。
- 第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。
即服务端收到连接释放报文段后即发出确认报文段(ACK = 1,确认号 ack = X+1,序号 seq = Y),服务端进入 CLOSE_WAIT(关闭等待)状态,此时的 TCP 处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入 FIN_WAIT-2(终止等待2)状态,等待服务端发出的连接释放报文段。
- 第三次挥手:服务端将未处理完的数据处理并传输完毕后,服务端断开连接,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。
即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN = 1,ACK = 1,序号seq = Z,确认号ack = X+1),服务端进入 LAST_ACK(最后确认)状态,等待客户端的确认。
- 第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。
即客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK = 1,seq = X+1,ack = Z+1),客户端进入 TIME_WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间 2MSL 后,客户端才进入 CLOSED 状态。
8.1、为什么需要四次挥手?
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。
- 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
- 服务端收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等客户端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。等服务端收到客户端发送的 ACK 确认包后进入 CLOSED 状态。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,因此是需要四次挥手。
但是在特定情况下,四次挥手是可以变成三次挥手的。
8.2、什么情况下可以出现三次挥手?
当被动关闭方(上图中的服务端)在TCP挥手过程中,没有数据要处理发送并且开启了TCP延迟确认机制(默认开启),那么第二次挥手和第三次挥手就会合并传输,这样就出现了三次挥手。
8.3、为什么主动方需要 TIME-WAIT 状态(等待关闭状态)?
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
需要 TIME-WAIT 状态,主要是两个原因:
- 防止历史连接中的数据,被后面相同四元组的连接错误的接收;
- 保证「被动关闭连接」的一方,能被正确的关闭;
8.3.1、防止历史连接中的数据,被后面相同四元组的连接错误的接收
为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。
- 序列号,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0。
- 初始序列号,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时。
序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据。
如上图:
- 服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
- 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。
为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 2MSL 时长,这个时间足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
8.3.2、保证「被动关闭连接」的一方,能被正确的关闭
如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。
假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。如图:
为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。