
我一直觉得TCP 的三次握手和四次挥手是网络基本功里最值得反复咀嚼的一块。无论是做后端服务、嵌入式联网还是写上位机做设备通信都会跟 TCP 连接打交道。面试的时候会问线上排查问题会用到自己写 socket 程序更是躲不开。很多人能背出三次握手、四次挥手这句话但真正问到为什么非得是三次、为什么关闭要四次、握手失败会卡在哪里能讲清楚的人其实不多。这篇文章我想把这些核心逻辑摊开来讲清楚从协议本身的设计意图到抓包看到的实际现象再到真实项目里踩过的坑。适合刚入门 TCP/IP 的读者建立体系也适合有几年经验但一直在背结论的工程师对照自己的理解做一次补全。1. 三次握手连接建立的底层逻辑1.1 得先认识 TCP 报文里那几个关键字段聊握手之前必须先搞清楚 TCP 报文里那几个控制位和序号字段否则后面的流程全是空中楼阁。SeqSequence Number序列号本报文段第一个字节在整条数据流中的序号用来让接收方排序、去重、确认接收进度。AckAcknowledgment Number确认号表示期望收到对方的下一个字节的序号同时也意味着你发给我的这个序号之前的数据我都收到了。SYNSynchronize连接建立阶段使用的同步标志表示我想建立连接并以此作为我的初始序列号起点。FINFinish关闭连接时使用表示我的数据已经发送完了准备关闭这一侧的数据发送能力。ACKAcknowledgment确认标志表示这个报文段携带有效确认号。除了第一次 SYN 之外之后几乎所有报文都会携带 ACK。序列号和确认号是 TCP 可靠传输的基础连接建立阶段最重要的任务其实就是互相交换初始序列号ISN并让对方确认自己已经收到了这个初始序列号。三次握手说白了就是完成一次双方初始序列号同步 互相确认的过程。1.2 一次完整的三次握手到底发生了什么假设客户端 A 要和服务端 B 建立连接整个过程可以拆成三个动作客户端发送 SYNseq x表示我要建立连接我的初始序列号是 x。这个报文不携带应用数据但会消耗一个序列号。服务端收到后如果同意建立连接回复 SYN ACKseq yack x 1。这里有两个关键信息一方面告诉客户端你的 SYN 我收到了下次请从 x 1 开始发数据另一方面也在说我的初始序列号是 y轮到你确认我了。客户端收到 SYN ACK 后再回一个 ACKseq x 1ack y 1。服务端收到这个 ACK双方才正式进入 ESTABLISHED 状态。注意第三步。很多人以为服务端收到 SYN 发出 SYNACK 就算连接建立了其实服务端此时只是进入了 SYN-RECEIVED 状态必须等最后一个 ACK 到达才会正式标记 ESTABLISHED。客户端呢是在收到第二步的 SYNACK 后直接进入 ESTABLISHED因为对客户端来说自己发出的初始序列号已经被对方确认了对方的初始序列号自己也收到了双向确认已经完成。这里有一个让不少人困惑的点前两次握手其实已经把双方的序列号都发给对方了第三步看起来只是客户端确认服务端的 SYN为什么不能省略这里涉及一个核心问题TCP 是双向的可靠传输一个方向的连接有效性必须由对方主动确认才作数。客户端确认了服务端的 ISN服务端也必须收到客户端对 ISN 的确认否则服务端根本无法断定自己发出去的报文客户端是否收到了。如果第二步之后直接开始传数据这个 SYN 可能因为网络丢包而没有被客户端收到服务端却傻傻地以为链路已经通了。1.3 为什么是三次少一次不行多一次浪费如果只需要一次握手那只有客户端单方面知道自己的序列号服务端完全不知道客户端的存在数据传过去没人认领显然不行。两次握手可以吗我见过很多面试题问这个问题。表面上看客户端发 SYN服务端回 SYNACK双方似乎都知道了对方的 ISN。但仔细想一个场景客户端第一次发的 SYN 因为网络拥堵被延迟了客户端超时重传后重新建立连接数据传完、连接关闭。这个时候那个延迟的旧 SYN 突然到达服务端服务端以为是新连接请求于是回复 SYNACK甚至进入 SYN-RECEIVED 状态等待客户端发送数据。可客户端根本没有这个连接的上下文这个幽灵连接会一直占用服务端资源。有了第三次握手客户端收到这个荒谬的 SYNACK 时会发现这个连接并不在自己维护的集合里直接丢弃并通知服务端终止或者回复 RST 把它打断。所以第三次握手的一个重要价值是防止已失效的连接请求突然传到服务端造成资源浪费和错误状态。那四次、五次呢理论上是能建立连接但没必要。三次已经完成了双向 ISN 同步和双向确认多一次握手意味着额外一轮 RTT 延迟对连接建立这种高频操作来说是完全不划算的。协议设计讲究最小充分原则三次就是这个平衡点。2. 从抓包和系统参数看三次握手的真实样子2.1 用 tcpdump 看一眼真正的握手光看理论容易飘我习惯在任何讨论之前先抓一次包。假设客户端 IP 是 192.168.1.10服务端是 192.168.1.20端口 8080在服务端执行tcpdump -i eth0 -nn tcp port 8080建立连接时会看到类似下面的包1 192.168.1.10.50001 192.168.1.20.8080: Flags [S], seq 3289442600, win 64240 2 192.168.1.20.8080 192.168.1.10.50001: Flags [S.], seq 1944383990, ack 3289442601, win 65160 3 192.168.1.10.50001 192.168.1.20.8080: Flags [.], ack 1944383991, win 64240这里 Flags [S] 是 SYN[S.] 是 SYNACK[.] 是纯 ACK。注意第二条报文的 ack 是 3289442601正好是第一条 seq 加 1第三条的 ack 是 1944383991也正好是第二条 seq 加 1。这个对方序号 1的确认方式代表了握手阶段对 SYN 的确认逻辑。数据阶段之后确认号就不是简单的加 1 了而是根据实际收到的字节数累加。如果服务端程序没起来或者防火墙直接把 SYN 丢了客户端会一直重传 SYN。tcpdump 里会看到同一个 seq 反复出现间隔时间翻倍递增1 秒、2 秒、4 秒、8 秒……这是 TCP 的指数退避重传机制。我见过不少人排查连接超时时直接怀疑代码其实先抓包看有没有 SYN 重传就能判断问题出在网络黑白名单还是服务端进程非常高效。2.2 半连接队列和全连接队列决定了服务的承载上限三次握手在服务端内核里其实要过两道队列半连接队列SYN Queue和全连接队列Accept Queue。当服务端收到 SYN 并回复 SYNACK 后连接还没有完成第三次握手此时连接放在半连接队列里。等到最后一个 ACK 到达连接从半连接队列移到全连接队列等待应用进程调用 accept() 取走。如果应用层 accept 不够快全连接队列会堆积满了之后内核会丢弃新的握手完成包客户端那边表现为连接建立了但后续数据发不出去或者干脆握手阶段反复超时。Linux 下可以用 ss 命令看这两个队列的溢出情况ss -lnt输出里 Recv-Q 和 Send-Q 的数值Listen 状态下 Send-Q 是全连接队列的最大长度Recv-Q 是当前已占用数量。当 Recv-Q 长时间接近 Send-Q说明应用层 accept 不过来需要检查服务端线程模型或连接处理逻辑。半连接队列的溢出则不容易直接从 ss 看到一般靠 netstat -s 里的 SYNs to LISTEN sockets dropped 统计判断。这里有个我踩过的坑把 listen 的 backlog 调大解决不了全连接队列堆积问题。backlog 只是告诉内核全连接队列最多排多长真正决定消费速度的是应用层的并发模型。曾经有个服务压测时大量请求超时我一开始怀疑内核参数不够后来用 ss 一看Accept Queue 顶满了而服务端处理线程只有两个CPU 根本没跑满纯粹是消费能力不足。这种问题靠调参数是治标不治本。2.3 SYN Flood 攻击和常见内核参数调整三次握手最出名的一个安全隐患就是 SYN Flood。攻击者只发 SYN不回 ACK让服务端一直把连接放在半连接队列里队列一满正常用户的握手包就进不来服务直接不可用。应对办法有很多层。内核层面可以调整半连接队列上限开启 syn cookies 机制。syn cookies 的基本思路是不在半连接队列里保存连接状态而是把连接信息编码进 SYNACK 的序列表里客户端回 ACK 时再解码验证从根上避免队列被塞满。Linux 下可以用sysctl -w net.ipv4.tcp_syncookies1还有一组和握手相关的参数经常需要一起看net.ipv4.tcp_syn_retries # 客户端 SYN 重传次数 net.ipv4.tcp_synack_retries # 服务端 SYNACK 重传次数 net.ipv4.tcp_max_syn_backlog # 半连接队列上限不过说实话内核参数只有在理解了队列机制之后才有意义。单纯把 tcp_max_syn_backlog 调到非常大遇到真正的 SYN Flood 依然扛不住因为内存总会耗尽。高防上通常还有 SYN Proxy 这类方案把握手交互放到防护设备上完成再跟后端建立真实连接而不是让业务服务器直接面对攻击流量。3. 四次挥手拆掉一条 TCP 连接的讲究3.1 断开连接时双方在干什么三次握手是建立双向连接四次挥手则是把双向通道逐个拆掉。TCP 连接是双向的每一侧的发送能力都是独立的关闭时必须让双方各自的发送方向都得到确认所以至少需要四次交互。标准流程如下主动关闭方假设是客户端发送 FINseq m表示我的数据发完了我要关闭我这边的发送通道。被动关闭方回复 ACKack m 1表示收到你的 FIN我已经知道你不再发数据了。但从这一刻起被动关闭方仍然可以继续向客户端发送数据因为它的发送方向还没关闭。被动关闭方在也把自己的数据处理完后发送 FINseq n表示我的数据也发完了现在关闭我这边的发送通道。主动关闭方回复 ACKack n 1确认收到对方的 FIN关闭整个连接。这个流程里第 2 步和第 3 步之间通常有时间差因为被动关闭方可能还有剩余数据要发这也是为什么挥手比握手多一次的根本原因握手时双方没有存量数据序列号确认可以合并挥手时两个方向的关闭动作是独立的不能强求合并。3.2 为什么挥手的次数会比握手多一次握手为什么可以三次因为第二次握手时服务端的 SYN 和 ACK 可以放在同一个报文里一个是对客户端 SYN 的确认一个是服务端向客户端发起的同步请求。这两个动作在同一时刻发生合并开销为零。挥手则不同。被动关闭方收到 FIN 后第一反应是确认收到但它的数据可能还没发完不能立刻把 FIN 一起发出去。如果像握手那样合并等于强迫被动关闭方在收到对方 FIN 的同时也关闭自己这边的发送通道这显然不合理。只有等被动关闭方的应用层也决定关闭连接它才会发起自己的 FIN。所以 2、3 两步往往被拆开形成了四次交互。有一种半关闭half-close场景可以更清楚地看到这个机制客户端发送 FIN 后服务端保持连接不关闭继续发送数据。TCP 规范允许服务端在收到 FIN 后继续发送应用数据直到它自己也发送 FIN。所以四次挥手并不是对称的两个 FIN 来回而是两个方向上各完成一次请求关闭 确认关闭。3.3 TIME_WAIT 到底是好是坏四次挥手最后一步有个非常值得展开的内容主动关闭方发送最后一个 ACK 之后会进入 TIME_WAIT 状态默认等 2 MSLMaximum Segment Lifetime报文最长存活时间Linux 下通常配置为 60 秒左右总时长约 2 分钟。为什么要等这么久两个原因。第一最后一个 ACK 可能会丢失。如果被动关闭方没收到这个 ACK它会超时重发 FIN。主动关闭方只有待在 TIME_WAIT 状态才能重新回复 ACK。如果主动关闭方直接进入 CLOSED重发的 FIN 就没人理被动关闭方会一直卡在 LAST_ACK 状态连接资源释放不了。第二防止旧连接的网络报文串到新连接里。假设一个端口立即被复用旧连接里延迟到达的数据包可能被新连接误认为是自己的数据而 TCP 去重依赖的是序列号。TIME_WAIT 等待 2 MSL是为了确保旧连接在网络中的所有报文都已经消亡新连接不会受到历史报文干扰。但 TIME_WAIT 也会带来实际问题高并发短连接场景下大量连接由客户端主动关闭会导致客户端端口被 TIME_WAIT 占用最终出现 Address already in use。这个问题在后面的实战部分会细说。4. 连接管理在真实业务里的那些坑4.1 端口耗尽与Address already in use网络热词里有一条是java tcp客户端重连时报地址已在使用这几乎是每个写网络程序的人都会遇到的报错。原因并不复杂客户端主动关闭连接后本地端口进入 TIME_WAIT在 2 MSL 时间内该端口不能立即用于新连接如果此时新连接渴望复用同一个本地端口系统就会报地址已占用。解决办法有几个方向。第一客户端不要用自己的固定端口去连服务端而是让内核自动分配临时端口。很多人在 Java 或 C 代码里显式 bind 了一个本地端口这在高频重连时几乎必然踩雷。第二如果确实要复用端口可以在 socket 上设置 SO_REUSEADDR它允许 TIME_WAIT 状态下的端口被重新绑定。不过这里要小心SO_REUSEADDR 解决的是 bind 阶段的问题并不能完全绕开 TIME_WAIT 对连接建立的影响具体行为和操作系统实现相关。第三从根源上减少主动关闭方的 TIME_WAIT 数量办法是让不需要保持连接的一端主动关闭或者直接使用长连接。我遇到过一个 C# 上位机通过 Modbus TCP 频繁轮询设备的情况每次轮询都是新建连接、主动关闭跑一晚上之后端口耗尽报地址已在使用程序崩溃。把连接改成复用式长连接或者把主动关闭逻辑改成由设备端关闭问题立刻消失。这里也回应了热搜词里反复出现的C# Modbus TCP场景——Modbus TCP 本身协议很轻但连接管理跟普通 TCP 完全一样不能因为它看起来像直接发报文就忽略底层机制。4.2 调整内核参数解决 TIME_WAIT 是否靠谱网上大量教程说遇到 TIME_WAIT 过多就开启 tcp_tw_reuse 和 tcp_tw_recycle这个说法需要谨慎对待。tcp_tw_reuse 的作用是允许客户端在 TIME_WAIT 尚未结束时复用本地端口发起新连接前提是新的连接序列号必须大于旧连接的序列号避免冲突。它对主动关闭方的出站连接有效对服务端监听场景无效。tcp_tw_recycle 则曾经被用来快速回收 TIME_WAIT 连接但它对 NAT 环境有严重副作用不同设备通过同一个公网 IP 地址发起连接时间戳信息会出现错乱导致服务端丢弃合法 SYN 包。这个问题在旧版 Linux 内核里坑过很多人现在新版内核虽然默认关闭甚至移除了该选项但网络上残留的教程依然很多我建议直接忽略这个参数。更实用的做法是结合业务场景做选择。短连接为主且客户端主动关闭的场景优先考虑长连接改造或者上调临时端口范围sysctl -w net.ipv4.ip_local_port_range1024 65535服务端被动关闭场景下TIME_WAIT 基本出现在服务端身上此时可以配合 SO_LINGER 设置 RST 方式关闭连接绕过 TIME_WAIT但这会牺牲 TCP 的可靠关闭保障只适合确定数据已经完整交互完毕的场景不能作为通用方案。4.3 连接建立背后的端口、协议栈与高并发限制热词里有一条nginx作为反向代理tcp最大连接数背后其实是一整套端口和文件描述符的资源账户。每个 TCP 连接至少消耗一个文件描述符反向代理同时维持大量上游连接和下游连接文件描述符上限、线程模型、内存都要跟着调。最容易被忽略的是连接队列和 worker 进程数的匹配。Nginx 作为 TCP 反向代理时每个 worker 处理大量连接如果 worker_connections 配得很大但内核的 somaxconn 很小下游连接根本进不了 accept 队列表现为代理层频繁超时。常见的一组配合参数包括net.core.somaxconn65535 net.ipv4.tcp_max_syn_backlog65535worker_connections 和 somaxconn 要匹配不能只改一层。还有一个常见认知误区TCP 连接上限不是单纯由端口数量决定的。服务端的连接四元组是源 IP、源端口、目的 IP、目的端口服务端某个端口可以同时接纳大量客户端连接因为区分连接的是四元组不是监听端口本身。真正限制连接数的是内存、文件描述符和内核连接表项。很多面试题问一台服务器最多能支持多少 TCP 连接标准答案不是 65535而是看资源。理解了这一点再去看 Nginx 调优、反向代理连接数问题思路会清晰很多。4.4 长连接、短连接与心跳机制的取舍握手需要浪费一次 RTT挥手又要等 TIME_WAIT所以高频交互场景下短连接的开销远比表面看起来大。假设一次请求 RTT 是 1 毫秒握手挥手至少各占一次 RTT再加上数据交换短连接将近一半时间花在建立和断开上。而长连接只需要建立时握手一次后续交互直接传数据效率明显更高。长连接的代价是连接需要保活。中间防火墙、NAT 设备可能把长期空闲的连接回收掉应用层如果不做心跳下一次发数据时会突然发现连接已经失效各种奇怪的超时问题接踵而至。我在嵌入式设备联网的项目里见过太多这种情况设备端 TCP 连接建立后几天不说话网关把连接清了设备再次上报数据时按 TCP 协议栈的视角连接还是正常的但数据发过去石沉大海直到超时重传才意识到连接断了。解决方案一般有两层。底层是 TCP KeepAlive但它的默认参数通常比较长2 小时起步对业务场景往往不够用。上层是应用层心跳比如 Modbus TCP 场景里定期读写保持寄存器或者自定义心跳报文。我个人的习惯是凡是物联网设备接入必须在应用层做心跳检测TCP KeepAlive 只能作为辅助不能完全依赖。心跳间隔一般取网络可能允许的空闲时长的三分之一到二分之一既不会太频繁浪费流量又能在网关回收连接前发现异常。5. 站在协议栈之上看 TCP 连接管理5.1 连接状态机的理解价值三次握手和四次挥手其实只是 TCP 连接状态机里的两条路径。完整的状态机包括 LISTEN、SYN_SENT、SYN_RECEIVED、ESTABLISHED、FIN_WAIT_1、FIN_WAIT_2、CLOSE_WAIT、LAST_ACK、TIME_WAIT、CLOSED 这些状态。我在排查问题的时候第一反应永远是看连接处于什么状态因为状态直接告诉我它卡在哪一步。举个例子被动关闭方如果应用层一直不关闭 socket连接会停在 CLOSE_WAIT这是线上服务最常见的连接泄漏信号。用 netstat 查看netstat -ant | grep CLOSE_WAIT如果 CLOSE_WAIT 数量持续上涨几乎可以断定是应用代码没有正确关闭已经收到 FIN 的连接。反过来大量 FIN_WAIT_1 或 FIN_WAIT_2 堆积通常是对方没有及时回包或者网络有问题。所以说TCP 状态机不是面试考点而是日常排障的地图。5.2 面向开发者的几个务实建议写了这么长最后沉淀几条我自己的实操体会。第一写 TCP 客户端时永远不要随便 bind 固定端口让系统分配临时端口最安全。固定端口短期测试没问题一旦面对高频重连、并发连接TIME_WAIT 就会找上门。第二服务端 accept 循环必须跟业务处理解耦accept 出来的 socket 尽快交给独立线程或线程池处理不要让 accept 阻塞在处理逻辑上否则全连接队列很容易打满。第三抓包是所有 TCP 疑难杂症的终极调试手段抓包看到 SYN 重传、看到 FIN 没人回、看到 RST 突然出现问题的边界一下就清晰了比盲目改代码高效太多。TCP 的握手和挥手看上去只是几个报文来回但它牵扯出的是整个协议栈在可靠传输、资源管理、故障处理上的设计哲学。把这些逻辑真正理解透再去接触 UDP、QUIC、gRPC 这些传输层之上的技术会发现很多设计都是相通的。后面有机会我再专门聊聊 UDP 和 TCP 的区别以及 QUIC 的连接迁移为什么能解决很多 TCP 老毛病。