
线上有一台服务端口连接数报警我用 netstat 一看满屏的 CLOSE_WAIT 和 TIME_WAIT顺手把系统参数里的 tcp_tw_reuse、tcp_fin_timeout 调了一圈看似平息了。但过两天换了个端口同样的问题又冒出来而且更诡异——连接状态在 FIN_WAIT_2 和 SYN_SENT 之间反复横跳日志里什么错误都没有。那时候我才意识到自己对 TCP 状态机的理解停留在背状态名和拷网上的调参模板上netstat 在我手里就是个黑盒子。后来花了两个晚上把内核里 socket 状态迁移的源码过了一遍再用 netstat 对照着抓很多玄学问题一下子变成了源代码里写得很明白的问题。这篇文章想跟你分享的就是这套从netstat 看现象到socket 源码看本质的排查思路。它适合那些已经在用 netstat、ss 查连接但遇到 CLOSE_WAIT 堆积、TIME_WAIT 异常、握手超时等问题时只能靠搜参数来调而不是靠逻辑来判的开发者。读完你会发现TCP 状态机并没有那么可怕它不过是一段 if-else 组成的有限状态迁移逻辑而 netstat 输出的每一列都能在源码里找到对应的变量。1. 多少人把 netstat 用成了黑盒温度计先说个扎心的现象大多数后端开发者排查网络问题时对 netstat 的使用程度就是看看连接状态数量然后根据经验去搜索引擎找xx 状态过多怎么办的调参建议。这种用法下netstat 和体温计没有区别——你只知道发烧了但不知道是细菌感染还是病毒感染更不知道应该用抗生素还是抗病毒药。而 TCP 状态机的复杂之处恰恰在于同一个状态过多背后的原因可能完全相反盲目调参可能让问题更严重。比如 CLOSE_WAIT 堆积。很多人第一反应是加大文件描述符调 tcp_keepalive_time但实际上 CLOSE_WAIT 的含义是本端已经收到了对端的 FIN但应用程序还没调用 close()。说得再直白一点是应用层把 socket 关晚了甚至是忘了关跟内核参数没有半毛钱关系。你再怎么调 net.ipv4.tcp_keepalive_time 也没用因为 keepalive 探测的是死连接而 CLOSE_WAIT 的连接其实还活着只是没人理它。再比如 TIME_WAIT 过多。网上一堆文章让你开 tcp_tw_reuse、tcp_tw_recycle但真正看懂 TIME_WAIT 的意义之后你会发现这个状态本身就是 TCP 协议为了保证最后一个 ACK 能可靠到达对端以及防止旧连接的报文干扰新连接而主动等待的。在正常的短连接高并发场景下TIME_WAIT 多恰恰说明你的四次挥手在正常进行反而不需要大动干戈。真正的病根往往在别处比如连接根本没有被复用、客户端不断新建连接而不是用连接池。那黑盒调参的问题出在哪出在我们把 netstat 当成了终点而不是起点。正确的姿势应该是netstat 给你一个状态的快照然后你要能回答三个问题——这个状态是怎么进来的、要满足什么条件才能出去、如果一直出不去是内核的哪个逻辑卡住了。前两个问题在 TCP 状态机图里有答案第三个问题就要去 socket 源码里找。1.1 状态名只是用户态翻译内核里全是整数这里有个容易被忽略的细节你在 netstat 里看到的 LISTEN、SYN_SENT、ESTABLISHED、CLOSE_WAIT、TIME_WAIT 这些字符串并不是内核直接输出的而是用户态工具根据 socket 状态编号翻译出来的名字。在 Linux 内核里TCP 状态实际上是一个枚举值定义在 include/uapi/linux/net_tstamp.h 旁边的 include/net/tcp_states.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 };这个枚举值得反复看几遍因为它是整个 TCP 状态机的坐标轴。每次状态迁移本质就是 sk-sk_state 这个字段从一个整数变成另一个整数。netstat 的 State 列读的就是这个字段。掌握这一点之后你看到的不再是一个单词而是一个有着明确迁移路径的节点。从源码角度socket 的状态字段存在 struct sock 里叫 sk_state而 TCP 特有的字段则在 struct tcp_sock 里。简单理解struct sock 是所有协议族共享的通用 socket 结构struct tcp_sock 是 TCP 协议私有的状态扩展包里面装着 snd_una、snd_nxt、rcv_nxt 这些滑动窗口相关的序号。状态迁移的判断主要发生在两个函数里tcp_rcv_state_process() 负责处理非 ESTABLISHED 状态下收到的报文tcp_v4_do_rcv() 负责根据当前状态把报文分发给不同的处理路径。1.2 你为什么需要关心状态迁移而不只是状态值我见过不少同学排查一个问题客户端连不上服务端netstat 一看服务端有大量 SYN_RECV于是断言半连接队列爆了直接调 somaxconn。这个判断方向没错但如果你只走到这一步就错过了一整片森林。SYN_RECV 是怎么产生的是服务端收到了 SYN发出了 SYNACK但还没收到客户端的 ACK。那问题是出在SYN 洪水导致半连接队列满还是出在客户端根本没收到 SYNACK还是出在客户端收到了但 ACK 丢了这三种情况的处置方式完全不同。想知道答案你得看另一面的状态如果客户端一直停留在 SYN_SENT说明 SYNACK 根本没到客户端如果客户端已经 ESTABLISHED 而服务端还在 SYN_RECV说明 ACK 丢了或者被过滤了。就这一步对比你的排查范围就从瞎猜缩小到了看包去了哪里。而这一步的底气来自你对状态迁移路径的了解SYN_SENT →收到 SYNACK→ ESTABLISHEDSYN_RECV →收到 ACK→ ESTABLISHED。2. netstat 到底能抓到什么状态、队列与计数器工具层面的基本功还是要过一遍但这次我们换个角度——不光知道每一列在显示什么还要知道它背后读的是内核的哪个数据。netstat 最常用的组合是netstat -anpt四列信息Proto、Local Address、Foreign Address、State分别对应协议、本地地址、远端地址、状态。但你需要注意真正干活的时候光看 State 不够Recv-Q 和 Send-Q 才是判断连接卡在哪的关键。2.1 Recv-Q 和 Send-Q 在不同状态下有完全不同的含义这是 netstat 参数里最容易被误解的地方。很多人以为 Recv-Q 就是接收缓冲区里没读走的数据量Send-Q 就是发送缓冲区里没发出去的数据量。这个理解在 ESTABLISHED 状态下基本正确Recv-Q 对应的是接收队列 sk_receive_queue 里的字节数Send-Q 对应的是发送缓冲区中尚未被确认的数据量。但一旦状态变成 LISTEN这两个值的含义就彻底变了。LISTEN 状态下的 Recv-Q 表示的是当前已完成三次握手、等待应用层 accept() 的连接数量也就是已完成连接队列的长度Send-Q 则表示该队列的最大长度而它的大小由 listen() 的 backlog 参数和 net.core.somaxconn 共同决定。判断半连接队列有没有溢出的一个有用信号是如果 LISTEN 状态下的 Recv-Q 经常超过 Send-Q或者持续占满说明应用层 accept() 的速度跟不上连接建立的速度这时调 somaxconn 只是治标真正要查的是应用为什么迟迟不 accept。对于对比我可以给一张简表状态Recv-Q 含义Send-Q 含义LISTEN已完成握手、待 accept 的连接数accept 队列上限backlogESTABLISHED接收缓冲区中未读走的字节数发送缓冲区中未确认的字节数SYN_SENT0没有实际含义0TIME_WAIT00CLOSE_WAIT可能还有未读走的数据应用层可能还在写数据为什么 TIME_WAIT 和 SYN_SENT 下这两个值总是 0因为 TIME_WAIT 状态下 socket 已经不再传输数据了它在等待内核定时器到期SYN_SENT 状态下连接还没建立发送缓冲区里可能只有 SYN 本身不统计业务数据。这两个值一旦异常出现大数反而值得警惕。2.2 用 netstat 采集状态分布比看一次有用得多单次执行 netstat 只能给你一个瞬时快照而排障最忌讳的就是根据一个瞬时快照下结论。我的习惯是写一个简单的循环每分钟采样一次统计各状态的连接数变化趋势再结合应用日志的时间点去对齐。给一个可以直接拿去改的小脚本while true; do date %Y-%m-%d %H:%M:%S netstat -anpt | awk /^tcp/ { split($4, local, :); split($5, remote, :); if ($6 ! ) state[$6]; } END { for (s in state) { printf %-12s %d\n, s, state[s]; } } sleep 60 done这个脚本的输出可以用来画趋势折线也可以直接看告警时间段的前后变化。实际操作中我有一次就是靠这个脚本发现 CLOSE_WAIT 并不是持续上涨的而是每隔半小时涨一波、过几分钟又降下去正好对上了上游定时任务批量调用接口的节奏——根本不是连接泄漏而是调用方的 HTTP 客户端没开 keep-alive每次请求完就断开而服务端处理完响应后没有及时关闭 socket。这种规律性是单次 netstat 永远看不出来的。不过 netstat 有一个明显的短板它不显示进程信息以外的更多细节比如 socket 属于哪个 inode、关联的文件描述符是多少、当前正在进行的系统调用是什么。想要更精细的数据就要换用 ss。ss 直接读取内核的 inet_diag 机制性能比 netstat 好很多输出的字段也更丰富比如可以加-o显示定时器信息加-e显示内存信息加-p显示进程。在排查 FIN_WAIT_2 迟迟不消失的问题时ss -tanpo能看到该连接当前的定时器剩余时间这比 netstat 干瞪眼强太多。3. 从内核 Socket 源码看状态迁移的每一步工具说到底只是眼睛真正做决策的是大脑。而大脑要想理解 TCP 状态机不能只看那张经典的状态迁移图因为状态图画的是理想模型真实内核代码里有各种边界分支。我建议的直接路径是打开 Linux 内核源码找到 net/ipv4/tcp_input.c 里的 tcp_rcv_state_process() 函数顺着它读一遍。这个函数是接收路径上处理 TCP 状态机的核心入口。3.1 tcp_rcv_state_process状态机的总调度室这个函数的大体逻辑是先根据当前 sk-sk_state 决定走哪条分支。如果是 TCP_LISTEN 状态收到的报文会走 tcp_v4_do_rcv() 中的监听处理逻辑并调用 tcp_v4_conn_request() 处理 SYN进而分配 request_sock 并进入 SYN_RECV。如果当前状态是 TCP_SYN_SENT说明本端主动发起连接后收到了对端的 SYNACK这时进入 tcp_rcv_synsent_state_process()完成进入 ESTABLISHED 前的最后确认。如果当前是 TCP_ESTABLISHED就进入 tcp_rcv_established()这是数据面处理的主路径。我当初读这个函数时印象最深的一点是内核在处理每一个状态时都会先检查这个报文在当前的 seq 范围内合不合法如果不合法直接丢弃并可能回一个 ACK 要求对端重传。换句话说状态迁移不是只要收到某个标志位就切状态而是收到合法范围内的某个标志位才切状态。这解释了很多线上怪现象比如客户端明明重传了 SYN服务端却不理它很可能是因为 SYN 里的 seq 落在了一个旧连接的窗口范围内而被丢弃了。在 tcp_rcv_state_process() 里对 TIME_WAIT 的处理也很有意思。当连接处于 TCP_TIME_WAIT 状态时如果收到对端重传的 FIN内核会重新发送最后一次 ACK 并重置 TIME_WAIT 定时器。这个逻辑保证了如果最后一个 ACK 在网络中丢失对端重传 FIN 时本端还能再回应一次。理解了这段代码你就明白为什么 TIME_WAIT 的 2MSL 必须存在也明白网上那些把 TIME_WAIT 设成 1 秒的激进调参有多危险——它在某些边界场景下确实不出问题但在报文延迟大的网络里就是在玩火。3.2 CLOSE_WAIT 和 FIN_WAIT_2半关闭状态的两个冤家CLOSE_WAIT 的核心机制在 TCP 的关闭路径里要看得更清楚。当本端收到对端的 FIN 时内核做了什么在 tcp_rcv_state_process() 中如果当前是 ESTABLISHED 状态且收到的报文带 FIN内核会进入 tcp_data_fin()把 sk-sk_state 切换到 TCP_CLOSE_WAIT然后唤醒正在等待数据的应用进程告诉它对端已经关闭了写方向你该 close 了。注意到这里内核就把球踢给了应用层——它不会主动关闭这个 socket只会通知。如果应用层不调用 close()这个连接就永远停在 CLOSE_WAIT。换句话说CLOSE_WAIT 从来不是内核问题它是应用层的问题。你可以用ss -tanp看到这个 socket 对应的进程和 fd然后去代码里查为什么收到可读事件后没有走到 close()。常见的几类原因我后面案例里会细说。FIN_WAIT_2 则刚好相反。主动关闭方发送 FIN 后进入 FIN_WAIT_1收到对端的 ACK 后进入 FIN_WAIT_2此后等待对端也发来 FIN。如果对端一直不发 FIN本端就会一直挂在 FIN_WAIT_2。在 Linux 里这个状态有一个兜底机制net.ipv4.tcp_fin_timeout控制的定时器默认 60 秒超时后直接关闭连接。所以 FIN_WAIT_2 堆积一般意味着对端应用迟迟不关闭连接甚至不读走数据。你光看本端 netstat 看到的是 FIN_WAIT_2 多真相往往要在对端找 CLOSE_WAIT 堆积这在分布式系统里尤其常见——一个服务 A 主动断开时服务 B 忘了 close。3.3 SYN_SENT、SYN_RECV 与握手重传的源码逻辑三次握手的核心状态是 SYN_SENT 和 SYN_RECV。客户端调用 connect() 后内核发出 SYN状态变为 SYN_SENT。此时如果迟迟收不到 SYNACK内核不会无限等tcp_syn_retries 控制的 SYN 重传定时器会反复重发。每次重传的间隔是翻倍的1 秒、2 秒、4 秒……直到达到重传上限。这个机制在 tcp_transmit_skb 和 tcp_write_timer 相关的路径里维护。所以看到客户端有大量 SYN_SENT 停在那里你的第一反应应该不是调参数而是抓包看 SYN 到底有没有出网卡、SYNACK 到底有没有回来。服务端收到 SYN 后进入 SYN_RECV 的路径也值得细看。在 tcp_v4_conn_request() 里内核首先要检查半连接队列syn queue是否已满如果满了且有 SYN 重传可能会直接丢弃然后要决定是走 SYN Cookie 还是正常的 request_sock 分配。request_sock 里保存了连接建立所需的最小上下文等收到客户端的 ACK 后把它升级为真正的 sock队列进入 accept 队列。这个阶段的相关参数是 tcp_max_syn_backlog、somaxconn 和 tcp_syncookies。但我想强调的是如果 SYN_RECV 大量出现且持续超过半分钟不消失多半是第三次握手的 ACK 没过来这时与其在服务端调参数不如到客户端确认它到底有没有进入 ESTABLISHED。4. 源码 netstat 双线定位一次真实抓包级排障理论铺垫完了讲两个我实际处理过的案例。这两个案例都不复杂也没有用到 tcpdump只用 netstat 配合源码里的状态迁移逻辑就把问题锁定了。它们很适合用来演示双线定位到底是怎么操作的。4.1 案例一接口偶发超时服务端全是 CLOSE_WAIT现象是这样的一个内部服务每隔几分钟就会有一批接口调用超时登录服务器执行netstat -anpt | grep 8080 | grep CLOSE_WAIT | wc -l数量在 30 到 80 之间波动。我第一时间没有去调任何参数而是先连续采样了一轮发现 CLOSE_WAIT 的数量与上游某一个数据同步任务的执行时间高度重合。这个任务每五分钟跑一次每次建立几十个连接处理完数据后关闭连接。然后我用ss -tanp | grep CLOSE_WAIT拿到这些 socket 对应的进程和 fd发现它们都属于服务的连接池线程。到这里根因基本浮出水面服务端处理完请求、数据已经回写完毕但连接池线程在收到 FIN 后没有执行 close()而是把半关闭的连接判断成了空闲连接放回了池子里。后续请求如果复用到这个连接写数据不会立刻报错因为 TCP 允许你在收到 FIN 后继续发送数据这就是半关闭但读取对端的响应会永远读不到最终超时。修复方式很朴素应用层在收到对端 FIN 后无论读写是否完成必须及时关闭 socket不能因为感觉上没数据了就跳过 close。内核在这里的职责只是通知你它不会替你决策。这里我还顺带解释一下为什么调 keepalive 无效——keepalive 探测是给那些长期无数据传输的死连接用的而 CLOSE_WAIT 的连接虽然半关闭了但内核并不认为它死了所以 keepalive 根本不会触发。4.2 案例二流量高峰期出现大量 SYN_SENT 但连接归零另一个案例是客户端这边的问题。业务高峰期时客户端日志里频繁报connect timeout我上客户端机器一看netstat 里大量 SYN_SENT 状态而且这些状态的连接数量还在往上涨。我脑子里立刻闪过源码里的重传逻辑SYN_SENT 会持续重传 SYN重传次数耗尽后 connect() 才会返回超时。于是我没有直接在客户端加参数而是抓了一下本机发出的包发现 SYN 确实在发但服务端对应网卡上却没有收到入向 SYN 的计数增长。问题定位到了中间网络设备而不是 TCP 层。后续让网络组查了一下发现是一台负载均衡设备的连接表在高峰期被打满对新建连接丢包。这个案例想说明的是源码里的状态迁移逻辑告诉你SYN_SENT 表明 SYN 已发出但 SYNACK 未回来于是你把排查方向指向网络链路而不是在内核参数里反复横跳。没有这层理解你可能已经在调 tcp_syn_retries 或者 tcp_max_syn_backlog 了但调的明明是无关变量。这里可以给出一个通用的排查顺序供参考netstat -anpt | awk {print $6} | sort | uniq -c | sort -rn看状态分布。对异常状态用ss -tanpo查看定时器和进程信息。根据状态迁移路径判断卡在了哪个环节SYN_SENT 卡在SYNACK 未到达SYN_RECV 卡在ACK 未到达CLOSE_WAIT 卡在应用未调用 closeFIN_WAIT_2 卡在对端未关闭写方向。如果卡点指向网络层而非内核层果断抓包或检查中间设备不要在内核参数上浪费时间。5. 我常用的三个监听脚本与状态机的体检思路既然聊到了实操再分享几个我平时用来给服务器 TCP 状态机做体检的小工具和思路。它们都不复杂但能帮你把状态机是否健康这件事量化出来而不是等告警了才被动排查。5.1 状态分布快照脚本适合放到 crontab 里每小时输出一次快照也可以在出问题时手动执行。我用的是这个版本它会输出每个状态的连接数、总计以及处于 LISTEN 状态的 accept 队列水位#!/bin/bash echo $(date %Y-%m-%d %H:%M:%S) TCP State Snapshot netstat -anpt 2/dev/null | awk BEGIN{OFS\t} /^tcp/ { state[$6]; total; if ($6 LISTEN) { split($2, a, :); split($3, b, :); } } END { for (s in state) printf %-15s %d\n, s, state[s]; printf %-15s %d\n, TOTAL, total; } ss -tanp 2/dev/null | awk NR1 { if ($1 LISTEN) { split($2, q, :); print listen backlog:, q[2]; } }这个脚本的输出会同时告诉你状态分布和accept 队列水位。我日常最关心两个比例一是 CLOSE_WAIT 占 ESTABLISHED 的比例正常应该趋近于 0如果超过 1% 就要警觉二是 SYN_RECV 的存量如果采样三次都在增加说明握手有问题。5.2 连接增长的窗口对比法状态快照只能看存量我想看增量时一般用这个思路每两分钟采样一次 ESTABLISHED 和 TIME_WAIT 的数量然后对比前后两次的差值。如果 ESTABLISHED 数量稳定但 TIME_WAIT 持续增长说明连接在大量建立然后主动关闭这时要看应用层是否合理使用了连接池如果 ESTABLISHED 和 CLOSE_WAIT 同步增长说明服务端处理完请求后没有及时关闭连接是我前面说的应用层问题。这里我可以给一个基于 netstat 的快速差值脚本netstat -anpt | grep -c ESTABLISHED /tmp/est.1 sleep 120 netstat -anpt | grep -c ESTABLISHED /tmp/est.2 diff /tmp/est.1 /tmp/est.2虽然简单得有点土但毛病少、容易让其他人接手。真正的重点不是脚本本身而是你拿到差值之后能不能解释它。如果解释不了就去翻源码里对应状态迁移的条件。5.3 关于 TCP 参数调优的正确打开方式最后实在绕不开调参这个话题但我得先把话说在前面参数是帮你修正边界条件的不是帮你救火的。救火的第一步永远是搞清楚状态迁移卡在哪一步。比如半连接队列溢出你应该抓 SYN 的流量特征如果只是瞬时高并发可以适当调大 tcp_max_syn_backlog 和 somaxconn如果从来就没调大过这两个值先确认 backlog 参数传的是不是太小再确认应用是否及时 accept。又比如 SYN-ACK 重传导致客户端一直 SYN_SENT如果你盲目调大 tcp_syn_retries只是把超时时间拉长让用户等得更久而问题根本没有解决甚至更糟。从源码角度理解参数之后你会发现参数和状态是挂钩的tcp_syn_retries 影响的是 SYN_SENT 状态能待多久tcp_synack_retries 影响的是 SYN_RECV 状态能待多久tcp_fin_timeout 影响的是 FIN_WAIT_2 的兜底时间tcp_keepalive_time 影响的是 ESTABLISHED 空闲连接的探测周期tcp_max_tw_buckets 和 tcp_tw_reuse 影响的是 TIME_WAIT 的存放和复用逻辑。每个参数对应一个状态你把它当作某个状态的最大停留时间来理解参数表就不再是死记硬背而是一张状态机超时明细表。5.4 一次调参的好习惯改前快照、改后对比、分环境验证我见过太多人改参数是一次性改一堆然后看整体效果。这在排查状态机问题时特别容易翻车因为多个参数之间的相互作用很难预测。我现在的习惯是先在测试环境复现问题用 netstat 拿到调参前的状态分布快照然后一次只改一个参数记录改动前的值用 sysctl -w 修改后立即重新采集快照如果状态分布没有改善先回滚再试下一个参数。比如试 tcp_tw_reuse 时我会同时观察 TIME_WAIT 数量和新建连接的耗时因为 tcp_tw_reuse 只对客户端生效它能让客户端安全地复用 TIME_WAIT 连接但对服务端无效。这个细节在源码注释里写得很明确但网上的教程很少强调。另外每次调参我都会在 /proc/sys/net/ipv4/ 下先备份当前值。不要嫌麻烦我至少有两次因为改完之后忘了原值最后只能翻内核默认值来恢复。写在最后的一点体会从背状态名到看源码理解状态迁移这个过程对我的排查效率提升是非常明显的。现在遇到连接异常我脑子里会自动弹出那个状态枚举和 tcp_rcv_state_process 的分支结构然后按图索骥去判断问题出在内核、应用层还是网络链路。netstat 也不再是一个黑盒温度计而是一块能看到内核状态的仪表盘——它显示的每一个状态我都能在代码里找到它停留的条件和离开的路径。如果你也想突破调参工程师这个阶段我建议你挑一个周末把 tcp_states.h 里的枚举和 tcp_input.c 里的 tcp_rcv_state_process 函数对着读一遍不需要全部读懂先把 ESTABLISHED、CLOSE_WAIT、FIN_WAIT_1/2、TIME_WAIT 这五个和你线上问题最相关的路径理清楚。然后回到你的服务器上用 netstat 看一遍当前状态试着问自己如果这个状态的数量突然翻倍我第一反应应该查哪里能答上来你就已经比大多数只搜TCP 状态调参大全的人走得远了。