TCP连接管理详解:三次握手与四次挥手背后的原理与排障

发布时间:2026/10/15 7:35:31
TCP连接管理详解:三次握手与四次挥手背后的原理与排障
忙了半个下午帮某团队看一个服务间歇性连不上的问题。现象很奇怪部分客户端连接超时服务器端日志干干净净CPU、内存都正常。最后用ss -s一查半连接队列堆了一堆 SYN_RCVD这才定位到是握手阶段就卡住了。事后那个负责的同事说TCP 这些东西学校里都背过可真到了线上压根不知道从哪下手。这才是我写这篇东西的动机。传输层是计算机网络里最考试友好的一章——提供服务、报文段格式、三次握手、四次挥手每个考点都能画成图背下来。但能背和能用之间隔着一整条线的经验。这篇文章就围绕标题里的三件事展开传输层提供的服务到底解决了什么问题TCP 报文段那 20 字节头部每个字段为什么这么设计以及连接建立和释放背后的状态机、异常场景和攻防逻辑。适合正在学计网的在校生、准备面试的求职者以及把 TCP 当黑盒用、想补底层功底的开发运维朋友。我不打算平铺直叙地念课本而是尽量把每个知识点还原成线上会以什么形式坑你。1. 传输层为什么存在把进程到进程这件事说透1.1 网络层管的是机器传输层管的是程序很多人学到这里容易绕晕觉得 IP 包明明已经能把数据送到目的地了传输层不是多此一举吗。我习惯用一个类比解释网络层解决的是从北京西二旗的 A 栋送到上海张江的 B 栋它只关心机器到机器但数据到了 B 栋之后得有人告诉前台这封信是给 3 楼的财务部还是给 5 楼的研发部。这个前台分拣的动作就是传输层的工作。落实到技术上网络层的地址是 IP它定位的是一台主机的网卡传输层的地址是端口Port它定位的是这台主机上的一个进程。一台服务器上同时跑着 Web 服务、数据库、SSH外网包过来时内核靠 TCP 或 UDP 头里的目的端口号知道该把数据丢给哪个进程。所以传输层最基础的两个本领一个是进程到进程的通信一个是复用与分用——多个进程共用一个 IP靠不同端口号区分这就是复用收包时按端口号分发到对应进程这就是分用。有个点经常被忽略端口号不是全局唯一的它只在IP 端口这个组合里才有意义。两个不同 IP 上都有 8080 端口互不冲突。真正唯一标识一条 TCP 连接的是四元组源 IP、源端口、目的 IP、目的端口。排查问题时我见过有人只看目的端口就下结论结果把跨节点的连接问题误判成端口冲突方向就错了。1.2 端口不是随便一个数复用与分用的规则端口是 16 位范围 0 到 65535。常见划分是这样0 到 1023 是知名端口HTTP、HTTPS、DNS、SSH 都在这段1024 到 49151 是注册端口给应用层服务主动监听用的49152 到 65535 是动态/私有端口一般作为客户端发起连接时的源端口。实际排查时客户端源端口是内核临时分配的你控制不了具体值服务器要做的就是绑定一个固定端口并监听。如果服务没起来或者端口被占用客户端就会收到 connection refused这是最直白的传输层提供的服务没就绪的表现。另外Linux 下ss -tlnp看监听端口时State 那列出现 LISTEN 才是真的能接受新连接出现 TIME_WAIT 或 ESTABLISHED 的端口都不是正在提供服务的端口。1.3 UDP 也在提供服务有些业务恰恰不需要可靠说到传输层服务课本必然对比 TCP 和 UDPTCP 提供面向连接、可靠交付、流量控制、拥塞控制的字节流服务UDP 提供无连接、不可靠、首部只有 8 字节的数据报服务。很多人默认TCP 比 UDP 好这在工程上是不成立的。举几个典型场景DNS 查询一个请求一个响应丢了就超时重发一次用 TCP 反而要握手、释放延迟和开销都更高实时视频通话一帧数据晚到比丢包更致命TCP 的重传机制会引入惊人数量的排队延迟反而让画面卡成幻灯片游戏位置同步旧的状态包已经失去意义重传旧数据是帮倒忙。所以 UDP 的不可靠不是缺陷而是把可靠性选择权交给应用层。这是传输层设计里非常关键的思想下层不需要替上层做价值判断。我自己的经验是对延迟敏感、能容忍少量丢失的业务优先考虑 UDP 或基于 UDP 封装的自定义协议比如加了可靠性机制的类 QUIC 方案对准确性要求极高、允许延迟的业务才选 TCP。这属于典型的没有银弹问题。2. 报文段头部的 20 字节每一比特都有用2.1 源端口与目的端口一进一出的门牌号TCP 报文段标准头部 20 字节没有选项的情况下所有字段加起来正好 20 字节整。前 4 字节是源端口和目的端口各占 16 位。它们是连接四元组的一半负责让接收端能找到对应 socket。这俩字段本身没太多故事但抓包时常会看到客户端端口是 50000 多的高位随机端口服务器端口是 443、8080 这类固定端口。道理很简单客户端发起连接的数量可能很多用随机端口避免冲突服务器端的知名服务必须固定端口否则客户端不知道该连谁。2.2 序号与确认号字节流里的页码系统这是 TCP 最核心的机制。TCP 是面向字节流的协议发送方把应用层数据切成一堆 TCP 报文段接收方按序重组后交给应用层。为了让乱序到达、甚至中间丢失的报文段能拼回原来的数据顺序TCP 给每一个字节的数据都编了一个序号。序号Sequence Number32 位本报文段数据部分第一个字节在字节流中的位置。比如要发送的数据从字节 101 开始这 1000 字节分成两段第一段的 seq 是 101第二段的 seq 就是 501。确认号Acknowledgment Number32 位期望收到对方下一个字节的序号也就是我已经完整收到了直到 N-1 的所有字节请从 N 开始发。这个语义特别容易记岔。确认号说的不是我刚收到了多少而是我期待下一个是什么。所以累计确认的机制下如果对方发来 seq101 和 seq501 两段且中间没丢接收方回复的 ACK 是 1501 的累计值。如果第二段先到了、第一段还没到TCP 不会回复我期待 1501而会仍旧回复我期待 101——重复 ACK 也是快速重传的重要信号。还有个细节很多教材不写建立连接时的 SYN 报文段虽然不携带数据但它会消耗一个序号连接请求本身的编号也要被确认所以握手第二次报文的确认号必须是客户端的初始序号 1。同样的道理挥手时 FIN 报文段也消耗一个序号这就是为什么第四次挥手时 ACK 的确认号是对方 FIN 的序号 1。很多人在画四场面图时这里对不上号本质就是没搞懂谁消耗序号。2.3 数据偏移、六个标志位与窗口把 TCP 语义压缩进 16 比特数据偏移占 4 位表示 TCP 头部的长度单位是 4 字节。标准头是 20 字节所以数据偏移字段通常为 55 × 4 20。当头部带着选项MSS、时间戳、SACK 等时这个值会变大最大是 15对应 60 字节头部其中选项区最多 40 字节。这个字段的本质作用是告诉接收方数据从哪里开始——因为 TCP 头不是固定长度的。接下来的六个标志位是理解 TCP 行为的关键。逐个说URG表示紧急指针字段有效。这个字段设计是用来发送带外紧急数据的但实际应用中用得极少现代应用基本走普通数据通道。ACK确认号有效。除握手最初阶段外几乎所有报文段都要置上。PSH要求接收方立即把数据交给应用层不要在缓冲区攒着。实际内核一般也能容忍短延迟否则交互式应用会卡。RST异常复位连接。收到 RST 的一端应立即关闭连接不需要走四次挥手。端口未监听时收到 SYN内核回的就是 RST。SYN建立连接时置位。它是连接请求的标志同时消耗一个序号。FIN表示发送方数据发完了请求释放连接。它同样消耗一个序号。窗口Window占 16 位它是接收方给发送方的流量许可额度表示从确认号开始接收方还能收多少字节的数据。这样发送方就不能一股脑儿灌数据把接收方缓冲区塞爆。窗口机制是 TCP 流量控制的核心发送窗口大小由接收方的可用缓冲区决定而不是由发送方自己定。这也是为什么抓包时经常看到 Windows Size 在动态变化的直接原因。校验和16 位覆盖范围不止 TCP 头和数据还包含一个 12 字节的伪首部源 IP、目的 IP、协议号、TCP 长度。这等于把网络层的部分信息也纳入了校验防止数据被送到错误的协议、错误的主机上。它算的是反码和这个细节应付选择题有用就不展开了。2.4 抓一个真实报文段Wireshark 里你应该看哪些字段纸上完了实操一把。用 Wireshark 抓一个 TCP 连接最直观过滤表达式可以写tcp.port 443然后看首个 SYN 报文。你会看到类似这样的层次Transmission Control Protocol, Src Port: 51234, Dst Port: 443, Seq: 0, Ack: 0 Source Port: 51234 Destination Port: 443 [Stream index: 0] Sequence Number: 0 (relative sequence number) Acknowledgment Number: 0 Header Length: 32 bytes Flags: 0x002 (SYN) Window: 64240 Checksum: 0x1234 [unverified] Options: (12 bytes), Maximum Segment Size, No-Operation, Window Scale Maximum Segment Size: 1460 Window Scale: 7 (multiply window by 128)注意两点。第一Wireshark 默认显示的是相对序号relative sequence number第一个 SYN 的 seq 显示为 0实际线上的随机初始序号ISN要右键Protocol Preferences关掉 Relative Sequence Numbers 才看得到。很多新手第一次抓包看到 seq0 就以为 TCP 初始序号固定为 0这是个经典误解。第二Options 里的 MSS1460这是双方协商的最大报文段长度由链路层 MTU常见 1500减去 IP 头 20 字节和 TCP 头 20 字节得到。MSS 是传输层选项不是链路层属性但它的上限必须参考 MTU否则就会触发 IP 分片而 IP 分片是性能杀手。3. 三次握手建立连接的默契与博弈3.1 一次握手的完整时序和状态流动建立连接的全过程课本上那张时序图大家都见过但状态机的变化值得反复看客户端初始是 CLOSED主动调 connect 后发出 SYN进入 SYN_SENT服务端从 LISTEN 状态收到 SYN进入 SYN_RCVD并回 SYNACK客户端收到 SYNACK 后状态变为 ESTABLISHED并回送最后一个 ACK服务端收到这个 ACK也进入 ESTABLISHED。自此双方都能正常收发数据。各状态的 Linux 表示服务端处于 SYN_RCVD 时ss -tln能看到对应监听端口客户端处于 SYN_SENT 时ss -tn能看到对应连接。线上排查连接建立失败时这两个状态是第一个要看的。还有一个特别容易记混的细节第三次握手报文允许携带应用数据前两次不行。因为第三次 ACK 发出时客户端已经确认服务端可以收发了服务端收到第三次 ACK 时也知道客户端状态正确。此时捎带数据能节省一次往返。但前两次中任何一方都不能带数据——尤其第二次 SYNACK 如果携带数据客户端还没进入 ESTABLISHED处理起来是违反协议语义的。3.2 为什么必须是三次两次和四次都不行为什么是三次握手而不是两次是面试高频题。最严谨的答案要落到两点同步双方的初始序号、防止失效的历史连接请求干扰。先说同步序号。TCP 收发数据依赖双方的序号达成一致客户端要知道服务端的初始序号服务端也要知道客户端的初始序号。这个过程本质是双方的一次确认闭环客户端发 SYN带上自己的 ISN_c。服务端收到后知道客户端序号从 ISN_c 开始。服务端回 SYNACK带上自己的 ISN_s同时 ACK ISN_c 1告诉客户端你的序号我收到了。客户端回 ACK ISN_s 1告诉服务端你的序号我也收到了。只有到第三步服务端才能确认客户端确实收到了我的 SYNACK——如果不确认服务端就不知道客户端是否知道服务端的初始序号。两步做不到这件事。再说历史连接问题。假设一个 TCP 连接请求在网络里堵了很久客户端超时重传后搭好了新连接结果旧 SYN 被延迟后又到达了服务端。如果是两次握手服务端收到这个旧 SYN 会立刻分配资源、进入 ESTABLISHED然后傻等客户端发数据——客户端这边根本没这个连接也不会理它白白浪费资源还可能把旧连接里的脏数据误当成新连接的数据。三次握手时服务端即使收到了旧 SYN 回发 SYNACK客户端也会根据自己当前上下文判断这是旧请求回一个 RST 把它断掉服务端收到 RST 后释放资源干净利落。3.3 为什么四次多余信息论的朴素解释既然三次能同时完成双方确认彼此收发能力 序号同步第四次就没有任何新信息了。多一次只会白白增加一个 RTT 的建连延迟。对经常短连接的场景少一次握手就是实打实的性能节省。所以为什么不是四次的答案一句话三次已经构成完备的确认闭环第四次是纯冗余。这个话题常被延伸成TCP 三次握手为什么不叫三次握手协议而叫三次——稍微补充一下RFC 793 里其实叫three-way handshake它强调的是三个报文段完成双向同步而不是三次交互。含义一样但理解成需要三个包会更准确。3.4 ISN 随机化序列号不是从 0 开始的TCP 报文的序列号初始值 ISN 并不是固定的。协议要求初始序号是随机生成的目的之一是防止同一个连接在恢复后出现新旧报文序号重叠更重要的防恶意攻击。如果 ISN 可预测攻击者可以伪造一个合法的 SYN在服务端回复 SYNACK 之后抢在真实客户端之前发一个带预测序号的 ACK从而污染连接、注入数据。这就是经典的序列号预测攻击。现在的操作系统对内核的 ISN 生成做得很激进——比如基于时间和哈希生成不可预测的随机值。这就是为什么抓包工具里会看到一个巨大的随机序号而 Wireshark 为了方便默认显示相对值。和 ISN 相关的还有一个安全场景SYN Flood 攻击。攻击者源源不断发送 SYN但从不回最后一个 ACK把服务端的半连接队列SYN_RCVD 状态的连接撑爆。新来的正常连接就进不了队列表现为服务“连不上”。应对方案里最出名的就是 SYN Cookie服务端收到 SYN 时不分配连接资源而是把连接信息加密编码进 SYNACK 的初始序号里等到第三个 ACK 回来时验证序号、再真正分配资源。这个机制在现代 Linux 上默认开启或可通过 sysctl 调整是防御握手层攻击的重要防线。4. 四次挥手和 TIME_WAIT所有延迟都藏在这张图里4.1 挥手全流程四个 FIN/ACK 和一个 TIME_WAIT连接释放时主动关闭方发 FIN之后两条方向的数据通路各自独立关闭。全过程状态变化是主动方调 close发 FIN进入 FIN_WAIT_1。被动方收到 FIN回 ACK确认号 对方 FIN 序号 1进入 CLOSE_WAIT。主动方收到 ACK 后进入 FIN_WAIT_2。被动方的应用层处理完剩余数据后也调用 close发出 FIN进入 LAST_ACK。主动方收到这个 FIN回最后一个 ACK然后进入 TIME_WAIT等待 2MSL 后关闭被动方收到 ACK 后直接 CLOSED。注意被动方第一次回的 ACK 和它自己发出的 FIN 是两个独立的东西中间可能隔一段时间——取决于被动方应用层还有多少数据没发完。所以四次挥手不是对称的往返第二次和第三次之间可以有一个明显的时间差抓包时如果看到 FIN 和 ACK 之间隔着几百毫秒不一定是问题。很多人困惑的是为什么第三步是被动方也发 FIN而不是把 ACK 和 FIN 合并成一个包。答案回到 TCP 的全双工特性每一方向数据的结束都要单独宣告一次。主动方说我不再发了被动方得先确认我知道了然后被动方把自己要发的东西发完再说我也不再发了。这两个动作之间数据可能还在单向流动。这种只关闭一个方向的方法叫半关闭四次的根本原因就在这里。4.2 半关闭才是四次挥手的根本原因如果 TCP 是单工协议挥手最多两次就完事。但 TCP 是两条独立的数据通路所以关闭也必须分两次。理解半关闭最好的例子是文件传输协议客户端发完下载请求后可以 shutdown 掉发送方向半关但保留接收方向继续收文件数据文件收完服务端再关闭它的发送方向连接才完全关闭。这样设计让协议不必等双向数据同时没了才关闭灵活性大幅提升。实际编程里主动 shutdown(SHUT_WR) 之后如果没有关读端连接会停在 FIN_WAIT_2如果对端一直不发 FIN这个状态会挂很久。线上经常能看到 FIN_WAIT_2 堆积多数原因是应用层关了一半忘了另一半或者对端进程僵死。4.3 2MSL 等待到底在防什么TIME_WAIT 是连接管理里最爱考、实战也最痛的状态。主动关闭方在发出最后一个 ACK 之后要停留 2MSLMSLMaximum Segment Lifetime报文最大生存时间才能回到 CLOSED。两个理由缺一不可第一个理由万一最后的 ACK 丢了。被动方处于 LAST_ACK如果迟迟收不到确认会超时重发 FIN。主动方需要在这段时间里留着才能再补一个 ACK。如果收到 FIN 就直接关闭那被动方重发的 FIN 就没人应答它会一直重试、长时间无法释放。第二个理由让旧连接的报文在网络中自然消亡。假设我们不等满 2MSL 就立即重用同一个四元组建立新连接那么网络上可能还残留着旧连接延迟到达的数据包新连接收到后会把它当作自己的数据造成数据混乱。2MSL 保证了所有属于旧连接的报文要么被收到、要么已经消失新连接不会跨时空收到脏数据。正因为这样TIME_WAIT 里的连接是不能被立即复用的。你看到的ss -tan里一堆 TIME_WAIT其实是 TCP 用主动等待换来了协议的健壮性。4.4 生产环境里被 TIME_WAIT 折磨的日子短连接和踢得比较勤的高频请求很容易在服务器端堆积大量 TIME_WAIT。比如某内部系统频繁拉取配置每个请求新建连接高峰期ss -s能看到 TIME_WAIT 上千甚至上万。这种状态本身不算错误但会占着四元组和少量内存极端情况下可能导致新建连接变慢。治理手段从三个方向考虑使用连接池 / 长连接从源头减少连接频率。这是最推荐的做法能同时降低握手开销和 TIME_WAIT 压力。调整 MSL 相关的内核参数缩短 TIME_WAIT 停留时间。代价是牺牲防脏包的安全窗口生产环境一般不建议动。开启tcp_tw_reuse。它允许内核把处于 TIME_WAIT 的 socket 复用为新的连接前提是开启 TCP 时间戳选项且一般只建议在客户端场景使用。老内核里的tcp_tw_recycle问题很多会和 NAT 环境下的时间戳校验互相冲突出现部分用户连不上、抓包却一切正常的诡异现象新内核基本已移除。这个改动实战里吃过亏的人不少。我的建议是先统计业务的连接特征确认确实有大量短连接再考虑优化如果只是偶发几百个 TIME_WAIT完全不需要管它TCP 自己会慢慢处理完。5. 从背诵到排查连接管理的高频误区和一条排障路径5.1 先自检这些细节你背对了吗把几个常见误区和大家容易踩的细节整理成一张表方便自测项目错误理解正确理解确认号已收到的字节数期望收到的下一字节序号SYN 与 FIN不消耗序号各消耗 1 个序号所以 ACK 的确认号是序号 1三次握手双方都发 SYN 才能建立第二次是 SYNACK 合并初始序号固定 0 或 1随机生成且不可预测第四次挥手后主动方状态直接 CLOSED先进入 TIME_WAIT 等 2MSLTIME_WAIT 归属被动关闭方主动关闭方每一次线上排障或者说每一道面试深挖题基本都是从这张表里扯出来的。比如为什么抓包看到握手只有三个包——因为第二个包是 SYNACK 合并的但这不影响三次的语义。5.2 连接建立不起来从 SYN_SENT 到 SYN_RCVD 的排查路径我处理连不上问题时基本按这个链路走几乎覆盖了八成场景第一步客户端看状态。ss -tn state syn-sent能看到卡在 SYN_SENT 的连接。如果客户端自己一直不发 SYN那是应用层没调用 connect属于代码问题。第二步确定包有没有到服务器端。服务端tcpdump -i eth0 port 8080抓包看有没有持续来的 SYN。如果没有就是中间网络路径上被防火墙或负载均衡策略丢弃了。这里一个常见的坑是安全组规则只放行了目标端口却没放行回程流量导致 SYNACK 回不去客户端永远停在 SYN_SENT。第三步如果 SYN 到了但服务端没回 SYNACK检查半连接队列是否溢出。ss -ltn sport :8080观察 Recv-Q 大小表示全连接队列积压数配合ss -s看 SYN_RCVD 总量。队列打满时通常伴随客户端 connect 超时的日志服务端却无应用异常——因为问题在协议栈层应用还没机会感知。第四步确认服务端进程还活着、还在监听。ss -tlnp | grep 8080看 State 是不是 LISTEN。如果进程崩了端口消失客户端会收到 RST表现是 connection refused 而不是 hang 住。这个区分本身就能快速缩小排查范围。5.3 抓包和状态命令一套顺手的基础工具链最后给一套够用的基础工具链覆盖的是日常 90% 的连接管理排查工作。状态类命令习惯用ss而不是老的netstat# 查看某端口的监听状态和进程 ss -tlnp | grep 8080 # 查看所有 TCP 连接状态汇总 ss -s # 过滤出指定端口的已建立连接 ss -tn state established ( dport :8080 or sport :8080 )抓包类命令tcpdump是必须会的# 抓特定端口流量并保存为文件供 Wireshark 分析 tcpdump -i eth0 -s 0 -w /tmp/conn.cap -nn port 8080 # 只抓建立连接过程中的包 tcpdump -i eth0 -nn tcp[tcpflags] tcp-syn ! 0 and tcp[tcpflags] tcp-ack 0抓到包之后最值得练的复盘动作是找到完整连接的生命周期按时间顺序勾出 SYN、SYNACK、ACK、中间数据、FIN、ACK、FIN、ACK 这几个关键包再对照状态机推演一遍每端的当前状态。做上三五次你对这套机制的理解会超过死记硬背几十张图。我个人还有个习惯遇到想不明白的连接问题先不急着翻内核参数而是回到序号 确认号 标志位 状态这条主轴上推演。TCP 这一层没有魔法所有行为都写在报文里所有坑都有对应的字段和状态。把这个主轴攥熟了不管是考试、面试还是线上事故都不会再觉得无迹可寻。