TCP协议参数优化

TCP 协议是由操作系统实现的,调整 TCP 必须通过操作系统提供的接口和工具。

这就需要理解 Linux 是怎样把三次握手中的状态暴露给我们,以及通过哪些工具可以找到优化依据,并通过哪些接口修改参数。

1. TCP 三次握手和优化参数

三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输。

1.1 tcp_syn_retries(syn重传)

SYN 开启了三次握手,此时在客户端上用 netstat 命令(后续查看连接状态都使用该命令)可以看到连接的状态是 SYN_SENT(顾名思义,就是把刚 SYN 发送出去)。

客户端在等待服务器回复的 ACK 报文。正常情况下,服务器会在几毫秒内返回 ACK,但如果客户端迟迟没有收到 ACK 会怎么样呢?客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次:

第 1 次重试发生在 1 秒钟后,接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试,最后一次重试会等待 64 秒,如果仍然没有返回 ACK,才会终止三次握手。所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。

可以适当调低重试次数,尽快把错误暴露给应用程序。

1.2 tcp_max_syn_backlog(syn半连接队列)

当服务器收到 SYN 报文后,服务器会立刻回复 SYN+ACK 报文,既确认了客户端的序列号,也把自己的序列号发给了对方。此时,服务器端出现了新连接,状态是 SYN_RCV(RCV 是 received 的缩写)。这个状态下,服务器必须建立一个 SYN 半连接队列来维护未完成的握手信息,当这个队列溢出后,服务器将无法再建立新连接。
TCP协议参数优化_第1张图片

1.3 tcp_syncookies (syncookie取代syn半连接)

如果 SYN 半连接队列已满,只能丢弃连接吗?并不是这样,开启 syncookies 功能就可以在不使用 SYN 队列的情况下成功建立连接。syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功。

TCP协议参数优化_第2张图片

1.4 tcp_synack_retries (synack重传)

当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 去通知服务器,同时己方连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。服务器端连接成功建立的时间还要再往后,到它收到 ACK 后状态才变为 ESTABLISHED。如果服务器没有收到 ACK,就会一直重发 SYN+ACK 报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。

1.5 tcp_abort_on_overflow(accept队列)

服务器收到 ACK 后连接建立成功,此时,内核会把连接从 SYN 半连接队列中移出,再移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。

listen 函数的 backlog 参数就可以设置 accept 队列的大小。事实上,backlog 参数还受限于 Linux 系统级的队列长度上限,当然这个上限阈值也可以通过 somaxconn 参数修改。

当下各监听端口上的 accept 队列长度可以通过 ss -ltn 命令查看,但 accept 队列长度是否需要调整该怎么判断呢?还是通过 netstat -s 命令给出的统计结果,可以看到究竟有多少个连接因为队列溢出而被丢弃。

1.6 tcp_fastopen(cookie代替三次握手)

我们看看如何绕过三次握手发送数据。

三次握手建立连接造成的后果就是,HTTP 请求必须在一次 RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,Google 对此做的统计显示,三次握手消耗的时间,在 HTTP 请求完成的时间占比在 10% 到 30% 之间。

因此,Google 提出了 TCP fast open 方案(简称TFO),客户端可以在首个 SYN 报文中就携带请求,这节省了 1 个 RTT 的时间。

TFO 到底怎样达成这一目的呢?它把通讯分为两个阶段,第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能,这样服务器会把客户端 IP 地址用只有自己知道的密钥加密(比如 AES 加密算法),作为 Cookie 携带在返回的 SYN+ACK 报文中,客户端收到后会将 Cookie 缓存在本地。之后,如果客户端再次向服务器建立连接,就可以在第一个 SYN 报文中携带请求数据,同时还要附带缓存的 Cookie。

当然,为了防止 SYN 泛洪攻击,服务器的 TFO 实现必须能够自动化地定时更新密钥。

linux通过tcp_fastopen参数,打开TFO功能。

2. TCP 四次握手和优化参数

为什么建立连接是三次握手,而关闭连接需要四次挥手呢?

这是因为 TCP 不允许连接处于半打开状态时就单向传输数据,所以在三次握手建立连接时,服务器会把 ACK 和 SYN 放在一起发给客户端,其中,ACK 用来打开客户端的发送通道,SYN 用来打开服务器的发送通道。这样,原本的四次握手就降为三次握手了。

但是当连接处于半关闭状态时,TCP 是允许单向传输数据的。为便于下文描述,接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。

当主动方关闭连接时,被动方仍然可以在不调用 close 函数的状态下,长时间发送数据,此时连接处于半关闭状态。这一特性是 TCP 的双向通道互相独立所致,却也使得关闭连接必须通过四次挥手才能做到。
TCP协议参数优化_第3张图片
互联网中往往服务器才是主动关闭连接的一方。这是因为,HTTP 消息是单向传输协议,服务器接收完请求才能生成响应,发送完响应后就会立刻关闭 TCP 连接,这样及时释放了资源,能够为更多的用户服务。

这就使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。

2.1 四次挥手流程和状态

  1. 当主动方关闭连接时,会发送 FIN 报文,此时主动方的连接状态由 ESTABLISHED 变为 FIN_WAIT1。
  2. 当被动方收到 FIN 报文后,内核自动回复 ACK 报文,连接状态由 ESTABLISHED 变为 CLOSE_WAIT,顾名思义,它在等待进程调用 close 函数关闭连接。
  3. 当主动方接收到这个 ACK 报文后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,主动方的发送通道就关闭了。
  4. 再来看被动方的发送通道是如何关闭的。当被动方进入 CLOSE_WAIT 状态时,进程的 read 函数会返回 0,这样开发人员就会有针对性地调用 close 函数,进而触发内核发送 FIN 报文,此时被动方连接的状态变为 LAST_ACK。
  5. 当主动方收到这个 FIN 报文时,内核会自动回复 ACK,同时连接的状态由 FIN_WAIT2 变为 TIME_WAIT,Linux 系统下大约 1 分钟后 TIME_WAIT 状态的连接才会彻底关闭。
  6. 而被动方收到 ACK 报文后,连接就会关闭。

2.2 tcp_orphan_retries(FIN重传)(主动方)

close 调用后,哪怕对方在半关闭状态下发送的数据到达主动方,进程也无法接收。此时,这个连接叫做孤儿连接。

主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态下,该状态通常应在数十毫秒内转为 FIN_WAIT2。只有迟迟收不到对方返回的 ACK 时,才能用 netstat 命令观察到 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制

2.3 tcp_max_orphans(最大孤儿连接)(主动方)

对于正常情况来说,调低 tcp_orphan_retries 已经够用,但如果遇到恶意攻击,FIN 报文根本无法发送出去,解决这种问题的方案是调整 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

2.4 tcp_fin_timeout(孤儿连接超时)(主动方)

对于 close 函数关闭的孤儿连接,这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长。

2.5 time_wait(主动方最后一个状态)(主动方)

TIME_WAIT 是主动方四次挥手的最后一个状态。

当收到被动方发来的 FIN 报文时,主动方回复 ACK,表示确认对方的发送通道已经关闭,连接随之进入 TIME_WAIT 状态,等待 60 秒后关闭,为什么呢?我们必须站在整个网络的角度上,才能回答这个问题。

TIME_WAIT 状态的连接,在主动方看来确实已经关闭了。然而,被动方没有收到 ACK 报文前,连接还处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。

如果主动方不保留 TIME_WAIT 状态,会发生什么呢?此时连接的端口恢复了自由身,可以复用于新连接了。然而,被动方的 FIN 报文可能再次到达,这既可能是网络中的路由器重复发送,也有可能是被动方没收到 ACK 时基于 tcp_orphan_retries 参数重发。这样,正常通讯的新连接就可能被重复发送的 FIN 报文误关闭。

2.6 tcp_max_tw_buckets(最大time_wait连接数)(主动方)

虽然 TIME_WAIT 状态的存在是有必要的,但它毕竟在消耗系统资源,比如 TIME_WAIT 状态的端口就无法供新连接使用。

Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。

2.7 tcp_tw_reuse(time_wait连接重用)(主动方)

当然,tcp_max_tw_buckets 也不是越大越好,毕竟内存和端口号都是有限的。有没有办法让新连接复用 TIME_WAIT 状态的端口呢?如果服务器会主动向上游服务器发起连接的话,就可以把 tcp_tw_reuse 参数设置为 1,它允许作为客户端的新连接,在安全条件下使用 TIME_WAIT 状态下的端口。

2.8 close_wait(被动方)

当被动方收到 FIN 报文时,就开启了被动方的四次挥手流程。内核自动回复 ACK 报文后,连接就进入 CLOSE_WAIT 状态,顾名思义,它表示等待进程调用 close 函数关闭连接。

当你用 netstat 命令发现大量 CLOSE_WAIT 状态时,要么是程序出现了 Bug,read 函数返回 0 时忘记调用 close 函数关闭连接,要么就是程序负载太高,close 函数所在的回调函数被延迟执行了。此时,我们应当在应用代码层面解决问题。

3. TCP缓冲区

TCP 连接是由内核维护的,内核为每个连接建立的内存缓冲区,既要为网络传输服务,也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输速度就会很慢;如果连接的内存配置过大,那么服务器内存会很快用尽,新连接就无法建立成功。

3.1 滑动窗口是怎样影响传输速度的?

TCP 必须保证每一个报文都能够到达对方,它采用的机制就是:报文发出后,必须收到接收方返回的 ACK 确认报文(Acknowledge 确认的意思)。如果在一段时间内(称为 RTO,retransmission timeout)没有收到,这个报文还得重新发送,直到收到 ACK 为止。

这种确认报文方式太影响传输速度了。提速的方式很简单,并行地批量发送报文,再批量确认报文即可。

然而,这引出了另一个问题,接收方有那么强的处理能力吗?

当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,网络效率非常低。怎么限制发送方的速度呢?接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。

3.2 发送窗口/接收窗口与带宽

我们知道,TCP 的传输速度,受制于发送窗口与接收窗口,以及网络传输能力。

当发送方从报文中得到接收方的窗口大小时,就明白了最多能发送多少字节的报文,这个数字被称为发送方的发送窗口。如果不考虑网络拥塞控制,发送方的发送窗口就是接收方的接收窗口。

怎样计算出网络传输能力呢?带宽描述了网络传输能力,但它不能直接使用,因为它与窗口计量单位不同。

当最大带宽是 100MB/s、网络时延是 10ms 时,这意味着客户端到服务器间的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。这个 1MB 是带宽与时延的乘积,所以它就叫做带宽时延积(缩写为 BDP,Bandwidth Delay Product)。这 1MB 字节存在于飞行中的 TCP 报文,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1MB,就一定会让网络过载,最终导致丢包。

3.3 调整滑动窗口

内核缓冲区决定了滑动窗口的上限,但我们不能通过 socket 的 SO_SNFBUF 等选项直接把缓冲区大小设置为带宽时延积,因为 TCP 不会一直维持在最高速上,过大的缓冲区会减少并发连接数。

Linux 带来的缓冲区自动调节功能非常有效,我们应当把缓冲区的上限设置为带宽时延积。其中,发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启,其中调节的依据根据 tcp_mem 而定。

3.4 拥塞窗口

拥塞控制的问题,也是通过窗口的大小来控制的,滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

TCP 发送包常被比喻为往一个水管里面灌水,而 TCP 的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽。

水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延。在理想状态下,水管里面水的量 = 水管粗细 x 水管长度。对于到网络上,通道的容量 = 带宽 × 往返延迟。

参考:https://time.geekbang.org/column/article/237612

你可能感兴趣的:(网络协议,linux内核)