
干过几年后端和网络排查的人应该都有同感很多线上疑难杂症追到最后都会落到 TCP 头上。连接数上不去、客户端时不时报端口被占用、服务端莫名其妙出现大量 CLOSE_WAIT这些表面上看是应用代码或者容器配置的问题真正定位到根因时十有八九都在传输层的状态机、队列和超时参数里。TCP 作为传输层的绝对主力从 RFC 793 诞生到现在已经稳定运行了几十年它把“可靠交付”这四个字变成了一套极其精密的机制连接管理、序列号确认、重传、流量控制、拥塞控制每一层都是独立的学问。这篇内容适合正在学计算机网络的学生、刚接触网络编程的开发者以及日常需要排查连接问题的运维和后台工程师。我会从 TCP 在传输层到底承担什么职责讲起把三次握手、四次挥手、可靠传输、拥塞控制这些核心机制拆开揉碎再配合 tcpdump 抓包实例和一批实际线上问题把从原理到排障的完整链路串起来。目标只有一个让你下次面对 TCP 相关问题时不是靠猜而是能照着状态和报文一步步推理出结论。1. TCP 到底在传输层承担了什么职责1.1 从网络分层看 TCP 的定位网络协议栈的分层设计里传输层夹在网络层和应用层中间。网络层IP负责把数据包从一台主机送到另一台主机它只保证“尽力而为”包可能丢失、重复、乱序甚至在中途被拆分。传输层则是在这种不可靠的信道上为应用进程之间建立起一条逻辑上的端到端通道。TCP 和 UDP 是这条通道的两种完全不同风格UDP 直接、无状态、不回头TCP 则做了一整套确认、重传、排序、流控机制把不可靠的 IP 网络“包装”成了一条看起来非常可靠的字节流管道。从使用者的视角理解IP 层像是邮政系统它负责把信件从一个城市送到另一个城市但路上丢了、湿了、顺序乱了没人管。TCP 则像是一个负责任的秘书发信之前先和对方秘书通电话确认收件方式每封信寄出后都要等回执没收到就重新抄写再寄收到的信按编号排列好再交给领导发现对方处理不过来时主动降低寄信频率。这个类比基本能把 TCP 的核心机制映射全了——确认、重传、排序、流量控制缺一不可。这也是为什么传输层选型往往决定了整个系统的能力边界。实时音视频这类能容忍少量丢包、但绝不能容忍延迟的选 UDP文件传输、数据库复制、HTTP 请求这类要求数据完整无损的选 TCP。理解了各自定位才能在做技术选型时心里有底。1.2 TCP 与 UDP 的核心差异很多人背过 TCP 和 UDP 的区别表格但实际排障时未必能立刻对应到具体场景。我把最值得记住的差异整理成一张表后面讲机制时都会反复引用对比维度TCPUDP连接状态面向连接需先建立连接无连接直接发数据可靠性确认、重传、排序数据可靠尽力而为可能丢包乱序传输模式字节流无消息边界数据报有消息边界流量控制滑动窗口机制无拥塞控制有会主动降低发送速率无发送速率由应用决定首部开销20 字节起8 字节典型场景HTTP、FTP、数据库、消息队列DNS、RTP、游戏同步、日志上报这里我想重点说一个容易忽略的细节UDP 虽然没有拥塞控制但是网络出现拥塞时UDP 流量并不会自动“礼让”它会把带宽占满反过来挤压 TCP 流量的空间。所以在高丢包率的弱网环境中大量的 UDP 流量可能让 TCP 连接的重传概率急剧上升整条链路的延迟和抖动都会被放大。这个现象在共享网络出口的场景下非常常见排查“为什么 TCP 忽快忽慢”时需要顺带看一眼 UDP 流量占比。2. 连接管理机制三次握手与四次挥手2.1 三次握手的完整时序TCP 在传输数据之前会先经过一个连接建立过程也就是常说的三次握手。整个过程涉及两个角色——主动发起方客户端和被动监听方服务端以及两个核心报文标志位 SYN 和 ACK。最经典的时序是这样的客户端发送 SYN并携带一个初始序列号 ISN假设为 x进入 SYN_SENT 状态服务端收到 SYN 后回复 SYNACK携带自己的初始序列号 y同时确认客户端的序列号 x1进入 SYN_RCVD 状态客户端收到 SYNACK 后发送 ACK确认服务端的序列号 y1双方进入 ESTABLISHED 状态。很多人刚开始学的时候会奇怪为什么握手要把序列号算得这么细x 和 y 之后还都要加 1。这里的本质是TCP 的每个字节都有编号而 SYN 虽然不携带应用数据但它本身也要消耗一个序列号。所以客户端确认服务端时确认号是 y1表示“我期望收到你下一个字节的编号是 y1”同时也告诉服务端“你的 SYN 我已经收到了”。这个1的细节在抓包时看到 seq 和 ack 的变化就能理解得非常透彻。2.2 为什么是三次而不是两次这是理解 TCP 最关键的“为什么”之一。两次握手的核心漏洞在于它无法防止迟到的历史 SYN 包干扰新连接。设想一个场景客户端先发了一个 SYN编号为 x由于网络拥堵这个包被卡了很久客户端等不到回复超时后重新发起新的连接双方通过两次握手进入了 ESTABLISHED 状态。可就在这时那个旧的 SYN 包也到达了服务端。服务端并不认识这个包是旧的它只会认为客户端又发起了一次新连接于是分配资源、回复 SYNACK凭空建立了一条“幽灵连接”。在旧网络里这个幽灵连接不仅消耗服务端资源还可能让数据流错乱。三次握手解决了这个问题服务端在收到 SYN 后不会直接确认建立连接而是把选择权交给客户端——如果客户端发现自己并没有发起这次连接或者在已有连接中收到了重复的 SYNACK它会发送 RST 来终止这条可疑连接。换句话说三次握手通过双方互相确认“发送能力”和“接收能力”并且让发起方拥有对历史连接的最终判断权从而避免了半开连接和重复连接带来的歧义。这也是为什么 TCP 的设计者坚持要求连接必须由双方明确共识而不是单方面确认。2.3 四次挥手与 TIME_WAIT 的深层逻辑连接的关闭比建立更复杂因为 TCP 是“全双工”的双方的数据通道互相独立一方不再发送数据不代表对方也没有数据要发。所以关闭需要四次报文主动关闭方发送 FIN对方回复 ACK等对方数据传输完也发送 FIN主动关闭方再回复 ACK。两个关键状态值得单独拎出来说。第一个是 CLOSE_WAIT。当对端发送 FIN 后本端内核回复了 ACK但应用进程迟迟没有调用 close()本端就会停留在 CLOSE_WAIT。这个状态本质上不是网络问题而是应用层没有正确关闭 socket。如果程序逻辑里漏了关闭操作或者线程处理迟迟不结束CLOSE_WAIT 就会越积越多最终耗光文件描述符。线上排查看到大量 CLOSE_WAIT第一反应应该是看代码而不是调内核参数。第二个是 TIME_WAIT。主动关闭方在发送最后一个 ACK 之后会进入 TIME_WAIT保持 2MSLMaximum Segment Lifetime报文最大生存时间后才彻底关闭。Linux 下这个时间通常在 60 秒左右。之所以要等这么长时间一是为了确保最后的 ACK 能够到达对端如果对端没有收到它会重发 FIN本端需要能再次回应二是为了让网络上残留的旧报文段自然消失避免它们出现在后续的新连接中。TIME_WAIT 不是 bug而是 TCP 可靠性设计的一部分只是在高并发短连接场景下它的存在会导致端口资源紧张这一点后面在问题排查部分会详细展开。3. 可靠传输的核心机制拆解3.1 序列号、确认号与重传机制TCP 把应用层数据看成连续的字节流并为每个字节分配一个序列号。发送方以字节为单位发送数据接收方收到数据后会回复 ACK其中携带的确认号表示“这个编号之前的字节我都收到了”。如果发送方没有在超时时间内收到 ACK就会重传这段数据。这里有一个非常容易搞混的点TCP 重传的最小单位不是报文段而是字节范围。发送方维护着一个发送缓冲区一旦某个字节区间内的数据没有被确认无论它是最初那次发送的一部分还是已经重传过只要还没收到对应 ACK发送方就默认为它“还在路上”或者“丢了”。这也是为什么 TCP 重传会带来延迟——因为重传需要先等待超时而超时时间的计算有明显的高斯分布特性不可能做到“立刻知道丢了”。为了减少等待超时造成的空白期TCP 引入了快速重传机制。接收方如果收到了乱序的数据会立刻返回一个重复 ACKDupACK告诉发送方“我还没等到某某编号”。当发送方连续收到 3 个相同的重复 ACK 时就会认为该段数据大概率已经丢失不用等超时就立即重传。这个机制在线上抓包时经常能看到表现为大量相同确认号的 ACK 报文分组出现——遇到这种特征基本可以判定链路中发生了丢包或严重乱序。3.2 流量控制滑动窗口的工作原理流量控制解决的是“发送方不要比接收方处理得快”的问题。TCP 的每个 ACK 里都带着一个窗口字段Window它告诉对端我的接收缓冲区还有多少空间你最多还能发多少字节。这个数值是动态变化的接收方应用程序消费数据越快窗口越大消费不过来窗口就缩小甚至缩到零。把滑动窗口想象成一条传送带上的空隙管理。发送方只能在窗口范围内发送数据窗口前沿随着 ACK 的到达向右滑动。如果接收方把窗口通告成 0发送方就必须停止发送进入持续探测状态——发送方会周期性地发送一个 1 字节的窗口探测包询问接收方窗口是否已经重新打开。这个机制在排障中对应一个问题如果抓包看到一堆 “Window Probe” 或 “Zero Window”说明接收方应用处理速度跟不上网络传输速度应用层可能存在数据库查询过慢、日志刷盘阻塞等问题。这时候调大内核缓冲区只是治标真正要优化的是接收方的消费能力。3.3 拥塞控制慢启动与拥塞避免流量控制管的是“对端能收多少”拥塞控制管的是“网络能承载多少”。两者的区别一个是本地视角一个是全局视角。TCP 通过维护一个拥塞窗口cwnd不断探测网络带宽的容量再取拥塞窗口和接收窗口中的较小值作为实际可发送的数据量。慢启动阶段拥塞窗口从 1 个报文段或按 RFC 6928 初始为 10 个开始每收到一个 ACK窗口翻倍增长呈指数式上升。当窗口达到慢启动阈值ssthresh时进入拥塞避免阶段窗口改为线性增长。一旦发生丢包传统算法会认为网络发生了拥塞把阈值降到当前窗口的一半并将窗口重置之后再次开始慢启动。后来出现的 CUBIC 算法Linux 默认则让窗口在丢包后更快地“恢复”到阈值附近利用一个三次函数曲线来增长窗口在高带宽长链路场景下有显著优势。再后来的 BBR 算法则完全换了一套思路以测量链路带宽和延迟代替丢包信号规避了“丢包即拥塞”这一传统假设带来的问题。实际操作中如果你在监控图表里看到流量像锯齿一样起伏先搞清楚是 cwnd 被削了还是接收窗口被通告成了 0这两者的处理方向完全不同。4. 用抓包和系统命令把 TCP 看透4.1 tcpdump 实战观察一次完整连接原理说了很多落到实操才有实感。tcpdump 是我排查 TCP 问题时的第一工具用法非常简单关键是知道要抓什么、看什么。以下命令抓取某个端口上的所有 TCP 报文并输出可读的序列号、确认号和标志位sudo tcpdump -i eth0 -nn -S tcp port 8080 -w tcp.cap抓到包里会看到类似这样的握手报文IP 10.0.0.2.50001 10.0.0.1.8080: Flags [S], seq 3028910153 IP 10.0.0.1.8080 10.0.0.2.50001: Flags [S.], seq 612481932, ack 3028910154 IP 10.0.0.2.50001 10.0.0.1.8080: Flags [.], ack 612481933注意这里的 -S 参数很重要它让 tcpdump 显示真实的绝对序列号而不是相对序列号。默认情况下 tcpdump 会把序列号显示成相对值也就是从 0 开始方便阅读但排查重复 ACK、乱序问题时绝对序列号才能还原完整的字节流偏移。握手之后的数据报文确认号会不断增长增长的字节数就是对端实际收到的数据量。用这个数值和应用的发送字节数比对就能快速定位是发送端没发够还是中途丢了。4.2 连接状态怎么看ss 和 netstat确认服务端当前有哪些连接、处于什么状态用 ss 比 netstat 更高效。命令输出里 Recv-Q 和 Send-Q 两列在监听套接字上有特殊含义对 LISTEN 状态的 socketSend-Q 显示的是 accept 队列长度上限Recv-Q 显示当前 accept 队列中等待应用 accept 的连接数。如果 Recv-Q 持续接近 Send-Q 的数量说明应用处理连接的速度跟不上内核接收新连接的速度新连接会开始被丢弃。用一行命令统计当前所有 TCP 状态的数量是排查连接问题的起手式ss -ant | awk {print $1} | sort | uniq -c正常服务端的状态分布应该以 ESTABLISHED 为主。如果 TIME_WAIT 数量异常庞大说明存在大量短连接且连接由服务端主动关闭严格说是服务端先发 FIN就会在服务端留下 TIME_WAIT如果 CLOSE_WAIT 数量增长说明应用漏关闭SYN_SENT 堆积则说明对端响应异常或者防火墙在丢包。4.3 服务端参数调优的核心思路不要一上来就盲目改内核参数。遇到连接问题时我的建议是先抓包确认问题发生在哪个阶段再决定调什么。常见的几个调优项和适用场景整理如下内核参数作用适用场景net.core.somaxconnlisten 队列上限accept 队列频繁打满net.ipv4.tcp_max_syn_backlogSYN 半连接队列长度瞬时高并发 SYN 请求net.ipv4.tcp_syncookies半连接队列满时启用 SYN Cookie防御 SYN Floodnet.ipv4.tcp_tw_reuseTIME_WAIT 端口复用仅客户端主动发起大量短连接的客户端net.ipv4.tcp_fin_timeoutFIN_WAIT_2 超时时间对端不关闭连接的场景这里特别提醒一个容易踩的坑tcp_tw_reuse只能用于主动发起连接的一方而且它要求连接的时间戳选项开启。很多人把这个参数开在服务端以为能解决 TIME_WAIT 过多的问题实际上对于服务端监听端口来说基本无效。服务端真正缓解 TIME_WAIT 的措施是尽量让客户端主动关闭连接或者使用长连接池、连接复用把 TIME_WAIT 的消耗控制住。像 nginx 这类反向代理高并发场景下 TIME_WAIT 多出现在 proxy 与 upstream 之间的短连接上解法往往是配置 keepalive 复用到上游的 TCP 连接而不是去调 TIME_WAIT 相关的内核参数。5. 常见 TCP 问题排查实录5.1 TIME_WAIT 过多导致端口耗尽现象客户端日志里出现 “Cannot assign requested address” 或者频繁的连接失败服务端的监控图表里 TIME_WAIT 数量一直维持在高位。这种问题的高发场景是 Java、PHP 等语言写的服务端程序每处理完一个请求就主动 close() 连接导致服务端的每个本地端口在关闭后都进入 60 秒的 TIME_WAIT 期。而一个四元组中服务端的 IP端口是固定的可变化的是客户端 IP端口如果服务端主动断开大批量 TIME_WAIT 会占满本地端口范围新连接就得排队等待端口释放。对策一般从三个方向入手让客户端主动关闭连接把 TIME_WAIT 留在客户端一侧用连接池复用连接减少建连和断连次数如果确实没有办法再考虑调小tcp_max_tw_buckets或者启用tcp_tw_reuse。但永远记住线性增加连接数不是解决问题的方向长连接才是。5.2 客户端重连报“地址已在使用”EADDRINUSE这个报错最常见于 Java 写的 TCP 客户端。程序每次请求都 new 一个 Socket数据发送完后主动 close()紧接着下一次请求又要建立连接。由于上一个连接的本地端口还处于 TIME_WAIT 状态内核默认不允许立刻复用同一端口于是再次 connect 时就抛出Address already in use。解决方案按优先级排序把短连接改成 HttpClient 或连接池复用的长连接一劳永逸如果必须使用短连接客户端设置SO_REUSEADDR允许端口在 TIME_WAIT 状态下被复用再不行就重试加上退避策略错开 TIME_WAIT 高峰期。这里多说一句很多人认为是内核参数问题其实根因是应用层的连接管理策略不合理。把连接生命周期管理好这类报错基本能消失绝大部分。5.3 CLOSE_WAIT 堆积代码问题还是网络问题CLOSE_WAIT 堆积几乎不需要排查网络它是应用层 bug 的表现。典型场景是服务端收到客户端的 FIN 后内核回 ACK但应用进程没有调用 close()导致 socket 一直处于半关闭状态既不能接收新数据也不能断开。代码里典型的错误包括在 IO 异常分支里忘记关闭 socket、使用连接池但池内连接没有检测对端 FIN 保活、或者事务处理时间过长导致关闭逻辑被延迟到超时后才执行。排查步骤先用ss -ant | grep CLOSE_WAIT确认数量再结合服务的线程堆栈和日志看这些 socket 对应的业务处理是否卡住。我曾经定位过一个问题服务端应用使用了一个没有设置超时时间的 HTTP 客户端去调用第三方接口第三方服务端一直不关闭连接也不返回数据导致调用线程永远阻塞。表面看起来是 CLOSE_WAIT 堆积真正的根因是依赖调用缺少超时熔断机制。这一类问题监控只能暴露表象代码审查和链路追踪才能找到根源。5.4 半连接队列溢出与 SYN Flood服务端收到 SYN 后会把它放入半连接队列等待三次握手完成后再转入 accept 队列。如果攻击者或压测工具发送大量 SYN 但不回复 ACK半连接队列就会被填满正常的握手请求也会被内核丢弃表现就是客户端 connect 超时服务端日志里出现 “listen queue overflow” 之类的计数。内核的 SYN Cookie 机制在检测到半连接队列满时不再把 SYN 存入队列而是将连接信息编码到 SYNACK 的序列号中等客户端回 ACK 时才重建连接。这个设计很巧妙也是抵御 SYN Flood 的第一道防线。实操中可以用netstat -s查看TCPReqQFullDrop或SYNsToLISTEN之类的计数器确认是否有队列溢出。压测环境下出现这个现象说明并发量超过当前实例的 accept 能力可以增大 backlog、开启 syncookies必要时做水平扩容。但要注意SYN 队列和 accept 队列是两个不同的队列很多人只调somaxconn不调tcp_max_syn_backlog问题依然存在。5.5 dup ack 机制引发的性能波动抓包看到一个 TCP 流里出现大量重复 ACK很多人第一反应是网络丢包。重复 ACK 确实是快速重传的触发条件但它的出现还有一个常见原因——网络乱序。报文经过不同路径先后到达接收方接收方收到序列号更大的数据但它最想等的前面字节还没到于是只能对之前已确认的字节再次发送 ACK。如果乱序程度不严重这种重复 ACK 不一定代表实际丢包发送方的快速重传机制可能被“误触发”导致不必要的重传反而放大网络负担。区分丢包和乱序可以在抓包里对比重复 ACK 之后是否真的跟上了对应序列号的重传报文。如果看到重传但又没有持续出现空洞大概率是链路存在乱序此时调整tcp_reordering参数可以让发送方对重复 ACK 更“容忍”减少误判。如果重传报文本身又被丢弃那就是实打实的丢包需要顺着路径查交换机的丢弃计数、环形缓冲溢出指标和网卡错误包统计。这个案例告诉我们TCP 的可靠性机制也会在特定场景下放大问题理解了机制才能准确判断现象背后的真实意图。结尾的个人体会TCP 这套协议在数据网络里运行了几十年机制看似复杂但每一条设计背后都有非常务实的原因。我自己的习惯是遇到连接异常先抓包看时序再对照状态机和队列计数值最后才动内核参数。排错过程中把握住“谁在等待谁的什么”这个大原则很多问题都能一眼看穿。比如 SYN 发送后没有回应先确认对端路由可达SYNACK 发出后客户端没有 ACK先查半连接队列压力连接建立后数据发送慢再分流量控制和拥塞控制去定位。希望大家下次在 tcpdump 里看到那些一排排的“[]”标志位时不再是两眼一抹黑而是能想象出背后两台主机之间正在进行的对话。