
手写TCP这块很多人第一步就理解歪了拿个tcpdump抓包看到三次握手四次挥手就觉得自己懂了但其实TCP最值钱的东西全在数据面里——序号怎么保证不乱、丢包怎么被感知、窗口怎么卡着发送方、拥塞怎么逼着两端降速。这篇我按自己的理解把TCP可靠传输的主线串一遍结合Linux上的真实排查经验讲不用背字段把机制背后的“为什么”整明白。适合刚接触Linux网络、准备面试、或者线上TCP出问题不知道怎么下手的同学。1. 先别急着背字段TCP到底在解决什么问题在聊机制之前必须把TCP的定位弄清楚。TCPTransmission Control Protocol工作在传输层跑在不可靠的IP协议之上提供的是面向连接、可靠、基于字节流的传输服务。这句话信息量很大。“面向连接”意味着通信双方要先建立一个逻辑通道通信完之后还要“拆”掉它。“可靠”意味着数据不能丢、不能错、不能重、不能乱——或者说即使下层出了幺蛾子TCP也能通过自己的机制把它纠正回来。“基于字节流”意味着TCP不关心你上层发的是什么类型的数据它把数据看成一串没有边界的长流怎么分块、怎么装进报文段是TCP自己决定的事。可靠传输是TCP最核心的价值。IP层只负责尽力而为地把数据报送到目的地不保证顺序、不保证可靠、甚至可能丢包。TCP要想在这上面建可靠传输必须自己解决四个问题接收方怎么知道数据有没有收到——所以有确认ACK机制发送方怎么判断是不是丢了——所以有超时重传机制接收方怎么处理乱序和重复——所以有序号和去重机制双方怎么避免把对方or网络打爆——所以有流量控制和拥塞控制。这四板斧之间不是孤立的序号是地基确认和重传是“发现错误并纠正”窗口是“控制发送节奏”。下面我把这些机制整体串一遍你会发现TCP并不复杂它只是把一整套“收发快递”的逻辑搬到了网络里。2. 连接的建立与拆除三次握手和四次挥手为什么非要是这个形状这一节讲TCP连接管理这是面试高频题也是线上问题重灾区。但我不是让你背状态名而是理解每一步的意义之所以是这个形状是因为每一步都在传递不可或缺的“事实”。2.1 三次握手不是礼貌是双方在同步序列号三次握手的整个过程客户端发送SYN服务端回SYNACK客户端再回ACK。很多资料说这是为了让双方确认“能收能发”这个说法没错但不够本质。握手的核心目的其实是同步初始序列号ISNInitial Sequence Number。序号是TCP可靠传输的地基接收方要靠序号对数据排序所以通信双方必须都知道对方的起始序号是什么。第一次握手客户端发SYNseqx相当于告诉服务端“我从x号开始编号你准备接我数据”第二次握手服务端回SYNACKseqyackx1相当于说“收到你的请求我从y号开始编号此刻我已经成功收到你的x所以你接下来从x1开始发就行”第三次握手客户端发ACKseqx1acky1相当于说“我收到你的编号了你的y1是下一个我要接收的数据号”。如果只有两次握手服务端没法确认客户端的接收能力是正常的。网络上有个经常提到的例子——旧的连接请求在网络中滞留如果只有两次握手服务端傻傻地建立连接并等待数据客户端这边根本不想要这个连接资源就白白浪费了。加上第三次握手服务端收不到客户端的ACK就会明白对方“变卦”了不会傻等。在Linux上排查握手问题最直接的是看ss -tnp里的连接状态。大量SYN_RECV状态的连接基本可以判定半连接队列被塞满或遭遇了SYN Flood。这时候net.ipv4.tcp_syncookies这个内核参数就该出场了——它能在半连接队列用尽时通过编码ISN来校验握手省下维护半连接的状态。2.2 四次挥手为什么被动关闭方要多走一步四次挥手主动方发FIN被动方回ACK被动方再发FIN主动方回ACK。这里的关键是FIN只是一个“我这边数据发完了”的声明不等于“我不接收数据了”。所以被动关闭方在收到FIN之后可能还需要继续发送自己剩余的数据。它回ACK表示“我知道了”然后接着发还没发完的数据直到自己也发完才发FIN。一条宝贵的实战经验如果你用close关闭socket内核会立即发FIN哪怕接收缓冲区还有没被应用读完的数据也会被丢弃。如果只是想表示“我不再发送数据了”但是还希望把未读完的接收数据读完应该用shutdown(fd, SHUT_WR)让TCP进入半关闭状态保留读通道继续收数据。关于四次挥手最磨人也是面试最爱问的永远是TIME_WAIT。主动关闭方在收到对方的FIN并回完ACK之后不会立刻进入CLOSED而是进入TIME_WAIT持续2MSLMaximum Segment Lifetime报文最大生存时间。MSL在Linux默认是30秒或60秒不同内核版本配置不一样所以TIME_WAIT一般会持续60秒左右——其实不用刻意去背这个数字理解它存在的两个原因就够了确保“最后一个ACK”万一丢了对方会重发FIN自己还能再回一次ACK不至于让对方永远挂在LAST_ACK让这条连接上的旧报文在网络上彻底消散防止它串到新连接里造成数据错乱。在Linux高并发短连接场景比如Nginx代理、RPC短连接下TIME_WAIT连接可能堆积到上万条。这在Linux 4.x以上内核不算大问题真正的风险是它占用本地端口四元组中本地端口有限害得新连接无法建立。这时候可以谨慎开net.ipv4.tcp_tw_reuse1让内核在安全前提下复用TIME_WAIT连接但请注意它只能用于主动发起连接的一端通常是客户端不能无脑在两端都开。2.3 TCP状态机与Linux排查命令掌握了握手挥手的报文变化就能串起TCP状态机CLOSED → LISTEN → SYN_SENT / SYN_RECV → ESTABLISHED → FIN_WAIT_1/2、TIME_WAIT、CLOSE_WAIT、LAST_ACK → CLOSED。我之前排查过一个接口超时问题ss -tn刷出来一堆CLOSE_WAIT连接就立刻明白了——是服务端应用代码没关闭socket。因为对端发了FIN想优雅关闭内核和应用层都收到了EOF信号但进程没调用close连接就停在CLOSE_WAIT。这个状态的本质是对方想分手你这边程序忘了说“好”。排查CLOSE_WAIT第一件事就是去查应用层代码里的socket释放逻辑不要先怀疑什么内核参数问题。状态含义常见原因初步排查方向SYN_RECV收到SYN半连接建立半连接队列满、SYN Floodtcp_syncookies、tcp_max_syn_backlogESTABLISHED正常建立连接--CLOSE_WAIT收到对方FIN自己没关socket应用未关闭连接查代码检查资源泄漏LAST_ACK等待最后的ACK对方不回了/网络异常抓包看ACK是否送达TIME_WAIT主动关闭后等待2MSL大量短连接tcp_tw_reuse主动方3. 数据面可靠传输序号、确认、重传、去重连接建好了才开始真正运输数据。这一节是TCP最硬核的部分数据怎么做到不丢、不乱、不重。核心靠四条序号机制、确认机制、重传机制、去重机制。3.1 序号seq和确认号ackTCP可靠性的地基TCP把应用层给的字节流按顺序编号每个报文段的头部携带两个关键字段seq这个报文段里数据的起始序号和ack期望对方下次发来的第一个字节序号。有了seq接收方就能对乱序到达的段进行排序有了ack发送方才能知道哪些字节已经被对端确认了。这个设计朴素但极其高效——它让可靠性不再依赖每条物理链路的稳定性而是依赖接收端的反馈。注意TCP确认是累计确认ackn表示“n之前的所有字节我都收到了”不需要对每个段单独回复。这个设计简化了接收方的处理但代价是如果一个段丢了重传的单位不太好确定。这也是为什么后面会引入SACK。一个很有用的抓包经验分析TCP流时看ack增长了没比看seq更重要。如果对端一直回同一个ack说明有数据包丢了而且丢的恰好就是ack号指向的那个包。3.2 超时重传与RTO计算发送方发出数据后会启动一个定时器等待对方的ACK。如果等了一阵RTORetransmission Timeout还没等到就判定数据丢了重传这份数据。RTO不能太大否则网络一抖就严重影响延迟也不能太小太小容易在ACK还在路上时就重传白白浪费带宽。RTO的依据是RTT——报文往返时间。经典算法里RTT的变化会动态调整RTO现代内核还引入了Karn算法重传报文不参与RTT采样和指数退避不行就再等更长。这背后就是“退避”思想第一次超时可能是抖动如果真丢了再传还是丢的概率更高那就把超时时间加倍避免把一个坏网络锤死。在Linux系统上与重传相关最常用的参数是net.ipv4.tcp_retries1和net.ipv4.tcp_retries2。tcp_retries2默认大概15次影响TCP判断“连接彻底失败”的时间如果应用层经常报超时可能需要配合业务需求调它但我不建议为了“表面不报错”而盲目调大那只会让故障反应更慢。3.3 快速重传与SACK/D-SACK超时重传等待时间太长TCP设计了“快速重传”来加速如果发送方连续收到3个重复的ACK说明不仅仅是乱序大概率是丢了包——对端反复提醒“我还在等这个包”发送方不用等超时立刻重传。快速重传的问题在于它只知道“从哪开始丢”不知道哪些后续数据已经收到了所以往往要重传很多数据Go-Back-N屁股后面一大串全重发。SACKSelective Acknowledgment选择性确认解决了这个问题。SACK选项允许接收方在ACK里追加几个TLV字段告诉发送方“我虽然没收到3号包但是5号包我已经收到了”发送方就可以只重传真正丢掉的3号包效率立刻上去。更精细的是D-SACKDuplicate-SACK它能告诉发送方“你重传的数据其实我早就收到了”。这个信息在排查网络问题时非常宝贵如果经常出现D-SACK说明存在不必要的重传多半是RTO太敏感、或链路存在突发延迟导致重复发送了。Linux上SACK默认是开启的net.ipv4.tcp_sack1很多老教程让你在广域网拥塞时关掉它但现在基本不需要关了现代协议栈对SACK的处理已经很成熟。3.4 滑动窗口与流量控制TCP的发送方不能一股脑把数据全怼到网络上接收方也不能一次性处理无限数据。所以TCP用了一个滑动窗口机制接收方在ACK中带上自己“还能接收多少数据”的能力rwnd接收窗口发送方把未确认的数据限制在这个窗口内窗口随着ACK的到达而滑动。这个机制实现了流量控制保护的是接收方不被发送方打爆。Linux上有两个方向要区分清楚Recv-Qss第一列接收队列积压如果一直涨可能是应用读得慢接收窗口会不断缩小Send-Qss第二列发送队列积压如果一直是满的说明链路拥塞或对端接收窗口为0。典型场景对端回了win0即零窗口发送方就得暂停发送并定期发窗口探测报文去询问接收方窗口是否恢复。如果Windows上开了窗口缩放选项window scale抓包时看到的窗口数要乘以缩放因子才是真实窗口。新手排查时经常被这个假窗口误导以为对端不收数据了。这里要补充一个很有实战价值的组合延迟确认Delayed ACK与Nagle算法两者单独用没问题一旦碰在一起会产生“愚笨窗口综合症”。延迟确认接收方不立刻回ACK而是稍微等一会儿Linux默认约40ms等有数据要发时捎带ACK顺便合并多个ACKNagle算法发送方要攒够一个MSS或者等到所有数据都收到ACK才发下一批数据。如果双方都“等”结果就是发送方在等ACK腾出窗口接收方在等更多数据一起确认双方就这么干等延迟直接被拉到几十毫秒以上。线上如果遇到小包交互延迟异常偏高先看是不是Nagle在捣乱可以考虑在应用里用TCP_NODELAY关闭NagleRedis、很多RPC框架都默认关了。4. 拥塞控制当路上的车多到堵死时流量控制保护的是接收方那么谁保护网络答案是拥塞控制。TCP发送方不能只看接收方的窗口还要看网络是否吃得消。如果所有主机都不管不顾地同时发数据路由器缓冲区被占满新到的包被丢弃导致大量重传又加剧拥塞最后全网瘫痪——这叫“拥塞崩溃”。TCP的拥塞控制就是通过一个发送方维护的“拥塞窗口”cwndcongestion window来动态控制发送速率实际发送上限 min( cwnd, rwnd )。4.1 慢启动、拥塞避免与快速恢复现代TCP拥塞控制的核心体系包括三个组件慢启动连接刚建立时cwnd从很小Linux老版本约10个MSS新版内核会按BDP动态初始开始每收到一个ACKcwnd翻倍增长。这个阶段是指数增长增长极快目的是在短时间内找到带宽的上限。拥塞避免当cwnd超过ssthresh慢启动阈值不再翻倍而是每个RTT只增加一个MSS。这是线性增长增长要“小心翼翼”。快速恢复收到3个重复ACK时因为快速重传已经介入发送方会标记丢包把ssthresh降为当前cwnd的一半然后cwnd从ssthresh开始继续拥塞避免阶段。它避免了一步退回慢启动导致的带宽剧烈抖动。这套机制统称AIMD加法增加乘法减少它的特点是“缓慢试探、迅速撤退”网络好就慢慢加码一旦探测到丢包就迅速砍半。Linux上的默认拥塞控制算法在较新内核中已切换到BBR或CUBIC具体看发行版版本。CUBIC适合大带宽高延迟网络BBR则通过建模瓶颈带宽来主动调节能减少队列延迟。生产环境更换算法前建议在不同链路类型上先做对比测试不要一上来就全换。4.2 Linux中的拥塞控制内核参数涉及到拥塞控制最常调的Linux参数就这几个参数作用建议net.ipv4.tcp_congestion_control拥塞控制算法广域网优先BBR或CUBICnet.ipv4.tcp_notsent_lowat发送缓冲区未发送数据阈值有小包低延迟需求时可调小net.ipv4.tcp_window_scaling窗口缩放大带宽长RTT链路务必保持1net.ipv4.tcp_slow_start_after_idle空闲后是否重置cwnd长连接复用优先设为0那一年我们一台大带宽业务的服务器间歇性卡顿排查很久最后发现是链路中某个中间设备的队列缓冲区被占满服务端开启BBR之后卡顿明显缓解。原因就是BBR能感知瓶颈带宽主动限制发送速率避免把中间设备的缓冲打满。大家在线上遇到“链路明明带宽很足但TCP传输速度上不去”这种问题优先检查拥塞控制算法的选择和cwnd的初始值而不是无脑调大socket缓冲区。5. 在Linux上“看见”TCP机制抓包、计数器和工具理解了机制还得会观察。TCP设计得非常精巧几乎每个机制都会在某个计数器或报文特征中留下痕迹排查问题就是顺着这些痕迹往上找源头。5.1 ss命令与TCP状态解读排查TCP问题第一步永远是ss -tanp看连接状态、Recv-Q/Send-Q积压情况。ss -s可以输出TCP连接汇总比如当前系统timewait、closed、syn_recv、established数量。如果syn_recv的数量与syn_dropped同步上涨说明半连接队列溢出如果timewait数量巨大但端口资源耗尽了看established里是不是有无数对外短连接。还有一个容易被忽略的字段Send-Q显示的是发送队列中还没被确认的数据量。在做大文件传输时如果Send-Q一直高企而Recv-Q为0通常表示数据停在本地协议栈没发出去或发出去没收到确认更偏向于网络链路问题反过来Recv-Q持续上涨说明应用层消费慢更偏向于应用处理性能问题。5.2 tcpdump抓包观察握手、重传与窗口多数时候ss只能看到表象要想验证到底是网络丢包还是对端没回ACK必须抓包。tcpdump -i eth0 tcp port 8080 -nn -w /tmp/tcp.pcap抓包后重点看几个特征三次握手SYN、SYNACK、ACK三个包seq/ack的编号关系和本章第2节一致重传包抓包文件里会出现大量[TCP Retransmission]标记说明发送方触发了超时重传或快速重传零窗口对端持续回[TCP ZeroWindow]这就是流量控制生效接收方已经吃不消了重复ACK如果收到多个[TCP Dup ACK]说明发生了乱序或丢包但要结合SACK来判断到底是哪种。我自己的习惯是先用nstat -az看本机TCP栈的统计计数比如TcpRetransSegs、TcpInSegs、TcpOutSegs确认有重传趋势再抓包精确定位丢包位置。这样能大幅减少无头绪抓包的时间。5.3 用netstat和nstat快速定位重传率Linux自带的nstat命令可以直接输出TCP协议栈的累计计数nstat -az | grep -E TcpRetrans|TcpOut|TcpIn|TcpTimeouts|TcpLoss关键的几个指标TcpRetransSegs累计重传段数量长时间持续增长说明网络环境堪忧TcpOutSegs累计发送段数量。重传率 TcpRetransSegs / TcpOutSegs一般超过1%就要关注TcpTimeoutsTCP超时次数频繁超时会直接影响应用延迟TcpLoss丢包事件计数丢包率升高往往伴随页面卡顿、接口变慢。如果重传率高于阈值先检查本地网卡是否有大量错误包ethtool -S eth0看dropped/errors、是否存在网卡降速和链路不稳定再怀疑中间网络设备。很多超时问题其实是网线/光模块松了排查优先级要放在最前面。5.4 半连接队列与全连接队列溢出TCP服务器监听接口时有两个队列半连接队列SYN队列存已经收到SYN但还没完成握手的连接全连接队列Accept队列存已完成握手、等待应用accept()取走的连接。应用层accept()速度太慢全连接队列就会爆新完成的握手连接直接被内核丢弃或RESET。检查方法很直接ss -lntSend-Q在LISTEN状态下显示的其实是全连接队列的最大长度Recv-Q则是当前已建立但还没被accept的连接数。如果Recv-Q长期占满Send-Q或者应用日志出现connection reset by peer、accept queue full之类提示基本就是全连接队列溢出了。内核参数net.core.somaxconn和socket监听时传入的backlog参数共同决定了全连接队列上限。调整时要同时改这两个地方只改一个不生效高并发服务端建议把两者都调成2048或更高。6. TCP排查实战典型问题的定位思路最后整理一张速查表涵盖我在Linux上排查TCP问题时遇到最频繁的几类现象、原因和解决方向。别看它简单很多时候比翻半天文档有用得多。现象可能原因定位方向大量SYN_RECV半连接队列溢出、SYN Floodnetstat -s查syn_dropped开tcp_syncookies大量CLOSE_WAIT应用未关闭socket查代码、查fd泄漏lsof -p PID | wc -l大量TIME_WAIT主动关闭短连接过多开tcp_tw_reuse主动方业务层连接池Send-Q持续满网络拥塞或对端不接收抓包看零窗口、重传标志ping/mtr查路径Recv-Q持续增长应用消费慢查应用吞吐优化读取逻辑接口时快时慢链路抖动 / RTO不合理抓包看重传间隔traceroute观察延迟毛刺连接被RST重置全连接队列满、对端崩溃ss -lnt看队列抓包看RST触发点这里有一个我重复踩过的坑线上服务出现大量连接被对端RST桩了半天中间链路最后发现是服务端应用监听的backlog队列溢满了内核没有空间接受新连接索性直接回了RST。所以每次排查RST问题第一步不是到网络侧找问题而是先看本地accept队列压力。另外“重传”这个词对不同的人来说含义完全不同。应用层看到的是“接口超时”内核看到的是“TcpRetransSegs上涨”抓包看到的是“TCP Retransmission标记”。排查时要沿着应用日志 - 内核计数 - 抓包特征这条线逐层下钻不要一开始就陷入细节里否则很容易误判。7. 写在最后TCP可靠传输的“道”与“术”TCP可靠传输这套设计看起来只是一堆状态机、定时器、窗口算法其实背后就一句话在不可信的信道上建立可信的通信。它不做“保证不丢”而是做到“丢了能发现、发现能重传、重传不爆炸”它不做“保证不慢”而是做到“能快的时候拼命快要退的时候果断退”。这种设计哲学放到任何一个分布式系统、消息队列、分布式存储里都成立——先接受底层的不可靠再在上层做兜底和补偿。我在实际运维和开发里最深的一个体会是TCP的可靠性不是免费的。每一次可靠交付背后都有序号、确认、缓冲、重传、定时器在默默工作。你在Linux上看到的Recv-Q、Send-Q、各种TIME_WAIT堆积不是内核闲着没事刷数字而是TCP在跟现实世界的不完美博弈。理解了这层你再去看ss的输出、tcpdump的报文、nstat的计数器才算是真正读懂了它们。实战中还有个容易被忽视的建议排查TCP问题前先把应用层的读写逻辑梳理干净。很多所谓“TCP性能问题”查到最后其实是应用层死等、没及时读走接收缓冲区数据或者没及时关闭连接导致的——TCP机制本身没毛病是使用者用错了姿势。把机制搞懂了、把手里的工具用熟了再遇到奇怪的网络现象至少知道该往哪个方向使劲不至于像无头苍蝇一样乱撞。