TCP排障全解析:bind error、TIME_WAIT、滑动窗口与重传机制
蒂姆最近在做一个支付网关的压测系统服务端跑在 CentOS 上压测脚本刚启动没两分钟就报了一堆Address already in use的异常。第一反应是端口配错了检查一遍发现配置没问题然后netstat -ant | grep 8000一看——满屏的TIME_WAIT。后来我用了SO_REUSEADDR解决了线上服务重启问题但客户端那边的端口复用又踩了另一个坑。这篇文章就把这次排障过程中涉及到的 TCP 基础知识全部串起来讲一遍bind error 的成因与解决、TIME_WAIT 与 CLOSE_WAIT 的产生逻辑、滑动窗口的运作机制以及超时重传和快重传这两兄弟到底有什么区别、怎么配合。无论你是刚接触网络编程的新手还是写了几年服务端的老手这些内容都能帮你在排查网络问题时少走弯路。1. bind error 排查全记录Address already in use 的成因与端口复用解法1.1 bind() 到底绑定的是什么bind()系统调用做的事情很直白——把 socket 文件描述符和一个本地地址IP 端口绑定在一起。但这里有个大多数人没注意到的细节bind 绑定的不是 端口 这个数字而是一个四元组 (协议, 本地IP, 本地端口) 的 占用标记。Linux 内核在inet_csk_get_port()里维护了一张哈希表用来记录当前系统里所有已经绑定的端口。当你再次 bind 同一个端口时内核会去这张表里查状态。具体来说bind 返回EADDRINUSEerrno 98分两种情况另一种情况是端口被另一个连接的四元组占用了。比如客户端连着服务器 :8000此时本地有一个 socket 绑定了 :8000这个 socket 处于 ESTABLISHED 或 TIME_WAIT 状态那再次 bind 就会冲突。另一种情况是某个 socket 正在 LISTEN。如果 :8000 已经有一个 LISTEN 中的 socket那任何新的 bind 尝试都会失败。这里有个关键的认知点——很多新手以为连接建立后就不占用本地端口了这是错的。一条 TCP 连接的四元组源IP、源端口、目的IP、目的端口中源IP和源端口就是绑定在本地 socket 上的。只要连接还没释放本地端口就一直被占用。所以高并发短连接场景下客户端频繁 connect 又断开大量 socket 进入 TIME_WAIT导致本地端口全部被占满新连接自然建不起来。我在压测时就是这种情况。用ss -s看了一眼$ ss -s Total: 245 (kernel 260) TCP: 136 (estab 2, closed 121, orphaned 0, timewait 119)119 个 TIME_WAIT把本地临时端口段/proc/sys/net/ipv4/ip_local_port_range默认一般是 32768-60999占了一大半。不到 3 万个端口压测并发一高就耗尽。1.2 端口复用SO_REUSEADDR 与 SO_REUSEPORT 的准确差异解决 bind error 最经典的方案是SO_REUSEADDR。这个选项的核心作用是允许内核在 socket 处于 TIME_WAIT 状态时重新绑定同一端口。int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));注意SO_REUSEADDR不是随便复用任意状态的端口它的行为在 Linux 上分几种场景如果两个 socket 都设置了SO_REUSEADDR并且都 bind 同一个 IP:端口只有前一个 socket 处于 TIME_WAIT 状态时后面的 bind 才会成功。如果前一个 socket 是 ESTABLISHED 或 LISTEN后一个照样失败。如果你的服务经常重启且重启前旧进程还留着一堆 TIME_WAIT socket设置SO_REUSEADDR后就不会出现 bind error这是最常见的正确用法。但是如果两个进程同时 bind 同一个 IP:端口做监听比如 nginx 的 master/worker 模式或者想在同一端口上跑多个实例SO_REUSEADDR是做不到的需要的是SO_REUSEPORT。SO_REUSEPORT是 Linux 3.9 之后才加入的选项它允许多个进程或线程的 socket 绑定到完全相同的 IP:端口内核在收到新连接时会对这些 socket 做负载均衡基于四元组哈希。很多高性能代理都用这个特性实现多进程 accept。但它有一个前提所有使用 SO_REUSEPORT 的进程必须都设置这个选项并且 UID 相同否则内核会拒绝后面的 bind。我见过一个真实的线上事故——某团队给服务加上SO_REUSEPORT结果老的旧版本进程没设置该选项还在监听新进程启动时 bind 直接失败报的也是EADDRINUSE。这种场景下需要先停旧进程再启新进程或者代码里兼容处理。1.3 服务端与客户端端口复用的选择策略实际工程里服务端和客户端处理端口复用问题的思路完全不同。服务端如果频繁重启唯一要做的事就是 listen socket 设置SO_REUSEADDR。这是行业标配几乎所有网络框架Netty、libevent、golang net 包都在默认打开这个选项。Java NIO 里如果不主动设置重启时很容易踩BindException: Address already in use: JVM_Bind。客户端的情况要复杂一些。客户端主动 connect 的端口是临时端口如果应用短连接多TIME_WAIT 会把临时端口耗光此时即使设了SO_REUSEADDR也解决不了客户端的问题因为SO_REUSEADDR在 connect 时是不生效的——它是给 bind/listen 用的。客户端要复用端口内核提供的是另一个策略时间戳 安全复用。这个在 Linux 上有个内核参数叫tcp_tw_reuse注意不是tcp_tw_recycle后者因为 NAT 环境下会产生严重问题在内核 4.12 里已经彻底移除了。tcp_tw_reuse1时内核允许在新建连接时复用仍然处于 TIME_WAIT 状态的端口前提是新连接的序列号比上一个连接的最后序列号要大依赖 TCP 时间戳机制。$ sysctl -w net.ipv4.tcp_tw_reuse1我自己实测下来这个参数在纯客户端场景下效果显著。但要注意如果你在服务器上开tcp_tw_reuse并且服务器是 NAT 环境可能会出乱子——因为 NAT 会改写源 IP导致时间戳错乱极端情况下会丢包或者连接重置。稳妥的做法是只在本机出站流量多的时候开服务端入站场景尽量不开。2. TIME_WAIT 的真相为什么主动断开方要等 2MSL2.1 四次挥手状态流转全解析TIME_WAIT 来自 TCP 连接关闭的四次挥手。假设客户端主动发 FIN客户端发 FIN进入FIN_WAIT_1。服务端收到 FIN回复 ACK客户端进入FIN_WAIT_2服务端进入CLOSE_WAIT。服务端把剩余数据发完后发 FIN进入LAST_ACK。客户端收到 FIN回复 ACK进入TIME_WAIT服务端收到 ACK 后进入CLOSED。客户端在 TIME_WAIT 里要停留2 * MSLMaximum Segment Lifetime报文最大生存时间。RFC 793 建议 MSL 设为 2 分钟Linux 里一般是 30 秒所以 TIME_WAIT 默认持续 60 秒。为什么主动断开方要傻等 2MSL两个原因缺一不可保证最后一个 ACK 能到达对端。如果这个 ACK 在网络里丢了服务端被动关闭方在 LAST_ACK 状态下会超时重传 FIN客户端就能在 TIME_WAIT 期间重新回 ACK。如果客户端不等待直接关 socket那服务端会一直重传 FIN 直到超时进入 CLOSED并且连接状态会被当成错误处理。保证旧连接的报文段在网络中彻底消失。一个报文段从发送到在网络中传播最大生命周期就是 MSL。等待 2MSL 后本端发出的所有报文段都已经过期消亡不会跟新连接的同端口报文搞混。我打个比方TIME_WAIT 就像排队结账后站在原地等一下确认收银员听到你说的谢谢了再走顺便确认自己没落下什么东西。虽然排队的人觉得你耽误时间但走了可能会回来扯皮。2.2 为什么高并发短连接会出现大量 TIME_WAIT原理清楚了就不难理解压测场景为何 TIME_WAIT 爆表短连接 主动断开方是客户端。每次 HTTP 请求完成客户端主动发 FIN或者服务端在 Keep-Alive 超时后主动发 FIN这种情况就是服务端产生 TIME_WAIT。连接多、生命周期短TIME_WAIT 堆积的速度远超 60 秒的释放速度。我压测时 20 万的连接累计TIME_WAIT 峰值能到 2 万 。TIME_WAIT 多的核心危害不是占内存每个 TIME_WAIT socket 很小大概 200 字节而是占端口。系统临时端口范围默认只有 28232 个32768-60999每秒新建连接数一旦超过端口数 / TIME_WAIT 时长新连接就建不起来了。公式大概是这样$$最大新建连接速率 \approx \frac{临时端口数}{TIME_WAIT时长}$$如果你的临时端口是 28000 个TIME_WAIT 60 秒那理论上限是每秒 466 个新连接。压测工具稍一加压就突破这个值。2.3 处理 TIME_WAIT 过多的三板斧我在压测和线上实践中处理 TIME_WAIT 一般按优先级排序第一板斧客户端复用连接连接池。这是最根本的解法。HTTP Keep-Alive / 长连接让连接建一次用很多次TIME_WAIT 自然就少了。但改造成本高不是所有场景都能立刻改。第二板斧开启tcp_tw_reusetcp_timestamps。把 TIME_WAIT 端口复用掉立刻见效。注意这是给客户端场景用的。第三板斧调低tcp_max_tw_buckets。这个参数控制内核最多同时保留多少个 TIME_WAIT socket超过就直接丢弃。我见过有人把它调成 5000TIME_WAIT 确实少了但这么做的本质是强制自杀可能造成连接重置或丢包不推荐在重要服务上这么干。另外一个容易被忽略的细节如果你用SO_LINGER把连接设置为 RST 强制关闭l_onoff1, l_linger0主动关闭方不会进入 TIME_WAIT直接关闭。这样能规避 TIME_WAIT但代价是丢掉了可靠性的最后一道保险一般用于确定对端不会再发包的内部场景公开服务别这么搞。3. CLOSE_WAIT 排查实录服务端连接泄漏的元凶3.1 CLOSE_WAIT 是怎么攒出来的如果说 TIME_WAIT 是主动断开方的宿命那 CLOSE_WAIT 就是被动关闭方的警示灯。CLOSE_WAIT 的状态含义是**对端已经发了 FIN我本端已经回 ACK 了但我自己的业务还没调 close() 关闭 socket **。这个状态下连接是半开半关的——对端不再发数据了但本端理论上还能发当然发了对端也会回 RST。CLOSE_WAIT 大量堆积只有一个原因程序没有正确关闭连接。常见场景包括读数据的循环里读到 EOF对端关闭后没有跳出循环、没有调用 close()。线程池/异步回调里连接对象没有正常释放异常分支忘了关。长时间不读数据、也不检测连接状态对端 FIN 到了内核已经回了 ACK 并进入 CLOSE_WAIT但应用层一直没感知。JDBC/HTTP client 连接池里空闲连接被对端关闭池子没有主动清理。排查时用一条命令就能定位规模$ netstat -ant | awk {print $6} | sort | uniq -c 19 CLOSE_WAIT 32 ESTABLISHED 1024 TIME_WAIT如果 CLOSE_WAIT 数量持续增长不下降基本可以断定有连接泄漏 bug。CLOSE_WAIT 和 TIME_WAIT 的性质完全不同TIME_WAIT 正常、必然只是量多量少的问题CLOSE_WAIT 出现一个都算事故因为它意味着资源被白白占着不放。3.2 一次实例Java 服务端 CLOSE_WAIT 堆积到 600 个我之前遇到过一个大事故一个 Java 服务上线跑了两周后接口响应越来越慢最后连健康检查都不通了。当时 grepnetstat -ant | grep CLOSE_WAIT | wc -l结果 600 多个。再看线程栈发现是某个 HTTP client 的 IO 线程全部卡在读取响应的阻塞点上了。根因是服务端上游设置了较短的 read timeout先发了 FIN我们的代码用的是老版本连接池连接在返回连接池前没有做闲置检测导致大量已被对端关闭的连接还躺在池子里应用层每次从池里捞连接时拿到一个已半关闭的连接读不到数据也感知不到错误就一直挂着。这件事的教训很实在所有用到连接池的组件必须开启闲置连接检测idle timeoutNetty 里就是IdleStateHandler。底层 Socket 一定要设置读写超时别依赖默认的无限阻塞。出问题先抓 CLOSE_WAIT再看线程栈比瞎调内核参数管用一百倍。3.3 CLOSE_WAIT 的预防与兜底经验写代码时牢记一个原则谁 accept 的 socket谁必须负责 close。服务端处理每个连接都要放到 try/finally 或者 defer 里保证关闭。用高级语言自带框架的也要注意回调方法返回后框架不会替你关闭。兜底手段也有在服务端加一层空闲超时如果某个连接超过 N 秒没有任何读写主动 close。这是最有效的保底方案。用内核参数tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes调整 TCP keepalive 的探测频率能提前发现死了半天的对端。监控脚本定期告警 CLOSE_WAIT 数量阈值设个位数比较合理比如大于 10 就告警。4. 滑动窗口机制拆解TCP 发送速率背后的真实逻辑4.1 窗口是个缓冲区不是令牌桶滑动窗口Sliding Window是 TCP 流量控制的核心。这里最容易被新手搞混的是——很多人以为 窗口 是某种操作权限或者令牌其实窗口的本质是缓冲区可用空间。每个 TCP 连接维护两组窗口发送窗口swnd发送方允许连续发送的数据量大小 min(接收方通告的 rwnd, 拥塞窗口 cwnd)。接收窗口rwnd接收方当前还能接收多少字节每次 ACK 里都会带上这个值通告给对端。为什么需要窗口如果没窗口发送方发一个包就得等一个 ACK一来一回就是一次 RTT。100 个包要 100 个 RTT延迟直接放大 100 倍。有了窗口发送方可以一口气把窗口内的所有数据连续发出去不用等每个 ACK。看这个伪代码就懂了# 发送方逻辑 while (已发送未确认的数据量 窗口大小): 发送下一个字节段 收到 ACK 后窗口向右滑动继续发送新数据窗口的作用可以类比成超市收银台排队窗口就是未出队但允许进店的人数收银台每结完一个收到 ACK后面就能再进一个。窗口越大单位时间能处理的人越多。4.2 流量控制是怎么通过窗口实现的接收方通过 ACK 报文里通告的 rwnd 来控制发送方的速度。如果接收方处理不过来它的接收缓冲区快满了就会把 rwnd 调小极端情况下 rwnd0发送方必须停下来这个状态叫零窗口。零窗口状态下发送方会定期发送窗口探测包Window Probe比如每秒一个探测接收方窗口是否恢复。如果连续多次探测都没有恢复会触发 TCP 的持久化定时器Persist Timer重传探测。这个机制防止了一方死等导致死锁。实际业务里你会看到这种现象接收方处理慢ss -nt里的Recv-Q一直在涨Send-Q为 0然后发送方的发送速率自动下降。这不是网络抖动是滑动窗口的流量控制在起作用。理解了这一点排查带宽明明很大但吞吐就是上不去的问题时就先去看Recv-Q而不是怀疑网络。4.3 窗口大小参数与性能调优的实际观察Linux 默认接收缓冲区rmem_default一般是 212992 字节约 208KB发送缓冲区wmem_default也是 208KB。窗口最大值可以扩展到几 MB但关键参数是 auto-tuning 机制——内核根据 BDP带宽延迟积自动调整缓冲区大小。BDP 的公式很简单$$BDP 带宽(bps) \times RTT(秒) / 8$$比如 100Mbps 带宽、20ms RTTBDP 就是 250KB。如果你的 socket 缓冲区小于 BDP那 TCP 的吞吐量天花板就被卡在缓冲区大小 / RTT了带宽再高也没用。这就是为什么做高吞吐单连接传输时要调大 socket buffer$ sysctl -w net.core.rmem_max8388608 $ sysctl -w net.core.wmem_max8388608想观察真实的窗口变化tcpdump 是利器。抓包时看 TCP option 里的Window字段$ sudo tcpdump -i eth0 tcp and port 80 -nn -vv你会看到三种窗口值初始 SYN 里的窗口、后续 ACK 里的通告窗口、以及 Wireshark 算出来的实际窗口大小考虑窗口缩放因子。另外注意TCP Window Scale选项——如果两边没协商 window scaling窗口最大值只能到 65535 字节否则带宽一高吞吐直接拉跨。4.4 拥塞窗口 vs 接收窗口别把两者混为一谈接收窗口rwnd是接收方限制的属于流量控制拥塞窗口cwnd是发送方自己限的属于拥塞控制。真正决定发送速率的窗口是二者的最小值min(rwnd, cwnd)。为什么要有拥塞窗口因为发送方不知道网络中间节点的承载能力。接收方告诉你我能收 100MB但中间链路只有 1Mbps你真发 100MB 过去路由器和交换机先扛不住开始丢包。所以 TCP 通过慢启动Slow Start和拥塞避免Congestion Avoidance逐步试探网络容量。慢启动的过程很有趣刚开始 cwnd 很小Linux 初始是 10 个 MSS约 14KB 左右每收到一个 ACKcwnd 翻倍呈指数增长超过 ssthresh 后进入线性增长一旦发生丢包cwnd 骤降重新开始拥塞避免。这解释了为什么短连接的吞吐总是上不去——连接刚建立时 cwnd 还在爬坡数据发完连接就关了根本来不及达到网络带宽上限。所以做高速传输时长连接比短连接有天然优势这才是真正的连接池真香原理。5. 超时重传与快重传两种丢包恢复机制的区分与配合5.1 超时重传最朴素的兜底方案TCP 是有序可靠的发送方必须保证每个报文都被对端确认。如果发送方在 RTO重传超时时间内没收到 ACK就认为这个报文丢了于是重传。RTO 不是定值而是根据当前网络的 RTT 动态计算的。经典算法是 Jacobson/Karels 算法SRTT SRTT α * (RTT - SRTT) // α 一般取 0.125 RTTVAR RTTVAR β * (|RTT - SRTT| - RTTVAR) // β 一般取 0.25 RTO SRTT 4 * RTTVAR初始 RTO 一般是 1 秒之后每重传一次RTO 翻倍1s - 2s - 4s - 8s... 这叫指数退避Exponential Backoff。为什么退避因为网络很可能已经拥塞了你越快重传只会让网络更堵。超时重传有个致命弱点它要等到 RTO 超时才知道数据丢了。如果 RTO 是 1 秒那 1 秒内这条连接的吞吐就白白浪费了。在高延迟链路比如跨洋专线 RTT200ms上更明显一次超时重传的代价极其高昂。5.2 快重传3 次重复 ACK 背后的高效配合快重传Fast Retransmit弥补了超时重传的等待劣势。它的触发条件不是超时而是发送方连续收到 3 次重复的 ACK。场景还原一下发送方发了包 1、2、3、4。假设包 2 丢了接收方收到包 1 后回 ACK 1收到包 3 时因为包 2 还没到序号不连续接收方还是回 ACK 1重复 ACK收到包 4 时依然回 ACK 1。发送方连续收到 3 个 ACK 1几乎立刻就能判断包 2 丢了因为如果是乱序一般最多 1-2 个重复 ACK连续 3 个重复 ACK 意味着丢包概率极高于是不等超时马上重发包 2。快重传的好处把丢包恢复时间从 RTO可能 1 秒缩短到 3 个 RTT。注意 3 个 RTT 是触发条件本身需要的网络往返时间。在低延迟内网里这几乎就是瞬时恢复。快重传里有个隐藏条件容易被忽略——接收方必须支持乱序阈值判断。如果接收方的实现是只要序号不连续就发重复 ACK那快重传才能生效。好在现代操作系统都是这个行为tcpdump 里你会看到连续多个[ACK]的确认号都指向同一个值。5.3 两者的区别、联系与共存关系超时重传和快重传不是互斥的它们分工明确机制触发条件恢复速度适用场景副作用超时重传RTO 超时慢1s ~ 指数级增长数据几乎必然丢失或网络严重拥塞可能触发重传风暴快重传连续 3 个重复 ACK快约 3 RTT单包丢失、乱序明显需要配合 SACK 才能精准重传这里区分一下联系TCP 的可靠性保证一定是超时重传兜底 快重传加速双管齐下。快重传是减少等待的优化手段但真正能保证最终一定送达的还是超时机制——如果快重传后的 ACK 依然不来发送方最终还是会等 RTO 超时再重传。所以你看代码里没有任何一个 API 能关闭超时重传它太基础了。另外必须提 SACKSelective Acknowledgment。没有 SACK 时发送方收到 3 个重复 ACK 后只知道 2 号包丢了但包 3、4 其实已经安全到达。没有 SACK 的旧实现会从丢包点开始重传所有数据Go-Back-N浪费带宽。开启 SACK 后ACK 选项里会携带已收到的数据段边界发送方可以只重传真正丢失的包。Linux 默认开启 SACK抓包时你会看到 ACK 里的SACK选项段这就是它能精准重传的原因。6. 状态机与传输机制的关系图谱一张表看懂 TCP 全局6.1 从连接生命周期到传输控制的层次划分写到最后我想把这些机制放进一个统一的框架里。很多人学 TCP 容易学成碎片化知识点今天背状态码明天看拥塞控制后天重传机制永远串不起来。其实把这些机制按照服务的目标排列一下就清晰了。TCP 要解决的终极问题就四个建立连接、可靠传输、流量控制、拥塞控制。建立连接三次握手 四次挥手对应状态机LISTEN、ESTABLISHED、TIME_WAIT、CLOSE_WAIT 等都是这个过程中的状态。可靠传输序列号 ACK 超时重传 快重传。流量控制滑动窗口的接收窗口rwnd接收方说了算。拥塞控制拥塞窗口cwnd、慢启动、拥塞避免发送方自己控制。状态机和窗口/重传机制有时会互相影响。比如 TIME_WAIT 里的 socket 不能用于收发数据但它在内核里还占着一个端口槽位影响的是端口资源而不是窗口机制。CLOSE_WAIT 状态下连接可以继续发送数据但由于对端已经关闭再发会受到 RST所以 CLOSE_WAIT 里其实还有窗口机制在运行只是行为已经不正常了。6.2 核心状态与机制速查表我整理了一张速查表你可以存下来随时查概念生命周期/层面产生条件影响常见解法TIME_WAIT连接关闭主动关闭方收到 FIN 并回复 ACK 后进入端口占用、短连接场景端口耗尽SO_REUSEADDR、tcp_tw_reuse、连接池CLOSE_WAIT连接关闭被动关闭方收到 FIN 但未调用 close连接资源泄漏、文件描述符耗尽代码修复、空闲超时、监控滑动窗口传输控制每个连接持续存在决定发送速率上限调大缓冲区、调整 rwnd/cwnd超时重传可靠传输RTO 超时未收到 ACK恢复慢指数退避动态 RTO、合理初始值快重传可靠传输连续 3 个重复 ACK快速恢复无需等 RTO配合 SACK 精准重传SO_REUSEADDR端口管理bind 时设置服务端重启免 bind error框架默认开启SO_REUSEPORT端口管理多进程绑同端口负载均衡、高可用确保版本一致、UID 一致6.3 实战排查路径与后续扩展思路结合我这次压测事故给你一个清晰的排查行动清单遇到 bind error先分清是服务端重启还是客户端连接失败。服务端ss -lnt看端口是否被 LISTEN 占用ss -ant | grep 端口看 TIME_WAIT直接加SO_REUSEADDR。客户端ss -s看全局 TIME_WAIT 数量超过临时端口范围一半就可能出事压测工具换成长连接模型或者开tcp_tw_reuse。出现 CLOSE_WAIT 增长抓线程栈、看连接池配置、检查异常分支是否漏关连接。吞吐不行ss -ant看 Recv-Q/Send-Q 积压情况算一下 BDP决定要不要调 socket buffer。这篇文章里所有机制都不是孤立的知识点它们组成了一套互相牵制的系统。你把这套系统的每个环节都理解透排障时才能做到看一眼状态就知道问题出在哪一层而不是拿tcpdump瞎抓一通包再无限猜测。