做网络开发或者后端开发的朋友,几乎都绕不开一道坎:TCP。面试问,线上排查也问,甚至看监控都能看到一大片关于连接状态的数据。但很多人学传输层是割裂的——先记一遍端口号,再背一遍三次握手四次挥手,最后问起“TCP报文段的第一版设计为什么这么写”,就答不上来了。这篇文章顺着一条主线走:传输层到底提供什么服务、TCP报文段里每个字段在解决什么问题、连接的建立和拆除为什么要设计成三步和四步。边讲原理边补实践,特别是在抓包和排查时候能直接用上的那部分经验。内容适合正在学计算机网络课程的同学,也适合那些“会用TCP但没想明白为什么”的开发工程师。看完你再去看抓包文件里的握手和挥手,感受会完全不一样。
1. 传输层到底在解决什么问题:从“主机到主机”到“进程到进程”
1.1 网络层的盲区:数据到了,交给谁?
先把范围划清楚。网络层负责的是“主机到主机”的连通性问题——IP地址把数据包送到某台机器。但一台机器上跑着几十上百个进程,数据到了之后该交给哪个程序?这个问题网络层是不管的。传输层要解决的,就是“进程到进程”的交付。
用人话说就是:IP地址是小区地址,传输层的端口号才是门牌号。快递送到小区门口只能算半程,真正要送到你手上,还得靠门牌号。TCP和UDP都用到16位的端口号,取值范围0到65535,其中0到1023通常是知名服务用的(比如HTTP的80、HTTPS的443),1024到49151是注册端口,49152到65535一般是临时端口。
实际开发里,一个客户端程序发起连接时,操作系统会从临时端口范围里挑一个空闲端口作为源端口;服务端进程则绑定固定的监听端口。当数据包到达目的主机,操作系统根据目的端口找到对应的socket,再唤醒那个进程。这个“找到正确进程”的动作,就是传输层的核心职责之一。
1.2 端口号与套接字:传输层的分用机制
“复用”和“分用”这两个词看起来抽象,说到底就是一个流量调度问题。多个进程都往网络里发数据,传输层要把这些来自不同进程的数据统一打包交给网络层,这是复用;网络层把数据送到主机后,传输层又要根据端口号把数据分发给正确的进程,这是分用。
一个TCP连接用四元组唯一标识:源IP、源端口、目的IP、目的端口。只要四元组不同,就是不同连接。所以你在服务端看到的监听IP是0.0.0.0:8080,新连接进来之后四元组不同,系统能同时维持几万甚至几十万个连接,靠的就是这条规则。
这也能解释一个常见现象:为什么同一台服务器上,两个socket端口可以一样?因为它们要么属于不同本地IP,要么对端IP端口不同。四元组里任何一个元素不同,就是不同的连接,谈不上冲突。
1.3 可靠传输的脚手架:校验、序号、确认、重传
传输层除了分用复用,还提供可选的服务。UDP提供的是最基础的不可靠数据报服务:有校验,但错了就丢,不重传。TCP提供的是可靠的全双工字节流服务,可靠不是凭空来的,靠的是几个基础机制一起配合。
把这几个机制理解成寄挂号信:校验和负责检查信纸有没有被涂改,序号给每封信编个号,收件人收到后按编号回报“哪几封已收到”(确认),发件人发现超时没收到的就重发(重传)。TCP把这套逻辑搬到字节流上,只不过它不按“封”来编号,而是按“字节”来编号。连接建立的时候就约定好初始序号,之后每发一个字节,序号就加一。
理解了这套“脚手架”,后面看报文段里的序号、确认号、窗口、标志位,就不会觉得它们是孤立的字段,而是一套精密协作的系统。
2. TCP报文段逐字段拆解:每个bit都是设计过的
2.1 端口与四元组:连接的唯一标识
TCP报文段的首部固定部分20字节,最前面就是源端口和目的端口,各占16位。这两个字段和IP头里的源地址、目的地址一起,构成上面说的四元组。
为什么要强调四元组?因为TCP连接不是靠“连接ID”来标识的,而是靠这个组合。你在排查问题时如果发现一个连接始终建立不起来,可以先核对四元组——是不是源端口被防火墙拦了,是不是目的IP被路由到了错误机器上。很多时候抓包都看不到异常,就是端口匹配不上,数据根本进不了正确的socket。
2.2 序号与确认号:可靠传输的坐标轴
序号字段和确认号字段各占32位,范围0到2^32-1,回绕后重新从0开始。序号表示本报文段第一个字节在发送方字节流中的位置,确认号表示期望收到对端的下一个字节序号。注意,确认号是“期望下一个”,不是“确认到哪”,这点很多人第一次容易搞反。
初始序号为什么必须随机,这是很多人不知道但特别重要的设计。如果初始序号固定(比如从0开始),攻击者可以预测后续连接的序号,往连接里注入伪造的数据,这就是TCP序列号预测攻击。另外,随机初始序号还能避免一个连接结束后,刚建立的新连接里混入旧连接的迟到报文段——如果新旧连接序号范围相似,接收方分不清这是旧包还是合法新包。
实际的抓包工具里默认显示的是“相对序号”,也就是把第一个SYN报文段的序号记为0,后续报文段序号都相对它来加减。这样便于人肉看交互,真实链路里传输的还是随机的绝对序号。
2.3 六个标志位:连接状态的控制开关
TCP首部第13字节附近的六个标志位,是理解连接管理的关键。它们分别是URG、ACK、PSH、RST、SYN、FIN。
SYN,用于建立连接时同步序号。握手的前两个报文段都会带上SYN,SYN包本身会消耗一个序号。 FIN,用于关闭连接,表示发送方不再有数据要发送了。FIN同样消耗一个序号。 ACK,确认号有效标志。除第一个SYN外的几乎所有报文段都会置上ACK。 RST,强制重置连接。端口没服务、进程崩溃、连接半开等场景都会用RST来干净利落地终止连接。 PSH,要求对方尽快把数据交给应用层,别等在缓冲区里。一些交互式应用会用到。 URG,紧急指针标志,表示这里有紧急数据需要优先处理。这个在实际网络中使用频率不高,很多后段开发几乎感知不到。
标志位单个看都不复杂,但它们组合起来就构成连接的状态机。三次握手就是SYN和ACK的组合,四次挥手就是FIN和ACK的组合。所以很多人背不住状态变迁,其实只要盯着标志位,再配合状态迁移图,逻辑就通了。
2.4 窗口、校验和与选项:看不见的性能机关
窗口字段16位,表示接收方当前还能收多少字节,这是流量控制的基础。64KB的上限在早期够用,但在高带宽长延迟的“长肥管道”里,窗口必须扩大。解决办法在选项字段里:窗口缩放选项把窗口扩到最大约1GB,让高带宽链路能把吞吐跑满。
校验和覆盖的范围很特别:除了TCP首部和数据,它还把IP头里的源IP、目的IP、协议号、TCP段长度组成的“伪首部”一起计算。为什么这么设计?因为TCP要防止IP层把数据送错目的地——如果IP头里的地址错了,TCP自身无法感知,必须通过校验伪首部来发现这种错误。所以你改端口、改目标IP,TCP校验和都会变。
选项字段里还有两个至关重要的东西。MSS选项在握手时协商,宣告本端能接受的最大报文段大小。以太网MTU是1500字节,减掉IP头20字节和TCP头20字节,经典MSS就是1460字节。SACK选项让接收方可以告诉发送方“我收到了哪些不连续的数据块”,避免发送方盲目重传一段完整的东西。时间戳选项用来测RTT和防32位序号在高速下回绕混淆。
这些选项在Linux里基本都是默认开启的。你在抓包里偶尔看到TSval、SACK_PERM这样的字样,就是它们在发挥作用。
3. 连接管理:三次握手为什么不能少,四次挥手为什么不能急
3.1 三次握手的状态机与历史连接问题
三次握手的流程人人都会背:客户端发SYN,服务端回SYN+ACK,客户端回ACK。但为什么必须是三次?这是面试最爱问、也最考验理解的问题。
关键在于防止“历史重复连接初始化”。假设网络里有一个延迟很久的旧SYN报文段,客户端早就放弃了这次连接。如果两次握手就够了,服务端收到这个旧SYN会直接建立连接,并维护一堆资源——结果客户端根本不认这个连接,服务端的资源就这么被白白占着。
三次握手解决这个问题的方法是:服务端收到SYN后先不确认资源,而是回复一个SYN+ACK,把确定权交回客户端。客户端看到这个SYN+ACK能判断它是否对应自己最近发起的连接。如果发现这是个旧包,客户端直接回一个RST,服务端收到RST后释放半连接资源。这个“反悔机制”只有三次才能实现,这也解释了为什么最初设计要留出这一步。
握手还有个细节:第三次ACK报文段可以携带数据。因为前两次握手已经确认了双方收发能力,连接已经建立,客户端带上应用数据是合法合理的。实际很多接口优化会在第三次握手时捎带第一笔请求,省掉一个RTT。
3.2 SYN Flood:半连接队列是如何被攻破的
服务端收到SYN后,会为这个未完成的连接分配一个半连接对象,放进半连接队列,等待客户端的ACK。如果攻击者伪造大量源IP地址发SYN,却不完成握手,半连接队列很快被塞满。此后正常用户的SYN进来没有位置排队,服务端表现为无法接收新连接。
应对手段主要是SYN Cookie。开启后,服务端不保存半连接状态,而是把连接参数加密编码进SYN+ACK的序号里。客户端回的ACK中必须带上这个序号,服务端校验通过才正式建立连接。因为服务端不占资源,所以半连接队列被挤爆的威胁就小了很多。
代价也有:SYN Cookie模式无法支持太多选项协商,某些对MSS、窗口缩放有依赖的场景会受限。所以它不是用来替代正常连接的,而是在压力下保底的策略。Linux里一般默认开启syncookies,在高负载时自动进入cookie模式。
3.3 四次挥手与TIME_WAIT:2MSL的代价
关闭连接比建立复杂,因为TCP是全双工的,两个方向的传输是独立的。主动关闭方发出FIN,表示“我的数据发完了”;对端回ACK,表示“知道了”;但对端可能还有自己的数据要发,所以要等对端也发FIN,主动关才再回一个最终ACK。这自然就是四个报文段、四个状态阶段。
为什么主动关闭的一方最后要进入TIME_WAIT,再等2MSL(报文最大存活时间的2倍)才关闭?两个原因。
第一,最后的ACK可能丢。如果丢了,对端收不到最终ACK会一直重发FIN,主动关闭方必须还活着才能重发ACK。2MSL足够完成一次FIN的丢失重传。
第二,给旧连接的迟到报文段足够时间在网络里消亡,避免它们混入一对新的、四元组相同的连接中。这个机制听起来很奢侈,但少了它,高并发服务很容易出现前后连接互相污染的问题。
实操里经常看到服务器大量TIME_WAIT,比如做了反向代理的机器上,一条连接结束都会由代理主动发起关闭,TIME_WAIT成千上万很常见。这本身不是bug,是协议设计的必然结果。不要一看到TIME_WAIT多就急着调tcp_tw_reuse或者什么魔法参数,那是在和协议可靠性作对。
3.4 RST:一条被协议栈拒绝的连接
RST是一个性格暴烈的信号,收到即终止。常见的触发场景:
一是访问一个没有进程监听的端口,内核直接回RST。 二是程序崩溃后,内核关闭了socket,后续的报文到达,内核回RST。 三是TCP keepalive探测失败,正常关闭。四是客户端收到一个自己没发过SYN的SYN+ACK,客户端回RST拒绝这个历史连接。
排查时如果看到大量RST,首先要看是主动的还是被动的。客户端发RST,多半是收到了意外的包;服务端发RST,多半是端口或进程有问题。别只盯着重传,RST的数量往往更能说明“这里有一条连接被协议栈判了死刑”。
4. 用Wireshark把理论变成肉眼可见的字节
4.1 抓包前的三个准备
理论学习再多,也不如亲手抓一次包记得牢。推荐在本地起一个最简单的TCP服务端和客户端,用Python的socket库几行代码就够。抓包时建议做三件事:第一,选好网卡接口,本机测试用loopback即可;第二,设置过滤器只看目标端口,比如tcp.port == 5000;第三,在Wireshark里开启“显示相对序号”和“显示绝对时间”,默认就是相对序号,这样阅读最直观。
取一个TCP连接建立和关闭的完整过程,保存成pcap文件,就能按部就班解读了。
4.2 三次握手抓包逐包拆解
打开抓包文件,会看到三条记录,用一个表格可以非常清楚地描述它们的差异:
| 方向 | 标志位 | 相对Seq | 相对Ack | 说明 |
|---|---|---|---|---|
| 客户端→服务端 | SYN | 0 | 0 | 客户端发起连接,初始序号随机 |
| 服务端→客户端 | SYN+ACK | 0 | 1 | 服务端确认客户端的SYN,并同步自己的序号 |
| 客户端→服务端 | ACK | 1 | 1 | 双方完成握手,连接进入ESTABLISHED |
注意第二个分组的确认号为什么是1?因为握手时的SYN消耗了一个序号,客户端发来的SYN占用了序号0,所以确认号要在它基础上加1。第三个分组里客户端的Seq变成1,因为要跟上自己消耗的SYN序号;确认号也是1,因为服务端的SYN同样占用了一个序号。
抓包时如果看到某个握手报文被反复重传,一般离不开两种原因:链路丢包,或者防火墙干预。配合日志看,如果客户端一直处于SYN_SENT且服务端没有任何收到痕迹,大概率是网络层路由或防火墙把SYN丢了;如果服务端有SYN_RECV但ACK回不去,就要考虑服务端socket backlog和全连接队列的处理能力。
4.3 挥手抓包与连接状态联动排查
关闭连接时会看到四个报文:一方FIN,另一方ACK,另一方FIN,发起方最后ACK。如果在抓包工具里勾选“Follow TCP Stream”,能把这几个报文所处的状态直接串起来。
用netstat或ss可以佐证这个过程。在服务器上执行:
ss -ant | grep 5000可以看到连接在FIN_WAIT_2、CLOSE_WAIT、TIME_WAIT、LAST_ACK这些状态间切换。我自己排查时习惯把抓包和状态统计结合起来看:如果抓包显示已经发出ACK,但服务端连接还卡在TIME_WAIT,那是等2MSL的正常过程;如果客户端明明没发FIN,服务端却大量出现CLOSE_WAIT,那基本是应用层没调用close函数,连接被挂在半空。
这里有个常见实践心得:CLOSE_WAIT是程序问题,TIME_WAIT是协议问题。看到CLOSE_WAIT堆积,直接去看进程和fd,十有八九是代码里资源没释放;看到TIME_WAIT多,先看是谁主动关闭了连接,再决定要不要优化长连接策略,别一上来就去动内核参数。
5. 实用排查:连接状态快查与经典的认知弯路
5.1 一张表看懂连接状态堆积
线上遇到连接异常时,先统计当前TCP状态分布是最高效的动作。一条常用命令:
ss -ant | awk '{print $1}' | sort | uniq -c | sort -nr把状态数量排序后,对照下面的速查表就能快速定位问题方向:
| 状态 | 可能原因 | 排查方向 |
|---|---|---|
| SYN_SENT 大量 | 客户端连接超时,被防火墙拦截或对端不可达 | 检查网络路径、路由规则、对端监听端口 |
| SYN_RECV 大量 | 半连接队列堆积,可能是攻击或backlog过小 | 查看somaxconn、syncookies、应用accept速率 |
| ESTABLISHED 异常高 | 连接数超过预期 | 检查连接池大小、线程数、fd上限 |
| TIME_WAIT 大量 | 主动关闭方太多,短连接高频 | 优先优化连接复用,谨慎调内核参数 |
| CLOSE_WAIT 大量 | 应用没有调用close,资源泄漏 | 定位进程和句柄,检查代码释放逻辑 |
| LAST_ACK 大量 | 对端不响应主动关闭方的FIN | 查看对端是否卡死或网络丢包 |
这张表我用了很多次,基本能从状态分布直接倒推出是网络问题、协议问题还是应用问题。比如某次线上接口偶发超时,ss统计发现CLOSE_WAIT数量逐小时上涨,顺着这个状态去找代码,最后定位到某个连接池没有处理服务端关闭时的回调,fd一直在涨,问题很快解决。
5.2 几个必须想明白的TCP细节
最后聊几个容易想歪的点。
第一个:粘包到底是谁的错?很多人说是TCP的问题,其实TCP只提供字节流,不维护消息边界。你应用层发两次write,可能被合并成一个报文段发送,也可能被拆成不同段。要消息边界,得自己定义:定长、分隔符、长度前缀都是常见做法。UDP不同,它保留报文边界,一次send对应一次recv,所以没有“粘包”概念。
第二个:三次握手和四次挥手能不能“偷步”?握手的第三次ACK确实可以带数据。挥手如果双方同时关闭,或者被动关闭方在收到FIN时正好没有数据要发,可以把ACK和FIN合并成两个报文而不是四个?不对,准确说是可以把第二个和第三个报文合并(即收到FIN后立即发FIN+ACK),这样四次挥手变成三次,但少见,别拿特例当默认。
第三个:校验和要不要自己算一遍?理论上接收端会做完整校验,但实战中有些硬件网卡开启了校验和卸载(checksum offload),抓包工具里看到的可能是错误的校验和值。看到wireshark标记校验和错误时先别慌,查一下网卡配置再说。
关于“长肥管道”也值得多一句:窗口缩放和时间戳选项是让TCP在高带宽高延迟下跑起来的关键。你要是遇到传输速度怎么也上不去,先去确认双方协商出的窗口缩放因子是多少。抓包看握手报文里的Window scale选项,如果有一方协商成0,后续吞吐可能就在64KB附近打转,速度自然上不去。
我个人在实际项目里最大的体会是:TCP不是背出来的,是“抓包抓出来”的。每次遇到诡异连接问题,先抓包看一眼,再对照状态和数据,比翻文档猜原因快得多。看完这篇文章,建议自己亲手起一个socket服务,在本地抓一次完整的建连、收数据、挥手过程,再把每一步和你刚读过的字段一一对上有哪些发现。真到线上排查时,你会发现那些报文段里的数字和标志位,自己会开口说话。