TCP 三次握手这块内容,基本是每个做网络、做后端、做嵌入式开发的都背过的八股文。但说句实话,能把三次握手真正用来排查线上问题的人,十个里未必有两个。我印象很深的一次线上事故:业务方反馈服务端口时而通、时而不通,应用日志里满是连接超时,开发翻了一整天代码也没找到原因。后来我们同时在客户端和服务器两端抓包,发现三次握手压根没走完——客户端只发了 SYN,服务器一直没回 SYN-ACK,SYN 就按 1 秒、2 秒、4 秒、8 秒的节奏反复重传。那一刻我意识到,八股文背得再熟,不如会看一张真实抓包。
这篇内容会围绕 TCP 三次握手展开,把握手原理、协议栈现场、线上事故复盘和排查命令串成一条线。适合被线上连接问题折磨过的后端开发、运维、嵌入式工程师,也适合准备面试但想更进一步理解 TCP 的读者。
1. 三次握手不是背出来的,是看出来的
1.1 真实网络上的三次握手长什么样
三次握手的流程教材里写得明明白白:客户端先发 SYN(同步报文),服务器回 SYN+ACK(同步+确认),客户端再回 ACK(确认)。用 seq/ack 来精确描述就是:
- 客户端 -> 服务器:SYN,seq=x
- 服务器 -> 客户端:SYN+ACK,seq=y,ack=x+1
- 客户端 -> 服务器:ACK,seq=x+1,ack=y+1
为什么必须是三次而不是两次?简单理解:SYN 是“我在线,你能听到吗”,SYN+ACK 是“我能听到你,你也能听到我吗”,ACK 是“我能听到你,我们开始说话吧”。只有三次交互,双方才能各自确认自己的发送能力和对方的接收能力都正常。这个类比不完美,但足够解释基本问题。
线上看三次握手,最常用的手段就是 tcpdump。抓包命令可以是:
tcpdump -i eth0 -nn 'tcp port 8080' -w /tmp/handshake.pcap抓下来的关键报文大概是这样的:
12:00:01.123456 IP 10.0.0.1.52341 > 10.0.0.2.8080: Flags [S], seq 1000, win 64240 12:00:01.123789 IP 10.0.0.2.8080 > 10.0.0.1.52341: Flags [S.], seq 2000, ack 1001, win 64240 12:00:01.123812 IP 10.0.0.1.52341 > 10.0.0.2.8080: Flags [.], ack 2001, win 64240这里 Flags [S] 是 SYN,[S.] 是 SYN+ACK,[.] 是纯 ACK。看包的时候我会特别关注两个信息:一是握手有没有完成,二是握手花了多长时间。如果 SYN 到 SYN+ACK 的间隔远大于同机房正常延迟,说明中间有丢包重传,或者服务器的处理链路出了问题,这时候就已经可以判断问题在 TCP 层之下或之上,而不是应用代码。
这里要强调一个很容易被忽略的细节:三次握手里任何一步丢了,应用层的表现都是同一个——连接超时。但背后的原因是完全不同的:可能是防火墙悄悄丢弃 SYN,可能是服务器的半连接队列满了,也可能是服务器的 accept 逻辑卡住。只看应用日志永远定位不到,必须看抓包。“连接超时”这四个字的背后,至少对应着五种完全不同的排障路径。
1.2 四次挥手和序列号机制:细节都在报文里
经常被一起问的还有三次握手和四次挥手的区别。挥手之所以是四次,核心原因是 TCP 连接是双向的,每一方向的关闭都需要独立的 FIN+ACK。A 发 FIN 表示“我没有数据要发了”,B 回 ACK 表示“我收到了”,但 B 此时可能还有数据没发完,所以 B 要等数据发完后再发自己的 FIN,A 再回 ACK。这个“等数据发完”导致多出一个步骤,于是就成了四次。
三次握手和四次挥手在线上都是事故高发区,但侧重点完全不同。握手阶段出问题,连接根本建立不起来;挥手阶段出问题,往往是连接释放不干净,表现为大量 TIME_WAIT、端口耗尽,或者连接被 RST 强行断开。这里不展开太多,后面第四部分我会给一张问题速查表。
三次握手里 seq 和 ack 的关系,是整个 TCP 可靠性的地基。客户端第一次发的 seq=x,服务器回的 ack=x+1,表示“我已经准备好接收你下一个字节”,这里的 +1 是因为 SYN 占一个序列号。真实抓包里看到的 seq 不是从 0 开始,而是随机的初始序列号 ISN,这是为了防止伪造连接。理解这一点之后,再看 Wireshark 里的相对序列号显示,就不会被一串大数字吓到了。
TCP 的 ACK 机制也不是逐包确认,而是累积确认:ack 号表示“这个序号之前的字节我都收到了”。基于这个机制,才能理解为什么乱序时会出现重复 ACK,进而触发快重传。这里顺带说一个 Wireshark 里偶尔看到的提示:acked unseen segment,意思是 ACK 号大于本地已经抓到的数据范围。我第一次见也以为出了问题,后来发现多数情况是网络中存在乱序、中间设备有缓存,或者抓包点不在完整链路上,对端确实已经收到了数据。线上遇到这个标识别慌,结合重传统计和丢包曲线一起判断即可。
2. 三次握手背后,是整个协议栈的现场
2.1 半连接队列和全连接队列:握手最容易翻车的地方
三次握手能不能完成,不完全取决于网络通不通,很大程度取决于服务器内核里两个队列的状态:半连接队列(SYN 队列)和全连接队列(accept 队列)。
半连接队列存的是“收到 SYN 但还没完成握手”的连接。客户端 SYN 到达后,如果半连接队列满了,服务器直接丢弃这个 SYN,客户端就只能等超时重传。全连接队列存的是“三次握手已完成、等待应用调用 accept”的连接。注意,全连接队列满并不意味着客户端不能完成握手,而是握手完成后的连接会被按策略丢弃或重置,客户端表现为“握手看着成功了,但连接马上不可用”。
Linux 下我一般用 ss 来看这两个队列:
ss -lnt输出中 Recv-Q 和 Send-Q 两列的含义要分情况看。对于 LISTEN 状态的 socket,Recv-Q 表示当前全连接队列里排队的连接数量,Send-Q 表示全连接队列的最大长度。如果 Recv-Q 长期等于 Send-Q,说明队列已经打满。
内核参数角度,重点关心这几个:
- net.ipv4.tcp_max_syn_backlog:限制半连接队列的大小
- net.core.somaxconn:限制 accept 队列的最大长度
- 应用层 listen 函数的 backlog 参数,会被 somaxconn 封顶
- net.ipv4.tcp_syncookies:半连接队列满时,用 SYN cookies 缓解
真实场景里我遇到过最经典的一次:某 Java 服务高峰期偶尔连接超时,监控里 CPU 不高、内存正常,但 ss 显示某个端口 Recv-Q 一直顶到上限。定位下来是 accept 线程池被慢调用拖住,线程全部阻塞在外部接口上,来不及 accept 新连接。修复方向不是调大 somaxconn,而是要去处理慢调用本身。加队列只能缓冲,解决不了根因。这也解释了为什么只调内核参数经常治标不治本。
2.2 握手时协商的那些参数,才是吞吐量的关键
很多人以为三次握手就是打招呼,其实握手报文里还顺带协商了一堆影响传输性能的参数。其中最重要的三个:MSS(最大段大小)、窗口缩放因子(Window Scale)、SACK 和时间戳选项。
MSS 相当于“一节车厢能装多少货”。握手时双方会把自己的 MSS 告诉对方,通常 MTU 1500 减去 IP 头 20 字节和 TCP 头 20 字节,得到 1460。如果中间网络设备的 MTU 更小,而握手时又没协商出合适值,就可能出现“连接是通的,但传大包时卡住”的怪问题。热词里提到的 iptables 的 tcpmss 选项,就是用来钳制 SYN 报文里的 MSS 值,避免 MTU 黑洞:
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu窗口缩放因子则是影响吞吐上限的关键。TCP 头的 window 字段只有 16 位,最大 65535 字节,如果握手时不协商缩放因子,一条 TCP 连接在延迟较大的链路上吞吐会非常有限。握手时双方会在 SYN/SYN-ACK 里带上 Window Scale 选项,实际可用窗口等于窗口字段值乘以 2 的缩放因子次方。想要跑满高带宽长链路,这个参数必须正常协商。
流量控制层面的滑动窗口,是接收方通过 ACK 里的 window 字段告诉发送方“你最多还能发多少字节”。如果接收方应用不读数据,窗口会逐渐减小,甚至变成 0,这就是零窗口状态。线上表现为连接还在、底层网络也通,但数据就是发不过去。很多人这时候会怀疑网络故障,其实只是接收方应用卡住了。这也顺便回答了一个热词里的疑问:TCP 比 UDP 更“省流”吗?严格说 TCP 不是省流,而是发送行为受到流量控制和拥塞控制的约束,不会像 UDP 那样无脑灌数据。
2.3 慢启动与拥塞控制:握手完成后数据怎么跑
三次握手完成,连接建立,数据开始飞。但 TCP 不会一开始就全速发,而是用慢启动逐渐探测网络容量。初始拥塞窗口(cwnd)很小,每收到一轮 ACK 就翻一倍,直到达到慢启动阈值(ssthresh)或发生丢包。
拥塞控制的几个阶段和线上事故直接相关:
- 慢启动:连接刚建立时 cwnd 从初始值开始指数增长
- 拥塞避免:到达 ssthresh 后线性增长,避免莽撞
- 快重传:收到三个重复 ACK,立即重传疑似丢失的包,不等待超时
- 快恢复:快重传后把 ssthresh 降为当前 cwnd 的一半,再进入拥塞避免
线上最典型的现象是:网络有轻微丢包时,TCP 一旦触发重传和拥塞控制,cwnd 会锐减,吞吐可能从几百 Mbps 掉到几 Mbps。用户感知就是“网卡了”,但链路本身没断,ping 也通,只有看 TCP 统计才能发现问题。我遇过不少“应用无响应”的事件,最后结论都是底层交换机端口有少量丢包,TCP 拥塞控制把吞吐压垮了,应用层的超时时间又定得太短。
这里顺带提一个实用工具思路:排查拥塞问题时,除了抓包看重传,还可以用 ss -ti 看单个 socket 的 cwnd、ssthresh、rtt 等信息:
ss -ti 'dport = :8080'输出里的 cwnd 和 rtt 能直接反映这条连接的传输状态。这个命令比 tcpdump 门槛低很多,适合第一轮快速排查。数据从网卡到 socket 接收队列,中间还要经过硬中断、软中断、IP 层、TCP 层,如果系统层面出现软中断堆积,即使三层四层都正常,应用同样感知不到数据,这条链路在排查时也应该纳入视野。
3. 用三次握手视角复盘一次线上事故
3.1 先别急着看代码,把现象分好类
说一个我自己处理过的典型案例。某内部 API 服务,客户端不定时报连接超时,重试几次后能恢复,但持续了大半天。初步排查过程是这样的:先 ping 网关,通;再用 telnet 测试端口,时通时不通;应用 CPU 和内存都正常,日志里只有“连接超时”四个字。
第一反应不要是去查业务代码,而是先确认“通”和“不通”分别对应 TCP 层的哪个阶段。最简单的做法就是客户端抓包,看请求到达哪一步:
- 如果连第一发报文都没有,可能是客户端网络配置或 DNS 问题
- 如果 SYN 发了但收不到 SYN+ACK,问题在中间链路或服务器
- 如果三次握手完成但应用没响应,问题在服务端应用或全连接队列
我们那次抓包一看,客户端只发了 SYN,服务器一直没回。再接服务器端抓包,发现服务器网卡上根本没有收到 SYN。链路到服务器之前就断了。最终定位到负载均衡设备的后端健康检查策略把部分源 IP 的流量丢了,节点本身没动静,但设备悄悄把那些源 IP 拉进了黑名单。这个定位过程不算复杂,但如果不是用三次握手的视角去拆解,很容易在业务代码里绕一天。
这里分享一个我自己养成的习惯:任何连接异常,优先用“哪一步没走完”来归类,而不是直接用“网络不好”来定性。三次握手的三段报文,天然把排查范围切成了三段——客户端出发段、中间链路段、服务器处理段,每一段对应的证据完全不一样。顺带说一句,ping 通只能代表 ICMP 可达,它和 TCP 走的是不同协议栈路径,不能拿它证明四层连通。
3.2 全连接队列溢出的典型现场
另一种比较常见的事故是握手能完成,但连接马上被重置。抓包会看到客户端发完 ACK 后,服务器直接回 RST,或者服务器端的 SYN+ACK 反复重发。这种情况要重点怀疑全连接队列溢出。
之前处理过一个有意思的现场:业务团队反馈每隔几分钟服务就“卡死”一次,重启能好,但很快复发。我们做了两个动作,一是抓包,二是看 ss -lnt。抓包显示有大量握手完成后立刻 RST 的记录;ss 显示 Recv-Q 在流量高峰前就已经接近 Send-Q 上限。根本原因是应用框架里连接监听 backlog 设置得很大,但框架背后某个线程池被数据库慢查询拖死,accept 停止运转,队列一满,内核就开始丢连接。
调优方向通常有两个层面。第一层是内核参数:把 net.core.somaxconn 调大,以及确认应用 listen backlog 设置合理;第二层才是真正的根因——处理线程不能阻塞。一个值得记住的组合是:
sysctl -w net.core.somaxconn=1024 sysctl -w net.ipv4.tcp_max_syn_backlog=1024但请注意,这只解决“队列不够大”这一层,解决不了“应用不去 accept”这一层。把 somaxconn 调得再大,accept 慢的问题只会被推迟暴露,不会消失。我向来建议团队在监控里加一台“全连接队列占用率”的指标,一旦逼近 100%,就说明应用处理能力已经到瓶颈,而不是等到用户报障。
3.3 嵌入式场景:连接只能保持一分钟是怎么回事
这个是我在排查工业设备时经常碰到的一类问题:设备重启之后能连上一小会儿,大约一分钟左右连接就断掉;再次连接必须重启设备,或者等很久才能连上。很多人第一反应是三次握手有问题,但抓包一看,三次握手明明是成功的,问题出在建立连接之后。
这种情况十有八九是“连接建立后再无数据导致中间设备清理会话”。典型路径是这样:设备跟服务器之间有一个 NAT 网关或者工业防火墙,这类设备会对 TCP 会话做老化,通常一两分钟内没有数据就会把会话表记录删掉。客户端和服务端各自认为连接还活着,但中间设备的会话已经没了,后面数据一到就被丢弃,表现为连接“假死”。
解决办法通常有两个方向。一是开启 TCP keepalive,让内核自动往连接里注入探测报文:
sysctl -w net.ipv4.tcp_keepalive_time=30 sysctl -w net.ipv4.tcp_keepalive_intvl=10 sysctl -w net.ipv4.tcp_keepalive_probes=3二是应用层做心跳,比如 Modbus TCP 这类工业协议,尽量在应用层维持短间隔的读写请求,让连接一直有流量,避免被老化。所以排查这类问题时要牢记:三次握手成功,不等于连接链路长期可用;中间设备的会话老化、NAT 超时、对端半开,都会在握手完成之后把连接“暗杀”掉。
这也顺便解释了一个很常见的现象:Modbus TCP 设备能 ping 通,但用 Modscan 之类的工具连不上。ping 走的是 ICMP,和 TCP 完全是两条路径;ICMP 能通只说明三层可达,TCP 连不上还得继续排查端口、服务进程、防火墙规则以及从站配置。三层通、四层不通、应用不通,是三个完全不同的问题。别以为只有后端才搞 TCP,C#、Qt 里写 TCP 通信一样要面对这些状态问题,排查思路完全通用。
3.4 几种一眼就能定位的 TCP 相关报错
最后列几个我平时经常见到的和 TCP 直接相关的报错,属于看一眼就能缩小范围的那种:
- Docker 启动容器时报 ports are not available: exposing port TCP 0.0.0.0,通常是宿主机端口被占或端口范围不足,跟连接本身无关,但本质也是 TCP 端口资源分配失败。
- 日志里出现 tcp: sendmsg failed due to socket memory overlimit,说明发送缓冲区内存超限,多数是 tcp_wmem 配置偏小或者系统内存压力大,严重的确实可能导致 ssh 等依赖长连接的程序异常退出。
- Wireshark 里成片 TCP Retransmission 且间隔固定,基本可以断定路径上有丢包;如果重传伴随乱序,优先怀疑中间链路设备开启了某项加速策略。
这些报错本身并不代表 TCP 协议出了问题,但都发生在 TCP 这条链路上,处理思路也一样:先看是哪一层没走通,再看具体参数和策略,最后回看应用逻辑。
4. 线上排查 TCP 连接问题的命令和速查表
4.1 常用的命令组合
排查 TCP 连接问题,我自己的工具箱里基本就这几样。首先是 tcpdump,用于拿到最底层证据:
# 抓指定端口的所有 TCP 包 tcpdump -i eth0 -nn 'tcp port 8080' -s 0 -w /tmp/tcp.pcap # 只抓 SYN 相关 tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-syn != 0'其次是 ss,看当前连接和队列状态,这个比 netstat 快且信息更全:
# 查看监听端口的队列情况 ss -lnt # 查看到某端口的连接细节,包括 cwnd、rtt ss -ti 'dport = :8080'然后是系统统计,用 netstat -s 或 nstat 看 SYN 重传、丢包、RST 等累积计数:
netstat -s | grep -i -E 'retrans|reset|overflow'最后是确认内核参数。不要凭感觉调,先把当前值记下来:
sysctl net.ipv4.tcp_syn_retries net.ipv4.tcp_max_syn_backlog net.core.somaxconn \ net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes \ net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_abort_on_overflow排障顺序我一般固定为:先抓包定性(哪一段丢了),再看系统统计定量(丢了多少),最后用 ss 定位到具体连接。这个顺序能避免一上来就被海量信息淹没。
4.2 常见现象、原因与排查方向速查表
结合前面几部分的内容,我整理了一张速查表,覆盖绝大多数线上 TCP 故障场景:
| 现象 | 可能原因 | 优先排查方向 |
|---|---|---|
| SYN 一直重传,无 SYN+ACK | 中间链路丢包、防火墙丢 SYN、半连接队列满 | 两端同时抓包,看 SYN 是否到达服务器 |
| SYN+ACK 发出但客户端没继续 | 客户端丢弃、NAT 表没有映射 | 客户端抓包,检查本地防火墙 |
| 三次握手完成但请求无响应 | accept 队列满、应用线程阻塞 | ss -lnt 看 Recv-Q 是否顶满 |
| 连接建立后数据发不出去 | 接收方窗口为零、中间设备丢包 | ss -ti 看 cwnd,抓包看 window 字段 |
| 连接一段时间后自动断开 | keepalive 未开启、NAT 会话老化 | 开启 keepalive,应用层心跳 |
| 大量 TIME_WAIT 导致新连接失败 | 短连接太多、端口被占用 | 看 ip_local_port_range,考虑长连接 |
| 大量 TCP Retransmission | 网络丢包、对端处理慢 | 抓包统计重传间隔和乱序情况 |
| 端口 bind 失败 | 端口占用或端口范围不足 | lsof 查占用,检查启用端口范围 |
| 容器端口映射失败 | 端口已被占用 | 检查宿主机端口占用,调整映射端口 |
这张表算是我这几年处理线上问题的浓缩版,大部分场景都能在这里找到入口。每次排查的时候,先对着表确认最接近的一行,再做详细抓包,不会乱。
4.3 排障思路和个人教训
最后说几个我认为比较重要的排障理念。第一,永远先看证据再看日志。应用日志里的“连接超时”只是一个症状,不代表问题在应用;抓包能够直接把问题定位到网络层还是传输层,这一步不能省。第二,抓包要两端同时抓,只看一端很容易误判。比如客户端只发 SYN 没收到 SYN+ACK,如果服务器网卡上根本没收到 SYN,那问题就不在服务器;如果服务器收到了 SYN 但没回包,那才轮到服务器内核和 socket 层。第三,查参数之前先看默认值,改完参数之后一定要验证,别把“调大 somaxconn”当成万能药。
我踩过最大的坑是早年遇到一个莫名其妙的连接超时,排查了半天,后来发现是某个防火墙设备把每五分钟一次的健康检查报文误判成攻击,直接把服务器 IP 拉黑了。那时候要是能第一时间用三次握手的角度去定位,至少能省半天时间。从那以后,我处理所有连接问题都坚持一个原则:先定位是哪一段报文断的,再去追原因。
我个人现在带团队,不管是不是网络组的人,都要先会看懂 tcpdump 里的三次握手报文。TCP 三次握手是面试题,但它同时也是每一次 TCP 真实连接建立过程的“现场”。下次线上再遇到连接超时,别急着甩锅给网络,先抓包看一眼 SYN、SYN-ACK、ACK 到底走到哪一步。八股文能不能解决线上事故,就看你有没有能力把它翻译成现场证据。
另外再分享一个小习惯:排障结束后,我会把抓包文件和当时的 ss 输出存档,写上结论和解决措施。几个月后回头看,很多“新问题”其实都有旧影子。TCP 这套协议几十年没变,变的是我们对它的理解深度。