1. 为什么“三次握手,四次挥手”总被讲废
我面过不少人,也带过不少新人,发现一个特别有意思的现象:几乎每个人都能背出“三次握手,四次挥手”这八个字,但真正让他把状态迁移图默写出来、或者解释清楚“为什么不能是两次”“为什么关闭要四次”,十个人里有八个会卡壳。问题出在哪?出在大多数讲解一上来就甩术语——SYN、ACK、FIN、TIME_WAIT,像报菜名一样念一遍,念完你记住了名词,但没记住逻辑。
我这篇东西想干的事很简单:把这条链路上每一个动作背后的“不得已”讲清楚。你只要理解了“为什么必须这样”,就再也不用死记硬背了。因为协议不是谁拍脑袋定的,它是被现实问题逼出来的最优解。
先给一个全局画面。TCP 是面向连接的可靠传输协议,所谓“面向连接”,就是双方在传数据之前得先确认彼此都在线、都愿意聊、并且对“从哪个字节开始编号”达成一致。这个确认过程就是握手。而“可靠”意味着数据不能丢、不能乱序、不能重复,所以连接用完还得好好拆掉,不能一方说走就走,这就是挥手。
适合谁看?如果你是刚学网络的在校生,这篇能帮你把课本上那几张干巴巴的图讲活;如果你是工作几年但一直没深究过的开发,这篇能帮你把面试常问的“为什么”一次性补齐;如果你是运维或者做后端调优的,后面关于 TIME_WAIT 和半连接队列的部分,大概率能帮你解决过真实故障。
下面我按“先讲设计动机,再拆每一步,最后落到排查”的顺序来。全程用生活化的类比,但该严谨的地方一个参数都不含糊。
2. 握手之前:先把几个关键角色认清楚
2.1 序列号、确认号到底在干嘛
很多人背握手流程时,把 SEQ 和 ACK 当成两个无关的数字,这是最大的误区。你得先明白它们的分工。
序列号(Sequence Number,简称 SEQ)是“我发出的字节流里,这个包的第一个字节的编号”。注意,它编号的是字节,不是包。比如我发了 SEQ=1、长度 100 的包,那下一个包的 SEQ 就是 101。这个设计是为了应对“包可能乱序到达”和“包可能丢失重传”这两个现实问题——有了字节级编号,接收方才能把乱序的包重新拼回去,也才能发现中间缺了哪一段。
确认号(Acknowledgment Number,简称 ACK)是“我期望你下一个发过来的字节编号”。比如我回你 ACK=101,意思就是“1 到 100 我都收到了,你从 101 开始发”。这里有个关键点:ACK 是累积确认。你收到 ACK=101,就说明 101 之前的所有字节都安全到达了,不需要一个个单独确认。这个设计极大减少了确认包的数量。
提示:SEQ 和 ACK 都是 32 位无符号整数,范围 0 到 2^32-1。超过之后会回绕(wrap around),这也是为什么初始序列号不能随便选 0,后面会讲。
2.2 那些控制位:SYN、ACK、FIN 分别代表什么态度
TCP 头部里有一排标志位,握手挥手主要用到三个:
- SYN:同步位。置 1 表示“我想跟你建立连接,并且我要告诉你我的初始序列号”。
- ACK:确认位。置 1 表示“确认号字段有效”。注意,连接建立之后,所有包的 ACK 位基本都是 1。
- FIN:结束位。置 1 表示“我没有数据要发了,我想关闭我这边的发送通道”。
这里有个容易混的点:ACK 既是标志位,又是字段名。说“发一个 ACK”通常指 ACK 标志位置 1 的包;说“ACK 号是多少”指的是那个 32 位字段的值。上下文能区分,但初学时要留个心。
2.3 初始序列号为什么不能从 0 开始
这是个经典问题。假设每次连接都从 SEQ=0 开始,会出什么事?考虑这样一个场景:你上一个连接里有个包因为网络拥塞,在路上飘了很久,连接都关了它才到。新连接又从 0 开始编号,接收方一看 SEQ=0,以为是新连接的正常包,就把这个陈旧的数据当成新数据收下了。数据就串了。
所以 RFC 规定初始序列号(ISN)要随时间变化,通常用一个基于时钟的算法生成,保证在一个报文最大生存时间(MSL)内不会重复。这也是为什么抓包时你看到的 SEQ 都是些奇奇怪怪的大数字,不是从 0 或 1 开始的。
理解了这三个角色,握手流程就变成了一道逻辑题,而不是记忆题。
3. 三次握手:每一步都在解决一个具体问题
3.1 第一次握手:客户端说“我想连,我的编号从 X 开始”
客户端主动发起连接,发一个 SYN 置 1 的包,同时带上自己的初始序列号,比如 SEQ=x。此时客户端进入SYN_SENT状态。
这一步的潜台词是:“服务端你好,我想跟你建立连接,我这边发送的数据将从字节 x 开始编号,你收到请确认。”注意,此时客户端还不知道服务端是否在线、是否愿意连,所以它只能等。
这里有个细节:SYN 包是不携带应用层数据的,但它会消耗一个序列号。也就是说,虽然 SYN 包里没有数据,客户端下次真正发数据时,SEQ 会从 x+1 开始。这个“SYN 占一个序号”的规则很重要,后面讲挥手时会呼应。
3.2 第二次握手:服务端说“收到,我也想连,我的编号从 Y 开始”
服务端收到 SYN 后,如果愿意连接,就回一个包,同时把 SYN 和 ACK 都置 1。这个包里有两个关键信息:ACK=x+1,表示“你的 x 我收到了,我期待你下一个发 x+1”;同时带上自己的初始序列号 SEQ=y。
此时服务端进入SYN_RCVD状态。这一步的潜台词是:“我收到你的请求了,我也同意连,并且我这边发送的数据将从字节 y 开始编号,请你确认。”
为什么服务端要把 SYN 和 ACK 合在一个包里发,而不是分两个包?纯粹是为了效率。能合并的确认就合并,少一个包就少一次往返,这在早期网络带宽紧张的时候是实打实的优化。
3.3 第三次握手:客户端说“收到,开始吧”
客户端收到服务端的 SYN+ACK 后,再发一个 ACK 置 1 的包,ACK=y+1,表示“你的 y 我收到了”。此时客户端进入ESTABLISHED状态,服务端收到这个 ACK 后也进入ESTABLISHED。
到这里,双方都确认了三件事:对方在线、对方愿意连、双方的初始序列号都同步了。连接正式建立,可以传数据了。
3.4 为什么是三次而不是两次:一个必须讲透的问题
这是面试高频题,也是理解握手的核心。假设只有两次握手——客户端发 SYN,服务端回 SYN+ACK,然后服务端就认为连接建立了。会出什么问题?
考虑一个场景:客户端之前发过一个 SYN,因为网络延迟,这个包在路上堵了很久。客户端等不到响应,超时后重发了一个 SYN,这次顺利建立了连接,传完数据,正常关闭。结果那个堵了很久的老 SYN 这时候才到服务端。服务端一看,以为是新连接请求,立刻回 SYN+ACK 并进入 ESTABLISHED,准备接收数据。但客户端根本没想连,收到这个莫名其妙的 SYN+ACK 会直接忽略或回 RST。服务端就傻傻地占着一个连接资源,等客户端发数据,等到天荒地老。
三次握手就是为了堵这个洞。第三次握手的本质是:让客户端对“服务端的接收能力”也做一次确认。只有客户端回了 ACK,服务端才敢确认“这个连接是双方都认可的、新鲜的”。两次握手只能确认“客户端能发、服务端能收能发”,但确认不了“客户端此刻真的想连”。
用生活类比:你打电话给客服,客服说“您好,请问有什么可以帮您”,这是第二次握手。但客服怎么知道电话那头是真人而不是自动拨号器的录音?得等你开口说“我想咨询个事”,这才是第三次握手。没有这一步,客服可能对着一个空线路自说自话。
3.5 半连接队列与 SYN Flood:握手带来的现实问题
理解了握手,就能理解一个经典攻击面。服务端收到 SYN 后、收到最终 ACK 前,这个连接处于 SYN_RCVD 状态,会被放进一个叫半连接队列(也叫 SYN 队列)的地方。这个队列是有大小限制的。
如果有人伪造大量源 IP,疯狂发 SYN 却从不回最后的 ACK,服务端的半连接队列很快就会被塞满,正常用户的连接请求进不来,这就是 SYN Flood 攻击的基本原理。
对应的防御手段也顺理成章:SYN Cookie。服务端收到 SYN 时不立即分配资源,而是根据源 IP、端口等信息算一个加密的 cookie 作为序列号回过去。只有收到带正确 cookie 的 ACK 时,才真正分配连接资源。这样半连接队列就不需要存东西了,攻击自然失效。
注意:SYN Cookie 在极端情况下会牺牲一些 TCP 选项(比如窗口缩放)的协商能力,所以通常是“检测到攻击时才启用”,而不是一直开着。这是典型的“安全与功能权衡”。
4. 四次挥手:为什么关闭比建立多一次
4.1 挥手为什么不能像握手一样合并
握手能三次搞定,是因为服务端收到 SYN 后,可以立刻把“同意”和“我的编号”合在一个包里回。但关闭不一样。
TCP 连接是全双工的,意思是两个方向的数据通道是独立的。客户端不发了,不代表服务端也不发了。所以关闭必须分方向进行:一方说“我不发了”,另一方确认;然后另一方说“我也不发了”,这一方再确认。每个方向各需要一次 FIN 和一次 ACK,加起来就是四次。
这就是四次挥手的本质:两个独立的半关闭过程。理解了“全双工”和“半关闭”,四次挥手就不需要背了。
4.2 第一次挥手:主动方说“我没数据要发了”
假设客户端主动关闭。它发一个 FIN 置 1 的包,带上当前序列号 SEQ=u。此时客户端进入FIN_WAIT_1状态。
注意,FIN 和 SYN 一样,也会消耗一个序列号。虽然 FIN 包里可能没有数据,但它标志着字节流的结束,所以占一个编号。
这一步的潜台词是:“服务端,我这边没有数据要发了,但你可以继续发,我还能收。”这就是半关闭——客户端关闭了自己的发送通道,但接收通道还开着。
4.3 第二次挥手:服务端说“知道了,但我可能还有话说”
服务端收到 FIN 后,回一个 ACK=u+1,进入CLOSE_WAIT状态。此时客户端收到 ACK,进入FIN_WAIT_2状态。
关键点来了:服务端回完 ACK 后,并不会立刻发 FIN。为什么?因为它可能还有数据没发完。比如服务端正在给客户端传一个大文件,客户端说“我不发了”,但服务端文件才传了一半,它得继续传完才能关。所以第二次挥手和第三次挥手之间,可能隔着很长一段时间,服务端在这期间可以继续发数据。
这也是为什么关闭比建立多一次的根本原因:建立时双方都没有历史包袱,可以立刻表态;关闭时一方可能还有未完成的工作,不能立刻跟着关。
4.4 第三次挥手:服务端说“我也发完了”
等服务端把剩余数据都发完,它发一个 FIN 置 1 的包,SEQ=w,进入LAST_ACK状态。这一步的潜台词是:“我的数据也发完了,我也要关了。”
4.5 第四次挥手:客户端说“收到,你关吧”
客户端收到服务端的 FIN 后,回一个 ACK=w+1。此时客户端进入TIME_WAIT状态,服务端收到 ACK 后进入CLOSED,连接彻底关闭。
但客户端还没完,它要在 TIME_WAIT 状态等2MSL(报文最大生存时间的两倍)才真正关闭。这个等待是四次挥手里最容易被忽略、也最容易被面试官追问的点。
4.6 TIME_WAIT 为什么要等 2MSL
MSL 是报文在网络中能存活的最长时间。2MSL 就是“一去一回”的最长时间。客户端等这么久,是为了解决两个问题。
第一个问题:确保最后一个 ACK 能到达服务端。如果这个 ACK 丢了,服务端会超时重发 FIN。如果客户端已经关了,收到重发的 FIN 会回 RST,服务端就会认为关闭异常。客户端在 TIME_WAIT 期间还能响应重发的 FIN,重新发 ACK,保证服务端能正常关闭。
第二个问题:让本次连接的所有报文都从网络中消失。如果客户端立刻用相同的四元组(源 IP、源端口、目的 IP、目的端口)建立新连接,而旧连接还有报文在路上飘,新连接就可能收到旧报文,导致数据错乱。等 2MSL,旧报文基本都超时被丢弃了,新连接才安全。
用生活类比:你挂电话后不立刻把手机扔了,而是等一会儿,万一对方还有话没说完又打过来,你还能接。等确认对方真的不打了,你再把手机收起来。
提示:Linux 上 TIME_WAIT 的默认时长是 60 秒(2MSL),这个值可以通过内核参数调整,但一般不建议改小,除非你非常清楚自己在做什么。
5. 状态迁移全图:把碎片串成一条线
5.1 客户端和服务端的状态对照
光看文字容易乱,我把双方的状态迁移整理成一张表,你对着看会清晰很多。
| 阶段 | 客户端状态 | 服务端状态 | 触发动作 |
|---|---|---|---|
| 初始 | CLOSED | LISTEN | 服务端监听端口 |
| 第一次握手 | SYN_SENT | LISTEN | 客户端发 SYN |
| 第二次握手 | SYN_SENT | SYN_RCVD | 服务端回 SYN+ACK |
| 第三次握手 | ESTABLISHED | SYN_RCVD | 客户端回 ACK |
| 连接建立 | ESTABLISHED | ESTABLISHED | 服务端收到 ACK |
| 第一次挥手 | FIN_WAIT_1 | ESTABLISHED | 客户端发 FIN |
| 第二次挥手 | FIN_WAIT_2 | CLOSE_WAIT | 服务端回 ACK |
| 第三次挥手 | FIN_WAIT_2 | LAST_ACK | 服务端发 FIN |
| 第四次挥手 | TIME_WAIT | LAST_ACK | 客户端回 ACK |
| 关闭完成 | TIME_WAIT | CLOSED | 服务端收到 ACK |
| 最终关闭 | CLOSED | CLOSED | 客户端等完 2MSL |
这张表建议你亲手默写几遍。默写的时候不要背,而是想“这一步谁在等谁、为什么等”,想通了自然就写出来了。
5.2 几个容易搞混的状态
CLOSE_WAIT是被动关闭方收到 FIN 后、还没发自己 FIN 之前的状态。这个状态如果大量堆积,通常意味着你的程序收到了关闭请求,但没有及时调用 close() 去关闭连接。这是代码 bug,不是网络问题。我见过一个服务因为忘了在 finally 里关连接,CLOSE_WAIT 堆了几万个,最后文件描述符耗尽。
FIN_WAIT_2是主动关闭方收到对方 ACK 后、等对方 FIN 的状态。如果对方一直不发 FIN,这个连接就会一直挂着。Linux 有个tcp_fin_timeout参数控制这个状态的超时时间,默认 60 秒。
TIME_WAIT是主动关闭方最后的状态,等 2MSL。这个状态大量出现是正常的,尤其是高并发的短连接服务。但如果它占满了本地端口,就会导致新连接建不起来,这时候才需要优化。
5.3 同时打开和同时关闭:两个冷门但有意思的边界情况
标准流程之外,还有两个边界情况值得知道。
同时打开:双方几乎同时发 SYN。这时双方都进入 SYN_SENT,收到对方的 SYN 后都进入 SYN_RCVD,再各回一个 ACK,最终都进入 ESTABLISHED。这种情况下握手变成了四次,但实际中极少发生。
同时关闭:双方几乎同时发 FIN。双方都进入 FIN_WAIT_1,收到对方 FIN 后都进入 CLOSING,再收到对方 ACK 后都进入 TIME_WAIT。这种情况下挥手也是四次,但状态迁移路径和标准流程不同。
这两个情况面试偶尔会问,知道有这么回事就行,不用深究,因为现实中几乎遇不到。
6. 抓包实战:用真实数据验证每一步
6.1 抓包环境准备
光讲理论不够,我带你实际抓一次包,看看真实的握手挥手长什么样。工具用 tcpdump 或 Wireshark 都行,我这里用 tcpdump 演示,因为它命令行就能跑,适合在服务器上排查问题。
假设服务端监听 8080 端口,客户端去连它。在服务端执行:
tcpdump -i any -nn -S port 8080 -w handshake.pcap参数说明:-i any监听所有网卡,-nn不解析主机名和端口名(直接显示 IP 和数字端口,避免 DNS 干扰),-S显示绝对序列号(不显示相对值,方便看真实数字),-w写入文件。
然后客户端发起连接并传点数据再关闭。抓完后用 Wireshark 打开,或者直接用 tcpdump 读:
tcpdump -nn -S -r handshake.pcap6.2 握手包逐行解读
你会看到类似这样的输出(我做了简化,只保留关键字段):
1. IP 10.0.0.1.54321 > 10.0.0.2.8080: Flags [S], seq 1000, win 64240 2. IP 10.0.0.2.8080 > 10.0.0.1.54321: Flags [S.], seq 2000, ack 1001, win 65535 3. IP 10.0.0.1.54321 > 10.0.0.2.8080: Flags [.], ack 2001, win 64240第一行Flags [S]就是 SYN 包,seq 1000 是客户端初始序列号。第二行Flags [S.]里的点表示 ACK 置 1,所以这是 SYN+ACK,seq 2000 是服务端初始序列号,ack 1001 表示确认了客户端的 1000。第三行Flags [.]只有 ACK,ack 2001 确认了服务端的 2000。
注意 seq 的跳变:客户端第一个数据包的 seq 会是 1001,因为 SYN 占了 1000 这个号。这个细节抓包时看得最清楚。
6.3 挥手包逐行解读
关闭阶段的包类似这样:
4. IP 10.0.0.1.54321 > 10.0.0.2.8080: Flags [F.], seq 1001, ack 2001 5. IP 10.0.0.2.8080 > 10.0.0.1.54321: Flags [.], ack 1002 6. IP 10.0.0.2.8080 > 10.0.0.1.54321: Flags [F.], seq 2001, ack 1002 7. IP 10.0.0.1.54321 > 10.0.0.2.8080: Flags [.], ack 2002第四行Flags [F.]是客户端发的 FIN,seq 1001。第五行服务端回 ACK,ack 1002 确认了 FIN(FIN 占一个号)。第六行服务端发自己的 FIN,seq 2001。第七行客户端回 ACK,ack 2002。
如果你在抓包时看到第五行和第六行之间隔了很久,说明服务端在关闭前还有数据要发,这正是四次挥手不能合并的实证。
6.4 用 ss 命令看状态
抓包看的是包,看状态用ss更直接:
ss -tan state established ss -tan state time-wait ss -tan state close-wait-t是 TCP,-a是所有,-n是数字显示。这三个命令分别看已建立、TIME_WAIT、CLOSE_WAIT 的连接。排查问题时,先看哪个状态的数量异常,再顺着状态去定位原因。
提示:
ss比老的netstat快很多,因为它是从内核直接读的,不遍历 /proc。高并发服务器上排查连接问题,优先用 ss。
7. 常见问题与排查技巧实录
7.1 连接建不上:从握手角度定位
连接建不上,问题一定出在三次握手的某个环节。按下面的顺序排查,基本能覆盖九成情况。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 客户端一直 SYN_SENT | SYN 包没到服务端,或被防火墙拦 | 服务端抓包看有没有收到 SYN |
| 服务端有 SYN_RCVD 但无后续 | 客户端没收到 SYN+ACK,或 ACK 丢了 | 客户端抓包看有没有收到 SYN+ACK |
| 服务端 SYN_RCVD 大量堆积 | 半连接队列满,可能被 SYN Flood | ss -tan state syn-recv看数量,检查队列参数 |
| 连接被拒绝(RST) | 端口没监听,或防火墙拒绝 | 服务端ss -tlnp看端口是否在听 |
这里有个经验:抓包要两边同时抓。只抓一端,你只能看到“我发了”或“我没收到”,看不到“对方到底收没收到”。两边一对比,问题立刻定位。
7.2 TIME_WAIT 太多怎么办
TIME_WAIT 多是高并发短连接的正常现象,不用慌。但如果它导致本地端口耗尽,新连接建不起来,就得处理。几个方向:
- 启用 TIME_WAIT 复用:
net.ipv4.tcp_tw_reuse=1,允许把 TIME_WAIT 状态的连接用于新的出站连接。注意这只对客户端有效,且需要时间戳选项支持。 - 扩大本地端口范围:
net.ipv4.ip_local_port_range,默认大概是 32768 到 60999,可以适当扩大。 - 改用长连接:这是最根本的解法。短连接每次都要握手挥手,长连接建一次用很久,TIME_WAIT 自然就少了。HTTP/1.1 的 keep-alive 和 HTTP/2 的多路复用都是这个思路。
注意:
tcp_tw_recycle这个参数在新版内核里已经被移除了,因为它会导致 NAT 环境下的连接问题。网上很多老文章还在推荐它,别用。
7.3 CLOSE_WAIT 堆积:这是代码问题
CLOSE_WAIT 堆积和 TIME_WAIT 完全是两码事。TIME_WAIT 是主动关闭方的正常状态,CLOSE_WAIT 是被动关闭方收到 FIN 后、还没关连接的状态。如果它大量堆积,说明你的程序收到了对方的关闭请求,但没有调用 close()。
常见原因:连接池没正确释放、异常路径没走 finally、回调里忘了关。排查方法是看 CLOSE_WAIT 连接对应的进程,然后去代码里找哪里漏了关闭逻辑。这个只能改代码,调内核参数没用。
7.4 握手超时参数怎么调
Linux 上和握手相关的几个关键参数:
net.ipv4.tcp_syn_retries = 6 # 客户端 SYN 重试次数 net.ipv4.tcp_synack_retries = 5 # 服务端 SYN+ACK 重试次数 net.ipv4.tcp_max_syn_backlog = 1024 # 半连接队列最大长度 net.core.somaxconn = 128 # 全连接队列最大长度tcp_syn_retries=6意味着客户端发 SYN 后,如果没响应,会重试 6 次,总耗时大约 127 秒才放弃。这个值太大,客户端会等很久;太小,网络稍微抖动就建不上。一般保持默认即可。
somaxconn这个值很多系统默认偏小,高并发服务建议调到 1024 或更大。注意它要和应用层的 listen backlog 参数配合,取两者较小值。
7.5 我踩过的一个坑:NAT 环境下的握手异常
早些年遇到过一个诡异问题:内网机器通过 NAT 网关访问外部服务,偶尔连接建不上,但重试就好。抓包发现 SYN 发出去了,SYN+ACK 也回来了,但客户端没回最后的 ACK。
查了很久才定位到:NAT 网关在转换时,如果短时间内同一源端口有多个连接,可能会把不同连接的包映射错。加上当时开了tcp_tw_recycle,它依赖时间戳做判断,而 NAT 后面多台机器的时间戳不一致,导致网关把合法的包当成陈旧包丢了。
关掉tcp_tw_recycle后问题消失。这个坑告诉我:内核参数不是越多越好,尤其是和 NAT、时间戳相关的,在复杂网络环境下很容易出反效果。调参之前一定要理解它解决什么问题、在什么场景下会失效。
8. 把协议讲给别人听:一个自检方法
学完这些,怎么验证自己真的懂了?我的方法是:假装给一个完全不懂网络的人讲,看能不能不用术语讲明白。
比如讲三次握手,你可以这么说:“我要给你寄快递,先打电话问你‘方便收吗,我从第 1 号包裹开始寄’,你说‘方便,我从第 100 号包裹开始寄’,我说‘好,那我开始寄了’。这三句话少一句都不行,少了最后一句,你不知道我到底寄不寄。”
讲四次挥手:“寄完了,我说‘我没包裹寄了’,你说‘知道了,但我还有几个没寄完’,等你寄完你说‘我也寄完了’,我说‘好,那结束’。为什么不能像开头一样三句搞定?因为你说‘知道了’的时候,可能手上还有包裹没寄完,不能立刻跟着说‘我也寄完了’。”
如果你能这样讲出来,说明你是真理解了,而不是背下来的。协议设计的每一步都是被现实问题逼出来的,你抓住了“问题”,就抓住了“答案”。
最后分享一个我自己的习惯:每次遇到连接相关的故障,先不急着查代码,而是先ss看一眼状态分布,再tcpdump抓一小段包。状态告诉你“现在是什么情况”,抓包告诉你“怎么走到这一步的”。这两个信息一结合,大部分问题都能在十分钟内定位。协议这东西,背是背不会的,用几次、抓几次包,自然就刻在脑子里了。