
1. 从一个抓包现场说起为什么TCP让人又爱又恨很多人第一次接触网络编程都是从“TCP是可靠的、UDP是不可靠的”这句话开始的。但如果你真去问一句“它到底怎么可靠的”能说清楚的人其实不多。我在带新人的时候特别喜欢做一件事让他们用抓包工具看一次完整的TCP传输过程然后问他们看到了什么。大部分人第一反应是“好多包”“看不懂”“怎么还有重复的”。这就对了因为TCP的可靠性从来不是靠一个机制撑起来的而是一整套组合拳。这篇文章想做的事情很明确把TCP保证可靠传输的底层机制一层一层拆开从序号确认、超时重传、滑动窗口到流量控制、拥塞控制、校验和每一个环节都讲清楚它解决的是什么问题、怎么实现的、实际抓包时能看到什么现象。不管你是刚学网络基础的学生还是写了几年业务代码但没深究过协议栈的开发者看完之后应该都能对“可靠”这两个字有更具体的理解。需要提前说明的是TCP的可靠性不是“保证数据一定到达”而是“保证数据要么正确到达要么让你知道它没到达”。这个区别很关键后面讲重传机制的时候会反复提到。另外本文涉及的所有抓包示例和参数计算都是基于常见Linux内核默认配置和标准RFC文档的合理推演具体数值在不同系统上会有差异但原理是通用的。2. TCP可靠性整体设计思路拆解2.1 可靠传输到底要解决哪几个问题先把问题定义清楚不然容易陷入细节。一条数据从发送端到接收端中间要经过无数个路由器和链路可能遇到的问题无非这几类数据丢了中间某个节点缓冲区满了直接把包扔了。数据错了电磁干扰或者硬件故障导致某些bit翻转了。数据乱了后发的包先到了顺序错乱。数据重复了网络抖动导致同一个包被投递了两次。数据太多了发送方发得太快接收方处理不过来。网络堵了中间链路带宽不够大量包被丢弃。TCP的可靠性设计就是围绕这六个问题展开的。你可以把它想象成一个特别靠谱的快递系统每个包裹都有编号收件人收到要签字确认寄件人长时间没收到确认就重发收件人发现编号断了就要求补发同时双方还会根据路况动态调整发货速度。2.2 为什么是“确认重传”而不是“一次性发完等结果”有人可能会想为什么不干脆发完所有数据再统一确认这样效率不是更高吗理论上可以但实际网络里行不通。因为网络延迟是不确定的如果等所有数据发完再确认一旦中间丢了几个包你根本不知道丢的是哪些只能全部重发。TCP采用的是累积确认机制接收方告诉发送方“我已经连续收到了编号N之前的所有字节”发送方就知道N之前的数据都安全了只需要关注N之后的部分。这个设计的好处是确认信息可以合并不需要每个包都单独确认。但坏处是如果中间有个包丢了后面的包即使收到了也没法确认因为累积确认要求连续。这就引出了后面要讲的快速重传和选择确认机制。2.3 滑动窗口可靠和效率的平衡点如果每发一个包就停下来等确认那网络利用率会低得可怕。假设往返时间RTT是50毫秒每个包1KB那每秒最多只能发20KB这在现代网络里跟蜗牛爬一样。滑动窗口机制允许发送方在没收到确认之前连续发送多个包窗口大小就是“最多允许多少字节在路上”。窗口越大吞吐量越高但占用的缓冲区也越多而且一旦丢包需要重传的数据也越多。所以窗口大小的选择是一个典型的工程权衡。实际系统中窗口大小会根据接收方的处理能力和网络的拥塞程度动态调整这就是后面要讲的流量控制和拥塞控制。3. 核心机制逐层拆解与实操观察3.1 序号与确认号一切可靠性的基石TCP把应用层交下来的数据看成一个个字节每个字节都有一个序号。注意是每个字节不是每个包。假设你要发送3000字节的数据MSS最大报文段长度是1000字节那会分成三个报文段序号分别是1、1001、2001。接收方回复确认号的时候填的是“我下一个期望收到的字节序号”比如收到了1到1000确认号就是1001。这里有个容易混淆的点序号是32位的从0到2^32-1超过之后会回绕。在高速网络里回绕是真实会发生的事情所以协议里还有时间戳选项来辅助判断新旧数据。我在实际抓包时见过因为序号回绕导致的老数据被误认为新数据的问题虽然概率极低但在长连接高吞吐场景下确实存在。确认号的设计还有一个精妙之处它是累积的。如果接收方收到了1-1000和2001-3000但1001-2000丢了它回复的确认号仍然是1001。发送方看到重复的确认号1001就知道1001-2000这段丢了。这种机制不需要接收方明确说“我丢了哪些”只需要重复确认最后一个连续收到的位置发送方就能推断出来。3.2 超时重传最原始但也最关键的兜底发送方每发一个报文段就会启动一个重传定时器。如果在定时器超时之前没收到对应的确认就重传这个报文段。这个机制看起来简单但最难的部分是定时器时间怎么定。定太短网络稍微抖动一下就触发重传造成大量不必要的重传反而加剧拥塞。定太长丢包后要等很久才重传传输效率极低。TCP的做法是动态估算先测量每个报文段的往返时间RTT然后用加权平均算出平滑往返时间SRTT再根据RTT的变化幅度算出一个偏差值RTTVAR最后超时时间RTO SRTT 4 × RTTVAR。我实测过一组数据在局域网内RTT大概0.2毫秒RTO通常被设成200毫秒左右有最小值限制在跨地域网络里RTT可能30毫秒RTO会算到300毫秒以上。这个4倍的系数是经过大量实验得出的经验值能在保证及时重传的同时避免过度反应。还有一个细节TCP采用指数退避策略。第一次重传超时后RTO翻倍再超时再翻倍。这是为了避免在网络严重拥塞时大量重传包把网络彻底压垮。你可以理解为“越堵越要慢点发”。3.3 快速重传不等超时的补救措施超时重传的问题是等待时间太长。如果发送方连续收到三个针对同一个序号的重复确认通常叫冗余ACK它就意识到某个报文段丢了但后面的报文段都到了。这时候不需要等定时器超时直接重传丢失的那个报文段这就是快速重传。为什么是三个而不是一个因为网络里偶尔会出现乱序一个重复确认可能是乱序造成的两个也可能是但连续三个重复确认基本可以确定是丢包了。这个阈值是经验值在大多数网络环境下能很好地平衡误判和及时性。快速重传在实际抓包中非常容易识别你会看到发送方连续发出多个报文段然后收到一串确认号相同的ACK接着发送方突然重发某个较早的报文段。整个过程在毫秒级别完成比等超时快得多。3.4 选择确认SACK只重传真正丢的那部分快速重传虽然及时但它有个前提接收方只能通过重复确认号告诉发送方“我缺了哪一段的开头”发送方并不知道丢失的范围有多大。如果一次丢了多个不连续的报文段发送方可能要把丢失点之后的所有数据都重传一遍效率很低。SACK选择性确认机制允许接收方在ACK选项里明确告诉发送方“我收到了1001-2000和3001-4000缺的是2001-3000。”这样发送方就能精确重传缺失的部分。SACK在现代网络里几乎是标配Linux内核默认就开启。不过SACK也有代价ACK选项会占用TCP头部的空间而且接收方需要维护一个SACK块列表增加了实现复杂度。在极端情况下如果接收方缓冲区被打乱得太厉害SACK块可能多到放不下这时候还是会退化成累积确认。3.5 校验和确保数据没被改坏每个TCP报文段都包含一个16位的校验和覆盖TCP头部、数据部分以及一个伪头部包含源IP、目的IP、协议号等信息。接收方收到报文后重新计算校验和如果不匹配就直接丢弃不发送任何确认。发送方等不到确认最终会触发重传。这里有个实际中容易忽略的点校验和的计算是在发送端网卡驱动之前完成的如果数据在网卡之后、到达对端之前被篡改TCP层是发现不了的。所以有些安全敏感的场景会在应用层再加一层校验。另外校验和是16位的理论上存在碰撞概率但在实际网络环境下配合链路层的CRC校验漏检概率极低。3.6 流量控制别把接收方撑死接收方在每个ACK里都会带上自己的接收窗口rwnd告诉发送方“我还能接收多少字节”。发送方的发送窗口不能超过这个值。如果接收方应用层读取数据很慢缓冲区快满了它就会把rwnd调小甚至设成0发送方就会暂停发送。当rwnd变成0之后发送方会启动一个持续定时器定期发送窗口探测报文询问接收方“现在能收了吗”。这个探测报文只带一个字节的数据或者不带数据目的是触发接收方回复最新的窗口大小。如果没有这个机制双方可能陷入死锁发送方等窗口更新接收方等数据。我在排查一个文件传输慢的问题时就遇到过接收方应用层处理逻辑有bug导致缓冲区长期接近满的状态rwnd一直很小发送方被迫频繁暂停。后来优化了接收端的读取逻辑传输速度直接翻了五倍。所以流量控制不只是协议栈的事应用层的读取速度同样关键。3.7 拥塞控制别把网络压垮流量控制解决的是“接收方能不能收”拥塞控制解决的是“网络能不能扛”。发送方维护一个拥塞窗口cwnd实际发送窗口取cwnd和rwnd的较小值。拥塞控制的核心思路是先试探性地发如果没丢包就慢慢加速一旦丢包就大幅减速。具体来说有四个阶段慢启动cwnd从很小开始通常10个MSS左右每收到一个ACK就翻倍。所以是指数增长很快就能探到网络容量的大致上限。拥塞避免cwnd涨到慢启动阈值后改成每RTT加一个MSS变成线性增长避免增长太快导致丢包。快速恢复收到三个重复ACK时cwnd减半然后进入快速恢复阶段继续线性增长。因为重复ACK说明网络还在传输数据只是有个别包丢了不需要回到起点。超时重传后的恢复如果超时了说明网络可能严重拥塞cwnd直接降到1重新慢启动。现代Linux还有更先进的算法比如CUBIC和BBR但核心思想一脉相承。CUBIC用三次函数代替线性增长在高带宽长距离网络里表现更好BBR则不再以丢包作为拥塞信号而是直接测量带宽和RTT。这些算法之间的取舍是另一个大话题这里不展开。4. 完整实操过程与关键环节实现4.1 搭建一个可观察的TCP传输环境要真正理解这些机制光看文字不够得动手抓包。我一般用两台Linux机器虚拟机也行一台跑服务端一台跑客户端中间用tc命令模拟网络延迟和丢包。具体操作如下在服务端启动一个简单的TCP服务可以用Python的socket模块写一个回显服务import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(1) conn, addr server.accept() while True: data conn.recv(4096) if not data: break conn.sendall(data)在客户端用tc命令给网卡加上100毫秒延迟和5%的丢包率tc qdisc add dev eth0 root netem delay 100ms loss 5%然后用tcpdump或者Wireshark抓包观察重传、重复ACK、窗口变化等现象。这个环境虽然简单但足以复现大部分可靠性机制。4.2 观察超时重传的完整过程在模拟了5%丢包的链路上发送一段较大的数据你会看到这样的现象大部分报文段正常发送和确认偶尔某个报文段发出后迟迟没有对应的ACK然后过了一段时间RTO发送方重发了这个报文段。在Wireshark里重传的报文段会被标记为“TCP Retransmission”。如果你仔细看时间戳会发现第一次重传的间隔大概是200毫秒到1秒之间取决于RTT估算值。如果重传后还是没确认下一次间隔会翻倍。我实测过在100毫秒延迟、5%丢包的条件下RTO初始值大约在400毫秒左右第一次重传后如果还丢间隔变成800毫秒再丢变成1.6秒。这里有个经验如果你在抓包时看到大量重传但RTT并不高那大概率不是网络延迟问题而是链路真的有丢包。这时候要去看中间设备的统计信息而不是盲目调TCP参数。4.3 触发快速重传并验证SACK效果快速重传的触发条件是收到三个重复ACK。为了稳定复现可以手动丢弃某个特定序号的报文段。用iptables或者tc的filter功能都能做到。比如丢弃序号在某个范围内的包tc qdisc add dev eth0 root handle 1: netem delay 50ms tc filter add dev eth0 parent 1: protocol ip u32 match ip dport 8888 0xffff flowid 1:1更精确的方式是用Wireshark的“丢包注入”功能但那个只影响本地显示不影响实际传输。实际测试中我通常用一台中间机器做桥接然后在桥上用ebtables丢弃特定包。当接收方收到乱序的报文段时它会立即发送重复ACK。发送方收到三个重复ACK后不等超时就直接重传丢失的报文段。如果你开启了SACK还能在重复ACK的选项里看到接收方已经收到了哪些块。这个信息对于分析丢包模式非常有价值如果SACK块显示接收方收到了大量不连续的块说明网络乱序严重如果SACK块很少说明是连续丢包。4.4 窗口变化的实际观察与参数计算在Wireshark的TCP流分析里可以看到窗口大小的动态变化。发送方的“计算窗口”是cwnd和rwnd的较小值。在慢启动阶段你会看到窗口快速翻倍进入拥塞避免后窗口增长变慢一旦出现丢包窗口会明显下降。我拿一组实测数据来说明在局域网内传输一个100MB文件初始cwnd是10个MSS假设MSS1460字节约14.6KBRTT约0.2毫秒。慢启动阶段每收到一个ACK窗口翻倍大约经过十几个RTT就涨到了几百KB。然后进入拥塞避免窗口线性增长。最终稳定在2MB左右这时候吞吐量大约是2MB除以0.2毫秒算下来是10GB/s已经接近万兆网卡的实际上限了。如果在跨地域网络里RTT是30毫秒同样的初始cwnd慢启动需要更长时间才能填满带宽。假设带宽是100Mbps那需要窗口达到100Mbps × 30ms 375KB才能跑满。如果慢启动阈值设得比较低可能需要几秒钟才能达到这个窗口。这就是为什么长距离传输时适当增大初始窗口能明显提升体验。5. 常见问题与排查技巧实录5.1 为什么抓包看到重传但应用层没感觉慢这是很常见的情况。TCP的重传对应用层是透明的只要重传在超时之前完成应用层根本感知不到。比如你发一个HTTP请求中间有个包丢了快速重传在几十毫秒内就补上了用户完全无感。只有当重传频繁发生或者超时重传触发时才会表现为延迟增加。如果你在抓包时看到重传但应用层性能正常那不用太担心。但如果重传率超过1%就值得关注了。我一般用这个经验值来判断局域网内重传率应该接近0广域网内低于0.1%算正常超过1%说明链路质量有问题需要排查中间设备。5.2 重复ACK很多但没触发快速重传是怎么回事快速重传需要三个连续的重复ACK。如果重复ACK之间夹杂了其他ACK或者重复ACK的数量不够就不会触发。常见的原因有乱序而不是丢包报文段只是走不同路径晚到了接收方发了重复ACK但很快乱序的包就到了重复ACK没有累积到三个。接收方窗口太小如果接收方缓冲区快满了它可能无法发送重复ACK因为SACK块放不下。中间设备改写了ACK有些负载均衡设备会修改TCP头部导致ACK序号不连续。排查的时候重点看重复ACK的间隔和内容。如果重复ACK的确认号在变化说明不是同一个位置的重复确认那可能是乱序。如果确认号一直相同但数量不够三个那可能是接收方处理有问题。5.3 窗口突然变成0然后卡住怎么办窗口变成0说明接收方缓冲区满了发送方会停止发送并启动持续定时器。正常情况下接收方应用层读取数据后会发送窗口更新通知发送方继续。但如果接收方应用层卡死了窗口就会一直是0连接就僵住了。我遇到过一次线上问题一个服务端程序在处理某个请求时进入了死循环导致TCP缓冲区一直没被读取窗口变成0。客户端等了几分钟后超时断开。后来加了应用层的心跳和超时机制才解决。所以如果你看到窗口长期为0第一反应应该是去查接收方应用层的状态而不是调TCP参数。5.4 拥塞控制算法选哪个更合适Linux默认是CUBIC在大多数场景下表现都不错。但如果你的业务对延迟特别敏感比如实时音视频可以考虑BBR。BBR不依赖丢包信号而是主动测量带宽和RTT在有一定丢包率的链路上能保持较高的吞吐量。不过BBR也不是万能的。在多流竞争的场景下BBR流可能会抢占过多带宽导致其他CUBIC流被压制。所以切换算法之前最好在测试环境里验证一下对现有业务的影响。我个人的经验是内网传输用CUBIC就够了跨地域传输如果丢包率超过1%可以试试BBR。5.5 常见问题速查表现象可能原因排查方向大量超时重传链路丢包严重或RTO设置过小检查中间设备丢包统计抓包看RTT是否突增重复ACK多但无快速重传乱序为主或接收方窗口不足看SACK块是否连续检查接收方缓冲区窗口长期为0接收方应用层未读取数据检查接收方进程状态和CPU占用吞吐量上不去但无丢包窗口太小或RTT太大计算带宽时延积对比实际窗口大小连接频繁断开重传次数超过系统限制检查tcp_retries2参数默认15次约15分钟6. 几个容易被忽略的细节与个人经验6.1 序号回绕不是理论问题前面提到过序号是32位的在高速网络里回绕是真实会发生的。假设一条连接持续传输每秒发送1GB数据那2^32字节大约4.3GB不到5秒序号就回绕一圈。如果此时网络里有延迟很大的老数据包接收方可能会把老包当成新包。TCP用时间戳选项PAWS机制来解决这个问题每个报文段带上发送时的时间戳接收方丢弃时间戳比已记录值小的报文段。这个机制在Linux里默认开启但如果你在抓包时看到时间戳选项被禁用那就要小心了。我见过一些老旧的嵌入式设备不支持时间戳选项在长连接高吞吐场景下出现过数据错乱。所以如果你在做物联网相关的开发最好确认一下设备的TCP选项支持情况。6.2 延迟确认和Nagle算法的相互作用延迟确认是指接收方不立即回复ACK而是等一小段时间通常40毫秒看能不能捎带数据一起发。Nagle算法是指发送方不立即发送小包而是等之前的数据被确认后再发。这两个机制单独看都是为了减少小包数量提高效率但合在一起就可能出问题。典型的场景是发送方发了小数据等待确认接收方收到后延迟确认等应用层响应应用层响应数据很小被Nagle算法拦住等之前的确认而确认又被延迟了。结果就是双方互相等待延迟凭空增加了40毫秒甚至更多。这就是著名的“延迟确认Nagle”问题。解决办法很简单对延迟敏感的应用设置TCP_NODELAY选项禁用Nagle算法。大部分RPC框架和游戏服务器都会默认开启这个选项。我在做即时通讯项目时所有长连接都设了TCP_NODELAY实测下来消息延迟从平均50毫秒降到了5毫秒以内。6.3 接收缓冲区大小对可靠性的隐性影响接收缓冲区大小决定了接收方能缓存多少乱序数据。如果缓冲区太小接收方收到乱序包时可能因为没地方放而直接丢弃导致发送方需要重传更多数据。Linux的默认接收缓冲区通常是128KB左右在高带宽高延迟网络里可能不够用。调整的方法是通过sysctl设置net.ipv4.tcp_rmem和net.ipv4.tcp_wmem分别控制接收和发送缓冲区的min、default、max值。我一般会把max设成16MB让内核根据实际情况自动调整。但要注意缓冲区不是越大越好太大会增加内存占用而且在丢包恢复时需要重传的数据也更多。6.4 抓包分析时容易被误导的几个点第一个坑是本地抓包看不到网卡之后的变化。如果你在发送端抓包看到包发出去了但接收端没收到那问题可能出在网卡驱动、交换机或者中间链路上。这时候需要在接收端也抓包对比。第二个坑是时间戳精度不够。Wireshark默认的时间戳精度是微秒级但在高速网络里两个包之间的间隔可能只有几十纳秒。如果要做精确的RTT分析需要开启纳秒级时间戳并且确保抓包机器的时钟同步。第三个坑是TCP流重组会掩盖细节。Wireshark默认会把TCP流重组后展示这样看起来很方便但会隐藏重传、乱序等细节。分析可靠性问题时一定要看原始的报文列表而不是重组后的流。6.5 关于可靠性的一个认知升级最后说一个我这些年体会最深的点TCP的可靠性是端到端的但它不保证应用层的可靠性。TCP保证的是字节流按序到达但如果应用层协议设计得不好比如没有处理半包、粘包或者没有应用层确认那数据照样可能出错。我见过一个案例客户端发送一个结构体服务端直接recv到一个固定大小的缓冲区里然后按结构体解析。结果因为TCP是字节流一次recv可能只收到半个结构体解析出来的数据全是乱的。这不是TCP的问题是应用层没有正确处理字节流边界。所以理解TCP可靠性机制的同时也要清楚它的边界在哪里。另外一个常见的误解是“TCP连接断了数据就丢了”。实际上如果发送方已经收到了确认那数据就已经安全到达接收方缓冲区了即使连接随后断开数据也不会丢。但如果发送方还没收到确认就断开了那数据可能丢了也可能没丢取决于断开时数据在网络中的位置。这就是为什么应用层需要自己的确认机制来处理关键数据。