1. 先说清楚:TCP到底干了什么
很多人一提到TCP协议,张口就是“TCP是可靠的传输协议”,但真要问他“可靠”这两个字是怎么实现的,往往支支吾吾说不上来。我最早做后端的时候也一样,那时候调接口超时,只会下意识看服务器负载、看数据库慢查询,完全没往网络层去想。直到有一次线上服务频繁出现“连接建立成功但数据传输超时”,抓包一看,满屏都是Dup ACK和Retransmission,才真正意识到——不把TCP的可靠数据传输机制吃透,排查问题永远隔着一层。
这篇文章就是想把TCP协议的核心原理拆开讲清楚。内容包括:TCP在TCP/IP协议栈里的位置、可靠数据传输依赖的序号/确认/重传/窗口机制、三次握手与四次挥手的深层原因、TCP与UDP的选型逻辑,以及我在实际环境中用抓包和模拟丢包验证TCP行为的完整过程。适合后端开发、运维、网络初学者,也适合那些被“TCP协议包怎么修改”这类问题困扰、想做协议实验的人。
先说结论:TCP的可靠传输,根本不是靠某一个单独机制实现的,而是序号、确认、重传、去重、流控、拥塞控制这一整套组合拳协同工作的结果。少了任何一环,所谓可靠都会大打折扣。
2. TCP在TCP/IP协议栈中的定位:可靠传输是从哪来的
2.1 从协议分层看TCP的职责边界
TCP/IP协议族是分层设计的,每层只干自己那点事。链路层管物理介质上的帧传输,网络层(IP协议)负责寻址和路由,传输层承载端到端的通信逻辑,应用层才是HTTP、SSH、MySQL这些业务协议。
这里有个关键点:IP协议本身是“尽力而为”的,它只管把数据报从源地址送到目的地址,不保证不丢包、不保证顺序、不保证不重复。就像你叫了个快递,快递公司说“我们尽量送到”,但路上丢了、碎了、送了两遍,它都不负责。而TCP协议的使命,就是在IP这个“不靠谱的快递网络”之上,自己搭一套签收、对账、补发的机制,让上层应用感觉自己在用一条“靠谱的专线”。
理解这个分层逻辑特别重要。我见过不少新手把IP和TCP混为一谈,一排查网络问题就先怀疑“是不是TCP包丢了”,实际上IP包丢了之后TCP才知道该重传。TCP协议的所有可靠机制,本质上都是在补偿下层网络丢包、乱序、重复这一系列不理想行为。
2.2 可靠数据传输要解决哪些问题
把问题列出来,你就会发现TCP的设计动机非常清晰:
- 丢包:发了10个包只到了9个,少了的那1个怎么补。
- 乱序:先发的包后到、后发的包先到,接收方怎么把它们恢复成原来顺序。
- 重复:同一个包发了两次,接收方怎么去重。
- 损坏:链路噪声或硬件问题导致数据位翻转,接收方怎么检测出来。
- 速度不匹配:发送方拼命发,接收方处理不过来,缓冲区溢出,怎么让发送方“慢一点”。
- 网络过载:中间的交换机路由器排队溢出丢包,发送方怎么感知到并主动降低速率。
TCP几乎为上述每个问题都设计了对应机制:序号解决乱序和去重,校验和解决损坏检测,确认与重传解决丢包,滑动窗口解决收发速度不匹配,拥塞控制解决网络过载。所以看TCP协议不能只看某一个字段,要把它当成一整套“数据可靠传输协议栈”来看。
2.3 TCP报文段的基本面貌
在讲机制之前,先得认识一下TCP协议包长什么样。TCP头最小20字节,核心字段包括:
- 源端口/目的端口:各16位,标识收发进程。
- 序号(Sequence Number):32位,表示本报文段第一个数据字节的序号。
- 确认号(Acknowledgment Number):32位,表示期望对方发送的下一个字节序号,同时也表示之前的数据已全部正确收到。
- 标志位:SYN、ACK、FIN、RST、PSH、URG等,控制连接状态。
- 窗口大小(Window Size):16位,表示接收方当前还能收多少字节。
- 校验和:对头部和数据做校验。
- 紧急指针、选项区:包含MSS、窗口缩放、时间戳、SACK等扩展能力。
很多人问“TCP协议包如何修改”,如果你只想改业务数据,那是应用层的事;如果你想改TCP头部,比如修改序号、窗口、标志位,那就必须用raw socket写构造报文,或者用Scapy这类工具直接逐字段构造。这个后面我会单独演示。
3. 可靠数据传输的四大支柱:序号、确认、重传、窗口
3.1 按字节编号:序号与确认号的运作逻辑
TCP是面向字节流的协议。什么叫面向字节流?就是你调用send写进去的数据,TCP不关心应用层怎么分块,它只把数据当成一串连续的字节,每个字节都有一个序号。序号是32位无符号数,从初始序号(ISN)开始累加,到最大值后回绕到0。
建立连接时,双方通过三次握手交换各自的初始序号。发送方发送数据时,会在TCP头的“序号”字段里填上该报文段第一个字节的序号。接收方收到后,回复的确认号 = 当前已收到的最后一个字节序号 + 1,表示“我期望你下一个字节从这个序号开始”。
举个例子:客户端初始序号1500,发送1000字节数据,这1000字节的序号为1500到2499。服务器收到后回复Ack=2500,等于告诉客户端“前2500个字节收到”。这里有个坑我刚学的时候常常弄错:确认号不是“已收到哪个包”,而是“下一个想要哪个字节”。搞混了就会把自己绕晕。
序号机制是可靠传输的地基,重传、去重、排序全部依赖它。接收方收到乱序包时,会先按序号缓存起来,等缺失的序号补齐后再按顺序交给上层。两个一模一样的包到达时,根据序号就能判断是重复包,直接丢弃。
3.2 重传策略:超时重传与快速重传哪个先触发
有确认还不够,发送方发出去的数据如果一直等不到确认,就必须重发。TCP的重传分两种触发方式。
超时重传:每个报文段发送时都启动一个定时器,时间到了还没收到确认,就重传。这个超时时间(RTO)不是固定的,而是基于采样到的往返时间(RTT)动态计算。经典算法是加权移动平均:
- SRTT = (1 - α) × SRTT + α × RTT,α通常取0.125。
- 再根据SRTT和RTT的偏差算出RTO,早期实现一般取2倍左右的余量。
实际Linux内核用的是RFC 6298的算法,RTO下限是200毫秒(初始值1秒),上限一般120秒。RTO太长,丢包后要干等很久才重传;RTO太短,可能包没丢只是稍微慢点,就会重复发送造成浪费。所以发送方会持续更新RTT样本,让RTO跟着网络状况走。
快速重传:如果接收方收到乱序数据,会立即回复一个重复的ACK,把期望的序号再报一遍。发送方连续收到3个重复ACK,就认为那个报文段丢了,不等超时立刻重传。这就是快速重传,比傻等超时效率高很多。
我实测中观察到的典型现象是:轻微丢包时,快速重传先救场,两条重传之间间隔极短;如果网络很差、RTT很大,超时重传才会上场。至于SACK(选择性确认)选项,它允许接收方告诉发送方“33到45号丢了,其他我都收到了”,这样发送方只重传缺失段,而不是从丢包点往后全部重传,对高丢包网络改善非常明显。
3.3 滑动窗口:让流量有序又不拥堵
没有窗口机制,TCP一次只能发一个包、等一个确认,效率低得没法用。滑动窗口允许发送方在未收到确认的情况下,连续发送窗口内的一批数据。窗口尺寸由接收方通告,也就是TCP头里的“窗口大小”字段,表示接收缓冲区当前还能收多少个字节。
这里的逻辑可以类比银行柜台:窗口里面能排几个人是有上限的,里面积压太多了,后面的人就得在外面等着。TCP的发送方始终维护三个位置:
- 已发送且已确认的字节
- 已发送但未确认的字节(在窗口内)
- 未发送但可发送的字节(也在窗口内)
接收方处理完数据并腾出缓冲区后,会在ACK里带上新的窗口大小。发送方收到后按新窗口调整发送节奏。如果窗口变成0,发送方就停止发送,并启动“零窗口探测”定时器,周期性发送1字节的探测包,确认接收方是否已经腾出空间。
很多人容易混淆滑动窗口和拥塞窗口。简单说:滑动窗口由接收方决定,防止接收方被灌爆;拥塞窗口由发送方根据网络状况自行控制,防止中间网络设备被灌爆。发送方实际能发多少,取决于两者的较小值。
3.4 拥塞控制:慢启动、拥塞避免、快速恢复
前几年做下载加速项目,我测试大文件传输时发现一个问题:明明局域网带宽很大,TCP的传输速率却上不去。后来查资料才明白,TCP不只是想“可靠”,还特别想“不要搞垮网络”,所以发明了拥塞控制。
拥塞控制的核心是维护一个拥塞窗口(cwnd),初始值一般很小(Linux上大概10个MSS),然后:
- 慢启动:每收到一个确认,cwnd增加一个MSS。效果是窗口指数增长,从1个MSS变成2个、4个、8个。增长速度非常快,所以叫“慢启动”指的是起点慢,不是速度慢。
- 拥塞避免:cwnd达到慢启动阈值(ssthresh)后,线性增长,每个RTT只增加1个MSS,试探网络的极限。
- 快速恢复:触发快速重传后,ssthresh减半,cwnd降到减半后的值,进入拥塞避免而不是重新慢启动。
- 超时则回到慢启动:如果超时重传,说明网络可能严重拥塞,ssthresh降为当前cwnd的一半,cwnd重置为初始值,重新慢启动。
我见过不少对性能异常敏感的团队会调大初始窗口,或者直接换BBR拥塞控制算法。BBR和传统基于丢包的算法(Cubic/Reno)思路不同,它不靠丢包判断拥塞,而是持续测量瓶颈带宽和最小RTT,让TCP一直跑在带宽利用率更高的状态。在跨国链路和弱网场景下,BBR的效果常常让人惊喜,只要内核支持,sysctl设一下就能切换。不过注意,BBR和中间设备的兼容性偶尔有坑,生产环境切换前一定要先灰度。
4. 连接管理:三次握手与四次挥手背后的问题
4.1 为什么一定得是三次握手
TCP是面向连接的协议,数据传输之前必须先建立连接。建立连接需要SYN标志位参与,流程是:
- 客户端发送SYN,携带初始序号x,状态进入SYN_SENT。
- 服务器回复SYN+ACK,携带自己的初始序号y,并确认x+1,状态进入SYN_RECEIVED。
- 客户端回复ACK确认y+1,连接建立。
为什么两次不行?最经典的解释是防止“历史重复SYN”对连接造成误初始化。假设客户端上一次发的SYN因为网络拥塞延迟了很久才到服务器,服务器以为这是新连接,就回了个SYN+ACK。如果只要两次握手,客户端收到这个ACK,连接就建立——但客户端此时可能根本不想建立连接,或者已经在处理一个更新的连接,这个残留连接就会白白占用服务器资源。
三次握手让客户端能在第二步发现这个SYN+ACK不是自己期望的,然后发送RST终止连接。这个机制在RFC 793里就写得很清楚。另一个作用是同步双方的初始序号,因为可靠传输的第一步就是双方要知道对方的字节序号从哪个数字开始。
从底层看,三次握手完成的其实是“双方证明自己收发能力正常”的确认:客户端能发、服务器能收;服务器能发、客户端能收,再加双方初始序号互相同步。
4.2 四次挥手为什么比握手多一次
断开连接时,主动方发FIN,对方回ACK,随后对方再发FIN,主动方回ACK。总共四次报文交互。
多出来的那一次,是因为TCP支持半关闭状态。你想:A发FIN表示“我的数据发完了,不再发新数据”,但A还能继续接收数据。B收到FIN后先回ACK,表示“我知道了”,但B可能还有数据要发,所以过一会儿再单独发FIN表示“我也发完了”。如果只有两次交互,B的“确认FIN”和“我也要关了”这两个信息没法合并成一条报文同时表达。
关于挥手,实践中最容易踩的坑是TIME_WAIT状态。主动关闭方在发送最后一个ACK后,不会立刻关闭,而是进入TIME_WAIT,等待2倍MSL(最长报文段寿命)后才完全释放连接。原因是:万一最后一个ACK丢了,被动方会重发FIN,主动方得等着再回一次ACK;另外防止本次连接中的残留数据包跑到后续相同端口的连接里。这也是为什么高并发短连接的服务端经常堆积大量TIME_WAIT,需要针对性地调优。
4.3 连接状态机是排查问题的地图
TCP连接状态迁移是排查问题的重要工具。用netstat或ss看到的状态,每个都有含义:
- LISTEN:服务器监听端口,等待连接。
- SYN_SENT:客户端发了SYN,等待服务器响应。
- SYN_RECEIVED:服务器收到SYN并回复SYN+ACK,等客户端最终ACK。
- ESTABLISHED:正常通信。
- FIN_WAIT_1 / FIN_WAIT_2:主动方等待被动方确认FIN。
- CLOSE_WAIT:被动方收到FIN,回复ACK后,等自己的上层应用关闭连接。
- TIME_WAIT:主动方收到FIN并回完ACK后等待2MSL。
我处理过很多网上服务连接数异常的问题,看到大量SYN_RECEIVED积压,多半是半连接队列满了或者SYN泛洪;大量CLOSE_WAIT堆积,多半是应用程序没有正确关闭socket;大量TIME_WAIT,多半是短连接请求量太大。每个状态背后对应一套排查方法论,状态机熟练了,问题定位速度会快很多。
5. TCP与UDP的选型:搞清楚区别才不会选错
5.1 一张表看透TCP和UDP的本质差异
这个热搜话题常年挂在网上,每届新手都会问一遍。弄清楚了,选型就不纠结了。
| 维度 | TCP | UDP |
|---|---|---|
| 连接性 | 面向连接,需三次握手 | 无连接,直接发数据 |
| 可靠性 | 可靠:有确认、重传、排序 | 不可靠:发出去不保证到达 |
| 有序性 | 字节流有序交付 | 报文独立,可能乱序 |
| 重复处理 | 去重 | 不处理 |
| 交付模式 | 字节流,无边界 | 数据报,有边界 |
| 首部开销 | 20字节起,带选项更大 | 固定8字节 |
| 流量控制 | 有滑动窗口 | 无 |
| 拥塞控制 | 有 | 无 |
| 传输效率 | 相对较低 | 很高 |
| 典型应用 | HTTP、HTTPS、SSH、数据库、文件传输 | DNS、NTP、视频会议、游戏、广播 |
这张表的核心结论是:TCP用“开销”换“可靠”,UDP用“不可靠”换“低延迟”。凡是业务上要求数据完整、顺序不能乱的,老老实实用TCP;凡是追求时效性、允许少量丢包、数据量很大的,可以考虑UDP。
5.2 UDP并不低端:实时场景下的理性选择
我最烦的一个说法是“UDP就是阉割版TCP”。举几个例子反驳:视频通话中偶尔卡顿掉几帧,用户还能接受;如果用TCP,一次重传引发的延迟反而会让画面彻底卡死。在线游戏里,你希望玩家开枪指令尽快送达服务器,旧位置的坐标包甚至可以直接丢弃,TCP的可靠重传反而变成负担。DNS查询用UDP,因为每个查询就一个包,重传逻辑应用层自己管就好,根本不需要TCP那么重的连接管理。
关键要说的是,很多现代协议的实现思路是“以UDP为基础,在应用层自行实现可靠性”。最典型的是Google的QUIC协议,它基于UDP,但在应用层实现了类似TCP的拥塞控制、重传、多路复用,还能解决TCP队头阻塞问题。这说明UDP不是给不了可靠性,而是把可靠性的选择权交给了应用层。适合的才是最好的。
5.3 TCP的“软肋”也要清楚
TCP也不是没有缺点,最明显的有三个:
- 队头阻塞:一个TCP连接上同时传输多路请求,中间丢了一个包,后续所有数据都得等重传完成后才能继续处理。这在现代HTTP/2多路复用场景下特别明显。
- 建立连接成本高:往返一次RTT才能建立连接,HTTPS还要加TLS握手,弱网场景叠加起来延迟感人。
- 首部开销大:每个TCP包至少20字节头,还要算IP头20字节,在强交互小包场景下开销占比较高。
选型时这些点需要权衡。比如事务类API,正确性优先,TCP没跑;高频状态上报,丢几个无所谓,UDP更合适;大文件传输、下载加速这类,TCP配合调优仍然是最稳的选择。
6. 实操复盘:抓包验证TCP的可靠传输机制
6.1 搭建实验环境
看完一堆原理,动手验证一遍比什么都管用。我的实验环境很简单:两台云服务器(或一台本机一台虚拟机),Linux系统,装好tcpdump和Wireshark(或者干脆用tcpdump + tshark)。
场景一,验证三次握手和数据传输。在服务器A上执行:
tcpdump -i eth0 -nn -S tcp port 8080 -w tcp_test.pcap在服务器B上用curl或nc访问A的8080端口,发送一段数据,然后停止抓包,用Wireshark打开tcp_test.pcap。你会看到清晰的SYN、SYN+ACK、ACK三步握手。接下来是PSH+ACK的数据报文和纯ACK确认报文。
这里有个细节:用-S参数显示绝对序号,而不是相对序号,这样能直接看出初始序号(通常是个随机大数)。第一次抓包看到随机序号时,我才真正理解“ISN随机化防伪造”的意义。
6.2 用netem模拟丢包观察重传行为
光看不练不行,接下来模拟丢包。Linux的tc命令配合netem模块可以轻松制造丢包:
# 在服务器A的网卡上模拟随机丢包,概率5% tc qdisc add dev eth0 root netem loss 5%然后在服务器B上连续大流量发送数据,比如用scp传一个100MB的文件,同时在A上抓包。Wireshark里过滤器写tcp.analysis.retransmission,会高亮出所有重传包。我实测下来的经典序列是:丢包后接收方连续回Dup ACK,发送方收到3个重复ACK触发快速重传,被重传的包立刻被确认。整个过程如果在毫秒级完成,说明快速重传起作用了;如果出现长达200毫秒乃至数秒的等待,那就是触发了超时重传。
验证完记得清掉规则:
tc qdisc del dev eth0 root不然网卡会一直丢包,导致后续实验全部跑偏。
6.3 使用Scapy构造和修改TCP包
回到热搜词里的“TCP协议包如何修改”。如果只是改业务数据,在应用层直接拼字符串就行;但想修改TCP头部字段,比如自定义序号、确认号、窗口大小,甚至伪造一个SYN包,就需要构造裸报文。Python的Scapy库是干这个事的最方便工具:
from scapy.all import * ip = IP(src="10.0.0.1", dst="10.0.0.2") tcp = TCP(sport=12345, dport=80, flags="S", seq=1000) send(ip / tcp, verbose=False)上面这段会发送一个SYN包,源端口12345到目标80端口,序号强制设为1000。你可以自己指定窗口大小、加SACK选项、设置TCP时间戳,全字段都能控制。我在验证“修改窗口大小会不会影响发送速率”时,就用Scapy构造了不同窗口值的TCP包灌给接收进程,观察它如何调整。
有一点要特别提醒:用raw socket或Scapy构造裸TCP包,是网络实验、教学、安全测试的常规手段,一定要在你自己控制的网络环境或加了防护墙的实验网里做。别拿这个去骚扰公网主机,一方面可能触发对方防火墙告警把你封了,另一方面这类行为在多数场合属于违规操作,后果得自己扛。
6.4 用抓包结果反向验证拥塞控制
丢包实验还能顺便验证拥塞控制。把丢包率调到10%,再用scp传大文件,抓包后看时间序列图,你会发现传输速率不是恒定不变的,而是“飙升—停顿—回落—再飙升”的节奏。这就是慢启动到拥塞避免的过程:cwnd指数增长到阈值,丢包触发ssthresh减半,速率骤降,再继续探测。
如果系统网卡支持,还可以用ss -i查看当前socket的拥塞窗口:
ss -i sport = :12345输出里的cwnd、rtt、rto、ssthresh都是状态值,对照抓包时间线看,整个拥塞控制机制就不再是课本上的抽象名词了。
7. 线上问题排查与内核调参实录
7.1 常见TCP异常现象速查
这些年排查过不少网络问题,把典型现象整理成一张速查表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 连接建立超时 | 防火墙拦截SYN、半连接队列满 | 抓包看SYN有没有发出/收到,看ss -lnt服务端队列 |
| 大量SYN_RECEIVED堆积 | SYN泛洪或accept队列过小 | 开启syncookies,调大tcp_max_syn_backlog |
| 传输慢且重传频繁 | 丢包率高、网络拥塞 | 抓包统计重传比例,检查中间链路质量 |
| 大量CLOSE_WAIT | 应用未close socket | 查进程fd,修复代码释放连接 |
| 大量TIME_WAIT | 短连接请求密集 | 评估是否启用tcp_tw_reuse,或用连接池 |
| 接收窗口持续为0 | 应用读取太慢 | 查应用处理瓶颈,不要只调网络参数 |
7.2 几个常用内核参数的正确调整姿势
Linux网络参数网上教程一堆,但很多都写得简单粗暴。我按实际经验挑几个重点讲。
net.ipv4.tcp_syncookies:半连接队列满时仍能对新连接进行合法性校验,应对SYN泛洪很有效。默认通常是1,不要关掉。
net.ipv4.tcp_tw_reuse:允许客户端(主动方视角)在TIME_WAIT状态下复用连接。但注意内核要同时开启tcp_timestamps。这个参数一直有争议,因为它可能导致新旧连接报文串扰,尤其在NAT场景下问题更明显。我的建议是优先考虑服务端改用连接池或长连接来减少TIME_WAIT,而不是盲目把这个参数打开。
net.ipv4.tcp_keepalive_time:默认7200秒,TCP保活探测间隔。如果服务端依赖TCP保活检测客户端掉线,可以适当调小到600秒左右。但要明白保活探测是TCP维护性的,不能完全替代应用层心跳。
net.core.wmem_max / net.core.rmem_max 与 tcp_wmem / tcp_rmem:收发缓冲区大小。大带宽长肥网络(高BDP)需要调大缓冲区,不然窗口再大也发挥不出来。计算公式是:缓冲区 ≥ 带宽 × RTT。比如100Mbps链路、RTT 20ms,带宽延迟积大约是100Mbps × 0.02s = 2Mbit,约250KB,那缓冲区至少250KB,留有裕量可以再放大两三倍。
tcp_slow_start_after_idle:连接空闲后重新发送时是否重新慢启动。默认是1,对突发短连接不友好,可以设成0让空闲后的连接直接从原cwnd继续发。
7.3 踩坑记录:调完参数反而更糟的两个案例
案例一:有次为了减少TIME_WAIT,我们直接把tcp_tw_reuse和tcp_tw_recycle一起开了。结果业务出现间歇性的连接失败,抓包一看,NAT后面过来的连接被内核当成“旧连接”直接丢弃了。原因就是tcp_tw_recycle在Linux 4.10之前依赖时间戳判断,NAT环境下不同主机的时间戳可能不一致,导致合法包被拒。后来关掉tcp_tw_recycle,只保留tcp_tw_reuse,问题消失。现在新内核已经移除tcp_tw_recycle,但老系统的教训值得记住:网络调参改动越少越多,每次只动一个参数并验证。
案例二:为了提升大文件传输速度,把socket收发缓冲区调到8MB,结果大量并发连接下内存吃紧,GC频繁,反而拖垮了整体性能。原因很简单:缓冲区是每个连接都要分配的,并发1000个连接,理论上限就是8GB内存。后来改成动态调整,按连接实际情况分配,既保住单连接吞吐,又不拖垮整体。
这两个案例告诉我一个朴素道理:网络调优不是参数越大越好,也不是教程上推荐什么就抄什么,必须结合实际流量模型、内存上限和网络拓扑来评估。
8. 我自己的一点收尾经验
做了这么多年网络排查,最大的体会是:TCP的可靠传输不是“绝对可靠”,而是在不可靠的IP网络上,通过确认、重传、窗口、拥塞控制这些机制,把不可靠性控制在了应用可接受的范围内。你理解了这套机制,抓包时看到的每个SYN、每个Dup ACK、每一轮cwnd增长,都不再是孤立字段,而是设计者在几十年前就想好的、环环相扣的解决方案。
如果你刚入门,别急着背参数,先装个Wireshark,用tcpdump抓一次自己的网页浏览过程,把三次握手、数据传输、四次挥手一个个对出来。能对着抓包文件完整地讲出这段TCP会话里发生的每件事,你的网络基础就已经超过大部分同时期的人了。至于调参,永远记住四个字:小步,灰度。
还有个延伸话题可以继续研究:如果还想更深入,可以用iperf3做带宽和RTT的基准测试,再配合BBR算法对比传统Cubic在不同丢包率下的吞吐表现,那会是一篇实践感很强的后续博文。