TCP 的状态转换

最近在写一个长连接服务器, 然后在测试中遇到了各种的问题, 然后在使用 redis 和 mysql 的连接池的时候,也遇到了一些和网络相关的问题, 走了很多弯路, 现在写篇文章总结一下。

通常遇到问题的时候,常用的命令是 netstat -a 可以看到机器上所有网络连接, 排查问题时通常需要注意其中 STATE 一栏。 可以用如下的命令统计每种状态的 tcp 连接数量。 在并发量高的时候,我们期望很多tcp 连接的状态是 ESTABLISHED, 这表示是正常的能收发数据的sockt , 当其他状态的 socket 数量较多的时候,通常就是程序某个地方出了问题。

netstat -an | awk '/^tcp/ {++state[$NF]} END {for(key in state) print key,"\t",state[key]}'

在开始之前, 先总结一下 TCP 建立连接和释放连接的过程, 也就是大家熟知的 三次握手和 四次握手。重点注意握手过程中的状态迁移。

TCP 为一个连接定义了11 种状态,客户端和服务端会在各种状态中迁移, 其中:

  • 客户端的状态迁移

    CLOESD->SIN_SENT->ESTABLISHED->FIN_WAIT_1->FIN_WAIT_2->TIME_WAIT->CLOSED    
    
  • 服务端的迁移

    CLOSED->LISTEN->SYN_RCVD->ESTABLISHED->CLOSE_WAIT->LAST_ACK->CLOSED
    

PS : 上述的状态迁移是由客户端主动断开连接才会出现的情况,虽然通常情况下都是客户端主动断开连接, 如果是服务端断开连接,(比如 HTTP/1.0)则交换两者 ESTABLISHED 之后的状态迁移。文章接下来都默认是客户端主动断开连接。

tcp 连接的分组交换

TCP 的三次握手

在一次TCP 连接建立需要三个 packet

  1. 服务端掉用 socket() 函数创建 socket, 调用 bind() 绑定了端口, 此时服务端的 socket 处于 CLOSED 。 服务端再掉用 listen(), socket 进入 LISTEN

  2. 客户端向调用 socket() 创建socket, 此时客户端是 CLOSED 。 接下来掉用 connect() 向服务端发送一个 SYN packet,SYN packet 中可能包括 MSS (maximum segment size)以及滑动窗口大小等选项。 客户端进入 SYN_SENT

  3. 服务端收到 SYN 后,确认客户端 SYN packet,发送 ACK packet, 并发送自己的 SYN packet 服务端进入 SYN_RCVD

  4. 客户端确认服务端的 SYN packet, 此时客户端的 认为 TCP 连接建立成功, 状态进入 ESTABLISHED 。此时客户端就可以收发数据了。

  5. 服务端收到 SYN 确认 packet 之后,认为连接建立成功, 进入 ESTABLISJHED

收发数据

当连接建立成功之后, 客户端和服务端不停的进行收发数据。

TCP 连接断开(四次握手)

因为 tcp 是全双工的链路,为了保证数据安全。当需要断开连接时, 需要四个数据包。

  1. 由断开连接方发送一个 FIN packet,(图中是客户端发送,服务端也可以主动断开连接) 进入 FIN_WAIT_1 状态。 客户端不能再向socket中发送数据但可以接收数据。

  2. 服务端收到 FIN 之后,状态进入 CLOSE_WAIT 。并立即回给客户端一个 ACK 。此时服务端可以选择继续给客户端发数据。但不能接收数据。

  3. 当客户端收到服务端的 ACK 之后,客户端进入TIME_WAIT

  4. 当服务端决定断开这个连接之后,发送一个FIN packet。
    服务端进入 LAST_ACK

  5. 客户端收到 FIN 之后, 进入 TIME_WAIT 。 经过 2MSL(maximum segment lifetime)超时后变为 CLOSED

  6. 服务端在收到 ACK 后,进入 CLOSED, 连接断开。

tcp 状态转换题

在上面的状态转换图中, 需要关注的通常是 TIME_WAITCLOSE_WAIT

sockt pair

一个tcp的 socket pair 是一个定义连接的两个端点的四元组:本地IP 本地端口 远端IP 远端端口。 一个 socket pair 唯一标志一个网络上的 tcp 连接。

TIME_WAIT

MSL maximum segment lifetime)指得是任何 IP 数据包能够在 internet 中存活的最大时间。因为每个数据报中包含一个字段 8 位的 TTL字段。最大值为 255。 我们假设拥有最大跳数 255 的分组在网络中不可能超过 MSL 秒。

通常情况下, 连接断开方在收到服务端的 FIN 之后会进入TIME_WAIT。 每种 tcp 的实现都必须设定一个 MSL 的数值, 一些系统通常设定的是 2 分钟,也就是 TIME_WAIT 状态会保持 4 分钟。

TIME_WAIT 存在的理由

  • 可靠的实现 TCP 全双工链路连接的终止: 在图 2-5 中, 如果最后一个 ACK 分组丢失了, 服务端会重新发送 FIN,客户端收到 FIN 之后再回 ACK。 如果客户端没有没有维护该连接的状态信息, 客户端则回给服务端一个 RST 。

  • 允许旧的分组在网络中消逝: 假设一个 socket pair(127.0.0.1:10101, 127.0.0.1:10102)连接断开后,过一段时间我们在用相同的 socket pair 建立一个连接,经过2 MSL 之后, 原来连接的分组最多经过 MSL 秒被丢弃,另一个方向上的回应也最多经过 MSL 秒被丢弃, 那么我们能保证新的 socket pair 所收到的分组全是新的连接的分组, 旧的连接的分组全部被丢弃。

TIME_WAIT 是我们经常遇到的问题,一般遇到问题了先检查自己的代码是否有问题, 导致频繁的建立和关闭连接。如果代码没问题我们可以考虑优化服务器的参数。

  • 在之前一个项目中,由于 redis 和 mysql 的连接池的参数设置的不合理, 导致频繁的建立和断开连接,导致程序产生了大量的 TIME_WAIT 状态的socket, 因为每个 socket 连接需要占用一个文件句柄, 每个进程所能打开的文件句柄 fd 是有限的, 使用 ulimit -a 就能查看,所以如果服务端的 fd 被耗尽的时候我们再试着去建立连接则会报错 too many open files。 当然我们可以修改进程可打开的文件数量避免这一问题。 除此之外还会受到 socket pair 的影响

  • 如果程序却是会频繁的建立连接和释放连接,比如爬虫程序等。WEB 服务器(WEB 服务器主动关闭连接的是服务端)等。 我们可以适当的优化系统的参数。 修改 /etc/sysctl.conf

net.ipv4.tcp_tw_reuse = 1 # 表示开启重用。允许将TIME_WAIT sockets重新用于新的TCP socket pair,默认为0,表示关闭 
net.ipv4.tcp_tw_recycle = 1 # 开启TCP连接中TIME_WAIT sockets的快速回收,默认为0,表示关闭
net.ipv4.tcp_fin_timeout  = 30 # 如果程序出现异常,断开连接方从 FIN_WAIT_2 到 TIME_WAIT 的时间

修改完之后可以执行 /sbin/sysctl -p 让修改参数生效

如果你觉得本文对你有帮助,欢迎打赏!