TCP
# 什么是TCP?
TCP是面向连接的、可靠的、基于字节流的传输层通信协议。
面向连接:一定是一对一才能连接,不能像UDP协议可以一个主机同时向多个主机发送消息,无法做到一对多。
可靠的:无论网络链路出现了怎样的变化,TCP都能保证一个报文一定能够到达接收端。
字节流:消息是没有边界的,无论我们的消息多大都可以进行传输。消息是有序的,当前一个消息没有收到时,即使它先收到了后面的字节,那也不能扔给应用层去处理,同时对重复的报文进行丢弃。
# TCP和UDP的区别
# 连接
TCP是面向连接的传输层协议,传输数据之前先要建立连接。
UDP是不需要连接,即刻传输数据。
# 服务对象
TCP是一对一的端到端连接。
UDP支持一对一,一对多和多对多的交互通信。
# 可靠性
TCP是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达。
UDP是尽最大努力交付,不保证可靠交付数据。
# 拥塞控制和流量控制
TCP有拥塞控制和流量控制的机制,保证数据传输的安全性。
UDP则没有,即使网络非常拥堵了,也不会影响UDP的发送速率。
# 首部开销
TCP首部长度较长,在没有使用选项字段时是20字节,如果使用了选项字段则会变长。
UDP首部只有8个字节,并且是固定不变的,开销较小。
# 传输方式
TCP是流式传输,没有边界,保证顺序和可靠性。
UDP是一个包一个包的发送,是没有边界的,但可能丢包和乱序。
# 分片不同
TCP的数据大小如果大于MSS大小,则会在传输层进行分片,目标主机收到后,同样在传输层组装TCP数据包,如果中途丢失了一个分片,只需要传输丢失的分片。注:MSS=MTU-20bytes(IP包头)-20bytes(TCP包头),MTU是链路层数据帧的数据字段的最大值,一般为1500字节。
UDP数据大小如果大于MTU大小,则会在IP层进行分片,目标主机收到后,在IP层组装完数据,接着再传输给传输层,但是如果中途丢失了一个分片,在实现可靠传输的UDP时则需要重传所有的数据包,这样传输效率变得非常差。
# 既然IP层会进行分片,为什么TCP层还需要MSS呢?
如果TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?
当 IP 层有一个超过 MTU 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,在交给上一层 TCP 传输层。
这看起来井然有序,但这存在隐患的,那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。
因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。
当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发**「整个 TCP 报文(头部 + 数据)」**。
因此,可以得知由 IP 层进行分片传输,是非常没有效率的。
所以,为了达到最佳的传输效能 TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。
# TCP三次握手
**注:**第三次握手是可以携带数据的,前两次握手不可以携带数据,客户端和服务端还在同步状态。
一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态
客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。
服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。
客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。
服务器收到客户端的应答报文后,也进入 ESTABLISHED 状态。
# 为什么是三次握手?不是两次、四次?
一个片面的回答是,只有三次握手才能保证双方具有接收和发送的能力。
这种说法似乎有一定道理,但是没有说出主要原因。因为TCP连接为了保证可靠性和流量控制,需要维护一些状态信息,包括Socket,序列号和窗口大小。这是连接的官方(RFC 793)定义。
Socket:由 IP 地址和端口号组成
序列号:用来解决乱序问题等
窗口大小:用来做流量控制
# 避免历史连接
三次握手的首要原因就是为了防止旧的重复连接初始化造成混乱。
网络环境是错综复杂的,往往并不是如我们期望的一样,先发送的数据包,就先到达目标主机,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机。
三次握手可以有效避免旧连接的问题:
一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;
那么此时服务端就会回一个 SYN + ACK 报文给客户端;
客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送 RST 报文给服务端,表示中止这一次连接。
如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,客户端因有足够的上下文来判断当前连接是否是历史连接:
如果是历史连接(序列号过期或超时),则第三次握手发送的报文是 RST 报文,以此中止历史连接;
如果不是历史连接,则第三次发送的报文是 ACK 报文,通信双方就会成功建立连接;
# 同步双方初始序列号
TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:
接收方可以去除重复的数据;
接收方可以根据数据包的序列号按序接收;
可以标识发送出去的数据包中, 哪些是已经被对方收到的;
可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。
其实,四次握手也能够实现可靠的同步双方初始序号,但是第二步和第三步可以优化成一步,所以就成了三次握手。
而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。
# 避免资源浪费
如果只有「两次握手」,当客户端的 SYN 请求连接在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端的 SYN 阻塞丢失了,重复发送多次 SYN 报文,那么服务器在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
即两次握手会造成消息滞留情况下,服务器重复接受无用的连接请求 SYN 报文,而造成重复分配资源。
# SYN攻击及其避免?
# SYN攻击
我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。
# 避免SYN攻击方式一
其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。
当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:
net.core.netdev_max_backlog
SYN_RCVD 状态连接的最大个数:
net.ipv4.tcp_max_syn_backlog
超出处理能时,对新的 SYN 直接回 RST,丢弃连接:
net.ipv4.tcp_abort_on_overflow
# 避免SYN攻击方式二
我们先来看下Linux 内核的 SYN (未完成连接建立)队列与 Accpet (已完成连接建立)队列是如何工作的?
当 「 SYN 队列」满之后,后续服务器收到 SYN 包,不进入「 SYN 队列」;
计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」返回客户端,
服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「 Accept 队列」。
最后应用通过调用 accpet() socket 接口,从「 Accept 队列」取出的连接。
# TCP断开连接
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。
客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。
服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。
客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。
等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。
客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态
服务器收到了 ACK 应答报文后,就进入了 CLOSE 状态,至此服务端已经完成连接的关闭。
客户端在经过 2MSL 一段时间后,自动进入 CLOSE 状态,至此客户端也完成连接的关闭。
# 为什么挥手需要四次?
再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了。
关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据。
服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 FIN 报文给客户端来表示同意现在关闭连接。
从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。
# 为什么TIME_WAIT等待时间是2MSL?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。
MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。
TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是:网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
- 比如,如果被动关闭方(这里是服务端)没有收到断开连接的最后的 ACK 报文,就会触发超时重发 Fin 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。
2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。
# 为什么需要TIME_WAIT
主动发起关闭连接的一方,才会有 TIME-WAIT 状态。
需要 TIME-WAIT 状态,主要是两个原因:
防止具有相同「四元组」(目的地址,目的端口,发送地址,发送端口)的「旧」数据包被收到;
保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;
# 防止旧连接的数据包
如上图黄色框框服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了。
这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。
所以,TCP 就设计出了这么一个机制,经过 2MSL 这个时间,足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。
# 保证连接正确关闭
也就是说,TIME-WAIT 作用是等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。
如上图红色框框客户端四次挥手的最后一个 ACK 报文如果在网络中被丢失了,此时如果客户端 TIME-WAIT 过短或没有,则就直接进入了 CLOSE 状态了,那么服务端则会一直处在 LASE-ACK 状态。
当客户端发起建立连接的 SYN 请求报文后,服务端会发送 RST 报文给客户端,连接建立的过程就会被终止。
如果 TIME-WAIT 等待足够长的情况就会遇到两种情况:
服务端正常收到四次挥手的最后一个 ACK 报文,则服务端正常关闭连接。
服务端没有收到四次挥手的最后一个 ACK 报文时,则会重发 FIN 关闭连接报文并等待新的 ACK 报文。
所以客户端在 TIME-WAIT 状态等待 2MSL 时间后,就可以保证双方的连接都可以正常的关闭。
# TCP重传机制
# 超时重传时间的选择?
当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值。
# 快速重传
TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动重传。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题。
比如对于上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的。
# 流量控制
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的****流量控制。
在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。
# 拥塞控制
# 有流量控制了为什么还要拥塞控制?
前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。
一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。
在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….
所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。
于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
# 如何判断网络出现了拥塞?
其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞****。
# 拥塞控制算法
拥塞窗口 cwnd是发送方维护的一个 的状态变量,它会根据网络的拥塞程度动态变化的。
我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于入了拥塞窗口的概念后,此时发送窗口的值是swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。
拥塞窗口 cwnd 变化的规则:
只要网络中没有出现拥塞,cwnd 就会增大;
但网络中出现了拥塞,cwnd 就减少;
# 慢启动-指数增长
慢启动的算法记住一个规则就行:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会增加两倍。
有一个叫慢启动门限 ssthresh (slow start threshold)状态变量。
当 cwnd < ssthresh 时,使用慢启动算法。
当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。
# 拥塞避免-线性增长
前面说道,当拥塞窗口 cwnd 「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。
一般来说 ssthresh 的大小是 65535 字节。
那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1。
所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。
就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。
# 拥塞发生
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
超时重传
快速重传
# 发生超时重传的拥塞发生算法
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,sshresh 和 cwnd 的值会发生变化:
ssthresh 设为 cwnd/2**,**
cwnd 重置为 1
接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。
# 发生快速重传的拥塞发生算法
还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。
TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:
cwnd = cwnd/2 ,也就是设置为原来的一半;
ssthresh = cwnd**;**
进入快速恢复算法
# 快速恢复
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。
正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:
cwnd = cwnd/2 ,也就是设置为原来的一半;
ssthresh = cwnd;
然后,进入快速恢复算法如下:
拥塞窗口 cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了)
重传丢失的数据包
如果再收到重复的 ACK,那么 cwnd 增加 1
如果收到新数据的 ACK 后,设置 cwnd 为 ssthresh,接着就进入了拥塞避免算法
# TCP的粘包问题
值得注意的是,粘包并不是 TCP 协议本身的“问题”,而是一个“现象”。TCP 本身面向字节流的特性,导致会有所谓的“粘包”问题,需要应用层进行拆分。所以也有一种说法是“TCP 粘包是一个伪命题”。
# 粘包的出现原因
TCP 是基于字节流的,数据块是没有边界、没有结构的字节流,因此可能产生粘包:
发送方为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小、数据量小的数据包,合并成一个大的数据包一次性发送。
接收方不能及时读取数据,导致缓冲区中的多个包粘连。
# 粘包的解决方法
发送方关闭 Nagle 算法
应用层定义消息边界,最常见的两种解决方案就是基于长度或者基于终结符(Delimiter)
基于长度的实现有两种方式,一种是使用固定长度;另一种方式是使用不固定长度,但是需要在应用层协议的协议头中增加表示负载长度的字段,HTTP 协议的消息边界就是基于长度实现的
HTTP 协议除了使用基于长度的方式实现边界,也会使用基于终结符的策略,当 HTTP 使用块传输(Chunked Transfer)机制时,HTTP 头中就不再包含 Content-Length 了,它会使用负载大小为 0 的 HTTP 消息作为终结符表示消息的边界
除了这两种方式之外,我们可以基于特定的规则实现消息的边界,例如:使用 TCP 协议发送 JSON 数据,接收方可以根据接收到的数据是否能够被解析成合法的 JSON 判断消息是否终结。
# 为什么 UDP 协议没有粘包问题?
UDP 是面向报文的,应用层交给 UDP 多长的报文,UDP 就照样发送,既不合并,也不拆分,而是保留这些报文的边界。