做网络开发或者后端开发的朋友几乎都绕不开一道坎TCP。面试问线上排查也问甚至看监控都能看到一大片关于连接状态的数据。但很多人学传输层是割裂的——先记一遍端口号再背一遍三次握手四次挥手最后问起“TCP报文段的第一版设计为什么这么写”就答不上来了。这篇文章顺着一条主线走传输层到底提供什么服务、TCP报文段里每个字段在解决什么问题、连接的建立和拆除为什么要设计成三步和四步。边讲原理边补实践特别是在抓包和排查时候能直接用上的那部分经验。内容适合正在学计算机网络课程的同学也适合那些“会用TCP但没想明白为什么”的开发工程师。看完你再去看抓包文件里的握手和挥手感受会完全不一样。1. 传输层到底在解决什么问题从“主机到主机”到“进程到进程”1.1 网络层的盲区数据到了交给谁先把范围划清楚。网络层负责的是“主机到主机”的连通性问题——IP地址把数据包送到某台机器。但一台机器上跑着几十上百个进程数据到了之后该交给哪个程序这个问题网络层是不管的。传输层要解决的就是“进程到进程”的交付。用人话说就是IP地址是小区地址传输层的端口号才是门牌号。快递送到小区门口只能算半程真正要送到你手上还得靠门牌号。TCP和UDP都用到16位的端口号取值范围0到65535其中0到1023通常是知名服务用的比如HTTP的80、HTTPS的4431024到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用于建立连接时同步序号。握手的前两个报文段都会带上SYNSYN包本身会消耗一个序号。 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自身无法感知必须通过校验伪首部来发现这种错误。所以你改端口、改目标IPTCP校验和都会变。选项字段里还有两个至关重要的东西。MSS选项在握手时协商宣告本端能接受的最大报文段大小。以太网MTU是1500字节减掉IP头20字节和TCP头20字节经典MSS就是1460字节。SACK选项让接收方可以告诉发送方“我收到了哪些不连续的数据块”避免发送方盲目重传一段完整的东西。时间戳选项用来测RTT和防32位序号在高速下回绕混淆。这些选项在Linux里基本都是默认开启的。你在抓包里偶尔看到TSval、SACK_PERM这样的字样就是它们在发挥作用。3. 连接管理三次握手为什么不能少四次挥手为什么不能急3.1 三次握手的状态机与历史连接问题三次握手的流程人人都会背客户端发SYN服务端回SYNACK客户端回ACK。但为什么必须是三次这是面试最爱问、也最考验理解的问题。关键在于防止“历史重复连接初始化”。假设网络里有一个延迟很久的旧SYN报文段客户端早就放弃了这次连接。如果两次握手就够了服务端收到这个旧SYN会直接建立连接并维护一堆资源——结果客户端根本不认这个连接服务端的资源就这么被白白占着。三次握手解决这个问题的方法是服务端收到SYN后先不确认资源而是回复一个SYNACK把确定权交回客户端。客户端看到这个SYNACK能判断它是否对应自己最近发起的连接。如果发现这是个旧包客户端直接回一个RST服务端收到RST后释放半连接资源。这个“反悔机制”只有三次才能实现这也解释了为什么最初设计要留出这一步。握手还有个细节第三次ACK报文段可以携带数据。因为前两次握手已经确认了双方收发能力连接已经建立客户端带上应用数据是合法合理的。实际很多接口优化会在第三次握手时捎带第一笔请求省掉一个RTT。3.2 SYN Flood半连接队列是如何被攻破的服务端收到SYN后会为这个未完成的连接分配一个半连接对象放进半连接队列等待客户端的ACK。如果攻击者伪造大量源IP地址发SYN却不完成握手半连接队列很快被塞满。此后正常用户的SYN进来没有位置排队服务端表现为无法接收新连接。应对手段主要是SYN Cookie。开启后服务端不保存半连接状态而是把连接参数加密编码进SYNACK的序号里。客户端回的ACK中必须带上这个序号服务端校验通过才正式建立连接。因为服务端不占资源所以半连接队列被挤爆的威胁就小了很多。代价也有SYN Cookie模式无法支持太多选项协商某些对MSS、窗口缩放有依赖的场景会受限。所以它不是用来替代正常连接的而是在压力下保底的策略。Linux里一般默认开启syncookies在高负载时自动进入cookie模式。3.3 四次挥手与TIME_WAIT2MSL的代价关闭连接比建立复杂因为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的SYNACK客户端回RST拒绝这个历史连接。排查时如果看到大量RST首先要看是主动的还是被动的。客户端发RST多半是收到了意外的包服务端发RST多半是端口或进程有问题。别只盯着重传RST的数量往往更能说明“这里有一条连接被协议栈判了死刑”。4. 用Wireshark把理论变成肉眼可见的字节4.1 抓包前的三个准备理论学习再多也不如亲手抓一次包记得牢。推荐在本地起一个最简单的TCP服务端和客户端用Python的socket库几行代码就够。抓包时建议做三件事第一选好网卡接口本机测试用loopback即可第二设置过滤器只看目标端口比如tcp.port 5000第三在Wireshark里开启“显示相对序号”和“显示绝对时间”默认就是相对序号这样阅读最直观。取一个TCP连接建立和关闭的完整过程保存成pcap文件就能按部就班解读了。4.2 三次握手抓包逐包拆解打开抓包文件会看到三条记录用一个表格可以非常清楚地描述它们的差异方向标志位相对Seq相对Ack说明客户端→服务端SYN00客户端发起连接初始序号随机服务端→客户端SYNACK01服务端确认客户端的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后立即发FINACK这样四次挥手变成三次但少见别拿特例当默认。第三个校验和要不要自己算一遍理论上接收端会做完整校验但实战中有些硬件网卡开启了校验和卸载checksum offload抓包工具里看到的可能是错误的校验和值。看到wireshark标记校验和错误时先别慌查一下网卡配置再说。关于“长肥管道”也值得多一句窗口缩放和时间戳选项是让TCP在高带宽高延迟下跑起来的关键。你要是遇到传输速度怎么也上不去先去确认双方协商出的窗口缩放因子是多少。抓包看握手报文里的Window scale选项如果有一方协商成0后续吞吐可能就在64KB附近打转速度自然上不去。我个人在实际项目里最大的体会是TCP不是背出来的是“抓包抓出来”的。每次遇到诡异连接问题先抓包看一眼再对照状态和数据比翻文档猜原因快得多。看完这篇文章建议自己亲手起一个socket服务在本地抓一次完整的建连、收数据、挥手过程再把每一步和你刚读过的字段一一对上有哪些发现。真到线上排查时你会发现那些报文段里的数字和标志位自己会开口说话。