1. 为什么面试官总爱问三次握手和四次挥手
如果你面过网络相关的岗位,不管是后端开发、运维还是测试,大概率都被问过这个问题。很多人背得滚瓜烂熟,但真到了线上抓包排查问题的时候,却连一个SYN丢包都看不出来。我自己刚入行那会儿也是这样,能把流程图画得漂漂亮亮,但遇到连接建立慢、连接数暴涨这类问题就完全懵了。
这篇文章不打算给你背八股文,而是想从实际排查问题的角度,把三次握手和四次挥手这件事讲透。我会先讲清楚每个报文到底在干什么,再讲为什么必须是三次而不是两次、四次而不是三次,然后落到真实的抓包分析、参数调优和常见故障上。适合已经知道个大概、但想真正搞明白底层逻辑的开发者,也适合需要排查网络问题的运维同学。
核心关键词就几个:SYN、ACK、FIN、半连接队列、TIME_WAIT、抓包分析。这些词你肯定都见过,但它们在真实系统里怎么表现、怎么影响你的服务,才是真正值钱的部分。
2. 三次握手:每个报文到底在解决什么问题
2.1 从"两个人在对讲机里确认通话"说起
三次握手的本质,是通信双方在正式传数据之前,先确认三件事:我的发送能力正常、你的接收能力正常、你的发送能力也正常。这就像两个人用对讲机通话,A先喊一句"能听到吗",B回一句"能听到,你能听到我吗",A再回一句"能听到"。到这一步,双方才敢确认这条链路是双向通的。
对应到TCP协议里,这三个报文分别是:
- 第一次:客户端发送SYN=1, seq=x,意思是"我想跟你建立连接,我的初始序列号是x"
- 第二次:服务端回复SYN=1, ACK=1, seq=y, ack=x+1,意思是"我收到了,我也同意建立连接,我的初始序列号是y"
- 第三次:客户端回复ACK=1, seq=x+1, ack=y+1,意思是"我收到你的确认了,连接建立成功"
这里有个细节很多人忽略:序列号不是从0或1开始的,而是随机生成的。为什么?因为如果每次都用固定初始序列号,攻击者很容易伪造报文插入到连接里。随机化初始序列号(ISN)是TCP安全性的第一道防线,虽然现在有更复杂的攻击手段,但这个设计思路一直保留着。
2.2 为什么是三次,两次到底差在哪
这是面试必问的点,但很多人答不到点子上。常见的回答是"两次的话服务端不知道客户端有没有收到自己的SYN+ACK",这个说法对,但不够深。
真正的核心在于:两次握手无法防止历史重复连接请求造成的资源浪费。设想一个场景:客户端之前发过一个SYN,因为网络延迟卡在某个节点,客户端超时后重发了一个新的SYN并完成了通信,之后关闭了连接。这时候那个卡住的旧SYN突然到达服务端,服务端以为是新请求,回复SYN+ACK并直接进入ESTABLISHED状态,分配资源等待数据。但客户端根本不认这个连接,服务端的资源就白白浪费了。
三次握手能解决这个问题,因为客户端在第三步会检查这个ACK对应的连接是不是自己当前想要的。如果是历史连接,客户端会发RST拒绝,服务端就不会傻等。所以三次握手的第三次数,本质上是给客户端一个最终否决权。
提示:理解这一点之后,你就能明白为什么SYN Flood攻击有效——攻击者大量发送SYN但不完成第三次握手,服务端的半连接队列被占满,正常用户就进不来了。
2.3 半连接队列和全连接队列:连接建立的真实瓶颈
光看报文流程是不够的,真正影响服务性能的是内核里的两个队列。
半连接队列(SYN Queue):服务端收到SYN、回复SYN+ACK之后,连接处于SYN_RCVD状态,这时候连接被放进半连接队列。等收到客户端的ACK,才移到全连接队列。
全连接队列(Accept Queue):连接已经完成三次握手,处于ESTABLISHED状态,等待应用层调用accept()取走。如果应用层处理慢,这个队列会堆积。
这两个队列都有大小限制,满了之后新连接会被丢弃或拒绝。相关参数:
| 参数 | 作用 | 默认值参考 |
|---|---|---|
| net.ipv4.tcp_max_syn_backlog | 半连接队列最大长度 | 受内存影响,通常1024或更大 |
| net.core.somaxconn | 全连接队列最大长度 | 通常128或4096 |
| listen()的backlog参数 | 应用层指定的队列长度 | 取somaxconn和backlog的较小值 |
这里有个实战经验:很多人只调了somaxconn,忘了应用层listen的backlog。比如Nginx的listen指令后面可以跟backlog参数,如果不设,默认值可能很小。我见过一个服务QPS不高但偶尔大量连接超时,最后查出来就是全连接队列溢出,内核直接丢ACK,客户端只能重传。
查看队列溢出情况可以用:
# 查看因全连接队列满而丢弃的连接数 netstat -s | grep -i "listen" # 或 ss -lnt # 输出里的Send-Q就是全连接队列上限,Recv-Q是当前等待accept的数量如果Recv-Q经常接近Send-Q,说明应用层accept太慢,要么加worker,要么调大队列。
3. 四次挥手:为什么比握手多一次
3.1 挥手流程拆解与"半关闭"状态
四次挥手比三次握手多一次,根本原因是TCP连接是全双工的,两个方向要分别关闭。握手的时候,服务端可以把SYN和ACK合并成一个报文发出去;但挥手的时候,被动关闭方收到FIN后,可能还有数据没发完,不能立刻回FIN,只能先回一个ACK,等数据发完了再单独发FIN。
完整流程:
- 第一次:主动关闭方发送FIN=1, seq=u,表示"我没数据要发了,但还能收"
- 第二次:被动关闭方回复ACK=1, ack=u+1,表示"我知道你要关了"
- 第三次:被动关闭方数据发完后,发送FIN=1, seq=w, ack=u+1,表示"我也没数据要发了"
- 第四次:主动关闭方回复ACK=1, ack=w+1,表示"收到,你可以关了"
第二次和第三次之间,连接处于**半关闭(Half-Close)**状态。这个状态很关键,它允许被动关闭方继续发送剩余数据。比如HTTP场景里,服务端处理完请求返回响应,如果客户端先关了,服务端还能把响应发完。
3.2 TIME_WAIT:那个让人又爱又恨的状态
主动关闭方发完最后一个ACK后,不会立刻进入CLOSED,而是进入TIME_WAIT,等待2MSL(Maximum Segment Lifetime,报文最大生存时间)后才真正关闭。Linux上2MSL默认是60秒。
为什么要等?两个原因:
第一,保证最后一个ACK能到达对方。如果这个ACK丢了,被动关闭方会重传FIN,这时候主动关闭方还在TIME_WAIT状态,能重新回ACK。如果直接关了,对方重传的FIN就没人理,会一直重传到超时。
第二,让本次连接的所有报文在网络中消散。如果不等待,新连接可能复用相同的四元组(源IP、源端口、目的IP、目的端口),旧连接的延迟报文可能被新连接误收,造成数据错乱。
TIME_WAIT本身是正常状态,但问题在于主动关闭方通常是服务端(比如Nginx主动关闭客户端连接),高并发下会积累大量TIME_WAIT,占用端口资源。相关调优参数:
# 允许TIME_WAIT状态的socket被新连接复用(谨慎使用) net.ipv4.tcp_tw_reuse = 1 # 快速回收TIME_WAIT(NAT环境下可能导致问题,新内核已移除) net.ipv4.tcp_tw_recycle = 0 # TIME_WAIT最大数量,超过后直接关闭 net.ipv4.tcp_max_tw_buckets = 262144注意:tcp_tw_recycle在较新的内核里已经被移除,因为它和NAT设备配合时会导致连接失败。如果你的内核还有这个参数,建议保持关闭。
3.3 谁先关闭连接,决定了谁背TIME_WAIT的锅
这是一个非常实用的经验:TIME_WAIT出现在主动关闭方。所以如果你的服务是主动关闭连接的一方,TIME_WAIT就会堆在你这边。
在HTTP/1.1里,默认是keep-alive,连接复用,谁先关取决于配置。Nginx有个keepalive_timeout,超时后Nginx主动关闭连接,TIME_WAIT就在Nginx上。如果后端服务主动关,TIME_WAIT就在后端。
我遇到过一个案例:某服务QPS很高,但端口耗尽,查下来是服务端主动关闭连接导致大量TIME_WAIT。解决方案有两个方向:一是让客户端主动关闭(调整keepalive策略),二是开启tcp_tw_reuse让端口能复用。最终我们选择了调整连接复用策略,因为改内核参数有风险。
4. 抓包实战:用tcpdump看清握手和挥手的每个细节
4.1 抓包命令与过滤表达式
光看理论不够,真正理解要靠抓包。最常用的工具是tcpdump,配合Wireshark分析。
# 抓取指定端口的TCP包,-S显示绝对序列号,-nn不解析域名和端口名 tcpdump -i eth0 -S -nn 'tcp port 8080' -w handshake.pcap # 只抓SYN和FIN包,快速看连接建立和关闭 tcpdump -i eth0 -nn 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0' # 抓取特定主机的包 tcpdump -i eth0 -nn host 192.168.1.100 and tcp port 80抓完之后用Wireshark打开,过滤表达式很关键:
tcp.flags.syn == 1只看SYN包tcp.flags.fin == 1只看FIN包tcp.analysis.retransmission看重传tcp.stream eq 0看某一条完整的流
4.2 从抓包结果里读出"故事"
一次正常的三次握手在Wireshark里是这样的:
No. Time Source Destination Info 1 0.000000 10.0.0.1 10.0.0.2 [SYN] Seq=0 Win=64240 Len=0 MSS=1460 2 0.000123 10.0.0.2 10.0.0.1 [SYN, ACK] Seq=0 Ack=1 Win=65535 Len=0 MSS=1460 3 0.000200 10.0.0.1 10.0.0.2 [ACK] Seq=1 Ack=1 Win=64240 Len=0注意几个细节:MSS(最大报文段长度)在SYN包里协商,双方各报自己的MSS,取较小值。Win是接收窗口,告诉对方自己还能收多少数据。这些信息在排查性能问题时非常有用。
如果看到这样的序列:
1 [SYN] Seq=0 2 [SYN, ACK] Seq=0 Ack=1 3 [SYN] Seq=0 <- 客户端重传SYN 4 [SYN, ACK] Seq=0 Ack=1 5 [ACK] Seq=1 Ack=1说明第三次握手的ACK丢了或者延迟了,客户端以为服务端没收到,重传了SYN。这种情况如果频繁出现,可能是网络质量差或者中间设备丢包。
4.3 常见异常抓包特征对照表
| 现象 | 抓包特征 | 可能原因 |
|---|---|---|
| 连接建立慢 | SYN后很久才出现SYN+ACK | 服务端半连接队列满、网络延迟 |
| 连接被拒绝 | SYN后收到RST | 端口未监听、防火墙拒绝 |
| 大量重传 | 同一序列号的包重复出现 | 网络丢包、MTU不匹配 |
| TIME_WAIT过多 | 大量FIN后跟ACK,无后续 | 主动关闭方集中,端口复用不足 |
| 连接卡在半关闭 | FIN后长时间无第二个FIN | 被动关闭方应用层未关闭socket |
这张表是我自己排查问题时总结的,基本上看到抓包特征就能定位到大概方向。
5. 那些年我踩过的握手挥手相关的坑
5.1 半连接队列溢出导致的新连接超时
有一次线上服务突然大量超时,但CPU和内存都很正常。抓包发现客户端发了SYN,服务端有时候回SYN+ACK,有时候不回。查netstat -s发现:
SYNs to LISTEN sockets dropped这个计数在涨,说明半连接队列满了。原因是当时有个促销活动,瞬时并发连接数暴涨,而tcp_max_syn_backlog还是默认的1024。调大之后问题缓解。
但这里有个坑:调大backlog之后,还要确认内核的somaxconn也够大,因为半连接队列的实际大小受多个参数影响。另外,如果服务端开启了tcp_syncookies,在队列满的时候会用cookie机制处理,不直接丢弃,但cookie机制有性能开销,不能完全依赖它。
5.2 客户端端口耗尽与TIME_WAIT的关系
另一个坑是客户端端口耗尽。有一台机器作为客户端频繁调用下游服务,短连接模式,结果报"Cannot assign requested address"。查下来是TIME_WAIT状态的连接太多,把本地端口占满了。
Linux默认本地端口范围是net.ipv4.ip_local_port_range,通常是32768到60999,大概28000个端口。如果每秒建立几千个短连接,60秒的TIME_WAIT就能把端口耗尽。
解决方案有几个:改用长连接、开启tcp_tw_reuse、扩大端口范围。我们最终改成了连接池,因为长连接不仅解决端口问题,还省去了握手开销,一举两得。
5.3 服务端主动关闭引发的连锁反应
还有一个比较隐蔽的坑:服务端配置了较短的keepalive_timeout,导致服务端频繁主动关闭连接。这本身没问题,但关闭时如果客户端正好在发数据,就会收到RST,客户端可能报"Connection reset by peer"。
这种问题的排查思路是:先确认谁主动关闭(看抓包谁先发FIN),再检查keepalive配置是否合理。一般来说,让客户端主动关闭更安全,因为客户端能更好地处理关闭时机。但具体要看业务场景,没有绝对的对错。
6. 参数调优:让握手挥手不再成为瓶颈
6.1 服务端调优清单
针对高并发服务端,我通常会检查这几个参数:
# 半连接队列 net.ipv4.tcp_max_syn_backlog = 8192 # 全连接队列 net.core.somaxconn = 8192 # SYN+ACK重传次数,默认5次,可适当降低加快失败感知 net.ipv4.tcp_synack_retries = 2 # 开启syncookies,队列满时兜底 net.ipv4.tcp_syncookies = 1 # TIME_WAIT相关 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_max_tw_buckets = 262144 # FIN_WAIT_2超时,防止对方不关连接 net.ipv4.tcp_fin_timeout = 30这些参数不是越大越好,要根据实际连接数和内存来定。比如tcp_max_tw_buckets设太大,TIME_WAIT积累过多会占内存;设太小,超过后连接直接被关闭,可能影响正常通信。
6.2 应用层能做的事比内核更多
内核参数是兜底,真正解决问题往往要靠应用层。几个实用建议:
使用连接池。不管是数据库连接、HTTP调用还是Redis,连接池都能大幅减少握手挥手次数。这是性价比最高的优化。
合理设置keepalive。长连接不是越长越好,要根据业务特点设置。比如内部服务调用可以设长一点,面向用户的接口要考虑负载均衡器的超时时间。
监控队列指标。ss -lnt看Recv-Q和Send-Q,netstat -s看溢出计数,这些指标要纳入监控,提前发现问题。
考虑使用TCP Fast Open。TFO允许在SYN包里携带数据,减少一次往返。但需要客户端和服务端都支持,且中间设备不能干扰。实际部署中兼容性一般,要谨慎评估。
6.3 调优前后的验证方法
调完参数不能拍脑袋说好了,要有验证。我通常这样做:
- 用
ss -s看整体socket统计,对比调优前后 - 用
netstat -s看各计数器变化,特别是drop和overflow - 压测工具模拟高并发,观察连接建立成功率和延迟
- 抓包确认握手流程正常,没有异常重传
只有数据证明有效,调优才算完成。我见过太多人改完参数就不管了,结果问题换个形式又出现。
7. 几个容易被问倒的延伸问题
7.1 SYN Flood攻击的原理和防御
SYN Flood就是利用三次握手的机制:攻击者大量发送SYN但不完成第三次握手,服务端半连接队列被占满,正常用户无法建立连接。防御手段包括:
- SYN Cookies:不立即分配资源,而是把连接信息编码到序列号里,收到ACK再验证。开启
tcp_syncookies即可。 - 缩短SYN+ACK重传超时:让半连接更快释放。
- 限制单IP连接数:用iptables或应用层限流。
- 部署专业防护设备:在流量入口做清洗。
SYN Cookies虽然好用,但它会丢失一些TCP选项信息(比如窗口缩放),在极端情况下可能影响性能。所以它是兜底手段,不能替代正常的队列调优。
7.2 为什么有时候抓包看不到三次握手
如果你在服务端抓包,但看不到完整的SYN、SYN+ACK、ACK,可能有几个原因:
- 抓包点不对:比如在容器里抓,但流量走的是宿主机网卡。
- 连接复用了:长连接已经建立,后续请求不会再有握手。
- 中间有代理:代理可能自己完成了握手,和后端是另一条连接。
- 抓包过滤太严:只抓了特定端口或协议,漏掉了握手包。
排查这类问题,我一般先用tcpdump -i any抓所有接口,确认流量路径,再逐步缩小范围。
7.3 四次挥手能不能变成三次
理论上可以,如果被动关闭方收到FIN后没有数据要发,可以把ACK和FIN合并成一个包,变成三次挥手。但实际中很少见,因为大多数场景下被动关闭方总有一些收尾工作要做。
另外,TCP还有同时关闭的情况:双方同时发FIN,各自回ACK,流程和四次挥手类似,但状态转换不同。这种情况更少见,但理解它有助于你真正掌握TCP状态机。
8. 我个人的排查工具箱和习惯
最后分享几个我平时排查握手挥手问题的习惯,都是踩坑踩出来的。
第一,先看状态分布。ss -s和netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'能快速告诉你当前连接处于哪些状态。如果TIME_WAIT特别多,就往主动关闭方向查;如果SYN_RECV多,就往半连接队列查。
第二,抓包要抓全。不要只抓一个方向,-i any或者同时抓两端,才能看清完整交互。我吃过只抓服务端的亏,以为是客户端没发ACK,结果发现是服务端的SYN+ACK根本没出去。
第三,改参数前先记录基线。调优不是玄学,要有数据支撑。我习惯把调优前的ss -s、netstat -s、连接成功率、P99延迟都记下来,改完再对比。没有对比就没有发言权。
第四,理解业务比理解协议更重要。同样是TIME_WAIT多,短连接服务和高频交易系统的处理方式完全不同。协议是死的,业务是活的,脱离业务谈调优都是耍流氓。
这些经验不一定适用于所有场景,但至少能让你在遇到问题时有个清晰的排查方向,而不是对着满屏的报文发呆。TCP这东西,看再多不如抓一次包,抓一次包不如真正解决一次线上问题。希望这篇内容能帮你少走点弯路。