1. 简介
在网络开发的深水区,当遇到连接超时、丢包重传或状态僵死等棘手问题时,往往只有回归协议本源才能找到答案。RFC 793,作为 TCP 协议的奠基之作,虽然发布于 40 多年前,但它定义的报文结构与状态机模型依然是我们理解现代网络通信的基石。
本文并非对 RFC 793 的简单翻译,而是试图通过 “源码级”的视角,重新拆解 TCP 报文头的每一个比特位含义,并深度剖析状态机流转背后的设计哲学。我们将重点聚焦于:
- 报文格式的深度解读:不仅看“是什么”,更要看“为什么这么设计”,理解序列号、窗口大小等字段如何协同工作以保障可靠性。
- 状态机的实战映射:将抽象的状态流转图(State Diagram)与实际生产环境中的
netstat/ss输出对应起来,教你如何通过状态快速定位是代码逻辑缺陷还是网络环境异常。 - 从理论到排查:结合经典案例(如
TIME_WAIT风暴、CLOSE_WAIT泄露),展示如何利用 RFC 793 的知识体系构建高效的排查思路。
希望通过这篇文章,能让你在下一次面对复杂的网络问题时,不再盲目猜测,而是拥有透视数据包流转的“上帝视角”。
2. TCP报文头
首先,让我们直观地看一下 RFC 793 定义的 TCP 报文头结构。请注意,在网络传输中,一般都是采用 大端序 传输,而在主机中则一般是 小端序存储。
2.1 端口号 (Source/Destination Port) - 各占 16 位
-
RFC 定义:标识发送端和接收端的应用进程。
-
设计哲学:IP 地址定位到“主机”,端口号定位到“进程”。16 位意味着最多支持 65536 个端口。
-
排查实战:
- 端口耗尽:在高并发短连接场景下,如果客户端大量使用临时端口(Ephemeral Ports),可能会遇到
Cannot assign requested address错误。这通常是因为TIME_WAIT状态的端口未及时释放,导致可用端口池枯竭。 - 知名端口:小于 1024 的端口通常需要 Root 权限,这是 Unix 系统的安全设计。
- 端口耗尽:在高并发短连接场景下,如果客户端大量使用临时端口(Ephemeral Ports),可能会遇到
2.2 序列号 (Sequence Number) - 32 位
-
RFC 定义:标识本报文段所发送数据的第一个字节的序号。
-
关键误区:SYN 和 FIN 标志位虽然不携带数据,但各自消耗一个序列号!
- 这是很多初学者在计算重传序列号或分析 Wireshark 抓包时容易出错的地方。
- 例如:初始序列号 (ISN) 为 x ,发送 SYN 后,下一个报文的 Seq 将是 x+1 。
-
设计哲学:32 位序列号提供了约 42 亿 ( ) 的编号空间。
- 回绕问题 (Wrap Around) :在千兆、万兆网络环境下,42 亿个字節可能在几分钟甚至几秒内用完。如果旧连接的延迟报文在网络中游荡很久后到达,可能会被误认为是新连接的数据。更加通俗的理解,32位序列号也就代表4GB的大小,现在随便一个游戏都是几十GB,这序列号这样看来根本不够用,但是它其实是一个可循环的序列号,下一个就是0了,这样就不用担心序列号耗尽了。但是带来的问题是,这个0到底是发送之后立马发送得到的,还是发送的时候,在很久之前0就发送了,只不过发送后才收到。
- 解决方案:RFC 1323 引入了 PAWS (Protection Against Wrapped Sequence numbers) 机制,通过时间戳选项来解决此问题。这也是为什么现代 TCP 实现几乎都开启了时间戳选项。 原理的话,简单来说就是,当发送报文的时候,时间戳标明是x,当收到0的报文后查看其时间戳,如果是x+1则表明是在其之后新鲜产生的报文,而若是x-1则表明是之前就产生的报文只不过在网络中游荡了很久。
2.3 确认号 (Acknowledgment Number) - 32 位
-
RFC 定义:期望收到对方下一个报文段的第一个数据字节的序号。
-
核心逻辑:TCP 使用累积确认 (Cumulative ACK) 。
- 如果 ACK = 1001,表示序号 1000 及之前的所有数据都已正确收到,期望收到从 1001 开始的数据。
-
排查实战:
- 重复 ACK (Dup ACK) :如果在 Wireshark 中看到连续 3 个相同的 ACK 号,通常意味着中间有数据包丢失,触发了快速重传 (Fast Retransmit) 机制。这是判断网络丢包最直接的信号。
2.4 数据偏移 (Data Offset) - 4 位
-
RFC 定义:指出 TCP 报文段的数据起始处距离报文段起始处有多远(以 32 位字为单位)。
-
实际意义:其实就是头部长度。
- 最小值:5 (即 5×4=205×4=20 字节)。
- 最大值:15 (即 15×4=6015×4=60 字节)。
- 限制:这意味着 TCP 的选项部分(Options)最大只能是 40 字节。如果选项太多放不下怎么办?这就限制了某些复杂扩展的引入,也是设计上的权衡。
2.5 标志位 (Flags) - 6 位 (URG, ACK, PSH, RST, SYN, FIN)
这是 TCP 状态机的“控制开关”,每个标志位都对应着连接生命周期的关键动作。
| 标志位 | 全称 | 含义与排查重点 |
|---|---|---|
| URG | Urgent | 指示紧急指针有效。现代网络极少使用,通常忽略。 |
| ACK | Acknowledgment | 绝大多数 TCP 报文该位都为 1。只有纯 SYN 报文(第一次握手)该位为 0。 |
| PSH | Push | 告诉接收方:“缓冲区别存了,赶紧交给应用层!”。 排查点: telnet/ssh 等交互式应用常置此位,保证实时性。若视频流未置此位可能导致缓冲延迟。 |
| RST | Reset | 强制复位。收到 RST,连接立即断开,无需四次挥手。 排查点:若频繁看到 RST,可能是: 1. 访问了未监听的端口 (Connection Refused)。 2. 防火墙拦截。 3. 一方进程崩溃或重启。 |
| SYN | Synchronize | 建立连接。同步序列号。 排查点:大量 SYN 而无 ACK 回应,可能是 SYN Flood 攻击 或服务端半连接队列满。 |
| FIN | Finish | 优雅关闭。发送方数据已发完,请求关闭连接。 排查点:FIN 发送后进入 FIN_WAIT 系列状态。若长期卡在 FIN_WAIT_2,可能是对方没发 FIN(对方应用僵死)。 |
2.6 窗口 (Window) - 16 位
-
RFC 定义:流量控制的核心。告知对方“我还能接收多少字节的数据”。
-
局限性:16 位最大只能表示 65535 字节。对于高带宽延迟积 (BDP) 的网络(如跨国专线),这远远不够。
-
演进:RFC 1323 引入了 窗口缩放因子 (Window Scale) 选项,将窗口左移,最大可支持 1GB 的窗口大小。
-
排查实战:
- Zero Window:如果抓到
Window=0的报文,说明接收方处理不过来,发送方必须停止发送。这通常是应用层处理瓶颈(如数据库锁、GC 停顿)的直接证据,而非网络问题!
- Zero Window:如果抓到
2.7 校验和 (Checksum) - 16 位
- 机制:覆盖首部 + 数据 + 伪首部 (源 IP、目的 IP、协议号、长度)。
- 意义:伪首部的加入确保了数据不仅没在传输中损坏,而且确实送达了正确的 IP 和协议端口。
- 注意:IPv6 中校验和是强制的,IPv4 中虽然可选但 TCP 强制要求。
2.8 紧急指针 (Urgent Pointer) - 16 位
- 仅在 URG=1 时有效,指向紧急数据的末尾。实际开发中极少用到。
2.9 选项 (Options) 与 填充 (Padding)
-
变长区域:用于扩展功能。常见的选项包括:
- MSS (Maximum Segment Size) :仅在 SYN 报文中出现,协商最大报文段长度。注意:MSS 不包含 TCP 头,只算数据部分。
- Window Scale:窗口扩大因子。
- SACK (Selective Acknowledgment) :允许接收方告知发送方哪些块收到了,哪些没收到,优化重传效率(RFC 2018)。
- Timestamps:时间戳,用于 RTT 测量和 PAWS。
-
Padding:因为头部长度必须是 4 字节的整数倍,不足部分用 0 填充。
3. 状态机拆解 —— 从“三次握手”与“四次挥手”看透连接生命周期
如果说报文头是 TCP 的“血肉”,那么状态机 (State Machine) 就是 TCP 的“灵魂”。RFC 793 定义了一个严谨的状态流转图,确保连接在任何异常情况下(丢包、重启、乱序)都能最终达成一致或安全关闭。
对于开发者而言,背诵状态图没有意义。真正的价值在于:当你看到 netstat 或 ss 命令输出一堆奇怪的状态时,能立刻在脑海中还原出网络中发生了什么,并定位是代码问题还是网络问题。
RFC 793 的状态图虽然严谨,但过于抽象。为了真正掌握它,我们将连接的生命周期拆解为两个核心剧本: “建立连接的三次握手” 和 “关闭连接的四次挥手” 。
3.1 三次握手
需要思考的是,为什么需要是“三次”握手?为什么不能是一次、二次?这样就能更快、更能节省资源了。
假如只有“两次握手”会发生什么?
假设只需要 Client 发 SYN,Server 回 SYN+ACK 就建立连接:
-
场景:Client 发送了一个 SYN(序号 100),但因为网络拥堵,这个包在路上卡了很久。Client 超时重传了一个新的 SYN(序号 200),并完成了通信关闭了连接。
-
灾难:那个迟到的旧 SYN(序号 100)终于到达了 Server。
- Server 以为 Client 想新建连接,于是回复 SYN+ACK(序号 Y, 确认 101),并直接建立连接进入
ESTABLISHED。 - Server 开始等待 Client 发数据。但 Client 根本没想建新连接,会忽略这个包或直接回 RST。
- 后果:Server 端白白浪费了一个连接资源(文件描述符、内存),一直空等直到超时。如果是高并发场景,这种 “历史连接幽灵” 会迅速耗尽服务器资源。
- Server 以为 Client 想新建连接,于是回复 SYN+ACK(序号 Y, 确认 101),并直接建立连接进入
结论先行:三次握手是在不可靠的信道上建立可靠连接的最小成本方案。
它的核心目的是解决三个问题:
- 确认双方的发送能力正常(我能发,你能收)。
- 确认双方的接收能力正常(我能收,你能发)。
- 同步初始序列号 (ISN) (这是可靠传输的基石)。
我们一步一步来看看三次握手是怎么解决这三个问题的。
3.1.1 第一阶段:客户端的“试探”与 SYN_SENT
动作:客户端调用 connect(),内核构造并发送第一个报文段。
报文特征:SYN=1, Seq=x (x 为随机生成的 ISN)。
状态变迁:CLOSED → SYN_SENT
-
客户端视角(发送后) :
-
已知:我的发送通道是通的(包已发出)。
-
未知:
- 服务端是否收到了我的请求?
- 服务端的接收能力是否正常?
- 服务端的发送能力是否正常?
- 我自己的接收能力是否正常?
-
状态:进入
SYN_SENT,处于“盲等”状态。
-
-
服务端视角(接收后) :
-
已知:
- 我的接收能力正常(成功收到了 SYN)。
- 客户端的发送能力正常。
- 关键动作:我已获取并记录了客户端的初始序列号(ISN = x ),为后续数据排序做好准备。
-
未知:
- 我的发送通道是否通畅?(我能回包吗?)
- 客户端能否收到我的回应?
- 客户端的接收能力是否正常?
-
A 深度原理细节
-
为什么 ISN (初始序列号) 必须是随机的?
- 历史教训:早期的 TCP 实现(如 RFC 793 最初建议)使用基于时间的线性递增 ISN。这意味着攻击者可以通过观察前几个连接的 ISN,推算出下一个连接的 ISN。
- 安全风险:如果 ISN 可预测,攻击者可以进行 IP 欺骗攻击 (IP Spoofing) 。攻击者伪造受信任主机的 IP 发送 SYN,虽然收不到服务器的 SYN+ACK,但他能猜出服务器回复的序列号,从而伪造第三个 ACK 包,直接建立连接并发送恶意指令(如“删除数据库”)。
- 现代实现:Linux 内核使用复杂的伪随机算法(结合时钟、计数器、哈希秘密值)生成 ISN。你在 Wireshark 中看到的 Seq 号总是跳变的,这就是安全防线。
-
客户端此时的心理活动
- “我发出了请求,但我不知道对方是否活着,也不知道网络是否通畅。我不能发数据,只能进入
SYN_SENT状态等待。” - 内核动作:启动重传计时器 (RTO) 。如果 1 秒内没收到回复,内核会重传 SYN 包。Linux 默认重传 5-6 次,每次等待时间指数退避(1s, 2s, 4s...)。如果全部失败,抛出
Connection timed out。
- “我发出了请求,但我不知道对方是否活着,也不知道网络是否通畅。我不能发数据,只能进入
B 工作实际与排查
-
现象:应用日志报错
Connection timed out,或者netstat看到大量SYN_SENT。 -
原因推断:
- 物理/网络层不通:路由错误、网线断了。
- 防火墙拦截:中间防火墙或服务端 iptables 直接 DROP 了 SYN 包(静默丢弃,所以客户端只能等超时)。
- 服务未启动:目标端口没有进程监听,且防火墙配置为 DROP 而非 REJECT。
-
排查命令:
# 实时查看重传情况 tcpdump -i any -n 'tcp port 80 and tcp[tcpflags] & tcp-syn != 0' # 如果看到客户端一直在发 SYN,但没有回包,就是网络或防火墙问题。
3.1.2 第二阶段:服务端的“接纳”与 SYN_RCVD
动作:服务端网卡收到 SYN 包,内核协议栈介入处理。
报文特征:SYN=1, ACK=1, Seq=y (服务端随机 ISN), Ack=x+1。
状态变迁:LISTEN → SYN_RCVD
-
服务端视角(发送后) :
-
已知:
- 我的发送和接收能力均正常(能收 SYN,能发 SYN+ACK)。
- 客户端的发送和接收能力... 等等,此时我还不能确定客户端收到了我的包!
-
未知:
- 客户端是否收到了我的 SYN+ACK?
- 如果客户端没收到,连接就无法真正建立。
-
状态:进入
SYN_RCVD。此时连接处于 “半打开” (Half-Open) 状态。服务端单方面认为连接已就绪,但实际上还在等待最终确认。
-
-
客户端视角(接收后) :
-
✅ 已知(此时客户端获得了全量信息):
- 发送能力确认:服务端收到了我的 SYN(因为它回了 ACK x+1x+1 )。
- 接收能力确认:我成功收到了服务端的 SYN+ACK。
- 对方能力确认:服务端的发送和接收能力均正常。
- 序列号同步:我已获取服务端的初始序列号(ISN = yy ),并发送了确认( Ack=y+1Ack=y+1 )。
-
❓ 未知:
- 我发出的这个最终确认(ACK) ,服务端能收到吗?
-
状态:客户端单方面进入
ESTABLISHED。此时客户端认为连接已建立,甚至可以开始发送数据(数据会携带 ACK 标志)。
-
A 深度原理细节:双队列机制的运作
当服务端收到 SYN 时,并没有立即创建完整的连接对象,而是执行以下操作:
-
检查半连接队列 (Syn Queue)
-
内核维护一个队列,专门存放那些“收到了 SYN,回复了 SYN+ACK,但还没收到第三次 ACK”的连接。
-
队列长度限制:由
/proc/sys/net/ipv4/tcp_max_syn_backlog参数控制。 -
如果队列满了怎么办?
- 默认行为:直接丢弃新的 SYN 包。客户端会触发重传。
- 防御模式 (SYN Cookies) :如果开启了
tcp_syncookies=1,内核不分配内存,不进入队列,而是通过哈希算法计算出一个特殊的序列号k作为 SYN+ACK 的 Seq 发给客户端。只有当客户端正确返回 ACK 时,内核才验证并重建连接。这是抗 DDoS 攻击的神器。
-
-
发送 SYN+ACK
- 服务端回复确认:
Ack = x + 1(确认收到了客户端的 SYN,因为 SYN 消耗一个序号)。 - 服务端同步自己的 ISN:
Seq = y。 - 状态标记:该连接在内核中被标记为
SYN_RCVD,放入 Syn Queue 等待第三次握手。
- 服务端回复确认:
B 工作实际与排查
-
现象:服务端监控显示
SYN_RECV状态连接数飙升,或者netstat -s中SYNs to LISTEN sockets dropped计数增加。 -
原因推断:
- SYN Flood 攻击:黑客发送海量伪造 IP 的 SYN 包,填满 Syn Queue,导致正常用户无法连接。
- 业务处理过慢:虽然少见,但如果应用层
accept()太慢,导致全连接队列满,有时也会间接影响半连接的处理(取决于内核版本和配置)。
-
优化策略:
- 调大队列:
sysctl -w net.ipv4.tcp_max_syn_backlog=2048 - 开启防御:
sysctl -w net.ipv4.tcp_syncookies=1 - 减少重传次数:
sysctl -w net.ipv4.tcp_synack_retries=2(加快失败检测)
- 调大队列:
3.1.3 第三阶段:最终的“确认”与 ESTABLISHED
动作:客户端收到 SYN+ACK,发送最后一个 ACK。
报文特征:ACK=1, Seq=x+1, Ack=y+1。此包可以携带数据!
状态变迁:
-
客户端:
SYN_SENT→ESTABLISHED -
服务端:
SYN_RCVD→ESTABLISHED(并移入全连接队列) -
客户端视角(发送后) :
- 已知:一切就绪。虽然我还不知道服务端是否收到了这个 ACK,但如果我发送数据,服务端收到数据后也会确认,从而间接完成闭环。
- 状态:保持
ESTABLISHED。
-
服务端视角(接收后) :
-
已知(拼图完成):
- 客户端收到了我的 SYN+ACK(因为它回了 ACK y+1 )。
- 客户端的接收能力确认无误。
- 历史幽灵清除:如果这是一个迟到的旧 SYN 导致的连接,客户端通常不会发送这第三次 ACK(或会发送 RST)。收到合法的第三次 ACK,证明这是一个新鲜、有效的连接请求。
-
状态:从
SYN_RCVD正式转入ESTABLISHED。 -
内核动作:将该连接从半连接队列 (Syn Queue) 移至 全连接队列 (Accept Queue) ,等待应用层
accept()。
-
A 深度原理细节
-
服务端的状态迁移与全连接队列 (Accept Queue)
-
当服务端收到第三次 ACK 时,内核执行关键操作:
- 从 Syn Queue 移除该连接。
- 将该连接移入 全连接队列 (Accept Queue) 。
- 状态正式变为
ESTABLISHED。
-
全连接队列长度:取
backlog参数(listen 函数中指定)和/proc/sys/net/core/somaxconn的较小值。 -
如果全连接队列满了:
- 内核会丢弃客户端发来的数据包(注意:此时连接已建立,客户端发的是数据而非 SYN)。
- 客户端表现:连接似乎成功了(因为收到了第二次握手的 ACK),但发送数据时超时或报错。
-
-
TCP 快速打开 (TFO) 的扩展
- 在现代高性能场景中,第三次握手甚至可以携带数据。
- 如果之前连接过,客户端可以在第三次 ACK 中直接带上 HTTP 请求数据。服务端验证 Cookie 后,可以直接处理数据,无需等待
accept()。这减少了 1 个 RTT 的延迟。
B 工作实际与排查
-
现象:客户端
connect()成功,但write()或read()卡住/超时。netstat看到服务端有大量ESTABLISHED但应用层无反应。 -
原因推断:全连接队列溢出。
- 并发量太大,应用层
accept()处理速度 < 新连接到达速度。 - 队列满后,内核丢弃了新连接的数据包,导致客户端重传数据,服务端不断回复
Dup ACK或窗口为零。
- 并发量太大,应用层
-
排查命令:
# 查看全连接队列溢出统计 netstat -s | grep -i "listen overflows" # 如果 ListenOverflows 非零,说明 backlog 不够大! -
优化策略:
- 调大系统限制:
sysctl -w net.core.somaxconn=2048 - 调大代码 backlog:
server_socket.listen(2048) - 根本解决:优化业务逻辑,加快
accept()和处理速度,或使用异步 IO (Netty, Nginx)。
- 调大系统限制:
3.1.4 异常场景全景表:丢包了会发生什么?
为了对“可靠性”有体感,我们推演一下每一步丢包的后果:
| 丢包阶段 | 谁丢了包? | 客户端行为 | 服务端行为 | 最终结果 |
|---|---|---|---|---|
| 第一次 | SYN 丢失 | 触发 RTO,重传 SYN (指数退避) | 毫无感知,保持 LISTEN | 重传成功后正常;多次失败后客户端报错 Timeout |
| 第二次 | SYN+ACK 丢失 | 没收到回应,重传 SYN | 处于 SYN_RCVD,收到重传 SYN 后重传 SYN+ACK | 重传成功后正常;服务端多次重传后关闭半连接 |
| 第三次 | ACK 丢失 | 认为已成功,进入 ESTABLISHED。若发数据,带 ACK 标志 | 处于 SYN_RCVD,没收到 ACK,重传 SYN+ACK | 情况 A (Client 发数据) : Server 收到数据包,确认连接,成功。 情况 B (Client 不发数据) : Server 重传多次后超时关闭;Client 以为连接存在,直到下次发送被 RST 打断。 |
TCP 的可靠性不是靠“不丢包”,而是靠 “超时重传” 和 “状态机自愈” 。即使丢包,协议也能自动恢复,这才是它强大的地方。
3.2 四次挥手
如果说三次握手是“热烈地相识”,那么四次挥手就是“体面地告别”。
TCP 是全双工的,任何一方(客户端或服务端)都可以主动发起关闭连接(四次挥手)。
谁先调用 close()(或 shutdown()),谁就是主动关闭方;另一方自然就成了被动关闭方。这与它是“客户端”还是“服务端”的角色无关,只取决于代码逻辑的执行顺序。
例如: HTTP 服务:服务器配置了 Keep-Alive Timeout,空闲时间过长后,服务器主动断开;业务逻辑:服务器处理完请求,发现数据错误,主动发送 RST 或 FIN 关闭。
很多初学者会问: “握手只要三次,为什么挥手非要四次?能不能像握手那样合并成三次?” 答案藏在 TCP 的全双工(Full-Duplex) 特性里。
3.2.1 第一阶段:主动方的“我要走了” (FIN_WAIT_1)
动作:客户端调用 close(),发送第一个报文段。
报文特征:FIN=1, Seq=u。
状态变迁:ESTABLISHED → FIN_WAIT_1
A 深度原理细节
-
FIN 的含义:
- FIN 标志位表示:“我没有数据要发了,但我还能收数据。”
- 消耗序列号:和 SYN 一样,FIN 虽然不携带应用数据,但消耗一个序列号。所以服务端回复的 ACK 是 u+1u+1 。
-
客户端的心理活动:
- “我的数据发完了,申请关闭发送通道。但我不知道你是否收到了,所以我进入
FIN_WAIT_1等待确认。” - 内核动作:启动重传计时器。如果没收到 ACK,会重传 FIN。
- “我的数据发完了,申请关闭发送通道。但我不知道你是否收到了,所以我进入
B 工作实际与排查
- 现象:
netstat看到大量FIN_WAIT_1。 - 原因:通常是因为网络极差,发出的 FIN 包丢了,或者对方主机直接宕机(无法回 ACK)。
- 排查:检查网络连通性,或对方服务是否已挂掉。
3.2.2 第二阶段:被动方的“我知道了” (CLOSE_WAIT) & 主动方的“等待” (FIN_WAIT_2)
动作:服务端收到 FIN,内核自动回复 ACK。
报文特征:ACK=1, Ack=u+1, Seq=v。
状态变迁:
- 服务端:
ESTABLISHED→CLOSE_WAIT - 客户端:
FIN_WAIT_1→FIN_WAIT_2
A 深度原理细节:为什么这里不能合并?(核心哲学)
这是 “四次”变“三次”的关键分歧点。
-
场景:服务端收到 FIN 时,可能还有数据没处理完(比如数据库查询还没返回,或者文件还没写完)。
-
TCP 的设计:TCP 是全双工的。
- 客户端 -> 服务端 的通道:客户端说关了,服务端确认(ACK),此时这个方向关闭。
- 服务端 -> 客户端 的通道:依然开放! 服务端还可以继续给客户端发数据。
-
为什么不能合并 FIN 和 ACK?
- 因为服务端此时还没准备好关闭。它只能先回一个 ACK 说“我收到你的关闭请求了”,然后继续干活。
- 只有等服务端真的干完活,调用
close()时,才能发送自己的 FIN。 - 结论:ACK 和 FIN 被应用层的处理时间强行拆开了,所以变成了四次。
(注:在某些特定情况下,如果服务端也没数据发了,Linux 内核可能会优化将 ACK 和 FIN 合并发送,变成“三次挥手”,但标准流程是四次) 。
B 工作实际与排查(重中之重!)
-
现象 A:服务端大量
CLOSE_WAIT- 含义:服务端收到了对方的关闭请求,回了 ACK,但应用层代码没有调用
close()。 - 原因:代码 Bug!通常是捕获了异常却没关闭 socket,或者逻辑死循环没走到关闭语句。
- 解决:查代码!查代码!查代码! 确保所有分支都执行了
socket.close()。
- 含义:服务端收到了对方的关闭请求,回了 ACK,但应用层代码没有调用
-
现象 B:客户端大量
FIN_WAIT_2- 含义:客户端收到了服务端的 ACK,知道对方知道了,但在等服务端的 FIN。
- 原因:服务端应用层僵死了,一直不调用
close(),导致永远发不出 FIN。 - 解决:检查对端服务的健康状况。
3.2.3 第三阶段:被动方的“我也走了” (LAST_ACK)
动作:服务端处理完所有数据,调用 close(),发送 FIN。
报文特征:FIN=1, ACK=1, Seq=w, Ack=u+1。
状态变迁:CLOSE_WAIT → LAST_ACK
A 深度原理细节
-
LAST_ACK 的含义:
- “我已经发出了最后的告别(FIN),现在我只需要等到最后一个确认(ACK),就可以彻底关门大吉了。”
-
序列号变化:注意此时的 Seq 是 w (取决于中间发了多少数据),而 Ack 依然是 u+1 (因为客户端在 FIN_WAIT_2 期间不能发数据)。
B 工作实际与排查
- 现象:看到少量
LAST_ACK是正常的。如果堆积,说明客户端没回 ACK(网络问题或客户端挂了)。 - 机制:服务端会重传 FIN,直到收到 ACK 或超时。
3.2.4 第四阶段:主动方的“保重”与“时间等待” (TIME_WAIT)
动作:客户端收到 FIN,回复最后一个 ACK。
报文特征:ACK=1, Ack=w+1。
状态变迁:
- 客户端:
FIN_WAIT_2→TIME_WAIT - 服务端:
LAST_ACK→CLOSED(连接彻底结束)
A 深度原理细节:为什么要等待 2MSL?
这是图中最底部的 2MSL 标注的意义所在。客户端发完 ACK 后,不能马上关闭,必须进入 TIME_WAIT 状态等待 2MSL (Maximum Segment Lifetime,最大报文生存时间)。Linux 默认 MSL=60s,即等待 120s。
两个核心理由:
-
保证最后一个 ACK 可靠到达(防止服务端重传)
- 场景:客户端发的最后一个 ACK 如果在路上丢了。
- 后果:服务端收不到 ACK,会触发超时重传,再次发送 FIN。
- 作用:如果客户端直接关闭了,收到重传的 FIN 会回复 RST(重置),导致服务端报错。
- 解决方案:客户端必须在
TIME_WAIT状态下,才能再次重传 ACK,让服务端正常关闭。
-
让本连接的所有旧报文在网络中消失(防止干扰新连接)
- 场景:假设客户端立即复用相同的四元组(IP+Port)建立新连接。
- 风险:如果旧连接的延迟报文(比如旧的数据包)突然到达,会被新连接误收,导致数据错乱。
- 作用:等待 2MSL(一来一回的时间),足以让网络上所有属于旧连接的报文都失效消失。
B 工作实际与排查
-
现象:客户端(主动关闭方)出现大量
TIME_WAIT。 -
原因:这是高并发短连接的正常特征(如爬虫、压测、网关转发)。
-
风险:占用本地端口资源。如果耗尽(约 28k-60k 个),会报错
Cannot assign requested address。 -
优化策略:
- 开启端口复用:
sysctl -w net.ipv4.tcp_tw_reuse=1(允许将 TIME_WAIT 端口用于新出站连接)。 - 调整 MSL:
sysctl -w net.ipv4.tcp_fin_timeout=30(缩短等待时间,慎用)。 - 架构优化:使用连接池 (Connection Pooling) ,保持长连接,避免频繁握手挥手。
- 开启端口复用:
3.2.5 异常场景全景表:丢包了会发生什么?
表格
| 丢包阶段 | 谁丢了包? | 主动方 (Client) 行为 | 被动方 (Server) 行为 | 最终结果 |
|---|---|---|---|---|
| 第一次 | FIN 丢失 | 触发 RTO,重传 FIN | 毫无感知,保持 ESTABLISHED | 重传成功后正常;多次失败后客户端报错 |
| 第二次 | ACK 丢失 | 处于 FIN_WAIT_1,没收到 ACK,重传 FIN | 处于 CLOSE_WAIT,收到重传 FIN 后重传 ACK | 重传成功后正常 |
| 第三次 | FIN 丢失 | 处于 FIN_WAIT_2,没收到 FIN,一直等待 (直到超时) | 处于 LAST_ACK,没收到 ACK,重传 FIN | 服务端重传多次后超时关闭;客户端最终超时关闭 |
| 第四次 | ACK 丢失 | 进入 TIME_WAIT,重传 ACK (如果收到重传 FIN) | 处于 LAST_ACK,没收到 ACK,重传 FIN | 关键点:正因为 Client 在 TIME_WAIT,才能重传 ACK,让 Server 正常关闭。如果 Client 不在该状态,Server 会收到 RST 报错。 |