ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TCP状态机排障实战:netstat与Socket源码深度解析

TCP状态机排障实战:netstat与Socket源码深度解析 1. 先把状态机画成地图再谈调参前几天线上一个服务连接数突然暴涨我随手netstat -antop看了一眼满屏 CLOSE_WAIT。团队里有人马上翻出运维手册准备调tcp_keepalive_time和tcp_keepalive_probes。我把他拦住了CLOSE_WAIT 跟 keepalive 没有半毛钱关系它是被动关闭方卡在“收到对端 FIN但应用还没调 close()”这个状态。改内核参数等于给黑盒喂药运气好缓解运气不好会把问题掩盖到更难查。所以真正该做的是把 TCP 状态机这张地图先画出来。TCP 之所以复杂不是因为报文格式有多难而是它整个生命周期都是靠状态迁移来驱动的。连接要建立在哪个状态、关闭要经过哪几个状态、异常情况下卡在哪个状态全都有明确对应关系。netstat 是我们从外面看状态机的取景框Socket 源码则是从里面解释状态为什么这么迁的说明书。两个配合起来TCP 不再玄学。1.1 一张表读懂TCP连接的十种状态所有 TCP 连接都会经历一组固定状态一种最直观、也最面试友好的理解方式是把它们分成三个阶段建连阶段、传输阶段、断连阶段。建连阶段主要就是三次握手会用到LISTEN、SYN_SENT、SYN_RCVD、ESTABLISHED四个状态。断开连接阶段则更热闹主动关闭方要经过FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT被动关闭方要经过CLOSE_WAIT、LAST_ACK极端情况下还会出现CLOSING。我把常见状态和含义整理成了下面这张表建议直接保存。状态含义什么时候会出现LISTEN服务端调用了 listen()端口处于监听状态服务端启动等待客户端来连SYN-SENT客户端已发出 SYN等待服务端回应客户端调 connect() 后立即进入SYN-RECEIVED服务端收到了 SYN并发送了 SYNACK等待客户端 ACK三次握手中服务端短暂停留ESTABLISHED连接建立完成双方可以正常收发数据数据传输阶段FIN-WAIT-1主动关闭方已发出 FIN等待对方 ACK调用 close() / shutdown() 写入关闭后FIN-WAIT-2主动关闭方已收到对端 ACK等待对端 FIN半关闭状态对端还没 close()CLOSE-WAIT被动关闭方收到了对端 FIN等待应用层调 close()对端已经 close本端应用尚未关闭LAST-ACK被动关闭方调了 close()发出 FIN等待对方最终 ACKCLOSE_WAIT 之后应用执行 close()TIME-WAIT主动关闭方收到对端 FIN 并回了 ACK等待 2MSL 后彻底关闭主动关闭方最后停留的状态CLOSED连接不存在初始状态或彻底关闭后这张表解决的是“识别问题”。看到一堆 CLOSE_WAIT就知道一定是应用层该 close 没 close看到大量 TIME_WAIT就要去查主动关闭方的连接创建逻辑是不是太频繁。记住这张表再看 netstat 输出就不慌了。1.2 状态迁移是事件驱动不是参数驱动很多人调 TCP 参数时默认它是一锅粥调大缓冲区、调低超时问题就解决了。但 TCP 状态机是严格的事件驱动模型。所谓事件就是“收到报文”“发了报文”“超时器到期”“用户进程执行了某次系统调用”。举个例子SYN_RCVD这个状态为什么一瞬而过因为服务端发出 SYNACK 之后只要等到客户端回一个 ACK就立刻迁移到ESTABLISHED。如果这个 ACK 一直不来状态就会一直挂在SYN_RCVD同时由tcp_synack_retries控制重发。如果重发达到上限还没收到 ACK内核才会放弃这条半连接把它关掉。也就是说状态迁移的每个箭头背后都绑定了一个可观测的触发条件。netstat能让我们看到当前停在哪一态Socket 源码能告诉我们是哪些代码逻辑把它推到了下一态。把这两样都抓在手里遇到问题就不是“瞎调参数”而是“沿着状态机走到卡住的节点找到卡住的代码”。2. netstat命令实操把状态机从内核里拽出来netstat 这个名字听起来像“网络统计”但它最大的价值其实是看连接状态。它能把内核协议栈维护的 TCP 状态表直接打印到终端认真看输出状态机的每个台阶都能踩到。2.1 我用得最多的netstat参数组合传统写法里最常用的是这一套netstat -antop拆开解释一下-a显示所有连接包括 LISTEN 和 ESTABLISHED甚至 TIME_WAIT-n不做域名反解用 IP 和数字端口显示避免卡在 DNS 查询上-t只看 TCP 协议-o显示计时器信息比如 TIME_WAIT 还剩多少秒-p显示占用连接的进程 PID 和名称。如果只看端口监听情况我会换成netstat -tlnp。如果还想看 UDP去掉-t改用netstat -aunp。但平时定位连接问题-antop基本一步到位。实际输出长这样Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:9000 0.0.0.0:* LISTEN 2233/python tcp 0 0 192.168.1.10:54821 192.168.1.5:3306 ESTABLISHED 4451/java tcp 0 0 192.168.1.10:54822 192.168.1.5:3306 TIME_WAIT 4451/javaLocal Address 是本端地址和端口Foreign Address 是对端。State 就是 TCP 状态机当前的状态值。Recv-Q 和 Send-Q 在不同状态下含义不太一样这个细节很容易被忽略后面单独说。2.2 从输出字段反向推导状态机很多人只看 State 那一列忽略了Recv-Q / Send-Q其实这两个字段也是状态机的侧写。对于LISTEN状态Recv-Q表示已完成三次握手、但还没被accept()取走的连接数量。换句话说它是“accept 队列”的当前长度。Send-Q则表示listen()时写入的 backlog 最大值。如果Recv-Q持续逼近Send-Q说明应用层 accept 太慢内核已经堆积了大量已完成握手的连接。对于ESTABLISHED状态Recv-Q/Send-Q通常表示收发缓冲区中还没被用户进程读取或发送的数据量。如果 Recv-Q 一直不为 0可能应用没及时读数据如果 Send-Q 一直不为 0可能对端收得慢或者网络拥堵导致 send buffer 积压。还有一个贴近内核源码的观察入口/proc/net/tcp。netstat 本身也是从这里读取连接表的。直接cat /proc/net/tcp会看到一堆十六进制数字其中状态字段是固定映射01ESTABLISHED, 02SYN_SENT, 03SYN_RECV, 04FIN_WAIT1, 05FIN_WAIT2, 06TIME_WAIT, 07CLOSE, 08CLOSE_WAIT, 09LAST_ACK, 0ALISTEN, 0BCLOSING我早期排查时喜欢同时开两个窗口左边cat /proc/net/tcp右边strace跟进程系统调用这样状态到底是被哪次调用推动的一目了然。2.3 没有netstat时用ss无缝切换现在很多新系统默认不再装 net-tools只保留了iproute2里的ss。很多博主会说“用 ss 替代 netstat”但实际场景里netstat 的输出格式已经被很多老运维刻进肌肉记忆临时换 ss 容易看岔。所以我建议两边都掌握。ss -antop和netstat -antop的作用几乎相同。区别是 ss 从内核 netlink 接口直接读取信息比解析/proc/net/tcp更快而且在高连接数场景下输出更及时ss -antop | grep 9000想看状态分布统计时我习惯用一条管道组合命令netstat -ant | awk {print $6} | sort | uniq -c | sort -rn输出类似156 ESTABLISHED 42 CLOSE_WAIT 13 TIME_WAIT配合watch就能实时观察状态迁移watch -n 1 netstat -ant | grep -E ESTABLISHED|TIME_WAIT|CLOSE_WAIT | awk {print \$6} | sort | uniq -c这一套下来状态机就像被装上了仪表盘能够看到它在线上不停流动。3. Socket源码与系统调用状态机背后的“执行者”netstat 只是显示结果真正推动状态迁移的是内核协议栈更准确地说是应用进程调用 Socket 相关系统调用之后内核里对应函数执行的结果。不理解这一层看到状态也只能猜。3.1 Socket生命周期与状态迁移的对应一次典型的连接建立和关闭用户态只做了很少几个函数调用。服务端socket(); bind(); listen(); // 进入 LISTEN accept(); // 三次握手完成后返回 ESTABLISHED ... close(); // 发起关闭进入 FIN_WAIT_1客户端socket(); connect(); // 发 SYN进入 SYN_SENT ... close(); // 主动关闭每个系统调用在内核里都会触发状态字段sk_state的更新。拿客户端connect()举例它并不只是“尝试去连一下”而是触发内核在tcp_v4_connect()里创建一条连接把状态置为TCP_SYN_SENT然后发出 SYN 报文。之后收包路径中的tcp_rcv_state_process()会根据当前状态和处理进程一步步把状态推下去。如果你在本地写一个简单的服务端import socket srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((0.0.0.0, 9000)) srv.listen(16) conn, addr srv.accept() print(accept, addr) conn.recv(1024) conn.close()执行listen()之后netstat 里就能看到LISTEN对端连接进来accept()返回后这条连接就变为ESTABLISHED最后conn.close()就触发了四次挥手。换句话说Socket 源码里的每一次调用都对应状态机地图上的一个或多个箭头。3.2 读内核源码的三个关键入口如果不是做内核开发没必要从头啃 TCP 实现但至少知道三个入口遇到问题能照着源码推理。第一个是状态枚举定义在 Linux 内核头文件include/uapi/linux/tcp.h里enum { TCP_ESTABLISHED 1, TCP_SYN_SENT, TCP_SYN_RECV, TCP_FIN_WAIT1, TCP_FIN_WAIT2, TCP_TIME_WAIT, TCP_CLOSE, TCP_CLOSE_WAIT, TCP_LAST_ACK, TCP_LISTEN, TCP_CLOSING, TCP_NEW_SYN_RECV, TCP_MAX_STATES };可以看到内核状态值和/proc/net/tcp里的十六进制数字是对应的说明 netstat 显示的 State 不是什么魔法就是内核 socket 结构体里一个枚举字段。第二个是tcp_rcv_state_process()在net/ipv4/tcp_input.c里。所有收到报文后的状态迁移基本都要过一遍这个函数。它内部就是一堆分支判断switch (sk-sk_state) { case TCP_SYN_RECV: // 处理三次握手最后一个 ACK case TCP_FIN_WAIT1: // 等待对端 ACK然后进入 FIN_WAIT2 case TCP_FIN_WAIT2: // 收到对端 FIN 后进入 TIME_WAIT case TCP_CLOSE_WAIT: // 应用层 close() 后进入 LAST_ACK ... }没读过源码的人看到 CLOSE_WAIT会以为它只是“一个奇怪的状态”。读过源码后就知道CLOSE_WAIT 其实暴露的是“应用层迟迟没有调用 close()”的事实——内核早就把连接推进到这个状态等应用了。第三个是连接建立路径中的tcp_v4_connect()和tcp_accept()。前者负责把客户端状态从CLOSE推到SYN_SENT后者在完成握手后把服务端连接从SYN_RECV推到ESTABLISHED。理解了这些函数位置再配合抓包状态机几乎可以用“报文 系统调用”两条线完整还原。3.3 应用层看不到状态机GETSOCKOPT(TCP_INFO) 可以有时候我们想在自己的程序里直接拿到连接状态而不是打开 netstat 去查。Linux 提供了一个强大但被很多人忽略的入口getsockopt(fd, IPPROTO_TCP, TCP_INFO, tcp_info, len)。调用后返回的struct tcp_info里有tcpi_state字段可以直接拿到当前连接状态。这个字段的取值和前面的枚举定义一致。比如想在客户端程序里检测连接是否还活着与其用网线拔了这种玄学判断不如读一次 TCP_INFO先看状态是不是ESTABLISHED再看tcpi_retransmits重传次数、tcpi_rtt延迟这些指标。在终端里用ss -tin也能看到一部分 TCP_INFO 数据比如发送队列、接收队列、拥塞窗口、RTT。这也是为什么我觉得 TCP 从来不是黑盒——内核已经把数据包、状态、参数全暴露出来了关键是你会不会取、会不会读。4. 实战在终端里亲眼看到三次握手和四次挥手理论讲再多不如亲手跑一轮。下面这组实验我建议每个人都做一遍它花不到十分钟但做完之后状态机在你的脑子里就不再是图而是活的过程。4.1 实验环境准备环境很简单一台电脑用回环地址就行。需要准备三个终端窗口终端A跑服务端代码终端B跑客户端代码终端C跑netstat和tcpdump观察。服务端代码存成server.pyimport socket import time srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((0.0.0.0, 9000)) srv.listen(16) print(listening on 9000) conn, addr srv.accept() print(connected:, addr) conn.recv(1024) print(server recv done) time.sleep(5) conn.close() print(server close done) time.sleep(60)客户端代码存成client.pyimport socket import time c socket.socket(socket.AF_INET, socket.SOCK_STREAM) c.connect((127.0.0.1, 9000)) c.send(bhello) time.sleep(3) c.close() print(client close done) time.sleep(60)先启动服务器再启动客户端。在终端C不断刷新 netstatwatch -n 0.5 netstat -antop | grep 9000同时在终端D跑 tcpdumpsudo tcpdump -i lo -nn -c 12 port 90004.2 场景A正常握手盯着SYN_SENT到ESTABLISHED第一次握手时如果手够快你会看到客户端连接短暂出现在SYN_SENT状态Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 127.0.0.1:54321 127.0.0.1:9000 SYN_SENT 4451/python紧接着变成ESTABLISHED。服务端那边刚监听时是LISTEN握手过程中会一闪而过出现SYN_RECV然后也进入ESTABLISHED。tcpdump 的输出会更直接127.0.0.1.54321 127.0.0.1.9000: Flags [S], seq 100 127.0.0.1.9000 127.0.0.1.54321: Flags [S.], seq 200, ack 101 127.0.0.1.54321 127.0.0.1.9000: Flags [.], ack 201S是 SYNS.是 SYNACK.是 ACK。三次握手三个包一眼就能数清楚。很多人在纸上画的图在这一刻会变成真实存在的报文队列。4.3 场景B主动关闭FIN_WAIT_2到TIME_WAIT客户端调用close()之后就会主动进入四次挥手。从 netstat 里可以看到一条连接的状态沿着这条路径走ESTABLISHED - FIN_WAIT1 - FIN_WAIT2 - TIME_WAIT服务端则经过ESTABLISHED - CLOSE_WAIT - LAST_ACK - 消失tcpdump 对应能抓到四个包127.0.0.1.54321 127.0.0.1.9000: Flags [F.], seq 101 127.0.0.1.9000 127.0.0.1.54321: Flags [.], ack 102 127.0.0.1.9000 127.0.0.1.54321: Flags [F.], seq 201 127.0.0.1.54321 127.0.0.1.9000: Flags [.], ack 202注意最后那个 ACK 不是立刻消失而是让主动关闭方进入TIME_WAIT状态默认持续 60 秒。这段时间在netstat -antop里也能看到计时器tcp 0 0 127.0.0.1:54321 127.0.0.1:9000 TIME_WAIT timewait (57.35 sec left)这就是为什么大量短连接并发时系统里会有一批 TIME_WAIT 残留。它不是 bug而是 TCP 为了保证最后一个 ACK 可能丢失的情况下能被重发而设计的保守策略。4.4 场景C故意不closeCLOSE_WAIT是怎么堆积的我把服务端代码改成这样conn, addr srv.accept() print(connected:, addr) conn.recv(1024) time.sleep(60)注意这里conn.recv(1024)读到了客户端发来的数据但后面既不回包也不调用conn.close()。客户端发送数据后直接关闭。运行起来后再看 netstat服务端这条连接会一直停在CLOSE_WAITtcp 0 0 127.0.0.1:9000 127.0.0.1:54321 CLOSE_WAIT 2233/python为什么会这样因为服务端收到了客户端的 FIN内核把状态从ESTABLISHED推到了CLOSE_WAIT然后静静地等应用层执行 close()。应用层不去关闭连接就永远挂在这里。很多线上故障的根因就是这么来的对端主动断开本端已经读到了 EOF但业务代码没把它当回事没有调 close。而因为连接对象还留在内存里文件描述符也没释放日积月累就是一堆 CLOSE_WAIT。这个实验特别适合在自己的测试环境做一次做完之后你会对“代码里每一次 close 都是状态机前进的动力”这句话有非常深的理解。5. 状态机排障速查常见异常与对应内核参数有了状态机的视角排障的顺序就变了。我现在的习惯是先看状态再推断原因最后才动参数。与其贴一大段内核参数说明不如直接按状态分类讲。5.1 CLOSE_WAIT堆积99%是代码忘了close排查命令netstat -antop | grep CLOSE_WAIT | wc -l如果数量一直在涨不用急着调内核参数。先去代码里找所有拿到的 socket 是不是都得到了妥善处理所有 try 块有没有严格 finally所有造成连接泄漏的分支有没有遗漏常见的两类坑服务端accept()后不读取客户端数据也不知道对端已经关闭用了连接池框架空闲连接被对端关闭后没有做失效剔除。CLOSE_WAIT 本质是“应用层该 close 而没有 close”。keepalive 参数能清理的是长时间没有数据的连接不是这种已经收到 FIN 的连接。前者是内核替你探活后者是应用自己必须做的事两者不能混为一谈。5.2 TIME_WAIT过多四元组占满端口排查命令netstat -antop | grep TIME_WAIT | wc -lTIME_WAIT 多不一定是坏事它是主动关闭方正常的收尾状态。真正的问题在于当大量短连接都连接同一个目标 IP 和端口时每次连接占用的四元组里本地端口只能在临时端口范围内选。TIME_WAIT 持续 60 秒而临时端口数量一般只有 28 到 30 万个一旦创建连接的速度超过回收速度就会出现本地端口不够用。Java 客户端重连时报Address already in use: connect多半就是这个原因。这时候正确思路不是把 TIME_WAIT 一刀切而是业务侧改成连接池减少频繁拔插服务端开启SO_REUSEADDR允许端口快速重绑定客户端如果确实要大量短连接再结合SO_REUSEPORT做负载分担内核侧可以适度调低net.ipv4.tcp_fin_timeout但不要调到破坏 TCP 可靠性。“地址已在使用”这类报错用 netstat 一看就能定位大概率能看到同一目标端口大量 TIME_WAIT 残留。这时候再回来改代码方向才不会跑偏。5.3 队列溢出和SYN重传接受队列问题连接建立失败、握手反复重传问题往往不在状态机图里那些大步骤上而在一开始的三次握手入口。服务端listen(fd, backlog)指定的 backlog 上限还要受内核参数net.core.somaxconn限制。如果 backlog 配置超过 somaxconn内核会悄悄裁剪。客户端大量连接进来如果accept()消费不够快已完成握手的队列就会堆积。观察方法是看LISTEN状态下的Recv-Qnetstat -ant | grep LISTEN | awk {print $2, $3, $4}如果 Recv-Q 长期接近甚至等于 Send-Q即 backlog说明握手完成后应用来不及 accept。此时调整方向有两个应用层提高处理并发的能力或者增加 accept worker适当调大listen()的 backlog 和net.core.somaxconn。SYN 重传的另一个检查点是半连接队列。内核用net.ipv4.tcp_max_syn_backlog限制尚未完成握手的连接数量。如果 SYN 洪水把队列塞满新连接连 SYN 都会被丢弃。netstat -s里的SYNs to LISTEN sockets dropped会给出线索。看到这类数字再把tcp_max_syn_backlog调大才有意义否则就是盲目加参数。5.4 故障速查表我把比较常见的问题整理成一个快速对照表方便现场排查时直接对号入座。状态或现象常见原因第一排查动作大量 CLOSE_WAIT应用未调用 close()用 netstat -antop 查对应进程查代码里的关闭逻辑大量 TIME_WAIT主动关闭方短连接频率太高检查目标端口是否固定评估连接池方案客户端报 address already in use本地临时端口被 TIME_WAIT 占满netstat 数 TIME_WAIT 数量查连接复用LISTEN 的 Recv-Q 持续增长accept 消费慢或 backlog 太小查看应用 accept 速率调大 backlog/somaxconnSYN_SENT 反复出现SYN 报文发出去收不到回应确认对端监听、防火墙、网络路由SYN_RECV 堆积半连接队列满或 ACK 一直不到检查 tcp_max_syn_backlog、抓包看 ACK卡在 FIN_WAIT1对方一直不回 ACK抓包看 FIN 是否发出、对端是否收到卡在 LAST_ACK最后的 ACK 对方没收到抓包确认 ACK 是否发出检查中间设备丢包这张表的价值不在于参数本身而在于它逼你先看状态、再定下一步。状态机告诉我们在哪个位置卡住代码和报文告诉我们是哪一步没走通。6. 把状态机装进脑子我的实操体会这套方法用久了最大的改变是我遇到 TCP 问题不再第一时间打开搜索引擎。遇到连接异常我会先跑一条 netstat看看状态分布再决定要不要抓包、要不要看系统调用。绝大多数问题其实在状态层就能定位个七七八八。我后来自己写了一个小示波器脚本核心逻辑很简单定时执行 netstat同时把 tcpdump 日志落盘再把/proc/net/tcp的状态分布画成趋势。本意是排查一个偶发的重连失败问题结果意外发现三次握手在某段时间经常卡在 SYN_SENT 到 SYN_RECV 之间。配合抓包才发现是中间交换机的会话超时清理导致 SYN 没有到达服务端。如果没有状态机视角很可能会先去调内核参数最后发现彻底跑偏。还有一个体会是TCP 参数不是不能调而是要调得有依据。tcp_keepalive_time是用来清理空闲连接的tcp_fin_timeout是控制 TIME_WAIT 时长的tcp_max_syn_backlog是控制半连接队列的somaxconn是控制 accept 队列的。每一个参数都有明确的作用边界对应状态机里一个具体节点。在没确认连接卡在哪个节点之前调参就像蒙着眼睛修电路。最后分享一个小技巧本地做实验时把 netstat 的刷新频率调到极值和 tcpdump 一起开再人为制造正常关闭、异常关闭、对端不读不写、服务端不 close 这几种场景。多跑几轮你会明显感觉到 TCP 状态机从“背下来的图”变成了“亲眼见过、亲手复现过”的知识。以后再遇到线上故障你会比大多数人更快定位到问题本身。
返回列表