1. TCP 基本认识
TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。
面向连接:一定是「一对一」才能连接,不能像 UDP 协议可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的;
可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;
字节流:用户消息通过 TCP 协议传输时,消息可能会被操作系统「分组」成多个的 TCP 报文,如果接收方的程序如果不知道「消息的边界」,是无法读出一个有效的用户消息的。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,那么也不能扔给应用层去处理,同时对「重复」的 TCP 报文会自动丢弃。
1.1. TCP 头部格式
TCP 头长度为 20个字节。
特别字段说明:
序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。
确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决丢包的问题。
控制位:
- ACK:该位为
1
时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的SYN
包之外该位必须设置为1
。- RST:该位为
1
时,表示 TCP 连接中出现异常必须强制断开连接。- SYN:该位为
1
时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。- FIN:该位为
1
时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换FIN
位为 1 的 TCP 段。
1.2. TCP 连接
TCP 四元组可以确定唯一的 TCP 连接,四元组包括:
- 源地址
- 源端口
- 目的地址
- 目的端口
有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少?
最大连接数 = 客户端的IP数 * 客户端的端口数
对 ipv4 客户端的 IP 数最大为 2^32, 端口数为 2^16次方,也就是服务器单机最大约 2^48。
当然,服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响:
- 文件描述符限制,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制:
- 系统级:当前系统可打开的最大数量,通过
cat /proc/sys/fs/file-max
查看;- 用户级:指定用户可打开的最大数量,通过
cat /etc/security/limits.conf
查看;- 进程级:单个进程可打开的最大数量,通过
cat /proc/sys/fs/nr_open
查看;- 内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。
2. TCP 三次握手
2.1. 三次握手的目的?
-------------------------------------------------------------------------------------------------------------------------
需要三次握手的原因:
- 三次握手才可以阻止重复历史连接的初始化
- 三次握手才可以同步双方的初始序列号
- 三次握手才可以避免资源浪费
-------------------------------------------------------------------------------------------------------------------------
为什么是三次不是四次?
按照正常理解,TCP协议是一个全双工协议,所以双方需要分别告知对方的 seq num 并返回对应得ack报文,那么其实是需要四次握手的,也就是这样一个流程:
客户端 服务器
----------- syn seqnum ------->
<--------- ack seqnum + 1 -------
<--------- syn seqnum --------
---------- ack seqnum + 1 ------->
从上面过程可以看出中间两次都是服务器发送得报文,可以合成一个报文,三次握手就可以
客户端 服务器
----------- SYN ------->
<--------- SYN + ACK --------
---------- ACK ------->
-------------------------------------------------------------------------------------------------------------------------
三次握手如何阻止历史连接?
如果同一个客户端多次发出SYN,如何确保不使用历史请求而使用最新的请求,假设如下:
客户端 服务器
----------- SYN seq=90 -------> (抵达服务器)
----------- SYN seq=100 -------> (未抵达服务器)
<--------- SYN + ACK ack= 90+1 --------
此时客户端期待收到的ack =100+1
---------- RST -------> (连接中断)
-------------------------------------------------------------------------------------------------------------------------
避免资源浪费?
如果只有「两次握手」,当客户端发生的 SYN 报文在网络中阻塞,客户端没有接收到 ACK 报文,就会重新发送 SYN ,由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 ACK 报文,所以服务端每收到一个 SYN 就只能先主动建立一个连接,这会造成什么情况呢?
如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。
-------------------------------------------------------------------------------------------------------------------------
2.2. 如何避免 SYN 攻击?
什么是syn攻击?
在短时间内伪造不同IP地址发送SYN报文,服务器每收到一个 SYN 报文,就会进入 SYN_RECV 状态,但服务发送出去的 ACK + SYN 报文,无法得到 ACK 应答,久而久之就会占满服务器的半连接队列,是的正常用户的请求被丢弃,导致服务瘫痪。
避免 SYN 攻击方式,可以有以下四种方法:
- 调大 netdev_max_backlog;
- 增大 TCP 半连接队列;
- 开启 tcp_syncookies;
- 减少 SYN+ACK 重传次数
3. TCP 四次挥手
3.1. 为什么要四次挥手?
由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。
- 第1次挥手:客户端发送一个FIN,用来关闭客户端到服务器的数据传送,客户端进入FIN_WAIT_1状态;
- 第2次挥手:服务端收到FIN后,发送一个ACK给客户端,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),服务端进入CLOSE_WAIT状态;
- 第3次挥手:服务端发送一个FIN,用来关闭服务端到客户端的数据传送,服务端进入LAST_ACK状态;
- 第4次挥手:客户端收到FIN后,客户端t进入TIME_WAIT状态,接着发送一个ACK给Server,确认序号为收到序号+1,服务端进入CLOSED状态,完成四次挥手。
3.2. TIME_WAIT 过多有什么危害?
只有主动发起断开的一方才会有TIME_WAIT状态。
过多的 TIME-WAIT 状态主要的危害有两种:
- 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
- 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。
3.3. 服务器出现大量CLOSE_WAIT状态?
CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。
所以,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接,主要分析的方向就是服务端为什么没有调用 close。
4. TCP重传机制
TCP 实现可靠传输的方式之一,是通过序列号与确认应答。
在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。
但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?
所以 TCP 针对数据包丢失的情况,会用重传机制解决。
接下来说说常见的重传机制:
- 超时重传
- 快速重传
- SACK
- D-SACK
4.1. 超时重传
重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传。
TCP 会在以下两种情况发生超时重传:
- 数据包丢失
- 确认应答丢失
上图中有两种超时时间不同的情况:
- 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
- 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。
4.2. 快速重传
在上图,发送方发出了 1,2,3,4,5 份数据:
- 第一份 Seq1 先送到了,于是就 Ack 回 2;
- 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
- 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
- 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。
- 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 。
所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。
快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传一个,还是重传所有的问题。
举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的, 那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、 Seq4、Seq5、 Seq6) 呢?
- 如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。
- 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。
可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。
为了解决不知道该重传哪些 TCP 报文,于是就有 SACK 方法。
4.3. SACK 选择性确认
还有一种实现重传机制的方式叫:SACK (Selective Acknowledgment) , 选择性确认。
这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将已收到的数据的信息发送给「发送方」,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据。
如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 SACK 信息发现只有 200~299 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。
如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。
4.4. Duolicate SACK
Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。
下面举例两个栗子,来说明 D-SACK 的作用。
示例 1:ACK 丢包
- 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)
- 于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000~3500,告诉「发送方」 3000~3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK。
- 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。
示例 2:网络延迟
- 数据包(1000~1499) 被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。
- 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」;
- 所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。
- 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。
可见,D-SACK 有这么几个好处:
- 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
- 可以知道是不是「发送方」的数据包被网络延迟了;
- 可以知道网络中是不是把「发送方」的数据包给复制了;
在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。
5. TCP 滑动窗口
TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了, 再发送下一个。
为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值。
窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。
6. TCP 流量控制
发送方不能无脑的发数据给接收方,要考虑接收方处理能力。流量控制是避免「发送方」的数据填满「接收方」的缓存。
如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。
为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。
流量控制通过下面两种机制实现:
滑动窗口:TCP使用滑动窗口机制来控制发送方和接收方之间的数据流量。发送方和接收方都有一个窗口大小的概念,发送方的窗口大小表示发送方能够发送的未被确认的数据量,接收方的窗口大小表示接收方能够接收的数据量。发送方根据接收方的窗口大小来控制发送数据的速率,确保不会发送过多的数据导致接收方无法处理。接收方可以通过调整窗口大小来通知发送方其可接收的数据量,实现流量控制。
拥塞控制:TCP还使用拥塞控制来处理网络拥塞情况。当网络拥塞时,发送方可能会收到丢失的确认消息,或者收到超时消息。在这种情况下,发送方会认为网络负载太重,降低发送速率,以减少网络拥塞的可能性。发送方通过动态调整拥塞窗口大小来控制发送速率,以避免网络拥塞。
7. TCP 拥塞控制
拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络。
拥塞控制主要是四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
7.1. 慢启动
TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?
慢启动的算法记住一个规则就行:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
这里假定拥塞窗口
cwnd
和发送窗口swnd
相等,下面举个栗子:
- 连接建立完成后,一开始初始化
cwnd = 1
,表示可以传一个MSS
大小的数据。- 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
- 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
- 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。
慢启动涨到什么时候是个头呢?
有一个叫慢启动门限
ssthresh
(slow start threshold)状态变量。
- 当
cwnd
<ssthresh
时,使用慢启动算法。- 当
cwnd
>=ssthresh
时,就会使用「拥塞避免算法」。
7.2. 拥塞避免算法
前面说道,当拥塞窗口
cwnd
「超过」慢启动门限ssthresh
就会进入拥塞避免算法。一般来说
ssthresh
的大小是65535
字节。那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd。
接上前面的慢启动的栗子,现假定
ssthresh
为8
:
- 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个
MSS
大小的数据,变成了线性增长。
7.3. 拥塞发送算法
当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:
- 超时重传
- 快速重传
这两种使用的拥塞发送算法是不同的,接下来分别来说说。
发生超时重传的拥塞发生算法
当发生了「超时重传」,则就会使用拥塞发生算法。
这个时候,ssthresh 和 cwnd 的值会发生变化:
ssthresh
设为cwnd/2
,cwnd
重置为1
(是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1)
7.4. 快速恢复算法
快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像
RTO
超时那么强烈。正如前面所说,进入快速恢复之前,
cwnd
和ssthresh
已被更新了:
cwnd = cwnd/2
,也就是设置为原来的一半;ssthresh = cwnd
;然后,进入快速恢复算法如下:
- 拥塞窗口
cwnd = ssthresh + 3
( 3 的意思是确认有 3 个数据包被收到了);- 重传丢失的数据包;
- 如果再收到重复的 ACK,那么 cwnd 增加 1;
- 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
8. TCP 半连接队列和全连接队列?
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
8.1. 半连接队列溢
如果怀疑应用程序半连接队列溢出怎么查看?
在服务端主机上执行查看当前 TCP 半连接队列大小:
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。
增加半连接队列的方法?
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
如果怀疑应用程序半连接队列溢出怎么查看?
在服务端主机上执行查看当前 TCP 半连接队列大小:
通过 netstat -s 观察半连接队列溢出的情况:
上面输出的数值是累计值,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象。
增加半连接队列的方法?
增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数:
8.2. 全连接队列溢出
如何知道应用程序的 TCP 全连接队列大小?
在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
- Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
- Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128;
在「非 LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
- Recv-Q:已收到但未被应用进程读取的字节数;
- Send-Q:已发送但未收到确认的字节数;
在服务端可以使用 ss 命令,来查看当前 TCP 全连接队列的情况:
其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP 全连接队列上升到了 129 大小,超过了最大 TCP 全连接队列。
当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
上面看到的 41150 times ,表示全连接队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列偶尔满了。
如何增大 TCP 全连接队列呢?
- somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其值;
- backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;
9. TCP 11种状态迁移
9.1. TCP 状态迁移图
9.2. 状态解释
LISTEN:等待从任何远端TCP 和端口的连接请求。
SYN_SENT:发送完一个连接请求后等待一个匹配的连接请求。
SYN_RECEIVED:发送连接请求并且接收到匹配的连接请求以后等待连接请求确认。
ESTABLISHED:表示一个打开的连接,接收到的数据可以被投递给用户。连接的数据传输阶段的正常状态。
FIN_WAIT_1:等待远端TCP 的连接终止请求,或者等待之前发送的连接终止请求的确认。
FIN_WAIT_2:等待远端TCP 的连接终止请求。
CLOSE_WAIT:等待本地用户的连接终止请求。
CLOSING:等待远端TCP 的连接终止请求确认。
LAST_ACK:等待先前发送给远端TCP 的连接终止请求的确认(包括它字节的连接终止请求的确认)
TIME_WAIT:等待足够的时间过去以确保远端TCP 接收到它的连接终止请求的确认。
TIME_WAIT 两个存在的理由:
1.可靠的实现tcp全双工连接的终止;
2.允许老的重复分节在网络中消逝。
CLOSED:不在连接状态(这是为方便描述假想的状态,实际不存在)