先说说我为什么想写这篇东西。前阵子线上某个网关服务突然大面积超时,CPU和内存都正常,日志里也看不到任何业务异常,但客户端就是感觉到明显的卡顿。我抓包一看,发现TCP重传率高得离谱,重传的报文在链路上绕了快一秒才回来,最终定位到是机房某个光模块故障导致的前向丢包。那次之后我意识到一件事:很多开发和运维把TCP当成一个"既定存在",出了问题只会看应用日志,极少有人真正理解传输层在替我们做什么、又不做什么。所以这篇我想把这些年踩过的TCP坑、理解过的机制,按一个工程师实操的视角重新梳理一遍,争取让看完的人拿到问题能自己定位,而不是只会重启服务。
1. TCP最容易被误解的一件事:它的三个承诺和一个不承诺
很多初学网络的人对TCP的第一印象是"它是一个可靠的传输协议"。这个说法没错,但也容易误导人——好像只要用了TCP,数据就一定能送到。我在实际排查中见过太多人把"可靠"理解成"不会丢",结果一遇到超时就懵了。
TCP真正承诺的是三件事:不缺失(通过序号和确认机制发现丢了就重传)、不重复(接收方通过序号去重)、不乱序(接收方按序号重组后再交给上层)。它没说"我一定能把数据送到",它说的是"我会尽力送,并且能告诉你到底送没送到"。这一点在CDN、长连接、弱网场景下尤其重要。各位做移动端开发的应该深有体会,电梯里、地铁上网络波动频繁,TCP会自己重传,但重传要时间,用户感知到的就是"转圈"。这不是TCP失效,而是TCP在按照它承诺的方式工作。
那TCP在协议栈里处于什么位置呢?一句话概括:IP负责把包从一个地址送到另一个地址,TCP负责在这个传输过程中把可靠性补齐。IP层只负责"尽力而为",它不管包有没有丢、有没有乱序;TCP跑在上面,给每个字节编上号,约定好确认和重传规则,把IP这条不太牢靠的路铺成一条看起来挺稳的管道。我用快递来打个比方:IP相当于你在快递面单上写地址,快递公司按地址送;TCP相当于快递公司的签收流程——小哥送到后让你签个字,没签上就再派一次,还要求包裹编号不能乱,先签的先拿。
顺便把UDP拎出来对比一下,两者的取舍在很多实际选型里会让你记一辈子:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接,三步握手建立、四步挥手拆除 | 无连接,发就完了 |
| 可靠性 | 确认 + 重传 + 序号排序 | 不确认不重传 |
| 有序性 | 保证按序交付 | 不保证,可能乱序到达 |
| 流量控制 | 滑动窗口机制 | 没有 |
| 拥塞控制 | 有,自动适应网络 | 没有,推多少发多少 |
| 典型场景 | HTTP、数据库连接、文件传输 | DNS、音视频实时传输、游戏同步 |
注意最后一行的场景区分。我之前有个同事做视频会议,非要用TCP传实时流,结果一旦丢包就开始重传,延迟越堆越高,画面卡成幻灯片。后来换了UDP + 应用层做丢包补偿,体感立刻好了。TCP为了可靠付出的代价是延迟和吞吐的动态调整,实时性要求高的场景反而要慎用。
2. 三次握手和四次挥手:不是背流程图,是理解双方在交换什么
2.1 三次握手为什么必须是三次,而不是两次
教科书上都会写:发送方先发SYN,接收方回SYN+ACK,发送方再回ACK。但很少有人说清楚:这三步到底交换了什么,以及为什么不能省。
关键在于双方需要交换并确认彼此的初始序号。TCP给字节编号,双方都得知道对方从哪个号开始编号,之后的确认和排序才有参照。第一次握手,客户端把自己的初始序号x发给服务端;第二次握手,服务端把自己的初始序号y带回来,同时确认收到x;第三次握手,客户端确认收到y。这里有个最实际的问题:如果只有两次握手,服务端在收到SYN之后就认为连接已建立,但客户端可能因为网络原因根本没收到SYN+ACK,它会重新发SYN。服务端这边就会莫名其妙多出一堆只建立了一半的连接,占用半连接队列。三次握手让服务端在收到ACK之前不把连接完全当作可用,避免浪费资源。
另一个理由是防止历史报文串扰。假设客户端第一次发的SYN在网络里迷路了很久,客户端等不及重发了一个新SYN,旧SYN又后到先至。如果只有两次握手,服务端没法区分新旧,可能建立一条基于过时序号的连接。三次握手加上序号确认后,接收方可以识别出这不是本次期望的确认号,直接丢弃。
我刚毕业那会儿觉得三次握手就是走个流程,直到自己用tcpdump抓到下面这样的交互才真正把逻辑串起来:
10:23:01.100000 IP 192.168.1.10.55123 > 192.168.1.20.443: Flags [S], seq 123456789 10:23:01.100120 IP 192.168.1.20.443 > 192.168.1.10.55123: Flags [S.], seq 987654321, ack 123456790 10:23:01.100200 IP 192.168.1.10.55123 > 192.168.1.20.443: Flags [.], ack 987654322注意看:SYN报文本身要消耗一个序号,所以第一次握手的ACK确认号是对方的seq加1。很多人背命令的时候容易忽略这个细节——任何情况下,SYN和FIN都占一个序号,纯ACK不占。
2.2 半连接队列、全连接队列和SYN Flood
每次握手时,服务端内核里其实排着两个队。一个是半连接队列,存放已收到SYN但还没完成握手的连接;另一个是全连接队列,存放已完成三次握手、等待应用accept掉用accept()接走的连接。Linux下这两个队都有长度限制。
如果有大量握手中的连接占满半连接队列,新来的SYN就直接被丢弃,表现就是客户端反复连接超时。常见攻击SYN Flood就是专门发SYN但不回ACK,把半连接队列塞满。现代Linux内核默认开启tcp_syncookies,队列满时改为生成一个cookie放进SYN+ACK里返回,客户端正常回ACK后,服务端再通过cookie重建连接信息,这样就绕开了半连接队列限制。排查时可以看ss -lnt输出的Send-Q和Recv-Q,也可以看netstat -s里的SYNs to LISTEN sockets dropped是否有增长。
2.3 四次挥手和TIME_WAIT的真相
断开连接是四次挥手,因为TCP支持半关闭——一端可以停止发送但仍继续接收。主动关闭方发出FIN后,对端回ACK,然后再发自己的FIN,再等ACK,一共四步。很多人不理解的是:为什么主动关闭方收到对端的FIN后不能立刻关闭,还要进入TIME_WAIT状态等2MSL(Linux下大约60秒)?
两个原因。一是怕最后的ACK丢失,对端收不到会重发FIN,主动方得留着状态能再回一次ACK;二是怕网络中还有对端的旧数据包游荡,等2MSL确保这些包在网络中自然消亡,不至于污染一条四元组完全相同的新连接。这一点在线上特别有存在感:高并发短连接服务经常能看到大量TIME_WAIT连接。很多人一看到ss -tan里一堆TIME_WAIT就慌,其实这是TCP在正常做事。真正要警惕的是CLOSE_WAIT堆积——那通常意味着对端断开了,但你的应用没调close(),连接资源泄漏了。我经历过一次线上事故,就是业务代码里某个分支忘了释放连接,CLOSE_WAIT涨到几万个,最后把文件描述符耗尽。
3. 可靠传输的地基:序号、确认号与重传策略
3.1 累积确认:不逐包确认也能保证有序
TCP不是收到一个包就回一个确认,它采用的是累积确认机制:一个ACK里携带的确认号表示"这个号之前的字节我都收齐了,请从这个号继续发"。这样做有几个好处:减少确认报文数量;接收方收到的乱序数据可以先缓存,等缺失部分补齐后一次性确认;发送方如果收到连续三次重复ACK,可以推断出某个段丢了。
实际抓包时你经常能看到接收方连收了好几个包后,才回一个ACK,确认号覆盖了之前所有数据。这也是为什么有人觉得TCP"懒",半天不见一个ACK——不是懒,是累积确认本身就允许合并。我在排查一个文件传输缓慢的问题时,就发现发送窗口经常卡在某个序号上不动,说明对端的ACK一直没覆盖到那个位置,顺着序号一查,果然中间有段数据在链路上丢了,触发了重传。
3.2 超时重传:RTO的动态调整逻辑
发送方发出数据后会启动一个定时器,如果超过重传超时时间(RTO)还没收到确认,就重传这段数据。RTO不能是固定值,因为网络时延一直在变。Linux内核会持续采样报文往返时间(RTT),用平滑算法估算出一个基础值,再加上一定的抖动余量作为RTO。所以你会在弱网环境下观察到RTO会逐渐变大,这是TCP在"自适应地等"——网络越不稳,它越不敢轻易判定你丢了。
这里有个实际操作经验:如果在ss -ti输出里看到rtt的值伴随大量重传明显增大,基本可以判断链路质量在恶化。重传本身不可怕,可怕的是重传次数多、退避时间越来越长。TCP每次重传后会把RTO加倍,指数退避,避免把已经拥挤的网络打得更烂。到了上限还传不出去,就会放弃连接并通知上层"我没辙了"。
3.3 快速重传:不傻等超时
等超时太慢了,尤其当丢包发生在早期时。TCP想了个优化:接收方收到乱序报文时,会立即回复一个携带期望序号的重复ACK。发送方如果收到三个重复ACK,就认为某段数据丢了,不等定时器到点立刻重传。这就是快速重传。
我遇到过一个有意思的场景:某服务从云上迁到自建机房后,偶尔出现接口偶发慢几百毫秒。抓包后发现出现了典型的"快速重传"信号——一堆重复ACK。顺着查下去,是交换机端口上出现了单包多发的情况,造成少量乱序。TCP用快速重传保住了正确性,但代价是HTTP请求多了一轮RTT,延迟就起来了。乱序和丢包一样,都会让TCP性能下降,这点常被忽略。
3.4 队头阻塞:可靠有序的代价
说一个TCP机制带来的连锁反应。因为TCP保证有序,接收方必须把乱序数据先存在缓存里,等缺失的前序数据补齐才能往上丢给应用。如果中间丢了一个包,即使后面的包都到了,应用也只能干等重传。这就是队头阻塞。
这个现象在HTTP/2时代特别扎眼。HTTP/2在一个TCP连接上跑多个流,如果其中一个流的数据包丢了,同一连接上其他流的处理也都会被卡住。很多公司为了避开它,放弃HTTP/2回到HTTP/1.1建多条连接,或者升级到HTTP/3直接把传输层换成基于UDP的QUIC。所以你看,TCP的"有序"既是优点也是代价,极端情况下它恰恰是性能瓶颈的源头。
3.5 从开发视角看"粘包"与"拆包"
因为TCP是字节流,它只保证按序送达,不保证消息边界。应用发1000字节,对端可能分两次读到600字节和400字节;或者两次各500字节的数据合在一次读到。这就是大家常说的"粘包/拆包"问题。
我见过好几个项目组在TCP应用层手写封包格式,最常见的做法是:固定头部加长度字段,前4个字节存消息体长度,后面跟消息体;对端先读够4字节拿到长度,再继续读到长度对应的数据才算一条完整消息。也有的用分隔符方案,每个包以特殊字符结尾。选哪种取决于你传输的内容——二进制数据建议长度前缀,文本日志用分隔符也够用。这些问题的根源都在于TCP本身没有"消息"概念,只是给你一条字节流,边界必须应用层自己划。
4. 滑动窗口与流量控制:让TCP不会撑死慢接收方
4.1 窗口到底是什么
谈可靠传输时,发送方不能一口气把所有数据全丢到网络里,那样接收方处理不过来。TCP用了一个滑动窗口的概念:发送方和接收方各维护一个窗口,窗口大小代表"对方还能收多少字节,你可以继续发这么多,不用停下来等ACK"。
这里有个区分大家容易混淆:接收窗口(rwnd)用于流量控制,拥塞窗口(cwnd)用于拥塞控制,实际能发出的数据量是两者中的较小值。接收窗口由接收方在每个ACK里通过TCP头部的Window字段通告给发送方。如果接收方应用读取数据的速率跟不上,接收缓冲区快满了,它会把Window字段调小,甚至调到0,告诉发送方"先停一停,我消化不过来了"。
高带宽长链路的场景里,窗口机制有个让人惊艳的设计:TCP头部的窗口字段只有16位,最大值65535,这在一千兆、万兆链路下根本不够用。所以后来有了窗口缩放选项,通过三次握手时交换一个缩放因子,把窗口的实际值放大到几GB级别。抓包时如果看到两端都带WS=128之类的选项,那就是在协商这个因子。
4.2 Nagle算法和延迟ACK合体:小包延迟的隐形杀手
流量控制里最实用的坑,我觉得是Nagle算法和延迟ACK相遇时的互相等待。Nagle算法的目的是减少小报文数量:发送方有多个小包要发时,先合并成一个大包,或者等收到之前所有包的ACK后再发下一个小包。延迟ACK则是接收方故意晚点回ACK,希望带上自己的数据(捎带)或者合并多个ACK,减少确认报文数量。
这两个机制单独用都没问题,但遇到"请求-响应"型的交互就会互相死等:发送方等ACK才发下一次请求,接收方等数据才回ACK,双方都抱着"等对方先动"的心态,延迟就上来了。我调过一个内部监控采集服务,单次采集只有几十字节,平均耗时却到了几十毫秒,排查后发现就是Nagle + 延迟ACK这种组合在作怪。解决办法很简单:对延迟敏感的长连接,把TCP_NODELAY打开,禁用Nagle;接收方的延迟ACK可以在内核参数里调小或者关闭,但要全局评估流量大小。
4.3 零窗口和探测报文
如果接收方处理不过来,把通告窗口降到0,发送方就必须进入"零窗口等待"状态,不能再发数据。为了防止双方就这么一起干等,发送方会定期发送一个窗口探测报文,问接收方"窗口开大了吗"。接收方哪怕窗口还是0,也要回一个ACK把当前窗口大小带过去。抓包时里你可能会看到标注为"Window Probe"和"Window Update"的报文,就是这两兄弟。
5. 拥塞控制:保护的不只是你和服务器,而是整条链路
5.1 为什么TCP要自己管"路况"
流量控制管的是两端的节奏,拥塞控制管的是整条链路的承载能力。互联网里有无数的路由器和交换机,它们的缓冲区有限。如果所有主机都按自己最大能力猛发,中间设备缓冲区被塞满,就开始丢包,丢包触发重传,重传又把链路打得更堵,最终全网瘫痪。TCP的拥塞控制本质上是一套"路上堵不堵"的自适应策略,每个发送方都收敛一点,大家才能共享网络。
这套策略经历了漫长的演进,经典算法由四部分组成:慢启动、拥塞避免、快速重传、快速恢复。现代Linux默认一般是CUBIC,谷歌后来又提出了基于测量的BBR。
5.2 慢启动:从小心翼翼地试探开始
新连接建立后,TCP不会一上来就把发送量拉满,而是从一个很小的拥塞窗口开始。老协议里初始窗口通常是一个MSS(约1460字节),后来按RFC 6928调整到了10个MSS。每收到一轮ACK,窗口就翻倍,数据量呈指数增长——所以叫"慢启动"其实是指数加速的,一点也不慢,它只是在"从零开始试探"。
窗口涨得快,直到出现丢包、到达慢启动阈值或者达到接收窗口上限,才开始转入拥塞避免。我举一个直观的例子:一个初始窗口10个MSS的TCP连接,经过5轮RTT就可以发到320个MSS,体量接近500KB。这就是为什么短连接小文件的HTTP请求能在两三个RTT内完成传输——慢启动在前几轮就把窗口撑起来了。
5.3 丢包驱动的传统拥塞控制
经典拥塞控制把丢包当作"路况恶化"的信号。进入拥塞避免阶段后,窗口不再翻倍,而是每个RTT只增加一个MSS,慢条斯理地往上试探。一旦发生丢包,快速重传触发快速恢复,拥塞窗口减半,进入线性增长。
这套机制在传统链路上表现稳定,但有个问题:丢包不一定代表拥塞,可能是无线传输的随机丢包,可能是路由器队列的瞬时溢出。在丢包率高的弱网环境下,传统算法会误判,把窗口降得很小,明明带宽充足却跑不出速度。跨洋传输、移动网络、卫星链路里这种现象特别明显。这也是BBR出现的动机。
5.4 BBR:不靠丢包判断路况
BBR的思路在我看来优雅得多:它不再把丢包作为拥塞信号,而是持续测量链路的带宽上限和最小RTT,把发送速率调整到"正好满负荷但不排队"的状态。我在一台支持BBR的服务器上做过对比测试,跨洋场景下同样的文件传输,CUBIC经常会因为离线的随机丢包把窗口砍下去,BBR则能保持更高的吞吐。如果你的系统跑在Linux 4.9以上内核,可以查看net.ipv4.tcp_congestion_control当前用的什么算法。想试BBR就把它改成bbr。但注意,改动拥塞控制算法会影响全网连接,生产环境先小范围灰度,观察指标再推广。
5.5 实际判断自己是否被拥塞控制"限速"
排查TCP吞吐上不去的时候,我一般先看两样东西:ss -ti输出里的cwnd(拥塞窗口)和rtt(往返时延)。如果cwnd很小,说明TCP认为链路不乐观,正在慢速试探;如果cwnd已经很大甚至触及上限但吞吐还是低,再去看是不是接收窗口限制、应用读取慢或链路本来就是小水管。很多人直接拉带宽买专线,结果发现瓶颈在TCP窗口上,那就白花钱了。
6. 抓包实战:亲手验证一个TCP连接的完整生命周期
6.1 抓下三次握手并读懂每一个字段
纸上谈兵到这里,我建议你亲手抓一次包。以Linux为例,一条最简单的命令就能在本地看到完整的TCP交互:
tcpdump -i lo -nn "tcp port 8080" -S -vvv另开一个终端,随便起个本地服务访问一下。你会看到类似这样的输出:
22:15:01.482311 IP 127.0.0.1.50001 > 127.0.0.1.8080: Flags [S], seq 1000000001 22:15:01.482404 IP 127.0.0.1.8080 > 127.0.0.1.50001: Flags [S.], seq 2000000001, ack 1000000002 22:15:01.482416 IP 127.0.0.1.50001 > 127.0.0.1.8080: Flags [.], ack 2000000002把几个字段对号入座:
Flags [S]表示这是SYN报文;Flags [S.]是SYN+ACK;Flags [.]是纯ACK。其他你还会见到[P.](带数据的ACK)、[F.](FIN+ACK)、[R.](RST+ACK)。seq是发送方当前使用的序号。ack是确认号,含义是"这个号之前的字节我都收好了"。- 注意SYN占一个序号,所以第二次握手的ack是第一个seq加1。这个细节当年可坑了不少人。
6.2 实测一次数据传输和挥手
数据交互阶段你会看到很多带[P.]标志的包,这是应用数据在TCP层被标记为"立即推送"的报文。数据量大的话,你会看到序号连续跳动,ACK确认号持续前移,这就是滑动窗口在流动。
关闭时需要你主动结束进程或者curl完天然断开连接,你会看到:
Flags [F.], seq 3000000001 Flags [F.], seq 4000000001, ack 3000000002 Flags [.], ack 4000000002两端各发一次FIN,各确认一次,四次挥手完成。期间主动关闭方会进入TIME_WAIT,你在另一个ss -tan里能看到它在LISTEN和ESTABLISHED之外多一个状态。
6.3 模拟丢包,看快速重传长什么样
为了理解重传,可以在服务端网卡上做一点扰动。比如用netem模拟随机丢包,然后观察前面的抓包输出里是否出现重复ACK和重传报文:
tc qdisc add dev eth0 root netem loss 10% # 测试完记得恢复 tc qdisc del dev eth0 root netem丢包场景下你会看到接收方反复发相同ack号的重复确认,随后出现发送方重传某段数据的包。对比一下没丢包时的正常流量,对这个机制的理解会瞬间落地。
6.4 回归业务:Modbus TCP 这类"连不上"该怎么查
顺着抓包再说一类高频问题,也是我之前被问得最多的:“为什么我的设备/上位机Modbus TCP连不上?”很多人的第一反应是“TCP协议出问题了”。但TCP是传输层,Modbus TCP是把Modbus报文直接放进TCP负载里,加了一个报文头标识长度和单元号。连不上的原因通常不是TCP本身,而是:端口号被防火墙挡了、从站IP配置错、报文长度字段计算不对、功能码或寄存器地址不在设备支持范围内。
排查方法论其实和我上面演示的完全一致:先在网关或上位机上tcpdump抓包,看三次握手有没有成功;握手都没成功,大概率是IP地址、端口、防火墙这类网络基础配置问题;握手成功但请求无响应,再看应用层报文内容和从站那边有没有在回包。**先用TCP状态把层切清楚,再用应用层协议往里深挖,很多工业现场问题几步就能定位,根本不用瞎猜。**这也侧面说明为什么我坚持工程师要懂传输层——TCP的报文只是表象,状态机、时序和排查思路才是通用的底层能力。
写在习惯里的一件事
最后分享一个我的强制习惯:新环境上线后,第一时间抓几分钟基线流量存档,记录正常状态下的RTT分布、重传率、队列深度和TIME_WAIT数量。等哪天线上真的出了诡异延迟,把这些基线和故障现场对比一下,再用上面这套状态机去推演,问题通常跑不出TCP这六个动作(握手、确认、重传、窗口、拥塞、挥手)。TCP看起来复杂,但它所有的行为都不是随机的,理解它设定背后的约束,你就在排查疑难网络问题时比别人多了一整层视野。