
先说我为什么想写这个题目。上周凌晨我被一个生产问题叫起来服务端日志里全是 Connection reset用 ss -s 看一眼TIME_WAIT 堆到五位数CLOSE_WAIT 还在往上爬。这种情况对做 Linux 运维、后端开发、甚至嵌入式联网设备的人来说都太熟悉了。最后解决靠的不是重启而是把 TCP 那套连接建立、状态流转、内核参数、socket 编程链路整体过了一遍才定位到是一个连接池没关干净导致的异常。所以这篇文章我想彻底展开讲一次 Linux 下的 TCP从三次握手抓包开始到内核参数调整、UDP 选型对比、socket 长连接写法最后落到几个高频线上问题的排障思路。不管你是 CentOS、Ubuntu还是现在越来越常见的统信 UOS、麒麟这类国产化系统底层都是同一套 Linux 内核 TCP/IP 协议栈之前积累的经验完全通用。文章里所有命令我都会给实际可跑的版本代码也是能直接编译试的适合照着敲一遍。1. 三次握手不是三次问候连接建立的全过程1.1 用 tcpdump 看三次握手的每一个字节理论书上说 TCP 三次握手是 SYN、SYN-ACK、ACK背起来很轻松但真要排查问题你需要建立看到数据包就能还原连接建立过程的肌肉记忆。我习惯的验证方法特别简单开两个终端一个跑抓包一个跑连接请求# 终端A tcpdump -i eth0 tcp port 9000 -nn -vv # 终端B nc -vz 127.0.0.1 9000如果没有 nc用 bash 自带的 /dev/tcp 也行timeout 2 bash -c echo /dev/tcp/127.0.0.1/9000。抓包结果大概是三行10:0.1.1.1.52340 10.0.1.2.9000: Flags [S], seq 3452345107 10.0.1.2.9000 10.0.1.1.52340: Flags [S.], seq 1928374655, ack 3452345108 10.0.1.1.52340 10.0.1.2.9000: Flags [.], ack 1928374656三个关键点。第一SYN 报文里的 seq序列号不是从 0 开始的而是个随机初始序列号 ISNInitial Sequence Number。这是为了防止旧连接的报文被误认为是新连接的报文本质上是安全设计。第二第二次握手同时带着S.两个标志把服务端自己的 ISN 发过去同时用 ack 把客户端的 seq1 确认回来。为什么必须是 1因为 SYN 本身要消耗一个序列号哪怕它不携带数据。第三第三次握手是个纯 ACK 包seq 也变成服务端 ISN1。到这里双方的序列号就同步了后续数据报文可以从任意方向安全发送。你可能会问为什么不干脆两次握手用个生活类比两个人打电话A 说你能听到我吗B 说能听到你能听到我吗A 说能听到。第三次是专门给 B 确认的让 B 知道自己的声音 A 确实能收到。如果只有两次B 永远不知道 A 能不能收到自己的回话连接建立就是单向确定的。1.2 backlog 的真相半连接队列和全连接队列握手看起来简单但内核在处理时走了两道关卡半连接队列SYN 队列和全连接队列accept 队列。当服务端收到 SYN会把连接放进 SYN 队列同时回复 SYN-ACK。之后客户端发来 ACK内核需要一个地方暂存这个已经完成握手的连接等应用层调accept()取走这个位置就是 accept 队列。理解这两个队列你对 backlog 的很多困惑都能解开。listen(fd, backlog)里的 backlog 参数控制的是 accept 队列的最大长度但最终生效值还要和内核参数net.core.somaxconn取较小值。比如你在代码里写了listen(fd, 1024)但 somaxconn 还是默认的 4096老内核是 128那么 accept 队列上限就是 1024。判断队列是否溢出有两个实用命令# 查看当前监听 socket 的队列占用Recv-Q 是已建立的待 accept 连接数Send-Q 是队列上限 ss -lnt # 查看协议栈统计里是否丢过握手完成的连接 nstat -az | grep -i listenTcpExtListenOverflows和TcpExtListenDrops这两个计数一旦非零说明应用层accept()太慢或 backlog 太小。nginx 调优时很多人会写listen 80 backlog4096;再配合内核net.core.somaxconn4096就是放大了这个队列。半连接队列的上限更复杂由tcp_max_syn_backlog、net.core.somaxconn以及内存压力共同决定一般不需要手动调。但如果通过ss -st state syn-recv看到大量 SYN-RECV 状态的连接说明半连接队列可能被 SYN Flood 攻击填满或者客户端网络半通发了 SYN 后收不到 SYN-ACK。1.3 SYN 重传与握手卡住的排查握手报文丢了怎么办有个很朴素的原则TCP 所有丢包都要重传。客户端发出 SYN 后如果没等到 SYN-ACK会按 1s、2s、4s、8s 的间隔重传默认最多重传 6 次net.ipv4.tcp_syn_retries6。服务端回复 SYN-ACK 后的重传次数则由net.ipv4.tcp_synack_retries控制默认 5。实际排障时我见过最典型的场景客户端telnet 服务端IP 8080一直卡住抓包发现客户端一直重发 SYN服务端一个包都不回。这时候方向就清楚了——报文根本没到服务端网络栈优先查云安全组、iptables/firewalld 规则、中间交换机 ACL而不是在服务端代码里找问题。反过来如果服务端能看到 SYN 且回了 SYN-ACK但客户端一直不进入 ESTABLISHED多半是客户端到服务端的回包路径有问题或者是客户端本地防火墙把入站报文吞了。我在虚拟机里调试时就踩过这个坑VMware 的虚拟网卡默认开启了仅主机模式宿主机能 ping 通但 TCP 回包被 Windows 防火墙拦截导致虚拟机里怎么都连不上宿主机的服务。2. 四次挥手与连接状态机TIME_WAIT 和 CLOSE_WAIT2.1 挥手过程与六个状态的流转连接关闭比建立更复杂。假设客户端主动关闭完整链路是这样的客户端发送 FIN进入 FIN_WAIT_1。服务端收到 FIN回复 ACK进入 CLOSE_WAIT客户端收到这个 ACK 后进入 FIN_WAIT_2。服务端应用层调用close()发送 FIN 给客户端进入 LAST_ACK。客户端收到 FIN回复最后一个 ACK进入 TIME_WAIT服务端收到 ACK 后进入 CLOSED。客户端等 2MSL 后才 CLOSED。我经常在面试里问为什么服务端收到客户端 FIN 后要先回 ACK然后才发 FIN因为 TCP 是双向的收到 FIN 只表示对方不再发数据了本端可能还有数据没发完。所以内核先回 ACK 确认收到关闭请求应用层等数据发送完毕后再关自己的写入方向。这个机制导致了一个经典问题——服务端可以继续保持 CLOSE_WAIT 很久如果代码不close()连接永远关不掉。用ss -tan看状态分布最直观ss -tan | awk NR1 {print $1} | sort | uniq -c | sort -rn正常线上会看到 ESTABLISHED、TIME_WAIT 为主偶尔有少量 SYN_SENT。如果 CLOSE_WAIT 大量堆积几乎可以断定是应用代码有 bug。2.2 TIME_WAIT 为什么是 2MSL以及 tcp_tw_reuse 的正确用法TIME_WAIT 是很多初学者的噩梦其实它是 TCP 最优雅的设计之一。两个作用第一个作用是确保最后的 ACK 能到达对方。最后这个 ACK 如果丢了服务端在 LAST_ACK 状态会重发 FIN所以客户端必须保持 TIME_WAIT 足够长时间以便收到这个重发的 FIN 后再次回复 ACK。第二个作用是让旧连接的所有报文在网络中消失。报文最长存活时间是 MSLMaximum Segment Lifetime两端加起来 2MSL 后旧连接的重复报文就全部消亡新连接不会被旧报文干扰。Linux 的 2MSL 硬编码是 60 秒不是有些书上写的 4 分钟这点很多人搞混。处理大量 TIME_WAIT首要思路是应用层复用连接而不是每次请求都新建。连接池、HTTP Keep-Alive、长连接本质上都是减少 TIME_WAIT 产生的效率手段。再讲内核参数。net.ipv4.tcp_tw_reuse我建议在客户端场景开启它允许内核把处于 TIME_WAIT 状态的连接用于新的出站连接前提是开启tcp_timestamps。但它对服务端入站连接无效别指望它解决服务端接入层的 TIME_WAIT 堆积。另一个老参数tcp_tw_recycle是一个大坑NAT 环境下开了它会导致大量连接被错误丢弃。好在内核 4.12 之后已经把它移除了如果你还能在旧文档里看到推荐开启的教程那篇文章基本可以判断是上古货。2.3 CLOSE_WAIT 泄漏连接耗尽的第一元凶CLOSE_WAIT 堆积几乎都是程序问题。流程是这样的服务端收到 FIN内核自动回 ACK 并把连接置为 CLOSE_WAIT此时应用层通过read()会返回 0表示对端关闭了。正确的处理是应用层也调用close()关闭本端 socket连接才会继续走到 LAST_ACK。如果程序一直不close()连接就死在这里。问题代码常见两类一类是读数据时只处理了read() 0的分支没处理read() 0的 EOF 分支。另一类是使用了某个 HTTP 客户端库或数据库驱动连接空闲了但底层的 TCP 连接没有正确释放比如 Java 的 HttpClient 在低版本时就有这个毛病。排查思路三步走# 1. 统计各状态连接数 netstat -tan | awk /^tcp/ {print $NF} | sort | uniq -c | sort -rn # 2. 找出 CLOSE_WAIT 连接对应的进程和本地端口 netstat -tnp | grep CLOSE_WAIT # 3. 用 lsof 看进程里哪些 socket 没关闭 lsof -p PID | grep TCP有一次我在一个 Java 服务里看到 4000 多个 CLOSE_WAIT进程 CPU 都在处理超时重试。lsof后发现是一批 OAuth 客户端每次调用令牌接口都通过new Socket()建连异常分支里没close()。改完连接池复用后CLOSE_WAIT 立刻降到了个位数。3. Linux 内核 TCP 参数调优从默认值到生产配置3.1 调优前先明确场景网上很多TCP 优化清单直接让你把一堆 sysctl 参数抄上去这是不负责任的。调优的前提是你清楚自己的流量模型。高并发短连接、长连接大流量、跨运营商长肥网络优化的方向完全不一样。高并发短连接重点看端口范围、TIME_WAIT 复用、文件描述符上限。长连接大流量重点看收发缓冲区、拥塞控制算法、窗口缩放。公网高丢包链路重点看拥塞控制算法比如 BBR和重传策略。内网低延迟低丢包默认参数通常已经不错不必乱动。所以调优第一步是ss -s和netstat -s看现状第二步才是改参数。下面这些参数是我在多个项目里实际验证过、改完能稳定提升效果的按组整理。3.2 缓冲区吞吐量卡在哪儿先算 BDPTCP 的收发缓冲区直接决定单连接的吞吐上限。你可能会遇到带宽明明 100Mbps单 TCP 连接却只能跑 5Mbps 的情况大概率是缓冲区不够大或者接收窗口受限。这里有个概念叫 BDPBandwidth-Delay Product带宽时延积。计算公式是BDP 带宽 × RTT比如 100Mbps 链路RTT 20ms100 * 10^6 * 0.02 / 8 250KB也就是说单连接的发送/接收缓冲区至少要 250KB否则无法填满链路。Linux 默认tcp_rmem的 max 在现代内核里通常是 6MB 左右一般够用但老系统可能只有 4MB高带宽大 RTT 场景就要手动调大。查看当前值sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem我常用的一组生产配置适合内网大流量场景net.ipv4.tcp_rmem 4096 131072 16777216 net.ipv4.tcp_wmem 4096 131072 16777216 net.core.rmem_max 16777216 net.core.wmem_max 16777216三个数字分别是最小值、默认值、最大值。中间那个值是 socket 建立时默认分配的会随实际吞吐动态调整不需要手动设置。用iperf3压测对比调前调后的差异是最直接的验证方式。3.3 拥塞控制算法CUBIC 还是 BBR拥塞控制决定了 TCP 在丢包时如何降低发送速率、如何探测带宽。Linux 默认是 CUBIC适合普通高带宽链路。如果链路本身丢包率高比如跨运营商公网、4G/5G 网络CUBIC 会把丢包误判为拥塞吞吐大幅下降。BBR 是 Google 提出的一种基于瓶颈带宽和往返时延的算法在非拥塞丢包场景下提升非常明显。我实测过一个跨省公网传输任务同样的文件CUBIC 全程 2-3Mbps切到 BBR 后稳定在 12Mbps 以上。查看和切换sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control # 切换到 BBR需要内核支持4.9 sysctl -w net.ipv4.tcp_congestion_controlbbr修改/etc/sysctl.conf需要加一行net.ipv4.tcp_congestion_controlbbr需要注意BBR 在内网低延迟低丢包环境收益很小而且某些网络设备对 BBR 的探测报文处理可能不友好。云厂商的负载均衡后端如果开了 BBR 遇到问题可以先关掉对比一下再决定。3.4 常用内核参数与 sysctl 配置清单下面是我在实际项目中经常调整的内核参数按用途分组写成一个可以直接抄的/etc/sysctl.conf片段。注意改完用sysctl -p生效最好在压测环境先跑一遍。# 端口范围高并发短连接客户端建议调大 net.ipv4.ip_local_port_range 1024 65535 # 文件描述符上限高并发连接必须调 fs.file-max 2097152 # 全连接队列上限 net.core.somaxconn 4096 # SYN 队列上限 net.ipv4.tcp_max_syn_backlog 16384 # FIN 等待超时调小可加速 TIME_WAIT 回收 net.ipv4.tcp_fin_timeout 30 # TIME_WAIT 复用仅客户端方向有效 net.ipv4.tcp_tw_reuse 1 net.ipv4.tcp_timestamps 1 # 长连接空闲多久发探测包配合应用层心跳使用 net.ipv4.tcp_keepalive_time 600 net.ipv4.tcp_keepalive_intvl 30 net.ipv4.tcp_keepalive_probes 3 # 自动调节缓冲区上下限 net.ipv4.tcp_rmem 4096 131072 16777216 net.ipv4.tcp_wmem 4096 131072 16777216 # 本机端口允许更多连接减少 NAT 场景下的瓶颈 net.netfilter.nf_conntrack_max 1048576有一条我踩过的坑必须提醒net.ipv4.ip_local_port_range默认是 32768-60999也就是大约 2.8 万个出站端口。如果你的服务作为客户端频繁发起短连接端口耗尽会报Cannot assign requested address把起始端口调到 1024 能缓解但根本解法还是连接池复用。3.5 CentOS 防火墙放行 TCP 端口的正确姿势热词里有个高频问题centos 防火墙开放 tcp 端口配置文件实测很多人连防火墙开着都不知道直接怀疑程序没起动。CentOS 7 默认用 firewalld配置文件在/etc/firewalld/zones/public.xml但不推荐直接改文件。标准操作用命令# 放行 8080 端口永久生效 firewall-cmd --permanent --add-port8080/tcp firewall-cmd --reload # 查看已放行端口 firewall-cmd --list-ports # 放行某个服务 firewall-cmd --permanent --add-servicehttp firewall-cmd --reload老系统还在用 iptablesiptables -I INPUT -p tcp --dport 8080 -j ACCEPT service iptables save排查端口不通时按这个顺序先ss -lntp | grep 端口确认进程在监听再firewall-cmd --list-ports确认防火墙放行然后检查云平台安全组。云服务器上经常遇到防火墙关了但外网还是不通十有八九是控制台里的安全组规则没放行。4. TCP 和 UDP 怎么选从协议对比到真实场景4.1 一张表看懂本质差异TCP 和 UDP 的选择困扰过很多人。先说结论如果你的应用对数据完整性有强要求选 TCP对实时性要求高、能容忍少量丢包、或者需要组播广播选 UDP其他情况按场景具体分析。两者的核心差异我用表格列出来维度TCPUDP连接状态面向连接需要三次握手无连接直接发数据可靠性可靠传输确认、重传、排序尽力而为不保证到达和顺序传输单元字节流无边界数据报保留消息边界流量控制有滑动窗口机制无拥塞控制有CUBIC/BBR 等无头部开销20 字节起8 字节广播组播不支持支持典型场景HTTP、数据库、文件传输音视频、DNS、游戏同步这里说一个容易被忽略的点TCP 是字节流意味着应用层发两次send()对端一次read()可能拿到合并后的数据也可能只拿到一部分。UDP 是数据报一个sendto()对应一个recvfrom()边界天然保留。4.2 场景一Modbus RTU 和 Modbus TCP到底转的是什么工控场景里 modbus 协议非常典型。Modbus RTU 跑在串口上报文帧带 CRC 校验Modbus TCP 跑在以太网上默认端口 502报文在 RTU 的基础上做了重组去掉 CRC加上了 MBAP 头事务标识 2 字节、协议标识 2 字节、长度 2 字节、单元标识 1 字节。所以做RTU 转 TCP时不能简单地把串口收到的字节流硬塞进 TCP 报文需要先解析 RTU 帧、剥掉 CRC、加上 MBAP 头再发给 TCP 客户端。反之 TCP 转 RTU 则要剥掉 MBAP、重新计算 CRC最后通过串口下发。很多老工程师第一次做协议网关时就栽在这里直接在 TCP 报文的 payload 里塞原始 RTU 帧结果上位机软件怎么都解析不出来。原因就是协议格式根本不是直接兼容的。4.3 场景二UDP 转 TCP 网关到底在转什么工业现场经常碰到设备只支持 UDP 上报但平台侧只开放 TCP 接入的情况比如用socat或者自研网关做转换。核心思路其实不复杂网关一边监听 UDP 端口收到数据报后把载荷重新封装成 TCP 流发送出去反向则相反。但这里面有个隐蔽的坑UDP 是消息边界分明的一个包就是一条完整消息TCP 是流没有边界。转发程序如果直接send(tcpSock, udpPayload, len, 0)对端收到的可能和多个 UDP 包粘在一起也可能被拆成半包。做这类网关时应用层必须自己定义消息边界常见做法是每个 UDP 包转发前加 4 字节长度前缀或者用特定分隔符隔开。光做载荷搬运不设计协议字段在线路上跑五分钟就会出现解析错位。还有一层是连接管理。TCP 长连接如果断了网关要能自动重连并缓存期间的数据UDP 端则要注意多来源数据怎么区分会话。这些细节决定了网关是能用还是能稳定用。5. Socket 编程背后的 TCP 细节从 accept 到心跳保活5.1 一个最小可编译的 TCP 回显服务不管是 Java 的 ServerSocket、Python 的 socket 模块还是 C 的 socket API底层内核做的事情是同一套。我建议每个人都亲手用 C 写一遍最小实现能编译能联通比看一百篇框架文章都管用。服务端示例#include stdio.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h int main() { int lfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); addr.sin_addr.s_addr htonl(INADDR_ANY); bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 128); printf(listening on 9000\n); while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr *)cli, len); char buf[1024]; ssize_t n read(cfd, buf, sizeof(buf) - 1); if (n 0) { buf[n] 0; printf(recv: %s\n, buf); write(cfd, buf, n); } close(cfd); } return 0; }客户端示例#include stdio.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h int main() { int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(9000); inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { write(fd, hello tcp, 9); char buf[1024] {0}; read(fd, buf, sizeof(buf) - 1); printf(recv: %s\n, buf); } close(fd); return 0; }编译运行gcc server.c -o server ./server gcc client.c -o client ./client这个 demo 背后有两个值得记住的点accept 返回的 cfd 是新的文件描述符和 lfd 监听 socket 不是同一个read 返回值 0 表示对端关闭了连接返回值小于 0 才是出错。很多连接泄漏 bug 都出在没处理返回 0 的分支上。5.2 粘包问题谁的责任怎么解决粘包拆包是 TCP 编程第一个绕不过去的坎。前面说过 TCP 是字节流消息边界需要应用层自己定义。我见过最朴素也最容易出问题的做法是用固定大小缓冲比如每次读 256 字节当作一条消息但真实数据长度一旦不是 256就必然错位。更稳的方案是加长度前缀。发送端先塞 4 字节的大端整数表示 payload 长度再发 payload接收端先攒够 4 字节解析出长度 n再继续读 n 字节读到完整消息才去解析。这个过程在 C 里要处理 read 可能一次读不满的情况代码简单写一下逻辑uint32_t len; read_exact(fd, len, 4); len ntohl(len); read_exact(fd, buf, len);read_exact要自己用循环实现因为 read 可能只读到部分数据。有些协议喜欢用分隔符比如 Modbus TCP 那种按锁步骤字节判断的也有但长度前缀在我看来是工程上最通用、最不容易出错的方案。真实项目里设计协议字段时一开始就加一个统一的消息头后面扩展的麻烦能少很多。5.3 长连接如何保活应用层心跳与 TCP Keepalive搜索词里有个怎么使用 socket 进行 TCP 长连接请求这正是我要展开讲的点。长连接建立的成本主要是三次握手所以大量请求场景下复用连接能显著降低延迟。但长连接最大的敌人是中间设备悄悄把连接断了最常见的就是 NAT 超时清除映射表。客户端和服务端之间如果长时间没有数据交互NAT 设备会在超时后删除这个映射之后任一方再发数据收到的就是 ICMP 不可达或者直接无响应。连接在应用层看起来还活着实际上已经死了。TCP 自带 Keepalive 探测机制默认tcp_keepalive_time7200也就是空闲 2 小时才探测一次太慢了满足不了线上需求。所以实际项目通常采用应用层心跳约定每隔 30 秒或 60 秒发一次心跳消息对端必须在规定时间内回复连续几次没回复就判定连接断开主动重启连接。心跳设计有两个级别。连接级心跳只探测链路通断发 PING 回 PONG 就行。应用级心跳要带业务序号确认对端应用进程和线程池都正常适合核心链路。我一般会同时做底层维护连接级心跳和超时重连上层业务再做必要的应用级探测。这样连接断开可以在秒级感知不会等调用业务时报错才发现。嵌入式场景更明显ESP32 这类芯片跑 TCP 客户端时Wi-Fi 断开后内核里的 TCP 状态不会自动恢复必须自己监听网络事件断开就主动connect()重新建立。所以长连接不只要会建还得会重连最好做指数退避别疯狂重试把服务器打挂。6. 线上排障实录五个高频 TCP 问题处理6.1 dial tcp connectexTCP 连接建立失败的通用排查搜索词里那个报错是 Go 程序输出connectex是 Windows 平台 socket 系统调用的返回信息本质上是 TCP 连接根本没有建立起来。我把它归纳为三类原因端口没人监听、中间链路丢弃、对端拒绝连接。排查流程我给一张可复用的路径# 1. 本机到目标机是否通先确认 IP 路由 ip route get 目标IP # 2. 对端端口是否在监听在对端执行 ss -lntp | grep 443 # 3. 全链路抓包看卡在哪一步本机执行 tcpdump -i any host 目标IP and tcp port 443 -nn如果本机和目标机之间有防火墙或者目标机在云上还要检查安全组规则。有些环境对出站方向有过滤只允许特定端口出去也会有同样表现。这类报错十有八九不是代码问题而是网络路径上的策略问题。临时验证对端端口是否能连可以用python3 -m http.server 8080在目标机上快速起一个 TCP 监听服务再用客户端连一下。6.2 failed to listen TCP on 1080端口被占用的定位与处理服务程序启动时报 listen 失败最常见原因是端口被别的进程占了。优先用ss -lntp | grep 1080找到占用进程ss -lntp | grep 1080输出里能看到 PID 和进程名比如users:((foo,pid1234,fd3))。确认确实是旧进程可以kill 1234或kill -9 1234。注意有一种隐蔽情况配置文件里同一个监听项写了两遍程序自己启动两次抢同一个端口也会报同样的错误。这种在 Docker 里重名容器比较常见。还有一种情况是只绑定冲突。比如一个程序监听0.0.0.0:1080另一个程序想监听127.0.0.1:1080Linux 会拒绝后者的 bind。反过来先监听 127.0.0.1 再监听 0.0.0.0 是可以的因为前者更具体。调试代理类工具时常遇到记一下ss -lntp里的Local Address列别只盯着端口看。6.3 Tomcat 里 RMI TCP Connection 线程从哪启动的Java 开发会看到 Tomcat 进程里一堆名字叫RMI TCP Connection的线程即使你改了server.xml的 connector 配置也不减少。原因很简单这不是 Tomcat 处理 HTTP 的线程而是 Java RMI 机制自己建立的 TCP 连接处理线程。RMI 默认走 JRMP 协议底层就是 TCP。每来一个 RMI 连接JVM 都会派一个线程处理线程名就叫 RMI TCP Connection。它的 AcceptThread 负责 accept 监听端口然后创建 IoHandler 线程处理连接上的请求。排查思路是对照连接数和线程数的关系先ss -tnp | grep java看当前 RMI 连接数再jstack pid | grep RMI TCP看线程数量。如果连接数持续增长、线程数同步增长就是调用方不断新建 RMI 连接且不释放。解决办法通常是把 RMI 客户端改成连接复用或者在架构上避免频繁调用。有些场景直接换用 REST/HTTP 协议能少走很多弯路。6.4 工控场景 Modbus TCP 连接不稳定KingsCada、PLC 串口模块转 Modbus TCP 这类场景我处理过几次共通问题都在连接管理上。很多 PLC 对 Modbus TCP 并发连接数限制很严比如只能同时接受 4 到 8 个连接。上位机如果每次轮询都新建 socket请求完就关闭再下次又新建连接表很快被 TIME_WAIT 占满PLC 就会拒绝新连接表现就是偶尔通频繁掉线。正确的做法是上位机和 PLC 之间维护一条长连接所有 Modbus 请求都在同一条连接上串行或按事务 ID 并发发送。同时把 socket 超时时间设得比 PLC 程序扫描周期长一些避免误判超时后重复重连加剧问题。如果现场用网线连接还要检查交换机的端口协商是否稳定网线松动是工控环境第一大隐形杀手。对欧姆龙 NX-CIF105 这类串口模块要转 Modbus TCP同样建议加一个稳定的协议网关统一管理连接和轮询而不是每个上层软件各连各的。6.5 面试和笔试里常考的六个 TCP 问题搜索词里有linux 面试题这六个问题和我前面讲的内容正好对应列成速查表问题一句话答案为什么三次握手不是两次必须让双方都确认对方的收发能力并且同步初始序列号为什么挥手要四次关闭是全双工的双方要各自关闭一次发送方向TIME_WAIT 为什么存在保证最后 ACK 可达同时让旧连接报文在网络中消亡CLOSE_WAIT 大量堆积怎么排查查代码里 read 返回 0 后有没有 closeTCP 和 UDP 怎么选要可靠有序选 TCP要低延迟和消息边界选 UDP粘包怎么解决应用层定义消息边界推荐长度前缀这些问题的答案在书上都写得比较抽象但如果你按这篇文章的实际操作做过一遍抓包和排障再回答起来就是我真的见过而不是我背过书。最后再分享一点实测体会TCP 是那种看起来简单、用起来水深的协议。我早期排查问题时看到 TIME_WAIT 就慌各种参数乱调后来才明白它在多数场景下是正常现象真正危险的往往是 CLOSE_WAIT 和半打开连接。踩过几次坑之后我现在接手任何一个网络项目都会先把ss -s、netstat -s、ss -lnt三个命令的结果存个档作为基线。后续出问题对比基线的变化比凭空猜测高效得多。这篇文章里我给的所有配置和代码都是我实测过可以落地的东西但参数照抄之前一定先用压测验证你的场景。网络排障的本质不是背参数而是顺着数据包的路径一步步看它在哪里停了。希望这篇长文能帮你少走我走错过的路。