1. 从一个抓包现场说起:为什么三次握手值得反复讲
很多人第一次接触TCP三次握手,都是在教科书或者面试八股文里。SYN、SYN-ACK、ACK三个词背得滚瓜烂熟,但真到了线上环境,遇到连接建立慢、半连接队列溢出、SYN Flood攻击告警,脑子里就一片空白。我见过不少工作两三年的后端同学,能默写出状态迁移图,却说不清楚为什么是三次而不是两次,也搞不明白netstat里那一堆SYN_RECV到底意味着什么。
这篇内容就是想把三次握手这件事从“背概念”拉到“能动手”的层面。我会先讲清楚它解决的核心问题,再拆解每个报文背后的设计考量,然后用一个可以本地复现的抓包案例,把整个过程完整跑一遍。中间会穿插大量实际运维和开发中踩过的坑,比如半连接队列怎么调、SYN Cookie什么时候开、为什么有时候抓包只看到两次。
适合谁看?如果你是刚入行的后端、运维或者测试同学,这篇能帮你把网络基础打扎实;如果你已经工作几年但一直没系统梳理过,这篇可以当作一次查漏补缺。所有案例都可以在你自己的机器上复现,不需要任何特殊环境。
2. 三次握手到底在解决什么问题
2.1 从“两个将军问题”说起
要理解三次握手,得先理解它面对的是一个什么困境。网络通信的本质是不可靠的:你发出去的报文可能丢失、可能延迟、可能重复。而TCP要做的,是在这样一条不可靠的信道上,建立起一条“可靠”的连接。
这里有个经典的类比叫“两个将军问题”。两支军队要协同进攻,只能靠信使传递消息,但信使可能被截获。将军A派信使告诉将军B“明天进攻”,将军B收到后回信“收到”,但这封回信也可能丢。将军A永远无法100%确认将军B知道了进攻时间,因为确认本身也需要被确认,无限递归下去。
TCP三次握手其实就是在这个困境里找了一个工程上的平衡点:它不追求100%的确认,而是通过三次交互,让双方都达到一个“足够确信”的状态。具体来说,三次握手要达成三个目标:确认双方的发送能力和接收能力都正常、同步双方的初始序列号、协商一些连接参数(比如MSS、窗口缩放因子)。
2.2 为什么两次不够,四次又多余
这是面试高频问题,但很多人答不到点子上。我用一个具体场景来说明。
假设只有两次握手:客户端发SYN,服务端回SYN-ACK,连接就建立了。问题出在“历史重复连接”上。客户端之前发过一个SYN,因为网络延迟,这个SYN在某个时刻才到达服务端。服务端以为是新连接请求,回SYN-ACK并直接建立连接。但客户端根本没想建这个连接,收到SYN-ACK后不会理睬。结果就是服务端单方面维持了一个无效连接,白白占用资源。
三次握手解决了这个问题。服务端回SYN-ACK后,必须等到客户端的ACK才算建立。对于那个延迟的旧SYN,客户端收到SYN-ACK后发现序列号对不上,会回一个RST,服务端就不会建立连接。这就是第三次握手的核心价值:让服务端确认客户端是“当前真的想连接”,而不是一个历史遗留的幽灵请求。
那为什么不是四次?因为三次已经足够让双方都确认对方的收发能力。具体看:
- 第一次(客户端发SYN):服务端确认了“客户端发送能力正常、服务端接收能力正常”
- 第二次(服务端回SYN-ACK):客户端确认了“服务端收发能力都正常、客户端收发能力都正常”
- 第三次(客户端回ACK):服务端确认了“客户端接收能力正常、服务端发送能力正常”
三次之后,双方都完成了双向确认。第四次没有任何新增信息,纯属浪费。
2.3 初始序列号ISN的玄机
三次握手还有一个容易被忽略的作用:同步初始序列号。TCP是字节流协议,每个字节都有序号。连接建立时,双方要告诉对方“我的起始序号是多少”。
为什么ISN不是从0开始?两个原因。第一,如果每次连接都从0开始,历史连接的延迟报文可能被误认为是当前连接的数据。第二,安全考虑,如果ISN可预测,攻击者可以伪造报文注入数据。所以RFC规定ISN要基于时钟产生,每4微秒加1,这样大约4.5小时回绕一次。现代实现更复杂,会加上一些随机偏移。
我实际抓包时经常看到ISN是一个很大的随机数,比如Seq=3847291056,这就是正常现象。如果你看到ISN总是从很小的数开始,那可能说明系统时钟有问题,或者用的是某种简化协议栈。
3. 报文细节与状态迁移全解析
3.1 三个报文的字段拆解
先看第一次握手,客户端发SYN报文。关键字段:
SYN=1:标志位,表示这是连接请求Seq=x:客户端随机生成的初始序列号Options:通常包含MSS(最大报文段长度)、窗口缩放因子、SACK permitted等
MSS这个选项特别重要。它告诉对方“我最多能接收多大的TCP payload”。以太网默认MTU是1500字节,减去IP头20字节和TCP头20字节,MSS就是1460。如果中间有PPPoE或者隧道,MTU会变小,MSS也要相应调整。我遇到过因为MSS没协商好导致大包被丢弃的案例,表现就是小请求正常,一传文件就卡死。
第二次握手,服务端回SYN-ACK:
SYN=1, ACK=1:两个标志位都置1Seq=y:服务端自己的初始序列号Ack=x+1:确认收到客户端的SYN,期望下一个字节是x+1Options:服务端也会带上自己的MSS等参数
注意Ack=x+1这个细节。为什么加1?因为SYN标志位本身消耗一个序列号。这是TCP的设计:SYN和FIN各占一个序号,纯ACK不占。所以后续数据传输的第一个字节序号是x+1。
第三次握手,客户端回ACK:
ACK=1Seq=x+1Ack=y+1
这个报文可以携带数据,但通常不携带。如果携带数据,服务端会在收到后直接处理。不过实践中很少这么做,因为会增加复杂度。
3.2 状态迁移:从CLOSED到ESTABLISHED
服务端和客户端的状态变化是面试必考,但死记硬背容易忘。我建议按“谁主动谁被动”来记。
客户端主动打开:
CLOSED→ 发SYN →SYN_SENT- 收到SYN-ACK → 发ACK →
ESTABLISHED
服务端被动打开:
CLOSED→ 创建监听socket →LISTEN- 收到SYN → 发SYN-ACK →
SYN_RCVD - 收到ACK →
ESTABLISHED
这里有个关键点:服务端在LISTEN状态时,收到SYN后会把连接放入“半连接队列”(SYN queue),进入SYN_RCVD。收到ACK后,再移入“全连接队列”(accept queue),状态变为ESTABLISHED。这两个队列的大小直接决定了服务端能承受多少并发连接建立请求。
我见过一个线上故障:某服务半连接队列设得太小,流量高峰时大量SYN被丢弃,客户端重试导致连接建立时间从几毫秒涨到几秒。后来把tcp_max_syn_backlog调大,同时开了tcp_syncookies才缓解。这个后面会详细讲。
3.3 超时与重传机制
三次握手不是一次就成功的,每个报文都可能丢。所以TCP为每个阶段都设了定时器。
客户端发SYN后进入SYN_SENT,如果没收到SYN-ACK,会重传。重传次数由tcp_syn_retries控制,默认是6次。每次超时时间翻倍,从1秒开始,总共大约127秒。这意味着如果服务端完全不可达,客户端要等两分多钟才报错。这个时间在实际开发中很关键,比如你写一个HTTP客户端,连接超时设得太短可能误判,设得太长用户体验差。
服务端发SYN-ACK后进入SYN_RCVD,如果没收到ACK,也会重传。重传次数由tcp_synack_retries控制,默认是5次。如果一直收不到,最终会重置这个半连接。
这里有个坑:如果客户端在发完ACK后立刻崩溃,服务端会认为连接已建立,但实际客户端已经没了。这种“半开连接”只能靠应用层心跳或者TCP keepalive来检测。keepalive默认是2小时,太长了,生产环境通常要调短。
4. 动手实操:本地抓包看三次握手
4.1 环境准备与工具选择
要复现三次握手,你需要一台Linux机器(虚拟机也行),安装tcpdump和curl。如果想看得更直观,可以装wireshark,但命令行用tcpdump就够了。
我选择用tcpdump是因为它几乎无处不在,生产环境也能用。wireshark适合事后分析,但抓包还是tcpdump方便。
先确认工具可用:
which tcpdump curl如果没有,用包管理器装一下。Ubuntu/Debian是apt install tcpdump curl,CentOS/RHEL是yum install tcpdump curl。
抓包需要root权限,或者给tcpdump加capability。我一般直接用sudo。
4.2 抓包命令与参数解读
开两个终端。第一个终端跑抓包:
sudo tcpdump -i lo -nn -S -vv 'tcp port 8080'参数解释:
-i lo:监听回环接口,因为我们本地测试不走物理网卡-nn:不解析主机名和端口名,直接显示IP和数字端口,避免DNS干扰-S:显示绝对序列号,而不是相对序列号。这个很关键,相对序列号看起来是0、1、2,不方便理解ISN-vv:更详细的输出,能看到TCP选项'tcp port 8080':过滤表达式,只看8080端口的TCP流量
第二个终端启动一个简单的HTTP服务。用Python自带的就行:
python3 -m http.server 8080然后第三个终端用curl发起请求:
curl -v http://127.0.0.1:8080/4.3 抓包结果逐行分析
抓包终端会输出类似这样的内容(我做了整理,实际输出是连续的):
IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [S], seq 3847291056, win 65495, options [mss 65495,sackOK,TS val 123456 ecr 0,nop,wscale 7], length 0 IP 127.0.0.1.8080 > 127.0.0.1.54321: Flags [S.], seq 2918374650, ack 3847291057, win 65483, options [mss 65495,sackOK,TS val 789012 ecr 123456,nop,wscale 7], length 0 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [.], ack 2918374651, win 512, options [nop,nop,TS val 123457 ecr 789012], length 0逐行拆解:
第一行,客户端发SYN。Flags [S]表示SYN置1。seq 3847291056是客户端ISN。win 65495是接收窗口。options里mss 65495是回环接口的MSS,因为回环MTU很大。sackOK表示支持选择性确认。wscale 7是窗口缩放因子,实际窗口要左移7位。
第二行,服务端回SYN-ACK。Flags [S.],点号表示ACK置1。seq 2918374650是服务端ISN。ack 3847291057,注意是客户端ISN加1。win 65483是服务端窗口。
第三行,客户端回ACK。Flags [.]只有ACK。ack 2918374651,服务端ISN加1。length 0表示没有数据。
到这里,三次握手完成。你可以看到curl随后开始发HTTP请求,那是第四次及以后的报文了。
4.4 用ss命令观察状态变化
抓包看的是报文,ss命令看的是内核里的socket状态。在curl执行前后分别跑:
ss -tan state syn-recv ss -tan state establishedsyn-recv是半连接状态,established是全连接。因为本地回环太快,你可能抓不到syn-recv,但可以写个脚本故意延迟ACK来观察。
更实用的场景是看队列溢出:
ss -lnt输出里的Send-Q对监听socket来说就是全连接队列的最大长度,Recv-Q是当前已建立但还没被accept的连接数。如果Recv-Q经常接近Send-Q,说明应用accept太慢,需要调大队列或者优化应用。
5. 生产环境常见问题与排查实录
5.1 半连接队列溢出:SYN Flood的典型表现
半连接队列溢出的症状是:客户端连接超时,服务端ss -s显示大量synrecv,dmesg里可能有SYN flooding的告警。
排查步骤:
- 看当前半连接数:
ss -tan state syn-recv | wc -l - 看队列上限:
sysctl net.ipv4.tcp_max_syn_backlog - 看是否开了SYN Cookie:
sysctl net.ipv4.tcp_syncookies
如果tcp_syncookies是0,建议改成1。SYN Cookie的原理是不再为每个SYN分配资源,而是把连接信息编码进SYN-ACK的序列号里。收到ACK后解码验证,合法才建立连接。这样半连接队列就不会被占满。
但SYN Cookie有代价:它不支持TCP选项的完整协商,比如窗口缩放因子会丢失。所以它适合作为攻击时的应急手段,不适合长期开启。我一般建议设成2,表示“仅在队列满时启用”。
5.2 全连接队列溢出:应用accept不及时
全连接队列溢出的表现是:客户端显示连接已建立,但发数据没响应,或者服务端直接不回SYN-ACK。
关键参数是net.core.somaxconn和listen()的backlog参数。Linux内核里,全连接队列大小取两者较小值。很多应用默认backlog是128,在高并发下根本不够。
排查方法:
ss -lnt看Recv-Q和Send-Q。如果Recv-Q持续等于Send-Q,说明队列满了。解决方案是调大somaxconn,同时改应用的listen backlog。
我踩过一个坑:某服务用Nginx做反向代理,后端是Java应用。Nginx的backlog设得很大,但Java应用的ServerSocket构造时backlog传了50。结果Nginx这边连接建立成功,转发到Java时被拒。后来把Java的backlog改成1024才解决。
5.3 抓包只看到两次握手:可能的原因
有时候抓包只看到SYN和SYN-ACK,没有第三次ACK。常见原因:
- 第三次ACK丢了,但服务端已经认为连接建立。客户端会重传ACK,但抓包点可能在丢包之后
- 抓包点位置不对,比如在客户端抓,ACK发出去但没经过抓包网卡
- 中间有防火墙或负载均衡,它代替客户端回了ACK
还有一种情况是TCP Fast Open。如果客户端和服务端都支持TFO,客户端可以在SYN里带数据,服务端在SYN-ACK里带数据,减少一轮交互。但TFO有安全风险,默认是关闭的。
排查时先确认抓包点,再看ss状态。如果服务端显示ESTABLISHED,说明握手其实完成了,只是你没抓到第三个包。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决方向 |
|---|---|---|---|
| 连接超时 | 半连接队列满 | ss -tan state syn-recv | 调大tcp_max_syn_backlog,开syncookies |
| 连接建立但无响应 | 全连接队列满 | ss -lnt看Recv-Q | 调大somaxconn和listen backlog |
| 抓包只有两次 | ACK丢失或抓包点问题 | ss -tan看状态 | 确认抓包位置,检查防火墙 |
| 大文件传输卡死 | MSS协商问题 | tcpdump看options | 调整MTU或MSS clamping |
| 连接建立慢 | SYN重传 | tcpdump看重传间隔 | 检查网络质量,调tcp_syn_retries |
6. 参数调优与内核配置建议
6.1 关键内核参数清单
以下参数建议根据业务特点调整,不要盲目照搬:
# 半连接队列上限 net.ipv4.tcp_max_syn_backlog = 8192 # SYN Cookie,2表示队列满时启用 net.ipv4.tcp_syncookies = 2 # 全连接队列上限 net.core.somaxconn = 32768 # SYN重传次数,默认6,内网可调小 net.ipv4.tcp_syn_retries = 3 # SYN-ACK重传次数,默认5 net.ipv4.tcp_synack_retries = 3 # TIME_WAIT相关,不是握手但常一起调 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_max_tw_buckets = 65536改完用sysctl -p生效。注意tcp_syncookies设成2比设成1更稳妥,避免正常流量也走Cookie路径。
6.2 应用层backlog怎么设
以Nginx为例,listen 80 backlog=4096;。Java的ServerSocket构造函数第二个参数就是backlog。Go的net.Listen底层用的是listen()系统调用,backlog由运行时决定,通常够用。
原则是:backlog要大于你的峰值QPS乘以平均连接建立时间。比如峰值10000 QPS,平均建立时间1毫秒,那backlog至少10。但实际要考虑突发流量,建议设成几千。
6.3 我的调优经验
我一般分三步走。第一步,先不改内核参数,用默认值压测,观察ss -s和netstat -s里的统计。第二步,找到瓶颈是半连接还是全连接,针对性调整。第三步,调整后再压测,确认没有引入新问题。
有个反直觉的点:tcp_max_syn_backlog不是越大越好。如果设得太大,SYN Flood时反而消耗更多内存。配合SYN Cookie使用才是正解。
另外,tcp_syn_retries调小可以加快失败感知,但会增加弱网环境下的失败率。内网服务可以调到2或3,公网服务建议保持默认或只调到4。
7. 从三次握手延伸出去的知识点
7.1 四次挥手为什么不对称
三次握手建立连接,四次挥手断开连接。为什么多一次?因为TCP是全双工的,每个方向要单独关闭。主动关闭方发FIN,被动方回ACK,此时主动方不再发数据但还能收。被动方处理完剩余数据后发FIN,主动方回ACK。中间多了一个“被动方可能还有数据要发”的阶段,所以是四次。
7.2 TIME_WAIT状态的意义
主动关闭方在发完最后一个ACK后进入TIME_WAIT,等待2MSL(报文最大生存时间的两倍)。目的是确保最后一个ACK能到达对方,以及让本次连接的延迟报文在网络中消散。没有TIME_WAIT,新连接可能收到旧连接的报文,造成数据错乱。
高并发短连接场景下,TIME_WAIT会大量堆积。解决方案是开tcp_tw_reuse,让内核复用TIME_WAIT状态的端口。但注意,这要求时间戳选项开启,且只对客户端有效。
7.3 握手过程中的安全考量
除了SYN Flood,还有TCP序列号预测攻击。攻击者猜测ISN,伪造报文注入数据。现代内核用复杂算法生成ISN,很难预测。另外,TFO虽然快,但允许在握手完成前发数据,有被滥用的风险,所以默认关闭。
我在实际工作中,如果遇到安全扫描报告说“TCP序列号可预测”,一般先确认内核版本,老版本确实有问题,升级后基本都能解决。
8. 一个完整的复现案例:模拟半连接队列溢出
8.1 实验设计
为了让你亲眼看到半连接队列溢出,我设计一个实验:把半连接队列调到极小,然后用工具发大量SYN但不回ACK,观察服务端行为。
需要hping3工具,没有的话用Python的scapy也行。这里用hping3演示:
sudo hping3 -S -p 8080 --flood 127.0.0.1-S发SYN,--flood不限速。这会瞬间填满半连接队列。
8.2 观察现象
先调小队列:
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=16 sudo sysctl -w net.ipv4.tcp_syncookies=0然后跑hping3,同时用ss -tan state syn-recv | wc -l观察。你会看到数字迅速涨到16左右就不再增加,新的SYN被丢弃。
此时用curl访问8080,会发现连接超时。dmesg里会有possible SYN flooding on port 8080的告警。
8.3 恢复与验证
停掉hping3,把参数改回去:
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192 sudo sysctl -w net.ipv4.tcp_syncookies=2再curl一次,连接正常。这个实验很直观地展示了半连接队列的作用和SYN Cookie的价值。
注意:这个实验只在本地回环做,不要在生产环境或公网机器上跑
hping3 --flood,可能触发安全告警或影响其他服务。
9. 我踩过的几个坑和实用技巧
第一个坑:以为netstat -s里的SYNs to LISTEN sockets dropped就是半连接队列溢出。其实这个计数包含多种丢弃原因,要结合ss -s里的synrecv一起看。
第二个坑:调大somaxconn后忘了改应用backlog。内核参数和应用参数取较小值,只改一个没用。
第三个坑:在容器环境里调内核参数。容器共享宿主机内核,sysctl改的是宿主机的值,可能影响其他容器。正确做法是在Pod的securityContext里设,或者用init容器调。
实用技巧:用tcpdump抓包时加-w保存到文件,再用wireshark分析。命令行看选项字段太费劲,图形化工具能直接展开TCP选项树。
另一个技巧:如果怀疑MSS问题,可以在tcpdump里加-v看options,或者用tracepath查路径MTU。ping -M do -s 1472可以测试特定大小的包是否被分片。
最后分享一个快速判断握手是否正常的命令组合:
ss -s && netstat -s | grep -i -E 'syn|listen|overflow'这两个输出结合起来,基本能定位80%的握手相关问题。