HTTP 查漏补缺

HTTP 查漏补缺

本章记录的是一些 HTTP 相关内容的补充,偏向于查漏补缺,主要是一些平常遇到或是看到过的知识点,面试题之类的,在这里简单的整理汇总一下,一方面补充些专业知识,另一方面也是方便以后查询或是复习,更多相关内容可以参考 前端知识体系整理

HTTP 协议优缺点

  • HTTP 特点
    • 灵活可扩展,一个是语法上只规定了基本格式,空格分隔单词,换行分隔字段等,另外一个就是传输形式上不仅可以传输文本,还可以传输图片,视频等任意数据
    • 请求/响应 模式,通常而言,就是一方发送消息,另外一方要接受消息,或者是做出相应等
    • 可靠传输,HTTP 是基于 TCP/IPTCP 提供可靠的字节流服务,IP 协议的作用是把各种数据传送给对方
  • HTTP 缺点
    • 无连接,限制每次连接只处理一个请求,服务器处理完客户的请求,并收到客户的应答后,即断开连接
    • 无状态,对于事务处理没有记忆能力,即服务器不知道客户端是什么状态
    • 明文传输,协议里的报文不使用二进制数据,而是文本形式,这让 HTTP 的报文信息暴露给了外界,给攻击者带来了便利
    • 队头阻塞,当 HTTP 开启长连接时,共用一个 TCP 连接,当某个请求时间过长时,其他的请求只能处于阻塞状态,这就是队头阻塞问题

HTTP 协议各版本差异

  • HTTP/0.9
    • 1991 年发布的原型版本,功能简陋,只有一个命令 GET,只支持纯文本内容
  • HTTP/1.0
    • 任何格式的内容都可以发送,另外还引入了 POST 命令和 HEAD 命令
    • HTTP 请求和回应的格式改变,除了数据部分,每次通信都必须包括头信息,用来描述一些元数据
    • 只使用请求头中的 If-Modified-SinceExpires 字段作为缓存失效的标准
    • 不支持断点续传,也就是说,每次都会传送全部的页面和数据
    • 通常每台计算机只能绑定一个 IP,所以请求消息中的 URL 并没有传递主机名
  • HTTP/1.1
    • 引入了持久连接
      • TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive,长连接的连接时长可以通过请求头中的 keep-alive 来设置
    • 引入了管线机制(Pipelining
      • 在同一个 TCP 连接里,客户端可以同时发送多个请求
    • 新增缓存控制标头
      • 添加了 E-tagIf-Unmodified-SinceIf-MatchIf-None-Match 等缓存控制标头来控制缓存失效
    • 支持断点续传
      • 通过使用请求头中的 Range 来实现
    • 使用了虚拟网络
      • 在一台物理服务器上可以存在多个虚拟主机,并且它们共享一个 IP 地址
    • 新增方法 PUTPATCHOPTIONSDELETE
  • HTTP/2
    • 二进制分帧
      • 采用二进制格式,这样报文格式就被拆分为一个个乱序的二进制帧,用 Headers 帧存放头部字段,Data 帧存放请求体数据等,因为不需要排队等待,一定程度上解决了队头阻塞问题
    • 头部压缩
      • HTTP/1.1 版本会出现 User-Agent/Cookie/Accept/Server/Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,导致头部偏重,HTTP/2 使用 HPACK 算法进行压缩
    • 多路复用
      • 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,一定程度上解决了队头阻塞的问题
    • 服务器推送
      • 允许服务器未经请求,主动向客户端发送资源,即服务器推送
    • 请求优先级
      • 可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验
  • HTTP/3
    • QUIC 协议
      • 运行在 QUIC 之上的 HTTP 协议被称为 HTTP/3QUIC 协议基于 UDP 实现,同时也整合了 TCPTLSHTTP/2 的优点,并加以优化
    • RTT 建立连接
      • 首次连接只需要 1 RTT,后面的连接更是只需 0 RTT,意味着客户端发给服务端的第一个包就带有请求数据
    • 连接迁移
      • QUIC 连接不以四元组(源 IP、源端口、目的 IP、目的端口)作为标识,而是使用一个 64 位的随机数,这个随机数被称为 Connection ID,即使 IP 或者端口发生变化,只要 Connection ID 没有变化,那么连接依然可以维持
    • 多路复用
      • QUIC 的传输单元是 Packet,加密单元也是 Packet,整个加密、传输、解密都基于 Packet,这样就能避免 TLS 的队头阻塞问题
      • QUIC 基于 UDPUDP 的数据包在接收端没有处理顺序,即使中间丢失一个包,也不会阻塞整条连接,其他的资源会被正常处理
    • 改进的拥塞控制
      • 热插拔,TCP 中如果要修改拥塞控制策略,需要在系统层面进行操作,QUIC 修改拥塞控制策略只需要在应用层操作,并且 QUIC 会根据不同的网络环境、用户来动态选择拥塞控制算法
      • 前向纠错 FEC,使用前向纠错(FECForward Error Correction)技术增加协议的容错性,一段数据被切分为 10 个包后,依次对每个包进行异或运算,运算结果会作为 FEC 包与数据包一起被传输,如果不幸在传输过程中有一个数据包丢失,那么就可以根据剩余 9 个包以及 FEC 包推算出丢失的那个包的数据
      • 单调递增的 Packet Number,与 TCPSequence Number 不同的是,Packet Number 严格单调递增,如果 Packet N 丢失了,那么重传时 Packet 的标识不会是 N,而是比 N 大的数字,比如 N + M,这样发送方接收到确认消息时就能方便地知道 ACK 对应的是原始请求还是重传请求
      • 更多的 ACK 块,QUIC 最多可以捎带 256ACK block,在丢包率比较严重的网络下,更多的 ACK block 可以减少重传量,提升网络效率
    • 流量控制
      • TCP 会对每个 TCP 连接进行流量控制,而 QUIC 只需要建立一条连接,在这条连接上同时传输多条 Stream

队头阻塞问题

关于队头阻塞问题,其实在整理 HTTP/1.1HTTP/2HTTP/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 的服务端都要求支持管线化
  • 只有幂等的请求(GETHEAD)能使用管线化,非幂等请求比如 POST 不能使用,因为请求之间可能会存在先后依赖关系
  • 队头阻塞并没有完全得到解决,服务端的响应还是要求依次返回,遵循 FIFOfirst 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/2HTTP 协议通信的基本单位缩小为一个一个的帧,这些帧对应着逻辑流中的消息,并行地在同一个 TCP 连接上双向交换消息

HTTP/2 虽然可以解决请求这个粒度的阻塞,但 HTTP/2 的基础 TCP 协议本身却也存在着队头阻塞的问题,HTTP/2 的每个请求都会被拆分成多个 Frame,不同请求的 Frame 组合成 StreamStreamTCP 上的逻辑传输单元,这样 HTTP/2 就达到了一条连接同时发送多条请求的目标,这就是多路复用的原理

我们看一个例子,在一条 TCP 连接上同时发送 4Stream,其中 Stream1 已正确送达,Stream2 中的第 3Frame 丢失,TCP 处理数据时有严格的前后顺序,先发送的 Frame 要先被处理,这样就会要求发送方重新发送第 3FrameStream3Stream4 虽然已到达但却不能被处理,那么这时整条连接都被阻塞

不仅如此,由于 HTTP/2 必须使用 HTTPS,而 HTTPS 使用的 TLS 协议也存在队头阻塞问题,TLS 基于 Record 组织数据,将一堆数据放在一起(即一个 Record)加密,加密完后又拆分成多个 TCP 包传输,一般每个 Record16K 左右,包含 12TCP 包,这样如果 12TCP 包中有任何一个包丢失,那么整个 Record 都无法解密

队头阻塞会导致 HTTP/2 在更容易丢包的弱网络环境下比 HTTP/1.1 更慢,所以就有了 HTTP/3 当中的 QUIC 协议,那么 QUIC 是如何解决队头阻塞问题的呢?主要有两点

  • QUIC 的传输单元是 Packet,加密单元也是 Packet,整个加密、传输、解密都基于 Packet,这样就能避免 TLS 的队头阻塞问题
  • QUIC 基于 UDPUDP 的数据包在接收端没有处理顺序,即使中间丢失一个包,也不会阻塞整条连接,其他的资源会被正常处理

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
2
3
4
5
6
7
8
9
10
11
12
13
14
const server = require('http').createServer()

server.on('request', (req, res) => {
if (req.url === '/index') {
// 设置数据类型    
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Length', 10)
res.write(`使用 Content-Length 设置传输数据形式`)
}
})

server.listen(3000, _ => {
console.log(`app is running at port 3000`)
})

不定长数据

现在采用最多的就是 HTTP/1.1 版本来完成传输数据,在保存 keep-alive 状态下,当数据是不定长的时候,我们需要设置新的头部字段 Transfer-Encoding: chunked,通过 chunked 机制,可以完成对不定长数据的处理,但是也有需要注意的地方

  • 如果头部信息中有 Transfer-Encoding,优先采用 Transfer-Encoding 里面的方法来找到对应的长度
  • 如果设置了 Transfer-Encoding,那么 Content-Length 将被忽视
  • 使用长连接的话,会持续的推送动态内容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const server = require('http').createServer()

server.on('request', (req, res) => {
if (req.url === '/index') {
// 设置数据类型    
res.setHeader('Content-Type', 'text/html; charset=utf8')
res.setHeader('Content-Length', 10)
res.setHeader('Transfer-Encoding', 'chunked')
res.write(`使用 Content-Length 设置传输数据形式`)
setTimeout(() => {
res.write(`第一次的数据`)
}, 1000)
res.write(`----`)
setTimeout(() => {
res.write(`第二次的数据`)
res.end()
}, 3000)
}
})

server.listen(3000, _ => {
console.log(`app is running at port 3000`)
})

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
    • WebSocketHTML5 定义的一个新协议,与传统的 HTTP 协议不同,该协议允许由服务器主动的向客户端推送信息
    • 优缺点
      • 优点是 WebSocket 是一个全双工的协议,也就是通信双方是平等的,可以相互发送消息
      • 缺点在于如果需要使用 WebSocket 协议,服务器端的配置比较复杂

至于什么是单工、半双工、全双工,区别如下表所示

类型 能力
单工 信息单向传送
半双工 信息能双向传送,但不能同时双向传送
全双工 信息能够同时双向传送

正向代理和反向代理

  • 正向代理,我们常说的代理也就是指正向代理,正向代理的过程,它隐藏了真实的请求客户端,服务端不知道真实的客户端是谁,客户端请求的服务都被代理服务器代替来请求
  • 反向代理,这种代理模式下,它隐藏了真实的服务端,当我们向一个网站发起请求的时候,背后可能有成千上万台服务器为我们服务,具体是哪一台,我们不清楚,我们只需要知道反向代理服务器是谁就行,而且反向代理服务器会帮我们把请求转发到真实的服务器那里去,一般而言反向代理服务器一般用来实现负载平衡

负载平衡的两种实现方式

  • 一种是使用反向代理的方式,用户的请求都发送到反向代理服务上,然后由反向代理服务器来转发请求到真实的服务器上,以此来实现集群的负载平衡
  • 另一种是 DNS 的方式,DNS 可以用于在冗余的服务器上实现负载平衡,因为现在一般的大型网站使用多台服务器提供服务,因此一个域名可能会对应多个服务器地址,当用户向网站域名请求的时候,DNS 服务器返回这个域名所对应的服务器 IP 地址的集合,但在每个回答中,会循环这些 IP 地址的顺序,用户一般会选择排在前面的地址发送请求,以此将用户的请求均衡的分配到各个不同的服务器上,这样来实现负载均衡

但是 DNS 的方式有一个缺点就是,由于 DNS 服务器中存在缓存,所以有可能一个服务器出现故障后,域名解析仍然返回的是那个 IP 地址,就会造成访问的问题

HTTP 缓存策略

关于缓存策略,我们之前在 浏览器缓存机制 一节当中已经详细整理过,这里我们在简单梳理一下,总的分为两种策略,即强缓存和协商缓存,下面我们一个一个来看

强缓存

简单总结一下

  • 强缓存有两个相关字段,ExpiresCache-Control
  • 分为两种情况,一种是发送 HTTP 请求,一种不需要发送
  • 首先检查强缓存,这个阶段不需要发送 HTTP 请求,通过查找不同的字段来进行,不同的 HTTP 版本所以不同
  • HTTP/1.0 版本,使用的是 ExpiresHTTP/1.1 使用的是 Cache-Control
Expires

Expires 即过期时间,时间是相对于服务器的时间而言的,存在于服务端返回的响应头中,在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求,比如下面这样

1
Expires: Mon, 29 Jun 2019 11:10:23 GMT

表示该资源在 201972911:10:23 过期,过期时就会重新向服务器发起请求,但是这种方式是存在一些问题的,比如服务器的时间和浏览器的时间可能并不一致,所以 HTTP/1.1 提出新的字段代替它

Cache-Control

HTTP/1.1 版本中,使用的就是该字段,这个字段采用的时间是过期时长,对应的是 max-age

1
Cache-Control: max-age = 6000

上面代表该资源返回后 6000 秒,可以直接使用缓存,还有其他一些相关指令,使用方式可以参考上文链接,这里需要注意的几点

  • ExpiresCache-Control 同时存在时,优先考虑 Cache-Control
  • 当缓存资源失效了,也就是没有命中强缓存,接下来就进入协商缓存

协商缓存

强缓存失效后,浏览器在请求头中携带响应的缓存 Tag 来向服务器发送请求,服务器根据对应的 Tag,来决定是否使用缓存

缓存分为两种,Last-ModifiedETag,两者各有优势,并不存在谁对谁有绝对的优势,这点与上面所讲的强缓存当中的两个 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 优于 ETagLast-Modified 记录的是时间点,而 Etag 需要根据文件的 MD5 算法生成对应的 Hash
  • 精度上,ETag 优于 Last-ModifiedETag 按照内容给资源带上标识,能准确感知资源变化,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 对比的话,主要的策略如下

  • 内容使用率高的话,文件优先进入磁盘
  • 比较大的 JavaScriptCSS 文件会直接放入磁盘,反之放入内存
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

下面我们再来看看 TCPUDP 两者的区别,如下表

区别 UDP TCP
是否连接 无连接 面向连接
是否可靠 不可靠传输,不使用流量控制和拥塞控制 可靠传输,使用流量控制和拥塞控制
连接对象个数 支持一对一,一对多,多对一和多对多交互通信 只能是一对一通信
传输方式 面向报文 面向字节流
首部开销 首部开销小,仅 8 字节 首部最小 20 字节,最大 60 字节
适用场景 适用于实时应用(IP 电话、视频会议、直播等) 适用于要求可靠传输的应用,例如文件传输

简单总结就是

  • TCP 向上层提供面向连接的可靠服务,UDP 向上层提供无连接不可靠服务
  • 虽然 UDP 并没有 TCP 传输来的准确,但是也能在很多实时性要求高的地方有所作为
  • 对数据准确性要求高,速度可以相对较慢的,可以选用 TCP

我们在上面也介绍了负责域名解析的 DNS 服务,那么 DNS 为什么使用 UDP 协议作为传输层协议而不是 TCP 呢?其实简单来说,DNS 使用 UDP 协议作为传输层协议的主要原因是为了避免使用 TCP 协议时造成的连接时延

  • 为了得到一个域名的 IP 地址,往往会向多个域名服务器查询,如果使用 TCP 协议,那么每次请求都会存在连接时延,这样使 DNS 服务变得很慢
  • 大多数的地址查询请求,都是浏览器请求页面时发出的,这样会造成网页的等待时间过长

下表是使用 UDPTCP 协议的各种应用和应用层协议

应用 应用层协议 运输层协议
名字转换 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 = 1seq = x + 1(上一个报文段的 ack),ack = y + 1(上一个报文段的 seq + 1,因为消耗了一个序号),这个 ACK 报文段可以携带数据,但是如果不携带数据则不会消耗序号,下一次 AB 发送报文段的初始序号 seq = 1,此时 A 进入已建立连接的状态,B 收到确认后也进入已建立连接的状态

从上图可以看出,SYN 是需要消耗一个序列号的,下次发送对应的 ACK 序列号要加 1,那么为什么会这样呢?这里只需要记住一个规则

凡是需要对端确认的,一定消耗 TCP 报文的序列号(SYN 需要对端的确认,而 ACK 并不需要,因此 SYN 消耗一个序列号而 ACK 不需要)

为什么不是两次握手

其实也就是为什么需要最后一次确认?

其实简单来说,这是防止已失效的连接请求报文段突然又传送到了 B 而引发错误,但是有可能出现异常情况,即 A 发送的连接请求并没有丢失,而是滞留了在网络中,如果在传输数据完成之后,这个请求又发到 BB 误以为 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 的连接释放的过程

  • AB 目前都处于已建立连接的状态,A 的应用进程向其 TCP 发出连接释放报文段,并停止发送数据,主动关闭 TCP 连接,此时 FIN = 1seq = uu 等于前面已发送的最后一个字节的序号加 1,这时 A 进入到 FIN-WAIT-1(终止等待 1)状态,等待 B 的确认,FIN 报文段即使不携带数据,也要消耗一个序号
  • B 收到释放连接后立即发出确认,此时,ACK = 1,确认号是 ack = u + 1(前面的 seq + 1,因为消耗了一个序号),序号 seq = vv 等于 B 前面所有已传送数据的最后一个字节的序号加 1B 进入到 CLOSE-WAIT(关闭等待)状态,TCP 服务器进程向 B 的高层应用进程告知,此时 AB 的连接已经释放,TCP 连接处于半关闭状态,但是,BA 这个方向的连接尚未关闭
  • A 收到 B 的确认后,就进入到 FIN-WAIT-2(终止等待 2)的状态,等待 B 发送连接释放报文段
  • B 已经没有数据需要发送,则应用进程通知 TCP 释放连接,这时 B 发送的报文段 FIN = 1ACK = 1seq = w(可能后面又发送了一些数据),ack = u + 1,并且这个报文消耗一个序号,B 进入到 LAST-ACK(最后确认)的状态,等待 A 的确认
  • A 收到 B 的确认后,必须对此发送确认报文,该报文中 ACK = 1seq = u + 1ack = w + 1,然后进入到 TIME-WAIT(时间等待)状态

但是需要注意的是,此时 TCP 连接并没有完全释放,必须经过时间等待计时器设置的时间 2MSL 之后,A 才进入 CLOSED 状态,时间 MSL 叫做最大报文段寿命

那么问题来了,为什么要等待 2MSL 的时间呢?主要原因有两点

  • 保证 A 最后发送的 ACK 报文段能够到达 B,因为这个报文可能丢失,因此 B 会重传最后一个确认报文段,A 再重新发送确认报文,并且重启计时器,直到 AB 都能正常进入到 CLOSED 状态
  • 防止上面提到的已失效的连接请求报文,这段时间内,这些连接请求报文就可能在网络中消失

简单来说就是

  • 1MSL 确保四次挥手中主动关闭方最后的 ACK 报文最终能达到对端
  • 1MSL 确保对端没有收到 ACK 重传的 FIN 报文可以到达

这就是等待 2MSL 的意义,此外 B 要比 A 先进入 CLOSED 状态

为什么是四次挥手而不是三次

因为服务端在接收到 FIN,往往不会立即返回 FIN,必须等到服务端所有的报文都发送完毕了,才能发 FIN,因此先发一个 ACK 表示已经收到客户端的 FIN,延迟一段时间才发 FIN,这就造成了四次挥手,那么如果是三次挥手会有什么问题?

如果是三次挥手,这就等于说服务端将 ACKFIN 的发送合并为一次挥手,这个时候长时间的延迟可能会导致客户端误以为 FIN 没有到达客户端,从而让客户端不断的重发 FIN

同时关闭会怎样

如果客户端和服务端同时发送 FIN,状态如下图所示

半连接队列与 SYN Flood 攻击

三次握手前,服务端的状态从 CLOSED 变为 LISTEN,同时在内部创建了两个队列『半连接队列』和『全连接队列』,即 SYN 队列和 ACCEPTAccept Queue) 队列

  • 半连接队列,当客户端发送 SYN 到服务端,服务端收到以后回复 ACKSYN,状态由 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 位,可表示的最大十进制数是151111),它的单位是 4 字节(也就是 32 位),因此首部长度最小值为 5(固定长度部分),可选长度最长为 40 字节
  • 区分服务,占 8 位,用来获得更好的服务
  • 总长度,占 16 位,首部和数据部分的总长度,单位为字节,因此 IP 数据报的最大长度为 2^16 - 1
  • 标识,占 16 位,当数据报的长度超过网络的最大传送单元使,就给该数据报的所有分片赋值相同的标识,相同的标识字段的值使分片后的各数据报片能正确的重装成原来的数据报
  • 标志,占 3位,但是只有两位具有意义
    • 标记字段中的最低位记为 MFMF = 1 表示后面还有分片,MF = 0 表示这是最后一个分片
    • 标志字段中间的一位记为 DF,意思是能否分片,只有 DF = 0 时才能分片
  • 片偏移,占 13 位,较长的分组在分片后,某片在原分组中的相对位置,也就是说,数据片相对于初始位置的距离,单位是 8 字节,因此除去最后一个数据片,每个数据片的长度都是 8 字节的倍数
  • 生存时间,占 8位TTLTime 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 等于 00 + 1 等于 11 + 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 层就已经处理了 IPTCP 只需要记录两者的端口即可
  • 序号,占 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 = 1ACK = 0 时代表是连接请求报文段,若对方同意建立连接,则应在相应报文段中使 SYN = 1ACK = 1 ,也就是说,SYN = 1 代表连接请求或者连接接受报文
  • 终止 FIN,用于释放一个连接,当 FIN = 1 时,代表此报文段的发送方的数据已发送完毕,并且请求释放运输连接

控制位至此结束

  • 窗口,占 2 字节,窗口值告诉对方,从本报文段中的确认号算起,接收方目前允许对方发送的数据量(以字节为单位),之所以设置这个限制,是因为接收方的数据缓存空间是有限的,总之窗口值作为接收方让发送方设置其窗口大小的依据
  • 检验和,占 2 字节,检验的范围包括首部字段和数据字段,和 UDP 检验的方法一样,只不过把伪首部第四个字段的 17 改成 6
  • 紧急指针,占 2 字节,只有在紧急 URG = 1 时才有效,它指出本报文段中的紧急数据的字节数
  • 选项,长度可变,最大 40 字节,注意,TCP 最初只规定了一种选项,即最大报文长度 MSSMSS 是每一个 TCP 报文段中的数据字段的最大长度,而并不是整个 TCP 报文段的长度

这里补充一些 ISN 相关内容,即 Initial Sequence Number(初始序列号)

在三次握手的过程当中,双方会用过 SYN 报文来交换彼此的 ISNISN 并不是一个固定的值,而是每 4ms 加一,溢出则回到 0,这个算法使得猜测 ISN 变得很困难,那么为什么要这么做呢?

如果 ISN 被攻击者预测到,要知道源 IP 和源端口号都是很容易伪造的,当攻击者猜测 ISN 之后,直接伪造一个 RST 后,就可以强制连接关闭的,这是非常危险的,而动态增长的 ISN 大大提高了猜测 ISN 的难度

以太网 MAC 帧格式

以太网 MAC 帧较为简单,由五个字段组成,前两个字段分别为 6 字节长的目的地址和源地址,第三个字段是 2 字节的类型字段,用来标志上一层使用的是什么协议,以便把收到的 MAC 帧的数据上交给上一层的这个协议,第四个字段是数据字段,其长度为 46 ~ 1500 字节(46 字节是因为最小长度 64 字节减去 18 字节的首部和尾部),最后一个字段是 4 字节的帧检测序列 FCS(使用 CRC 检测)

TFO 原理

TFOTCP 快速打开(TCP Fast Open),是对计算机网络中传输控制协议(TCP)连接的一种简化握手手续的拓展,用于提高两端点间连接的打开速度,在上面 SYN Flood 部分当中我们曾提到 SYN Cookie,这个 Cookie 可不是浏览器的 Cookie,用它同样可以实现 TFO,下面我们就来看看 TFO 的流程

首轮三次握手

首先客户端发送 SYN 给服务端,服务端接收到,但是需要注意,现在服务端不是立刻回复 SYN + ACK,而是通过计算得到一个 SYN Cookie,将这个 Cookie 放到 TCP 报文的 Fast Open 选项中,然后才给客户端返回,客户端拿到这个 Cookie 的值缓存下来,后面正常完成三次握手

首轮三次握手就是这样的流程,但是后面的三次握手则不一样

后面的三次握手

在后面的三次握手中,客户端会将之前缓存的 CookieSYNHTTP 请求发送给服务端,服务端验证了 Cookie 的合法性,如果不合法直接丢弃,如果是合法的,那么就正常返回 SYN + ACK

重点来了,现在服务端能向客户端发 HTTP 响应了,这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie 的合法性,就可以返回 HTTP 响应了,当然客户端的 ACK 还得正常传过来,流程如下

注意,客户端最后握手的 ACK 不一定要等到服务端的 HTTP 响应到达才发送,两个过程没有任何关系

TFO 的优势

TFO 的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie 并验证通过以后,可以直接返回 HTTP 响应,充分利用了 1RTT 的时间提前进行数据传输,积累起来还是一个比较大的优势

TCP 的超时重传时间是如何计算的

TCP 具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包,这个重传间隔也叫做超时重传时间(Retransmission TimeOut,简称 RTO),那么这个重传间隔是如何来计算的呢?主要有两种方式,一个是经典方法,一个是标准方法

经典方法

经典方法引入了一个新的概念 SRTTSmoothed 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.0lbound 是下界,ubound 是上界,其实这个算法过程还是很简单的,但是也存在一定的局限,就是在 RTT 稳定的地方表现还可以,而在 RTT 变化较大的地方就不行了,因为平滑因子 α 的范围是 0.8 ~ 0.9RTT 对于 RTO 的影响太小

标准方法

为了解决经典方法对于 RTT 变化不敏感的问题,后面又引出了标准方法,也叫 Jacobson/Karels 算法,一共有三步

  • 第一步,计算 SRTT,公式如下
1
SRTT = (1 - α) * SRTT + α * RTT

注意这个时候的 α 跟经典方法中的 α 取值不一样了,建议值是 1/8,也就是 0.125

  • 第二步,计算 RTTVARround-trip time variation)这个中间变量
1
RTTVAR = (1 - β) * RTTVAR + β * (|RTT - SRTT|)

β 建议值为 0.25,这个值是这个算法中出彩的地方,也就是说,它记录了最新的 RTT 与当前 SRTT 之间的差值,给我们在后续感知到 RTT 的变化提供了抓手

  • 第三步,计算最终的 RTO
1
RTO = µ * SRTT + ∂ * RTTVAR

µ 建议值取 1 建议值取 4,这个公式在 SRTT 的基础上加上了最新 RTT 与它的偏移,从而很好的感知了 RTT 的变化,这种算法下 RTORTT 变化的差值关系更加密切

TCP 的流量控制是如何实现的

我们之前在 HTTP 协议TCP/IP 协议 的章节当中简单介绍过 TCP 的流量控制,在这里就汇总整理一下,但是需要注意的是,HTTP/3 当中 QUIC 实现的流量控制与 TCP 当中有所区别,这里我们主要介绍的是 TCP 当中的流量控制,所以关于 HTTP/3 当中 QUIC 的实现方法就不详细展开了,详细可见 HTTP/3 流量控制

对于发送端和接收端而言,TCP 需要把发送的数据放到发送缓存区,将接收的数据放到接收缓存区,而流量控制索要做的事情,就是在通过接收缓存区的大小,控制发送端的发送,如果对方的接收缓存区满了,就不能再继续发送了,所以要具体理解流量控制,我们要从滑动窗口的概念开始了解

TCP 滑动窗口

TCP 滑动窗口分为两种,发送窗口和接收窗口,发送端的滑动窗口结构如下

其中包含四大部分

  • 已发送且已确认
  • 已发送但未确认
  • 未发送但可以发送
  • 未发送也不可以发送

发送窗口就是图中被框住的范围,SNDsendWNDwindowUNAunacknowledged,表示未被确认,NXTnext,表示下一个发送的位置,接收端的窗口结构如下

REVreceiveNXT 表示下一个接收的位置,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 Windowcwnd
  • 慢启动阈值(Slow Start Thresholdssthresh

涉及到的算法有这几个

  • 慢启动
  • 拥塞避免
  • 快速重传
  • 快速恢复

接下来,我们就来一一来梳理这些状态和算法,首先从拥塞窗口说起

拥塞窗口

拥塞窗口是指目前自己还能传输的数据量大小,那么之前介绍了接收窗口的概念,两者有什么区别呢?

  • 接收窗口(rwnd)是接收端给的限制
  • 拥塞窗口(cwnd)是发送端的限制

限制谁呢?其实是限制的是发送窗口的大小,在有了这两个窗口以后,我们来看看如何来计算发送窗口?

1
发送窗口大小 = min(rwnd, cwnd)

简单来说就是取两者的较小值,而拥塞控制,就是来控制 cwnd 变化的

慢启动

刚开始进入传输数据的时候,我们是不知道现在的网络情况到底是稳定还是拥堵的,如果发包太急,那么可能会疯狂丢包,造成雪崩式的网络灾难,因此拥塞控制首先就是要采用一种保守的算法来慢慢地适应整个网络,这种算法叫慢启动,运作过程如下

  • 首先,三次握手,双方宣告自己的接收窗口大小
  • 双方初始化自己的拥塞窗口(cwnd)大小
  • 在开始传输的一段时间,发送端每收到一个 ACK,拥塞窗口大小加 1,也就是说每经过一个 RTTcwnd 翻倍,如果说初始窗口为 10,那么第一轮 10 个报文传完且发送端收到 ACK 后,cwnd 变为 20,第二轮变为 40,第三轮变为 80,依次类推

当然不会无止境地翻倍下去,它的阈值叫做慢启动阈值,超出阈值则会导致网络拥塞,那么当 cwnd 到达这个阈值之后,如何来控制 cwnd 的大小呢?而这就是拥塞避免做的事情了

拥塞避免

其实简单来说,原理就是在到达某个限制(慢启动阈值)之后,指数增长变为线性增长,比如原来每收到一个 ACKcwnd1,现在到达阈值后,cwnd 只增加 1 / cwnd,所以一轮 RTT 下来,收到 cwndACK,那最后拥塞窗口的大小 cwnd 总共才增加 1

也就是说,以前一个 RTT 下来,cwnd 翻倍,现在 cwnd 只是增加 1 而已,当然慢启动和拥塞避免是一起作用的,是一体的

快速重传

TCP 传输的过程中,如果发生了丢包,即接收端发现数据段不是按序到达的时候,接收端的处理是重复发送之前的 ACK,比如第 5 个包丢了,即使第 67 个包到达的接收端,接收端也一律返回第 4 个包的 ACK,当发送端收到 3 个重复的 ACK 时,意识到丢包了,于是马上进行重传,不用等到一个 RTORetransmission Timeout,重传超时) 的时间到了才重传,这就是快速重传,它解决的是是否需要重传的问题

选择性重传

既然要重传,那么是只重传第 5 个包还是第 567 个包都重传呢?当然如果第 67 个都已经到达了的话,则会记录一下哪些包到了,哪些没到,针对性地重传

在收到发送端的报文后,接收端回复一个 ACK 报文,那么在这个报文首部的可选项中,就可以加上 SACK 这个属性,通过 left edgeright edge 告知发送端已经收到了哪些区间的数据报,因此,即使第 5 个包丢包了,当收到第 67 个包之后,接收端依然会告诉发送端,这两个包到了,剩下第 5 个包没到,就重传这个包,这个过程也叫做选择性重传(Selective AcknowledgmentSACK),它解决的是如何重传的问题

快速恢复

当然,发送端收到三次重复 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

大家都听说过 HTTPkeep-alive,不过 TCP 层面也是有 keep-alive 机制,而且跟应用层不太一样,试想一个场景,当有一方因为网络故障或者宕机导致连接失效,由于 TCP 并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的,这个时候就出现了 keep-alive,它的作用就是探测对端的连接有没有失效,在 Linux 下,可以这样查看相关的配置

1
2
3
4
5
6
7
8
9
10
sudo sysctl -a | grep keepalive

// 每隔 7200 s 检测一次
net.ipv4.tcp_keepalive_time = 7200

// 一次最多重传 9 个包
net.ipv4.tcp_keepalive_probes = 9

// 每个包的间隔重传间隔 75 s
net.ipv4.tcp_keepalive_intvl = 75

不过,现状是大部分的应用并没有默认开启 TCPkeep-alive 选项,这是因为如果我们站在应用的角度来看的话,会发现这是一个比较尴尬的设计,原因主要有下面两点

  • 7200s 也就是两个小时检测一次,时间太长
  • 时间再短一些,也难以体现其设计的初衷,即检测长时间的死连接

参考

# HTTP

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×