本章记录的是一些 HTTP
相关内容的补充,偏向于查漏补缺,主要是一些平常遇到或是看到过的知识点,面试题之类的,在这里简单的整理汇总一下,一方面补充些专业知识,另一方面也是方便以后查询或是复习,更多相关内容可以参考 前端知识体系整理
HTTP 协议优缺点
HTTP
特点- 灵活可扩展,一个是语法上只规定了基本格式,空格分隔单词,换行分隔字段等,另外一个就是传输形式上不仅可以传输文本,还可以传输图片,视频等任意数据
- 请求/响应 模式,通常而言,就是一方发送消息,另外一方要接受消息,或者是做出相应等
- 可靠传输,
HTTP
是基于TCP/IP
,TCP
提供可靠的字节流服务,IP
协议的作用是把各种数据传送给对方
HTTP
缺点- 无连接,限制每次连接只处理一个请求,服务器处理完客户的请求,并收到客户的应答后,即断开连接
- 无状态,对于事务处理没有记忆能力,即服务器不知道客户端是什么状态
- 明文传输,协议里的报文不使用二进制数据,而是文本形式,这让
HTTP
的报文信息暴露给了外界,给攻击者带来了便利 - 队头阻塞,当
HTTP
开启长连接时,共用一个TCP
连接,当某个请求时间过长时,其他的请求只能处于阻塞状态,这就是队头阻塞问题
HTTP 协议各版本差异
HTTP/0.9
1991
年发布的原型版本,功能简陋,只有一个命令GET
,只支持纯文本内容
HTTP/1.0
- 任何格式的内容都可以发送,另外还引入了
POST
命令和HEAD
命令 HTTP
请求和回应的格式改变,除了数据部分,每次通信都必须包括头信息,用来描述一些元数据- 只使用请求头中的
If-Modified-Since
和Expires
字段作为缓存失效的标准 - 不支持断点续传,也就是说,每次都会传送全部的页面和数据
- 通常每台计算机只能绑定一个
IP
,所以请求消息中的URL
并没有传递主机名
- 任何格式的内容都可以发送,另外还引入了
HTTP/1.1
- 引入了持久连接
TCP
连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive
,长连接的连接时长可以通过请求头中的keep-alive
来设置
- 引入了管线机制(
Pipelining
)- 在同一个
TCP
连接里,客户端可以同时发送多个请求
- 在同一个
- 新增缓存控制标头
- 添加了
E-tag
,If-Unmodified-Since
,If-Match
,If-None-Match
等缓存控制标头来控制缓存失效
- 添加了
- 支持断点续传
- 通过使用请求头中的
Range
来实现
- 通过使用请求头中的
- 使用了虚拟网络
- 在一台物理服务器上可以存在多个虚拟主机,并且它们共享一个
IP
地址
- 在一台物理服务器上可以存在多个虚拟主机,并且它们共享一个
- 新增方法
PUT
、PATCH
、OPTIONS
、DELETE
- 引入了持久连接
HTTP/2
- 二进制分帧
- 采用二进制格式,这样报文格式就被拆分为一个个乱序的二进制帧,用
Headers
帧存放头部字段,Data
帧存放请求体数据等,因为不需要排队等待,一定程度上解决了队头阻塞问题
- 采用二进制格式,这样报文格式就被拆分为一个个乱序的二进制帧,用
- 头部压缩
HTTP/1.1
版本会出现User-Agent/Cookie/Accept/Server/Range
等字段可能会占用几百甚至几千字节,而Body
却经常只有几十字节,导致头部偏重,HTTP/2
使用HPACK
算法进行压缩
- 多路复用
- 复用
TCP
连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,一定程度上解决了队头阻塞的问题
- 复用
- 服务器推送
- 允许服务器未经请求,主动向客户端发送资源,即服务器推送
- 请求优先级
- 可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验
- 二进制分帧
HTTP/3
QUIC
协议- 运行在
QUIC
之上的HTTP
协议被称为HTTP/3
,QUIC
协议基于UDP
实现,同时也整合了TCP
、TLS
和HTTP/2
的优点,并加以优化
- 运行在
- 零
RTT
建立连接- 首次连接只需要
1 RTT
,后面的连接更是只需0 RTT
,意味着客户端发给服务端的第一个包就带有请求数据
- 首次连接只需要
- 连接迁移
QUIC
连接不以四元组(源IP
、源端口、目的IP
、目的端口)作为标识,而是使用一个64
位的随机数,这个随机数被称为Connection ID
,即使IP
或者端口发生变化,只要Connection ID
没有变化,那么连接依然可以维持
- 多路复用
QUIC
的传输单元是Packet
,加密单元也是Packet
,整个加密、传输、解密都基于Packet
,这样就能避免TLS
的队头阻塞问题QUIC
基于UDP
,UDP
的数据包在接收端没有处理顺序,即使中间丢失一个包,也不会阻塞整条连接,其他的资源会被正常处理
- 改进的拥塞控制
- 热插拔,
TCP
中如果要修改拥塞控制策略,需要在系统层面进行操作,QUIC
修改拥塞控制策略只需要在应用层操作,并且QUIC
会根据不同的网络环境、用户来动态选择拥塞控制算法 - 前向纠错
FEC
,使用前向纠错(FEC
,Forward Error Correction
)技术增加协议的容错性,一段数据被切分为10
个包后,依次对每个包进行异或运算,运算结果会作为FEC
包与数据包一起被传输,如果不幸在传输过程中有一个数据包丢失,那么就可以根据剩余9
个包以及FEC
包推算出丢失的那个包的数据 - 单调递增的
Packet Number
,与TCP
的Sequence Number
不同的是,Packet Number
严格单调递增,如果Packet N
丢失了,那么重传时Packet
的标识不会是N
,而是比N
大的数字,比如N + M
,这样发送方接收到确认消息时就能方便地知道ACK
对应的是原始请求还是重传请求 - 更多的
ACK
块,QUIC
最多可以捎带256
个ACK block
,在丢包率比较严重的网络下,更多的ACK block
可以减少重传量,提升网络效率
- 热插拔,
- 流量控制
TCP
会对每个TCP
连接进行流量控制,而QUIC
只需要建立一条连接,在这条连接上同时传输多条Stream
队头阻塞问题
关于队头阻塞问题,其实在整理 HTTP/1.1
,HTTP/2
和 HTTP/3
相关内容的时候都有所涉及,但是可能是东一点西一点分散在各处,所以在这里就简单的汇总和梳理一下
什么是队头阻塞?
对于每一个 HTTP
请求而言,这些任务是会被放入一个任务队列中串行执行的,一旦队首任务请求太慢时,就会阻塞后面的请求处理,这就是 HTTP
队头阻塞问题,队头阻塞会导致带宽无法被充分利用,以及后续健康请求被阻塞,假设有五个请求同时发出,如下图
在第一个请求没有收到回复之前,后续从应用层发出的请求只能排队,请求 2,3,4,5
只能等待请求 1
的响应回来之后才能逐个发出,网络通畅的时候性能影响不大,一旦请求 1
因为什么原因没有抵达服务器,或者响应因为网络阻塞没有及时返回,影响的就是所有后续请求
如何解决
在 HTTP/1.1
当中,为了解决队头阻塞带来的延迟,协议设计者设计了一种新的 HTTP
管线化机制(pipelining
),管线化的流程图可以用下图表示
和之前相比最大的差别是,请求 2,3,4,5
不需要等待请求 1
的响应返回之后才发出,而是几乎在同一时间就把请求发向了服务器,2,3,4,5
及所有后续共用该连接的请求节约了等待的时间,极大的降低了整体延迟,下图可以清晰的看出这种新机制对延迟的改变
不过管线化并不是完美的,它也存在不少缺陷
- 管线化只能适用于
HTTP/1.1
,一般来说,支持HTTP/1.1
的服务端都要求支持管线化 - 只有幂等的请求(
GET
,HEAD
)能使用管线化,非幂等请求比如POST
不能使用,因为请求之间可能会存在先后依赖关系 - 队头阻塞并没有完全得到解决,服务端的响应还是要求依次返回,遵循
FIFO
(first in first out
)原则,也就是说如果请求1
的响应没有回来,2,3,4,5
的响应也不会被送回来 - 绝大部分的
HTTP
代理服务器不支持管线化
正是因为有这么多的问题,各大浏览器厂商要么是根本就不支持管线化,要么就是默认关掉了管线化机制,而且启用的条件十分苛刻,可以参考 Chrome
对于管线化的 问题描述
接下来,在 HTTP/2
当中提出了多路复用(multiplexing
)的解决方案(源自 SPDY
),多路复用允许同时通过单一的 HTTP/2
连接发起多重的 请求/响应 消息,众所周知在 HTTP/1.1
协议中,浏览器客户端在同一时间,针对同一域名下的请求有一定数量限制,超过限制数目的请求会被阻塞,也就是如果想并发多个请求,必须使用多个 TCP
链接
这也是为何一些站点会有多个静态资源 CDN
域名的原因之一,目的就是变相的解决浏览器针对同一域名的请求限制阻塞问题,而 HTTP/2
的多路复用(Multiplexing
)则允许同时通过单一的 HTTP/2
连接发起多重的 请求/响应 消息
因此 HTTP/2
可以很容易的去实现多流并行而不用依赖建立多个 TCP
连接,HTTP/2
把 HTTP
协议通信的基本单位缩小为一个一个的帧,这些帧对应着逻辑流中的消息,并行地在同一个 TCP
连接上双向交换消息
HTTP/2
虽然可以解决请求这个粒度的阻塞,但 HTTP/2
的基础 TCP
协议本身却也存在着队头阻塞的问题,HTTP/2
的每个请求都会被拆分成多个 Frame
,不同请求的 Frame
组合成 Stream
,Stream
是 TCP
上的逻辑传输单元,这样 HTTP/2
就达到了一条连接同时发送多条请求的目标,这就是多路复用的原理
我们看一个例子,在一条 TCP
连接上同时发送 4
个 Stream
,其中 Stream1
已正确送达,Stream2
中的第 3
个 Frame
丢失,TCP
处理数据时有严格的前后顺序,先发送的 Frame
要先被处理,这样就会要求发送方重新发送第 3
个 Frame
,Stream3
和 Stream4
虽然已到达但却不能被处理,那么这时整条连接都被阻塞
不仅如此,由于 HTTP/2
必须使用 HTTPS
,而 HTTPS
使用的 TLS
协议也存在队头阻塞问题,TLS
基于 Record
组织数据,将一堆数据放在一起(即一个 Record
)加密,加密完后又拆分成多个 TCP
包传输,一般每个 Record
有 16K
左右,包含 12
个 TCP
包,这样如果 12
个 TCP
包中有任何一个包丢失,那么整个 Record
都无法解密
队头阻塞会导致 HTTP/2
在更容易丢包的弱网络环境下比 HTTP/1.1
更慢,所以就有了 HTTP/3
当中的 QUIC
协议,那么 QUIC
是如何解决队头阻塞问题的呢?主要有两点
QUIC
的传输单元是Packet
,加密单元也是Packet
,整个加密、传输、解密都基于Packet
,这样就能避免TLS
的队头阻塞问题QUIC
基于UDP
,UDP
的数据包在接收端没有处理顺序,即使中间丢失一个包,也不会阻塞整条连接,其他的资源会被正常处理
HTTP 数据传输
这里主要分为定长数据与不定长数据的处理,我们一个一个来看
定长数据
对于定长的数据包而言,发送端在发送数据的过程中,需要设置 Content-Length
,来指明发送数据的长度,当然如果采用 Gzip
压缩的话,Content-Length
设置的就是压缩后的传输长度,另外
Content-Length
如果存在并且有效的话,则必须和消息内容的传输长度完全一致,也就是说,如果过短就会截断,过长的话,就会导致超时- 如果采用短链接的话,直接可以通过服务器关闭连接来确定消息的传输长度
- 那么在
HTTP/1.0
之前的版本中,Content-Length
字段可有可无,因为一旦服务器关闭连接,我们就可以获取到传输数据的长度了 - 在
HTTP/1.1
版本中,如果是keep-alive
的话,chunked
优先级高于Content-Length
,若是非keep-alive
,则跟前面情况一样,Content-Length
可有可无
下面我们来看看如何设置 Content-Length
,代码如下
1 | const server = require('http').createServer() |
不定长数据
现在采用最多的就是 HTTP/1.1
版本来完成传输数据,在保存 keep-alive
状态下,当数据是不定长的时候,我们需要设置新的头部字段 Transfer-Encoding: chunked
,通过 chunked
机制,可以完成对不定长数据的处理,但是也有需要注意的地方
- 如果头部信息中有
Transfer-Encoding
,优先采用Transfer-Encoding
里面的方法来找到对应的长度 - 如果设置了
Transfer-Encoding
,那么Content-Length
将被忽视 - 使用长连接的话,会持续的推送动态内容
1 | const server = require('http').createServer() |
SSL 连接断开后如何恢复
一共有两种方法来恢复断开的 SSL
连接,一种是使用 Session ID
,一种是 Session Ticket
通过 Session ID
使用 Session ID
的方式,每一次的会话都有一个编号,当对话中断后,下一次重新连接时,只要客户端给出这个编号,服务器如果有这个编号的记录,那么双方就可以继续使用以前的秘钥,而不用重新生成一把,目前所有的浏览器都支持这一种方法,但是这种方法有一个缺点是,Session ID
只能够存在一台服务器上,如果我们的请求通过负载平衡被转移到了其他的服务器上,那么就无法恢复对话
通过 Session Ticket
另一种方式是 Session Ticket
的方式,Session Ticket
是服务器在上一次对话中发送给客户的,这个 Ticket
是加密的,只有服务器能够解密,里面包含了本次会话的信息,比如对话秘钥和加密方法等,这样不管我们的请求是否转移到其他的服务器上,当服务器将 Ticket
解密以后,就能够获取上次对话的信息,就不用重新生成对话秘钥了
短轮询、长轮询和 WebSocket 间的区别
- 短轮询
- 短轮询的基本思路是,浏览器每隔一段时间向浏览器发送
HTTP
请求,服务器端在收到请求后,不论是否有数据更新,都直接进行响应,这种方式实现的即时通信,本质上还是浏览器发送请求,服务器接受请求的一个过程,通过让客户端不断的进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化 - 优缺点
- 优点是比较简单,易于理解
- 缺点是这种方式由于需要不断的建立
HTTP
连接,严重浪费了服务器端和客户端的资源,当用户增加时,服务器端的压力就会变大,这是很不合理的
- 短轮询的基本思路是,浏览器每隔一段时间向浏览器发送
- 长轮询
- 长轮询的基本思路,首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新,如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回,客户端
JavaScript
响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接 - 优缺点
- 长轮询和短轮询比起来,它的优点是明显减少了很多不必要的
HTTP
请求次数,相比之下节约了资源 - 长轮询的缺点在于,连接挂起也会导致资源的浪费
- 长轮询和短轮询比起来,它的优点是明显减少了很多不必要的
- 长轮询的基本思路,首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将这个请求挂起,然后判断服务器端数据是否有更新,如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回,客户端
WebSocket
WebSocket
是HTML5
定义的一个新协议,与传统的HTTP
协议不同,该协议允许由服务器主动的向客户端推送信息- 优缺点
- 优点是
WebSocket
是一个全双工的协议,也就是通信双方是平等的,可以相互发送消息 - 缺点在于如果需要使用
WebSocket
协议,服务器端的配置比较复杂
- 优点是
至于什么是单工、半双工、全双工,区别如下表所示
类型 | 能力 |
---|---|
单工 | 信息单向传送 |
半双工 | 信息能双向传送,但不能同时双向传送 |
全双工 | 信息能够同时双向传送 |
正向代理和反向代理
- 正向代理,我们常说的代理也就是指正向代理,正向代理的过程,它隐藏了真实的请求客户端,服务端不知道真实的客户端是谁,客户端请求的服务都被代理服务器代替来请求
- 反向代理,这种代理模式下,它隐藏了真实的服务端,当我们向一个网站发起请求的时候,背后可能有成千上万台服务器为我们服务,具体是哪一台,我们不清楚,我们只需要知道反向代理服务器是谁就行,而且反向代理服务器会帮我们把请求转发到真实的服务器那里去,一般而言反向代理服务器一般用来实现负载平衡
负载平衡的两种实现方式
- 一种是使用反向代理的方式,用户的请求都发送到反向代理服务上,然后由反向代理服务器来转发请求到真实的服务器上,以此来实现集群的负载平衡
- 另一种是
DNS
的方式,DNS
可以用于在冗余的服务器上实现负载平衡,因为现在一般的大型网站使用多台服务器提供服务,因此一个域名可能会对应多个服务器地址,当用户向网站域名请求的时候,DNS
服务器返回这个域名所对应的服务器IP
地址的集合,但在每个回答中,会循环这些IP
地址的顺序,用户一般会选择排在前面的地址发送请求,以此将用户的请求均衡的分配到各个不同的服务器上,这样来实现负载均衡
但是 DNS
的方式有一个缺点就是,由于 DNS
服务器中存在缓存,所以有可能一个服务器出现故障后,域名解析仍然返回的是那个 IP
地址,就会造成访问的问题
HTTP 缓存策略
关于缓存策略,我们之前在 浏览器缓存机制 一节当中已经详细整理过,这里我们在简单梳理一下,总的分为两种策略,即强缓存和协商缓存,下面我们一个一个来看
强缓存
简单总结一下
- 强缓存有两个相关字段,
Expires
和Cache-Control
- 分为两种情况,一种是发送
HTTP
请求,一种不需要发送 - 首先检查强缓存,这个阶段不需要发送
HTTP
请求,通过查找不同的字段来进行,不同的HTTP
版本所以不同 HTTP/1.0
版本,使用的是Expires
,HTTP/1.1
使用的是Cache-Control
Expires
Expires
即过期时间,时间是相对于服务器的时间而言的,存在于服务端返回的响应头中,在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求,比如下面这样
1 | Expires: Mon, 29 Jun 2019 11:10:23 GMT |
表示该资源在 2019
年 7
月 29
日 11:10:23
过期,过期时就会重新向服务器发起请求,但是这种方式是存在一些问题的,比如服务器的时间和浏览器的时间可能并不一致,所以 HTTP/1.1
提出新的字段代替它
Cache-Control
HTTP/1.1
版本中,使用的就是该字段,这个字段采用的时间是过期时长,对应的是 max-age
1 | Cache-Control: max-age = 6000 |
上面代表该资源返回后 6000
秒,可以直接使用缓存,还有其他一些相关指令,使用方式可以参考上文链接,这里需要注意的几点
- 当
Expires
和Cache-Control
同时存在时,优先考虑Cache-Control
- 当缓存资源失效了,也就是没有命中强缓存,接下来就进入协商缓存
协商缓存
强缓存失效后,浏览器在请求头中携带响应的缓存 Tag
来向服务器发送请求,服务器根据对应的 Tag
,来决定是否使用缓存
缓存分为两种,Last-Modified
和 ETag
,两者各有优势,并不存在谁对谁有绝对的优势,这点与上面所讲的强缓存当中的两个 Tag
有所不同
Last-Modified
这个字段表示的是最后修改时间,在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段,浏览器接收到后,如果再次请求,会在请求头中携带 If-Modified-Since
字段,这个字段的值也就是服务器传来的最后修改时间,服务器拿到请求头中的 If-Modified-Since
的字段后,其实会和这个服务器中该资源的最后修改时间对比
- 如果请求头中的这个值小于最后修改时间,说明是时候更新了,返回新的资源,跟常规的
HTTP
请求响应的流程一样 - 否则返回
304
,告诉浏览器直接使用缓存
ETag
ETag
是服务器根据当前文件的内容,对文件生成唯一的标识,比如 MD5
算法,只要里面的内容有改动,这个值就会修改,服务器通过把响应头把该字段给浏览器,浏览器接收到 ETag
值,会在下次请求的时候,将这个值作为 If-None-Match
这个字段的内容,发给服务器
服务器接收到 If-None-Match
后,会跟服务器上该资源的 ETag
进行比对
- 如果两者一样的话,直接返回
304
,告诉浏览器直接使用缓存 - 如果不一样的话,说明内容更新了,返回新的资源,跟常规的
HTTP
请求响应的流程一样
两者对比
- 性能上,
Last-Modified
优于ETag
,Last-Modified
记录的是时间点,而Etag
需要根据文件的MD5
算法生成对应的Hash
值 - 精度上,
ETag
优于Last-Modified
,ETag
按照内容给资源带上标识,能准确感知资源变化,Last-Modified
在某些场景并不能准确感知变化,比如- 编辑了资源文件,但是文件内容并没有更改,这样也会造成缓存失效
Last-Modified
能够感知的单位时间是秒,如果文件在1
秒内改变了多次,那么这时候的Last-Modified
并没有体现出修改了
最后,如果两种方式都支持的话,服务器会优先考虑 ETag
缓存位置
下面我们来看看,如果考虑使用缓存的话,那么缓存的位置在哪里呢?其实浏览器缓存的位置的话,可以分为四种,优先级从高到低排列分别
Service Worker
Memory Cache
Disk Cache
Push Cache
Service Worker
Service Worker
的缓存与浏览器其他内建的缓存机制不同,它可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的
PWA
的实现也是和这个有关,它借鉴了 Web Worker
思路,由于它脱离了浏览器的窗体,因此无法直接访问 DOM
,它能完成的功能比如离线缓存、消息推送和网络代理,其中离线缓存就是 Service Worker Cache
Memory Cache
指的是内存缓存,从效率上讲它是最快的,从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了
Disk Cache
存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,优势在于存储容量和存储时长,与 Memory Cache
对比的话,主要的策略如下
- 内容使用率高的话,文件优先进入磁盘
- 比较大的
JavaScript
,CSS
文件会直接放入磁盘,反之放入内存
Push Cache
Push Cache
(推送缓存)是 HTTP/2
中的内容,当以上三种缓存都没有命中时,它才会被使用,它只在会话(Session
)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在 Chrome
浏览器中只有五分钟左右
更多 Push Cache
更多内容可以参考 HTTP/2 push is tougher than I thought
总结
- 首先检查
Cache-Control
,看强缓存是否可用如果可用的话,直接使用 - 否则进入协商缓存,发送
HTTP
请求,服务器通过请求头中的If-Modified-Since
或者If-None-Match
字段检查资源是否更新资源更新,返回资源和200
状态码 - 否则返回
304
,直接告诉浏览器直接从缓存中去资源
TCP 如何保证可靠传输
TCP
协议保证数据传输可靠性的方式主要有以下几种方式
- 校验和,
TCP
在发送报文之前,发送方要计算校验和,收到数据后,接收方也要计算校验和,如果校验和不相等则丢弃 - 序列号与确认应答
- 序列号,
TCP
传输时将每个字节的数据都进行了编号,这就是序列号 - 确认应答,
TCP
传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答,也就是发送ACK
报文,这个ACK
报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发 - 序列号的作用不仅仅是应答的作用,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据,这也是
TCP
传输可靠性的保证之一
- 序列号,
- 超时重传,在
TCP
传输过程中,我们在发送一部分数据后,都会等待对方的ACK
确认报文,如果中间出现差错,没有收到ACK
报文,这时候需要启动超时重传机制,这种超时重传机制保证了TCP
在网络延迟或者报文丢失下的可靠传输,超时的原因主要有以下两点- 接收方没有收到
TCP
报文段,网络延迟或者丢包 - 发送方没有收到
ACK
报文段,网络延迟或者ACK
报文丢失
- 接收方没有收到
- 连接管理,连接管理就是三次握手与四次挥手的过程
- 流量控制,流量控制的目的是让接收方来得及接收数据,这样避免了数据丢包以及网络拥塞等情况
- 拥塞控制,拥塞控制就是防止过多的数据注入到网络中,这样使网络中的路由器或者链路不至于过载(四个核心算法,慢启动、拥塞避免、快速重传和快速恢复)
TCP 和 UDP 的区别
UDP
协议的特点有以下这些
- 面向无连接
- 首先
UDP
是不需要和TCP
一样在发送数据前进行三次握手建立连接的,想发数据就可以开始发送了,并且也只是数据报文的搬运工,不会对数据报文进行任何拆分和拼接操作,具体来说就是 - 在发送端,应用层将数据传递给传输层的
UDP
协议,UDP
只会给数据增加一个UDP
头标识下是UDP
协议,然后就传递给网络层了 - 在接收端,网络层将数据传递给传输层,
UDP
只去除IP
报文头就传递给应用层,不会任何拼接操作
- 首先
- 有单播,多播,广播的功能
UDP
不止支持一对一的传输方式,同样支持一对多,多对多,多对一的方式,也就是说UDP
提供了单播,多播,广播的功能
UDP
是面向报文的- 发送方的
UDP
对应用程序交下来的报文,在添加首部后就向下交付IP
层,UDP
对应用层交下来的报文,既不合并,也不拆分,而是保留这些报文的边界,因此,应用程序必须选择合适大小的报文
- 发送方的
- 不可靠性
- 首先不可靠性体现在无连接上,通信都不需要建立连接,想发就发,这样的情况肯定不可靠,并且收到什么数据就传递什么数据,并且也不会备份数据,发送数据也不会关心对方是否已经正确接收到数据了
- 再者网络环境时好时坏,但是
UDP
因为没有拥塞控制,一直会以恒定的速度发送数据,即使网络条件不好,也不会对发送速率进行调整,这样实现的弊端就是在网络条件不好的情况下可能会导致丢包,但是优点也很明显,在某些实时性要求高的场景(比如电话会议)就需要使用UDP
而不是TCP
- 头部开销小,传输数据报文时是很高效的
UDP
头部包含了以下几个数据- 两个十六位的端口号,分别为源端口(可选字段)和目标端口
- 整个数据报文的长度
- 整个数据报文的检验和(
IPv4
可选 字段),该字段用于发现头部信息和数据中的错误
- 因此
UDP
的头部开销小,只有八字节,相比TCP
的至少二十字节要少得多,在传输数据报文时是很高效的
TCP
协议的特点有以下这些
- 面向连接
- 面向连接,是指发送数据之前必须在两端建立连接,建立连接的方法是三次握手,这样能建立可靠的连接,建立连接,是为数据的可靠传输打下了基础
- 仅支持单播传输
- 每条
TCP
传输连接只能有两个端点,只能进行点对点的数据传输,不支持多播和广播传输方式
- 每条
- 面向字节流
TCP
不像UDP
一样那样一个个报文独立地传输,而是在不保留报文边界的情况下以字节流方式进行传输
- 可靠传输
- 对于可靠传输,判断丢包,误码靠的是
TCP
的段编号以及确认号,TCP
为了保证报文传输的可靠,就给每个包一个序号,同时序号也保证了传送到接收端实体的包的按序接收,然后接收端实体对已成功收到的字节发回一个相应的确认(ACK
),如果发送端实体在合理的往返时延(RTT
)内未收到确认,那么对应的数据(假设丢失了)将会被重传
- 对于可靠传输,判断丢包,误码靠的是
- 提供拥塞控制
- 当网络出现拥塞的时候,
TCP
能够减小向网络注入数据的速率和数量,缓解拥塞
- 当网络出现拥塞的时候,
TCP
提供全双工通信TCP
允许通信双方的应用程序在任何时候都能发送数据,因为TCP
连接的两端都设有缓存,用来临时存放双向通信的数据,当然TCP
可以立即发送一个数据段,也可以缓存一段时间以便一次发送更多的数据段(最大的数据段大小取决于MSS
)
这里我们简单介绍一下 MSS
,其实简单来说,就是一个网络包的最大长度,计算每个网络包能容纳的数据长度,协议栈会根据一个叫作 MTU
的参数来进行判断,MTU
表示一个网络包的最大长度,在以太网中一般是 1500
字节
MTU
是包含头部的总长度,因此需要从 MTU
减去头部的长度,然后得到的长度就是一个网络包中所能容纳的最大数据长度,这一长度叫作 MSS
下面我们再来看看 TCP
和 UDP
两者的区别,如下表
区别 | UDP | TCP |
---|---|---|
是否连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠传输,不使用流量控制和拥塞控制 | 可靠传输,使用流量控制和拥塞控制 |
连接对象个数 | 支持一对一,一对多,多对一和多对多交互通信 | 只能是一对一通信 |
传输方式 | 面向报文 | 面向字节流 |
首部开销 | 首部开销小,仅 8 字节 |
首部最小 20 字节,最大 60 字节 |
适用场景 | 适用于实时应用(IP 电话、视频会议、直播等) |
适用于要求可靠传输的应用,例如文件传输 |
简单总结就是
TCP
向上层提供面向连接的可靠服务,UDP
向上层提供无连接不可靠服务- 虽然
UDP
并没有TCP
传输来的准确,但是也能在很多实时性要求高的地方有所作为 - 对数据准确性要求高,速度可以相对较慢的,可以选用
TCP
我们在上面也介绍了负责域名解析的 DNS
服务,那么 DNS
为什么使用 UDP
协议作为传输层协议而不是 TCP
呢?其实简单来说,DNS
使用 UDP
协议作为传输层协议的主要原因是为了避免使用 TCP
协议时造成的连接时延
- 为了得到一个域名的
IP
地址,往往会向多个域名服务器查询,如果使用TCP
协议,那么每次请求都会存在连接时延,这样使DNS
服务变得很慢 - 大多数的地址查询请求,都是浏览器请求页面时发出的,这样会造成网页的等待时间过长
下表是使用 UDP
和 TCP
协议的各种应用和应用层协议
应用 | 应用层协议 | 运输层协议 |
---|---|---|
名字转换 | DNS (域名系统) |
UDP |
文件传送 | TFTP (简单文件传送协议) |
UDP |
路由选择协议 | RIP (路由信息协议) |
UDP |
IP 地址配置 |
DHCP (动态主机配置协议) |
UDP |
网络管理 | SNMP (简单网络管理协议) |
UDP |
远程文件服务器 | NFS (网络文件系统) |
UDP |
IP 电话 |
专用协议 | UDP |
流式多媒体通信 | 专用协议 | UDP |
多播 | IGMP (网际组管理协议) |
UDP |
电子邮件 | SMTP (简单邮件传送协议) |
TCP |
远程终端接入 | TELNET (远程终端协议) |
TCP |
万维网 | HTTP (超文本传送协议) |
TCP |
文件传送 | FTP (文件传送协议) |
TCP |
三次握手
TCP
建立连接的过程叫做握手,握手需要在客户和服务器之间交换三个 TCP
报文段,也叫三报文握手,之所以会有三次握手的过程,是因为需要确认双方的两样能力,即『发送的能力』和『接收的能力』
A
主动打开连接,B
被动打开连接,B
先进入收听状态(LISTEN
),A
打算建立TCP
连接时,先向B
发送连接请求报文段,其中同步位SYN = 1
,初始序号seq = x
,这个报文段不能携带数据,但是要消耗一个序号,接着A
进入同步已发送状态B
收到请求报文段,如果同意建立连接,则向A
发送ACK
确认报文段,其中同步位SYN = 1
,确认号ACK = 1
,初始序号seq = y
,确认号ack = x + 1
(请求报文段消耗了一个序号),这个ACK
报文段也不能携带数据,但是要消耗一个序号,与此同时B
进入到同步收到的状态A
收到B
的确认报文后,还要给B
发送确认报文,其中ACK = 1
,seq = x + 1
(上一个报文段的ack
),ack = y + 1
(上一个报文段的seq + 1
,因为消耗了一个序号),这个ACK
报文段可以携带数据,但是如果不携带数据则不会消耗序号,下一次A
给B
发送报文段的初始序号seq = 1
,此时A
进入已建立连接的状态,B
收到确认后也进入已建立连接的状态
从上图可以看出,SYN
是需要消耗一个序列号的,下次发送对应的 ACK
序列号要加 1
,那么为什么会这样呢?这里只需要记住一个规则
凡是需要对端确认的,一定消耗
TCP
报文的序列号(SYN
需要对端的确认,而ACK
并不需要,因此SYN
消耗一个序列号而ACK
不需要)
为什么不是两次握手
其实也就是为什么需要最后一次确认?
其实简单来说,这是防止已失效的连接请求报文段突然又传送到了 B
而引发错误,但是有可能出现异常情况,即 A
发送的连接请求并没有丢失,而是滞留了在网络中,如果在传输数据完成之后,这个请求又发到 B
,B
误以为 A
还要发送数据,因此发送确认报文,但是 A
没有运输需求,因此不予理睬,如果没有最后一次确认,B
一直等待 A
的确认,这样会造成的浪费
采用三报文握手,如果 B
没有收到 A
的确认,则可以知道 A
没有建立连接的需求,就可以避免上述这种情况
所谓的失效的连接请求就是
A
第一次先发送了一个请求,但是丢失了,于是A
再发送一个连接请求,重新建立连接,发送数据并释放连接
为什么不是四次握手
三次握手的目的是确认双方『发送』和『接收』的能力,当然四次握手也是可以的,甚至 100
次都可以,但为了解决问题,三次就已经足够了,再多用处就不大了
三次握手过程中可以携带数据么
第三次握手的时候,可以携带,前两次握手不能携带数据
- 如果前两次握手能够携带数据,那么一旦有人想攻击服务器,那么他只需要在第一次握手中的
SYN
报文中放大量数据,那么服务器势必会消耗更多的时间和内存空间去处理这些数据,增大了服务器被攻击的风险 - 第三次握手的时候,客户端已经处于
ESTABLISHED
状态,并且已经能够确认服务器的接收、发送能力正常,这个时候相对安全了,可以携带数据
如果同时打开会怎样
如果双方同时发 SYN
报文,这是一个可能会发生的情况,状态变迁如下
在发送方给接收方发 SYN
报文的同时,接收方也给发送方发 SYN
报文,此时发完 SYN
,两者的状态都变为 SYN-SENT
,在各自收到对方的 SYN
后,两者状态都变为 SYN-REVD
接着会回复对应的 ACK + SYN
,这个报文在对方接收之后,两者状态一起变为 ESTABLISHED
,这就是同时打开情况下的状态变迁
四次挥手
所谓的四次挥手其实也就是 TCP
的连接释放的过程
A
和B
目前都处于已建立连接的状态,A
的应用进程向其TCP
发出连接释放报文段,并停止发送数据,主动关闭TCP
连接,此时FIN = 1
,seq = u
,u
等于前面已发送的最后一个字节的序号加1
,这时A
进入到FIN-WAIT-1
(终止等待1
)状态,等待B
的确认,FIN
报文段即使不携带数据,也要消耗一个序号B
收到释放连接后立即发出确认,此时,ACK = 1
,确认号是ack = u + 1
(前面的seq + 1
,因为消耗了一个序号),序号seq = v
,v
等于B
前面所有已传送数据的最后一个字节的序号加1
,B
进入到CLOSE-WAIT
(关闭等待)状态,TCP
服务器进程向B
的高层应用进程告知,此时A
到B
的连接已经释放,TCP
连接处于半关闭状态,但是,B
到A
这个方向的连接尚未关闭A
收到B
的确认后,就进入到FIN-WAIT-2
(终止等待2
)的状态,等待B
发送连接释放报文段- 若
B
已经没有数据需要发送,则应用进程通知TCP
释放连接,这时B
发送的报文段FIN = 1
,ACK = 1
,seq = w
(可能后面又发送了一些数据),ack = u + 1
,并且这个报文消耗一个序号,B
进入到LAST-ACK
(最后确认)的状态,等待A
的确认 A
收到B
的确认后,必须对此发送确认报文,该报文中ACK = 1
,seq = u + 1
,ack = w + 1
,然后进入到TIME-WAIT
(时间等待)状态
但是需要注意的是,此时
TCP
连接并没有完全释放,必须经过时间等待计时器设置的时间2MSL
之后,A
才进入CLOSED
状态,时间MSL
叫做最大报文段寿命
那么问题来了,为什么要等待 2MSL
的时间呢?主要原因有两点
- 保证
A
最后发送的ACK
报文段能够到达B
,因为这个报文可能丢失,因此B
会重传最后一个确认报文段,A
再重新发送确认报文,并且重启计时器,直到A
,B
都能正常进入到CLOSED
状态 - 防止上面提到的已失效的连接请求报文,这段时间内,这些连接请求报文就可能在网络中消失
简单来说就是
1
个MSL
确保四次挥手中主动关闭方最后的ACK
报文最终能达到对端1
个MSL
确保对端没有收到ACK
重传的FIN
报文可以到达
这就是等待 2MSL
的意义,此外 B
要比 A
先进入 CLOSED
状态
为什么是四次挥手而不是三次
因为服务端在接收到 FIN
,往往不会立即返回 FIN
,必须等到服务端所有的报文都发送完毕了,才能发 FIN
,因此先发一个 ACK
表示已经收到客户端的 FIN
,延迟一段时间才发 FIN
,这就造成了四次挥手,那么如果是三次挥手会有什么问题?
如果是三次挥手,这就等于说服务端将 ACK
和 FIN
的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为 FIN
没有到达客户端,从而让客户端不断的重发 FIN
同时关闭会怎样
如果客户端和服务端同时发送 FIN
,状态如下图所示
半连接队列与 SYN Flood 攻击
三次握手前,服务端的状态从 CLOSED
变为 LISTEN
,同时在内部创建了两个队列『半连接队列』和『全连接队列』,即 SYN
队列和 ACCEPT
(Accept Queue
) 队列
- 半连接队列,当客户端发送
SYN
到服务端,服务端收到以后回复ACK
和SYN
,状态由LISTEN
变为SYN_RCVD
,此时这个连接就被推入了SYN
队列,也就是半连接队列 - 全连接队列,当客户端返回
ACK
,服务端接收后,三次握手完成,这个时候连接等待被具体的应用取走,在被取走之前,它会被推入另外一个TCP
维护的队列,也就是全连接队列
SYN Flood 攻击原理
SYN Flood
属于典型的 DoS/DDoS
攻击,其攻击的原理很简单,就是用客户端在短时间内伪造大量不存在的 IP
地址,并向服务端疯狂发送 SYN
,对于服务端而言,会产生两个危险的后果
- 处理大量的
SYN
包并返回对应ACK
,势必有大量连接处于SYN_RCVD
状态,从而占满整个半连接队列,无法处理正常的请求 - 由于是不存在的
IP
,服务端长时间收不到客户端的ACK
,会导致服务端不断重发数据,直到耗尽服务端的资源
如何应对 SYN Flood 攻击
- 增加
SYN
连接,也就是增加半连接队列的容量 - 减少
SYN
+ACK
重试次数,避免大量的超时重发 - 利用
SYN Cookie
技术,在服务端接收到SYN
后不立即分配连接资源,而是根据这个SYN
计算出一个Cookie
,连同第二次握手回复给客户端,在客户端回复ACK
的时候带上这个Cookie
值,服务端验证Cookie
合法之后才分配连接资源
报文头部的字段
主要分为 IP
数据报格式、UDP
数据报格式、TCP
报文段格式和以太网 MAC
帧格式
IP 数据报格式
IP
数据报首部可以分为固定长度(20
字节)和可选长度,固定长度是所有 IP
数据报所必须的,固定部分个字段的意义如下
- 版本,占
4
位,指IP
协议的版本,通信双方的协议版本必须一致 - 首部长度,占
4
位,可表示的最大十进制数是15
(1111
),它的单位是4
字节(也就是32
位),因此首部长度最小值为5
(固定长度部分),可选长度最长为40
字节 - 区分服务,占
8
位,用来获得更好的服务 - 总长度,占
16
位,首部和数据部分的总长度,单位为字节,因此IP
数据报的最大长度为2^16 - 1
- 标识,占
16
位,当数据报的长度超过网络的最大传送单元使,就给该数据报的所有分片赋值相同的标识,相同的标识字段的值使分片后的各数据报片能正确的重装成原来的数据报 - 标志,占
3位
,但是只有两位具有意义- 标记字段中的最低位记为
MF
,MF = 1
表示后面还有分片,MF = 0
表示这是最后一个分片 - 标志字段中间的一位记为
DF
,意思是能否分片,只有DF = 0
时才能分片
- 标记字段中的最低位记为
- 片偏移,占
13
位,较长的分组在分片后,某片在原分组中的相对位置,也就是说,数据片相对于初始位置的距离,单位是8
字节,因此除去最后一个数据片,每个数据片的长度都是8
字节的倍数 - 生存时间,占
8位
,TTL
(Time To Live
),单位为跳数,跳数表明该数据报至多能在互联网中经过多少个路由器,每经过一个路由器就减1
- 协议,占
8位
,协议字段指出该数据报携带的数据是使用哪种协议,以便使目的主机的IP
层知道应将数据部分上交给哪个协议进行处理
协议名 | ICMP |
IGMP |
IP |
TCP |
EGP |
IGP |
UDP |
IPv6 |
ESP |
OSPF |
---|---|---|---|---|---|---|---|---|---|---|
协议字段值 | 1 |
2 |
4 |
6 |
8 |
9 |
17 |
41 |
50 |
89 |
- 源地址,占
32
位 - 目的地址,占
32
位 - 首部校验和,占
16
位,这个字段只检验数据报的首部,但是不包括数据部分- 在发送方,先把数据报划分为许多
16
位的字的序列,并把校验和字段置为0
- 用反码算术运算(从低位到高位计算,
0 + 0
等于0
,0 + 1
等于1
,1 + 1
等于0
,但是要进1
)把所有的16
位字相加后,将得到的反码写入校验和字段 - 接收方接收到数据报之后,将首部的所有
16
位字再使用反码运算相加一次,将得到的和取反码,即得出接收方的检验和的计算结果,如果结果全为0
,则代表首部未发生变化,保留该数据报,反之则丢弃
- 在发送方,先把数据报划分为许多
UDP 数据报格式
UDP
用户数据报分为 = 首部字段 (8
个字节,4
个字段,每个字段 2
个字节)+ 数字字段,首部字段分为
- 源端口,源端口号,在需要对方回信的时候选用,不需要填
0
- 目的端口,目的端口号,必填
- 长度,
UDP
用户数据报的长度,最小为8
- 检验和,检测
UDP
用户数据报传输过程中是否有错,有错就丢弃
TCP 报文段格式
TCP
虽然是面向字节流的,但是 TCP
传输的数据单元却是报文段,一个报文段可以分为首部和数据两部分,TCP
报文段的首部的前 20
个字节是固定的,后面的 4n
字节是需要增加的选项,因此 TCP
首部的最小长度是 20
字节
首部部分字段的意义如下
- 源端口和目的端口,各占
2
个字节,分别写入源端口号和目的端口号,TCP
的分用功能也是通过端口号实现的- 那么如何标识唯一一个连接呢?答案是
TCP
连接的四元组,源IP
、源端口、目标IP
和目标端口,但是TCP
报文当中为何没有源IP
和目标IP
呢?这是因为在IP
层就已经处理了IP
,TCP
只需要记录两者的端口即可
- 那么如何标识唯一一个连接呢?答案是
- 序号,占
4
字节,在TCP
连接中传送的字节流中的每一个字节都按照顺序编号,首部中的序号字段值则代表本报文段所发送的数据的第一个字节的序号,作用有两个- 在
SYN
报文中交换彼此的初始序列号 - 保证数据包按正确的顺序组装
- 在
- 确认号,占
4
字节,代表期望收到对方下一个报文段的第一个数据字节的序号,需要注意若确认号= N
,则表明到序号N - 1
为止的所有数据都已正确收到 - 数据偏移,占
4
位,他指出TCP
报文段的数据起始处距离TCP
报文段的起始处有多远,一般情况下为20
字节,但是首部中还有不确定的选项字段,它的单位是4
字节,而它的最大值是15
,因此数据偏移最大值为60
字节,也就是说选项不能超过40
字节 - 保留,占
6
位,以防后续使用
下面是 6
个控制位(标记位),每个占一位
- 紧急
URG
,当URG = 1
时,表明紧急字段有效,它告诉系统此报文中有紧急数据,应该尽快传送 - 确认
ACK
,仅当ACK = 1
时确认号字段才有效 - 推送
PSH
,当两个应用进程进行交互式的通信时,有时一端的应用进程希望在键入一个命令后立即就能收到对方的相应,这时设置PSH = 1
- 复位
RST
,当RST = 1
时,表明TCP
连接中出现严重错误,必须释放连接,再重新建立运输连接,RST = 1
还可以用来拒绝一个非法的报文段或拒绝打开一个连接 - 同步
SYN
,在建立连接时用来同步序号,当SYN = 1
,ACK = 0
时代表是连接请求报文段,若对方同意建立连接,则应在相应报文段中使SYN = 1
,ACK = 1
,也就是说,SYN = 1
代表连接请求或者连接接受报文 - 终止
FIN
,用于释放一个连接,当FIN = 1
时,代表此报文段的发送方的数据已发送完毕,并且请求释放运输连接
控制位至此结束
- 窗口,占
2
字节,窗口值告诉对方,从本报文段中的确认号算起,接收方目前允许对方发送的数据量(以字节为单位),之所以设置这个限制,是因为接收方的数据缓存空间是有限的,总之窗口值作为接收方让发送方设置其窗口大小的依据 - 检验和,占
2
字节,检验的范围包括首部字段和数据字段,和UDP
检验的方法一样,只不过把伪首部第四个字段的17
改成6
- 紧急指针,占
2
字节,只有在紧急URG = 1
时才有效,它指出本报文段中的紧急数据的字节数 - 选项,长度可变,最大
40
字节,注意,TCP
最初只规定了一种选项,即最大报文长度MSS
,MSS
是每一个TCP
报文段中的数据字段的最大长度,而并不是整个TCP
报文段的长度
这里补充一些 ISN
相关内容,即 Initial Sequence Number
(初始序列号)
在三次握手的过程当中,双方会用过 SYN
报文来交换彼此的 ISN
,ISN
并不是一个固定的值,而是每 4ms
加一,溢出则回到 0
,这个算法使得猜测 ISN
变得很困难,那么为什么要这么做呢?
如果 ISN
被攻击者预测到,要知道源 IP
和源端口号都是很容易伪造的,当攻击者猜测 ISN
之后,直接伪造一个 RST
后,就可以强制连接关闭的,这是非常危险的,而动态增长的 ISN
大大提高了猜测 ISN
的难度
以太网 MAC 帧格式
以太网 MAC
帧较为简单,由五个字段组成,前两个字段分别为 6
字节长的目的地址和源地址,第三个字段是 2
字节的类型字段,用来标志上一层使用的是什么协议,以便把收到的 MAC
帧的数据上交给上一层的这个协议,第四个字段是数据字段,其长度为 46 ~ 1500
字节(46
字节是因为最小长度 64
字节减去 18
字节的首部和尾部),最后一个字段是 4
字节的帧检测序列 FCS
(使用 CRC
检测)
TFO 原理
TFO
即 TCP
快速打开(TCP Fast Open
),是对计算机网络中传输控制协议(TCP
)连接的一种简化握手手续的拓展,用于提高两端点间连接的打开速度,在上面 SYN Flood
部分当中我们曾提到 SYN Cookie
,这个 Cookie
可不是浏览器的 Cookie
,用它同样可以实现 TFO
,下面我们就来看看 TFO
的流程
首轮三次握手
首先客户端发送 SYN
给服务端,服务端接收到,但是需要注意,现在服务端不是立刻回复 SYN + ACK
,而是通过计算得到一个 SYN Cookie
,将这个 Cookie
放到 TCP
报文的 Fast Open
选项中,然后才给客户端返回,客户端拿到这个 Cookie
的值缓存下来,后面正常完成三次握手
首轮三次握手就是这样的流程,但是后面的三次握手则不一样
后面的三次握手
在后面的三次握手中,客户端会将之前缓存的 Cookie
、SYN
和 HTTP
请求发送给服务端,服务端验证了 Cookie
的合法性,如果不合法直接丢弃,如果是合法的,那么就正常返回 SYN + ACK
重点来了,现在服务端能向客户端发 HTTP
响应了,这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie
的合法性,就可以返回 HTTP
响应了,当然客户端的 ACK
还得正常传过来,流程如下
注意,客户端最后握手的 ACK
不一定要等到服务端的 HTTP
响应到达才发送,两个过程没有任何关系
TFO 的优势
TFO
的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie
并验证通过以后,可以直接返回 HTTP
响应,充分利用了 1
个 RTT
的时间提前进行数据传输,积累起来还是一个比较大的优势
TCP 的超时重传时间是如何计算的
TCP
具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包,这个重传间隔也叫做超时重传时间(Retransmission TimeOut
,简称 RTO
),那么这个重传间隔是如何来计算的呢?主要有两种方式,一个是经典方法,一个是标准方法
经典方法
经典方法引入了一个新的概念 SRTT
(Smoothed round trip time
,即平滑往返时间),没产生一次新的 RTT
,就根据一定的算法对 SRTT
进行更新,具体而言计算方式如下(SRTT
初始值为 0
)
1 | SRTT = (α * SRTT) + ((1 - α) * RTT) |
其中 α
是平滑因子,建议值是 0.8
,范围是 0.8 ~ 0.9
,拿到 SRTT
我们就可以计算 RTO
的值了
1 | RTO = min(ubound, max(lbound, β * SRTT)) |
β
是加权因子,一般为 1.3 ~ 2.0
,lbound
是下界,ubound
是上界,其实这个算法过程还是很简单的,但是也存在一定的局限,就是在 RTT
稳定的地方表现还可以,而在 RTT
变化较大的地方就不行了,因为平滑因子 α
的范围是 0.8 ~ 0.9
,RTT
对于 RTO
的影响太小
标准方法
为了解决经典方法对于 RTT
变化不敏感的问题,后面又引出了标准方法,也叫 Jacobson/Karels
算法,一共有三步
- 第一步,计算
SRTT
,公式如下
1 | SRTT = (1 - α) * SRTT + α * RTT |
注意这个时候的 α
跟经典方法中的 α
取值不一样了,建议值是 1/8
,也就是 0.125
- 第二步,计算
RTTVAR
(round-trip time variation
)这个中间变量
1 | RTTVAR = (1 - β) * RTTVAR + β * (|RTT - SRTT|) |
β
建议值为 0.25
,这个值是这个算法中出彩的地方,也就是说,它记录了最新的 RTT
与当前 SRTT
之间的差值,给我们在后续感知到 RTT
的变化提供了抓手
- 第三步,计算最终的
RTO
1 | RTO = µ * SRTT + ∂ * RTTVAR |
µ
建议值取 1
,∂
建议值取 4
,这个公式在 SRTT
的基础上加上了最新 RTT
与它的偏移,从而很好的感知了 RTT
的变化,这种算法下 RTO
与 RTT
变化的差值关系更加密切
TCP 的流量控制是如何实现的
我们之前在 HTTP 协议 和 TCP/IP 协议 的章节当中简单介绍过 TCP
的流量控制,在这里就汇总整理一下,但是需要注意的是,HTTP/3
当中 QUIC
实现的流量控制与 TCP
当中有所区别,这里我们主要介绍的是 TCP
当中的流量控制,所以关于 HTTP/3
当中 QUIC
的实现方法就不详细展开了,详细可见 HTTP/3 流量控制
对于发送端和接收端而言,TCP
需要把发送的数据放到发送缓存区,将接收的数据放到接收缓存区,而流量控制索要做的事情,就是在通过接收缓存区的大小,控制发送端的发送,如果对方的接收缓存区满了,就不能再继续发送了,所以要具体理解流量控制,我们要从滑动窗口的概念开始了解
TCP 滑动窗口
TCP
滑动窗口分为两种,发送窗口和接收窗口,发送端的滑动窗口结构如下
其中包含四大部分
- 已发送且已确认
- 已发送但未确认
- 未发送但可以发送
- 未发送也不可以发送
发送窗口就是图中被框住的范围,SND
即 send
,WND
即 window
,UNA
即 unacknowledged
,表示未被确认,NXT
即 next
,表示下一个发送的位置,接收端的窗口结构如下
REV
即 receive
,NXT
表示下一个接收的位置,WND
表示接收窗口大小
流量控制过程
这里我们用一个简单的例子来模拟一下流量控制的过程
- 首先双方三次握手,初始化各自的窗口大小,均为
200
个字节 - 假如当前发送端给接收端发送
100
个字节,那么此时对于发送端而言,SND.NXT
当然要右移100
个字节,也就是说当前的可用窗口减少了100
个字节 - 现在这
100
个到达了接收端,被放到接收端的缓冲队列中,不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理40
个字节,剩下的60
个字节被留在了缓冲队列中 - 此时接收端的情况是处理能力不够用,所以此时接收端的接收窗口应该缩小,具体来说缩小
60
个字节,由200
个字节变成了140
字节,因为缓冲队列还有60
个字节没被应用拿走
因此,接收端会在 ACK
的报文首部带上缩小后的滑动窗口 140
字节,发送端对应地调整发送窗口的大小为 140
个字节,此时对于发送端而言,已经发送且确认的部分增加 40
字节,也就是 SND.UNA
右移 40
个字节,同时发送窗口缩小为 140
个字节
这也就是流量控制的过程,尽管回合再多,整个控制的过程和原理是一样的
TCP 的拥塞控制
和上面流量控制部分类似,这里我们主要介绍的是 TCP
当中的拥塞控制,关于 HTTP/3
当中拥塞控制的热插拔和前向纠错等相关内容可见 HTTP/3 拥塞控制
上一节所说的流量控制发生在发送端跟接收端之间,并没有考虑到整个网络环境的影响,如果说当前网络特别差,特别容易丢包,那么发送端就应该注意一些了,而这也正是拥塞控制需要处理的问题,对于拥塞控制来说,TCP
每条连接都需要维护两个核心状态
- 拥塞窗口(
Congestion Window
,cwnd
) - 慢启动阈值(
Slow Start Threshold
,ssthresh
)
涉及到的算法有这几个
- 慢启动
- 拥塞避免
- 快速重传
- 快速恢复
接下来,我们就来一一来梳理这些状态和算法,首先从拥塞窗口说起
拥塞窗口
拥塞窗口是指目前自己还能传输的数据量大小,那么之前介绍了接收窗口的概念,两者有什么区别呢?
- 接收窗口(
rwnd
)是接收端给的限制 - 拥塞窗口(
cwnd
)是发送端的限制
限制谁呢?其实是限制的是发送窗口的大小,在有了这两个窗口以后,我们来看看如何来计算发送窗口?
1 | 发送窗口大小 = min(rwnd, cwnd) |
简单来说就是取两者的较小值,而拥塞控制,就是来控制 cwnd
变化的
慢启动
刚开始进入传输数据的时候,我们是不知道现在的网络情况到底是稳定还是拥堵的,如果发包太急,那么可能会疯狂丢包,造成雪崩式的网络灾难,因此拥塞控制首先就是要采用一种保守的算法来慢慢地适应整个网络,这种算法叫慢启动,运作过程如下
- 首先,三次握手,双方宣告自己的接收窗口大小
- 双方初始化自己的拥塞窗口(
cwnd
)大小 - 在开始传输的一段时间,发送端每收到一个
ACK
,拥塞窗口大小加1
,也就是说每经过一个RTT
,cwnd
翻倍,如果说初始窗口为10
,那么第一轮10
个报文传完且发送端收到ACK
后,cwnd
变为20
,第二轮变为40
,第三轮变为80
,依次类推
当然不会无止境地翻倍下去,它的阈值叫做慢启动阈值,超出阈值则会导致网络拥塞,那么当 cwnd
到达这个阈值之后,如何来控制 cwnd
的大小呢?而这就是拥塞避免做的事情了
拥塞避免
其实简单来说,原理就是在到达某个限制(慢启动阈值)之后,指数增长变为线性增长,比如原来每收到一个 ACK
,cwnd
加 1
,现在到达阈值后,cwnd
只增加 1 / cwnd
,所以一轮 RTT
下来,收到 cwnd
个 ACK
,那最后拥塞窗口的大小 cwnd
总共才增加 1
也就是说,以前一个 RTT
下来,cwnd
翻倍,现在 cwnd
只是增加 1
而已,当然慢启动和拥塞避免是一起作用的,是一体的
快速重传
在 TCP
传输的过程中,如果发生了丢包,即接收端发现数据段不是按序到达的时候,接收端的处理是重复发送之前的 ACK
,比如第 5
个包丢了,即使第 6
、7
个包到达的接收端,接收端也一律返回第 4
个包的 ACK
,当发送端收到 3
个重复的 ACK
时,意识到丢包了,于是马上进行重传,不用等到一个 RTO
(Retransmission Timeout
,重传超时) 的时间到了才重传,这就是快速重传,它解决的是是否需要重传的问题
选择性重传
既然要重传,那么是只重传第 5
个包还是第 5
、6
、7
个包都重传呢?当然如果第 6
、7
个都已经到达了的话,则会记录一下哪些包到了,哪些没到,针对性地重传
在收到发送端的报文后,接收端回复一个 ACK
报文,那么在这个报文首部的可选项中,就可以加上 SACK
这个属性,通过 left edge
和 right edge
告知发送端已经收到了哪些区间的数据报,因此,即使第 5
个包丢包了,当收到第 6
、7
个包之后,接收端依然会告诉发送端,这两个包到了,剩下第 5
个包没到,就重传这个包,这个过程也叫做选择性重传(Selective Acknowledgment
,SACK
),它解决的是如何重传的问题
快速恢复
当然,发送端收到三次重复 ACK
之后,发现丢包,觉得现在的网络已经有些拥塞了,自己会进入快速恢复阶段,在这个阶段,发送端如下改变
- 拥塞阈值降低为
cwnd
的一半 cwnd
的大小变为拥塞阈值cwnd
线性增加
总结
TCP
拥塞控制由 4
个核心算法组成,即『慢启动』、『拥塞避免』、『快速重传』和『快速恢复』
- 慢启动,发送方向接收方发送
1
个单位的数据,收到对方确认后会发送2
个单位的数据,然后依次是4
个、8
个呈指数级增长,这个过程就是在不断试探网络的拥塞程度,超出阈值则会导致网络拥塞 - 拥塞避免,指数增长不可能是无限的,到达某个限制(慢启动阈值)之后,指数增长变为线性增长
- 快速重传,发送方每一次发送时都会设置一个超时计时器,超时后即认为丢失,需要重发
- 快速恢复,在上面快速重传的基础上,发送方重新发送数据时,也会启动一个超时定时器,如果收到确认消息则进入拥塞避免阶段,如果仍然超时,则回到慢启动阶段
Nagle 算法和延迟确认
这里首先需要明确的是,前者意味着延迟发,后者意味着延迟接收,如果两者一起使用会造成更大的延迟,产生性能问题,所以需要小心
Nagle 算法
试想一个场景,发送端不停地给接收端发很小的包,一次只发 1
个字节,那么发 1
千个字节需要发 1000
次,这种频繁的发送是存在问题的,不光是传输的时延消耗,发送和确认本身也是需要耗时的,频繁的发送接收带来了巨大的时延,而避免小包的频繁发送,这就是 Nagle
算法要做的事情,Nagle
算法的规则如下
- 当第一次发送数据时不用等待,就算是
1byte
的小包也立即发送 - 后面发送满足下面条件之一就可以发了
- 数据包大小达到最大段大小(
Max Segment Size
,即MSS
) - 之前所有包的
ACK
都已接收到
- 数据包大小达到最大段大小(
延迟确认
试想这样一个场景,当我们收到了发送端的一个包,然后在极短的时间内又接收到了第二个包,那我是一个个地回复,还是稍微等一下,把两个包的 ACK
合并后一起回复呢?
延迟确认(delayed ack
)所做的事情,就是后者,稍稍延迟,然后合并 ACK
,最后才回复给发送端,TCP
要求这个延迟的时延必须小于 500ms
,一般操作系统实现都不会超过 200ms
,不过需要主要的是,有一些场景是不能延迟确认的,收到了就要马上回复
- 接收到了大于一个
frame
的报文,且需要调整窗口大小 TCP
处于quickack
模式(通过tcp_in_quickack_mode
设置)- 发现了乱序包
TCP 的 keep-alive
大家都听说过 HTTP
的 keep-alive
,不过 TCP
层面也是有 keep-alive
机制,而且跟应用层不太一样,试想一个场景,当有一方因为网络故障或者宕机导致连接失效,由于 TCP
并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的,这个时候就出现了 keep-alive
,它的作用就是探测对端的连接有没有失效,在 Linux
下,可以这样查看相关的配置
1 | sudo sysctl -a | grep keepalive |
不过,现状是大部分的应用并没有默认开启 TCP
的 keep-alive
选项,这是因为如果我们站在应用的角度来看的话,会发现这是一个比较尴尬的设计,原因主要有下面两点
7200s
也就是两个小时检测一次,时间太长- 时间再短一些,也难以体现其设计的初衷,即检测长时间的死连接