ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCP报文段与三次握手:从传输层原理到Wireshark抓包实战

TCP报文段与三次握手:从传输层原理到Wireshark抓包实战

做网络开发或者后端开发的朋友,几乎都绕不开一道坎: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说明
客户端→服务端SYN00客户端发起连接,初始序号随机
服务端→客户端SYN+ACK01服务端确认客户端的SYN,并同步自己的序号
客户端→服务端ACK11双方完成握手,连接进入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服务,在本地抓一次完整的建连、收数据、挥手过程,再把每一步和你刚读过的字段一一对上有哪些发现。真到线上排查时,你会发现那些报文段里的数字和标志位,自己会开口说话。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进