两台电脑要可靠地传输数据,前提是双方确认彼此在线、愿意建立连接,并且知道从哪个序号开始编号。TCP 三次握手就是为这件事准备的基础机制:客户端发送同步请求,服务端回复同步确认,客户端再确认一次,连接才算建立。这个机制几乎出现在所有网络通信、面试题、抓包分析里,也是许多后端新人最容易“看懂流程却说不清原因”的部分。这篇内容会把最小 TCP 服务端和客户端跑通,用 tcpdump 和 Wireshark 观察三次握手的数据包,再分析 connect 超时、端口占用、connection reset 等真实排错场景。读完后,你能独立解释为什么必须是三次,以及握手失败时该从哪一层开始查。
1. 为什么两台电脑建立连接必须先“三问三答”
1.1 TCP 连接要解决的三个基本问题
TCP 被称为“面向连接的、可靠的传输协议”,这里的“连接”并不是在物理链路上铺设一条专用线路。两台电脑之间建立连接,本质是在双方的内核协议栈中维护一组状态:对方的 IP、端口、自己的 IP、端口、初始序号、确认号、窗口大小、缓冲区等。要写出这组状态,通信双方必须交换一些最基础的信息。
归纳起来,三次握手要解决三个基本问题:
| 问题 | 对应握手动作 | 如果缺失会怎样 |
|---|---|---|
| 对端是否可达、是否在线 | 第一次 SYN 和第二次 SYN+ACK 的到达 | 客户端无法知道服务端是否真的存在 |
| 对端是否愿意接受连接 | 第二次 SYN+ACK 表示服务端同意 | 服务端可以立刻拒绝连接或返回 RST |
| 双方初始序号如何同步 | 三次报文中的 seq 和 ack | 后续数据无法排序、去重、实现可靠传输 |
很多资料把三次握手简化成“确认双方都有收发能力”,这个说法没有错,但不完整。真正要做的事情是同步初始序号。TCP 后续的可靠性,包括数据排序、去重、累计确认、滑动窗口,全部依赖一个共同前提:双方都知道对方的初始序号。
1.2 两次握手到底行不行
如果不做三次,只做两次:客户端发 SYN,服务端回 SYN+ACK,服务端就认为连接建立。问题是,服务端无法确认客户端是否真的收到了自己的 SYN+ACK。
一个经典的失效场景是:客户端第一次发送的 SYN 因为网络拥塞超时,客户端触发重传,第二次 SYN 成功到达服务端,连接建立并完成通信。此时第一次那个被延迟的旧 SYN 又漂到服务端。服务端收到旧 SYN 后,会认为这是一个新连接请求,开始分配资源并回复 SYN+ACK。对客户端来说,这个连接并不存在,于是可能不断重试或直接忽略,服务端却一直保留这条半空连接,造成资源浪费。
三次握手通过“最后的 ACK”让服务端知道:客户端确实收到了我的 SYN+ACK,而且客户端愿意基于这个序号继续通信。即使旧 SYN 延迟到达,客户端发现收到的 SYN+ACK 对应的是一个已经废弃的序号,可以发送 RST 主动取消这条连接,而不是让服务端一直等待。
从信息论的角度看,两次交互只确认了“服务端收到了客户端的请求”,无法确认“客户端收到了服务端的回复”。第三次握手就是对这个不确定性做最终确认。
1.3 三次握手的本质和作用
三次握手本质上是一次“初始化同步”:双方互相通告自己的初始序号,并在对方确认后进入 ESTABLISHED 状态。这个过程并不负责身份认证,也不负责应用层权限校验。它只能说明对端内核协议栈在线、端口可以接受连接、双方能够按 TCP 规则交换报文段。
很多新人容易把三次握手当成“鉴权流程”,这是误解。TCP 不验证发起方是不是你期望的客户端,也不验证数据内容。身份认证是应用层或 TLS 层的事。三次握手做完了,只能说明“链路可以用了”,至于对面是不是合法用户、能不能访问业务接口,需要上层协议继续验证。
理解这一点对排错很关键。如果应用层出现“连接建立了但请求失败”,问题往往不在三次握手,而在协议解析、鉴权、证书校验或业务逻辑。
2. 三次握手的报文字段和状态迁移
2.1 先看懂 TCP 报文段里这几个字段
要真正看懂三次握手,不能停留在箭头图上,需要知道报文字段里写的是什么。TCP 报文段里和握手直接相关的字段有这些:
| 字段 | 名称 | 握手中的作用 |
|---|---|---|
| 源端口 | Source Port | 客户端随机分配的一个大于 1024 的端口 |
| 目的端口 | Destination Port | 服务端监听端口,例如 80、443、6000 |
| seq | Sequence Number | 本端发送数据的初始序号,握手中就是 ISN |
| ack | Acknowledgment Number | 本端期望收到的下一个字节序号 |
| SYN | Synchronize Flag | 表示同步序列号,连接建立请求 |
| ACK | Acknowledgment Flag | 表示确认号有效 |
| Window | 窗口大小 | 告诉对方本端还能接收多少数据 |
seq 和 ack 是最容易混淆的两个值。seq 表示“我这次发送的报文段,第一个字节是全局数据流中的第几个字节”。ack 表示“我已经成功收到你的数据,现在期待你发送的下一个字节编号”。所以第三次握手里的ack=y+1,表达的就是“我已经收到了你的序号 y,请从 y+1 开始发送”。
初始序号 ISN 通常是随机数,不是固定从 0 开始。这样做是为了避免旧连接中的报文段被当作新连接的有效数据。如果每次都从 0 开始,前一个连接的迟到报文很容易污染后一个连接。
一个常用的文本交互过程可以这样表示:
Client Server |---------- SYN, seq=x ------------>| |<-------- SYN, ACK, seq=y, ack=x+1--| |---------- ACK, seq=x+1, ack=y+1 -->|2.2 每一步是“谁发给谁、带什么标记、为什么这么带”
第一次握手:客户端发送 SYN 报文,SYN=1,seq=x。含义是:我想建立连接,这是我的初始序号,你愿意吗。
第二次握手:服务端如果接受连接,会回复SYN=1,ACK=1,seq=y,ack=x+1。这里同时带 SYN 和 ACK,是为了在一条报文里完成两件事:一方面告诉客户端“我同意,我的初始序号是 y”,另一方面告诉客户端“我已经收到你的序号 x,接下来请从 x+1 发数据”。
第三次握手:客户端发送ACK=1,seq=x+1,ack=y+1。这次不需要带 SYN,因为 SYN 的作用在第一次已经完成了。客户端通过 ack=y+1 告诉服务端“我收到了你的初始序号 y,连接可以正常建立”。
| 次序 | 方向 | 控制位 | seq | ack | 含义 |
|---|---|---|---|---|---|
| 第一次 | 客户端 -> 服务端 | SYN | x | 无有效值 | 请求建立连接 |
| 第二次 | 服务端 -> 客户端 | SYN + ACK | y | x+1 | 同意连接,确认客户端序号 |
| 第三次 | 客户端 -> 服务端 | ACK | x+1 | y+1 | 确认服务端序号 |
第三次握手中的seq=x+1并不表示第三个报文只占一个字节,而是表示客户端已经消耗了序号 x 作为第一次握手的 SYN,后续数据从 x+1 开始编号。SYN 和 FIN 都会占用一个序号,这是很多面试题里容易忽略的细节。
2.3 连接建立过程的内核状态变化
三次握手不仅是报文交换,还伴随着两端 socket 状态的迁移。客户端从调用 connect 开始:
- 初始状态 CLOSED。
- 发送 SYN 后进入 SYN_SENT。
- 收到 SYN+ACK 并回复 ACK 后进入 ESTABLISHED。
- connect 函数返回成功,应用层可以开始读写。
服务端的状态变化分为两个层面:
- 调用 listen 后进入 LISTEN,等待连接。
- 收到 SYN 后进入 SYN_RCVD,回复 SYN+ACK。
- 收到第三次 ACK 后进入 ESTABLISHED。
- accept 函数返回一个新的已连接 socket,交给应用层处理。
用命令可以实时观察状态。服务端监听 6000 端口时执行:
netstat -an | grep 6000 ss -lntp | grep 6000如果连接建立很快,通常只会看到 LISTEN 和 ESTABLISHED。SYN_RCVD状态一闪而过,除非网络有问题或第三次 ACK 丢失。大量停留在 SYN_RCVD 的连接,往往是攻击、半连接队列满或上游设备丢包导致的。
3. 本地起一个最小服务端,用抓包验证三次握手
3.1 最小实验环境:一台电脑也能完整看到握手
学习三次握手不需要两台物理电脑。用 127.0.0.1 回环地址就能在本地看到完整的三个包。回环通信经过内核协议栈,不经过真实网卡,但 TCP 状态机、报文格式、握手流程和真实网络完全一致。
推荐在 Linux 上做这个实验,因为 tcpdump 安装简单,输出直接。需要准备:
- Python 3,用于写最小服务端和客户端。
- tcpdump,用于抓包。
- Wireshark,可选,用于可视化分析。
- nc 或 telnet,用于快速探测端口。
先检查工具是否存在:
python3 --version which tcpdump which nc如果是学习环境,不需要在生产服务器上操作。抓包需要 root 权限,因此 tcpdump 命令前面通常要加sudo。
3.2 Python 写一个最简单的 TCP Server 和 Client
服务端server.py的核心逻辑是:绑定地址、监听端口、接受连接、收一条数据、回一条数据。代码越简单越好,便于把注意力放在握手过程上。
import socket srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 6000)) srv.listen(5) print("listening on 127.0.0.1:6000") conn, addr = srv.accept() print("accepted from", addr) data = conn.recv(1024) print("recv:", data) conn.sendall(b"hello from server") conn.close() srv.close()客户端client.py的作用是主动连接服务端,并发送一条数据:
import socket cli = socket.create_connection(("127.0.0.1", 6000), timeout=5) cli.sendall(b"hello from client") data = cli.recv(1024) print("recv:", data) cli.close()socket.SOCK_STREAM表示使用 TCP。create_connection内部会依次完成 DNS 解析、connect、三次握手和超时控制。在应用层,你只需要等 connect 返回,三次握手已经由内核完成了。
运行方式:
python3 server.py再开一个终端:
python3 client.py如果一切正常,客户端会收到hello from server,服务端会打印收到的hello from client。这只是应用层验证,下面用抓包观察握手本身。
3.3 tcpdump 抓包结果怎么读
先启动服务端,再开启抓包,最后运行客户端。抓包命令:
sudo tcpdump -i lo port 6000 -nn -S-i lo指定回环网卡,-nn不做端口和 IP 反解,-S打印绝对序列号,方便对照三次握手中的 seq 和 ack。
运行客户端后,tcpdump 会输出类似下面的内容:
IP 127.0.0.1.45000 > 127.0.0.1.6000: Flags [S], seq 1201002000 IP 127.0.0.1.6000 > 127.0.0.1.45000: Flags [S.], seq 3200987000, ack 1201002001 IP 127.0.0.1.45000 > 127.0.0.1.6000: Flags [.], ack 3200987001这里三行就是完整的三次握手:
| 行 | Flags | 说明 |
|---|---|---|
| 第一行 | [S] | 客户端 SYN,seq 为客户端初始序号 |
| 第二行 | [S.] | 服务端 SYN+ACK,seq 为服务端初始序号,ack=客户端 seq+1 |
| 第三行 | [.] | 客户端 ACK,ack=服务端 seq+1 |
Flags 中的[S.]表示 SYN 和 ACK 同时置位,.表示 ACK 标志位有效但没有其他特殊控制位。如果你在第三行看到[P.],说明客户端发送数据时和 ACK 合并到了一个报文里,这同样是合法的。
抓包结果想保存下来分析,可以加-w:
sudo tcpdump -i lo port 6000 -nn -S -w handshake.pcap之后用 Wireshark 打开handshake.pcap,比在终端里看原始输出更直观。
3.4 Wireshark 里怎么看 [SYN] [SYN, ACK] [ACK]
Wireshark 打开抓包文件后,在过滤器栏输入:
tcp.port == 6000运行客户端,停止抓包,应能看到三行高亮的 TCP 报文。第一行 Info 列显示[SYN],第二行显示[SYN, ACK],第三行显示[ACK]。点击任意一行,展开中间的 Transmission Control Protocol 部分,可以逐项核对:
- Flags 下是否只有 SYN 或 ACK 位置位。
- Sequence Number 是否为对应报文的序号。
- Acknowledgment Number 是否为对方序号加一。
- Source Port 和 Destination Port 是否为预期地址。
学习时建议把 Wireshark 的 “Relative sequence numbers” 和 “Absolute sequence numbers” 切换着看。相对序号阅读方便,绝对序号能看出内核实际使用的 ISN。两者都能看,关键在于理解+1来自哪里。
注意:如果 Wireshark 开启了 TCP 快速分析,有时会把某些乱序或重传报文标成 TCP Retransmission。回环环境下一般不会出现,但如果出现,先确认抓包网卡是否选对,不要立刻怀疑 IP 报文真的丢了。
4. 握手异常排查:从 bind 报错、connect 超时到 connection reset
4.1 服务端无法监听:only one usage of each socket address
在开发本机启动服务时,一个常见报错是:
listen tcp 127.0.0.1:11434: bind: only one usage of each socket address这类报错在 Go 服务里很常见,Python、Java 等服务也会有类似提示。它发生在监听阶段,还没有进入三次握手,原因是目标 IP 和端口已经被其他进程占用了。
排查时先找到占用者:
netstat -ano | findstr 11434Linux 环境使用:
ss -lntp | grep 11434 lsof -i :11434处理方式有三种:
- 换一个未被占用的端口。
- 关闭占用该端口的进程,但先要确认这个进程是否可以停止。
- 在代码里设置
SO_REUSEADDR,允许 socket 复用处于 TIME_WAIT 状态的地址。
需要注意,如果端口被另一个正在正常服务的进程占用,单纯设置SO_REUSEADDR并不能解决问题,必须先释放端口。很多新人看到这个报错就重启服务,重启之后如果还是同样报错,大概率是另一个进程一直占着端口。
4.2 客户端 connect 超时:SYN 发出去了没人应答
连接超时是三次握手排错里最常见的问题。现象是客户端调用 connect 后长时间不返回,直到超时。Java 里常见ConnectException: Connection timed out,Python 里常见TimeoutError: timed out。
三次握手角度上,超时说明客户端发出的 SYN 没有等到 SYN+ACK,或者对端回复处理异常。可能原因包括:
| 可能原因 | 典型现象 | 排查方向 |
|---|---|---|
| 服务端未启动 | connect 快速失败或超时 | 在服务端查看监听状态 |
| 防火墙丢弃入站 SYN | 客户端看不到任何响应 | 检查安全组、iptables、云防火墙 |
| 跨网段路由不通 | 能 ping 通但不一定能连 | 检查路由、ACL、负载均衡状态 |
| 服务端半连接队列满 | 部分客户端能连,部分超时 | 查看 SYN_RCVD 数量、服务日志 |
先用 nc 快速测试端口:
nc -vz 192.168.1.10 6000再用 tcpdump 观察具体是哪个阶段断了:
sudo tcpdump -i any host 192.168.1.10 and port 6000 -nn抓包判断逻辑很直接:
- 只看到客户端发
[S],没有任何[S.]:SYN 丢失或服务端没有收到。 - 看到
[S]和[S.],但客户端没有回[.]:第三次握手没有完成,问题可能在客户端,也可能是中间设备干扰。 - 看到
[S]后直接收到[R]:服务端端口没有监听,或服务端主动拒绝。
客户端侧要设置合理的 connect 超时。默认等待时间可能过长,生产环境建议把连接超时控制在业务可接受范围,比如 2 到 5 秒,避免线程被长时间挂起。
4.3 curl: (35) TCP connection reset by peer 的常见成因
另一个高频报错是:
curl: (35) TCP connection reset by peer这个错误经常出现在 HTTPS 请求中,curl 35 和 TLS 握手阶段相关,但底层原因不一定是证书问题。抓包时如果看到 SYN 之后直接出现 RST,或者三次握手完成后客户端发出 TLS ClientHello 后立即收到 RST,都可以理解为“对端拒绝继续通信”。
常见原因有以下几类:
- 服务端端口没有监听,某些四层代理会返回 RST,而不是静默丢弃。
- 服务端能接 TCP 连接,但应用层协议不匹配。例如用 HTTPS 端口访问纯 HTTP 服务,或用 TLS 端口访问普通 TCP 协议。
- 反向代理或负载均衡在短时间内主动关闭连接。
- 服务端应用 accept 后立刻关闭,或设置了很短的握手超时。
排查时不要只看 curl 的提示,要做一次组合验证:
curl -v https://example.com/ --connect-timeout 5 sudo tcpdump -i any host example.com and port 443 -nn看抓包里 RST 出现的时机:
- 如果 SYN 后直接 RST,优先检查端口监听、防火墙、负载均衡规则。
- 如果 TCP 三次握手已完成,TLS ClientHello 后再 RST,优先检查应用层协议、SSL 配置、SNI、证书和代理策略。
同时要看服务端日志。TCP 层 RST 之后,应用日志通常会留下连接建立或读取失败的记录,能帮助快速定位是哪一层拒绝的。
4.4 一条完整的握手失败排查路径
遇到连接建立失败,推荐按这个顺序排查,避免在代码层面瞎试:
- 确认客户端访问的 IP、端口、协议是否写对。
- 确认服务端进程确实在监听目标端口。
- 确认从客户端到服务端的路由可达,先 ping 通不代表端口通,但 ping 不同基本不用继续。
- 用
nc -vz或telnet ip port从客户端做端口连通性测试。 - 在服务端或客户端抓包,观察 SYN、SYN+ACK、ACK 三个报文的出现位置。
- 查看系统防火墙、云安全组是否放行端口。
- 查看服务端半连接队列、全连接队列和应用日志。
这个排查路径适用于普通 TCP 服务,也适用于数据库连接失败、反向代理转发失败、容器端口不通等问题。核心思想是“分层定位”:先把问题收敛到网络层、传输层还是应用层,再处理具体原因。
4.5 Docker 端口映射也报地址占用时先查宿主端口
使用 Docker 部署服务时,可能会遇到类似报错:
ports are not available: exposing port tcp 0.0.0.0:8080: bind: address already in use原因通常不是容器内部端口被占,而是宿主机上的8080端口已经有一个进程在监听。Docker 需要把宿主端口和容器端口做映射,宿主端口无法绑定,容器自然起不来。
排查顺序:
sudo netstat -lntp | grep 8080 sudo lsof -i :8080然后选择释放端口或换一个宿主映射端口。比如把容器内部 8080 映射到宿主 18080:
docker run -p 18080:8080 your-image这里的关键认知是:Docker 的-p 宿主端口:容器端口左侧是宿主机端口,右侧是容器端口。报错发生在宿主机端口绑定阶段,和容器内服务的三次握手是否正常没有直接关系。
5. 连接建立后不意味着万事大吉
5.1 关闭连接为什么需要四次挥手
三次握手解决建立连接,关闭连接则需要四次挥手,因为 TCP 是全双工协议,两个方向的数据通道需要独立关闭。
正常关闭流程如下:
Client Server |---------- FIN ------------------>| |<---------- ACK ------------------| |<---------- FIN ------------------| |---------- ACK ------------------>|第一步,客户端发送 FIN,表示客户端不再发送数据。 第二步,服务端回复 ACK,表示收到关闭请求,但服务端可能还有数据要发。 第三步,服务端数据发完后,发送 FIN,表示服务端也不再发送数据。 第四步,客户端回复 ACK,连接彻底关闭。
和三次握手对比,四次挥手多出来的原因在于:服务端收到 FIN 后不能立刻保证自己已经没有数据要发,所以先回 ACK,再主动发送 FIN。如果服务端在收到 FIN 时确实没有数据要发,可以把 ACK 和 FIN 合并到同一个报文里发送,这时抓包看到的就是三次挥手。所以“四次挥手”是通用模型,具体报文数量取决于两端是否有能力合并控制位。
5.2 TIME_WAIT 和 Address already in use
主动关闭连接的一方,在发送最后一个 ACK 后会进入 TIME_WAIT 状态,持续一段时间。这个状态的目的是防止最后一个 ACK 丢失,也防止旧连接的迟到数据干扰新连接。
在高并发短连接场景下,主动关闭方会出现大量 TIME_WAIT 连接。这在客户端和服务端都是可能发生的。如果服务端频繁主动关闭客户端连接,服务端也会积累 TIME_WAIT。判断时不要只看状态数量,要看谁的连接先关闭。
TIME_WAIT 在某些平台上会导致地址无法立即复用。开发环境经常遇到的问题是:程序退出后马上重启,报Address already in use。解法之一是在创建 socket 时设置SO_REUSEADDR。生产环境调整内核参数前要谨慎评估,不要为了消除 TIME_WAIT 而盲目开启复用,可能引入旧连接串扰的问题。
推荐做法是优先优化连接生命周期:能复用连接就复用连接,避免每个请求都新建连接;需要短连接时,让连接池控制空闲连接,而不是靠内核参数掩盖问题。
5.3 不同语言、组态软件、PLC 和相机的 TCP 连接没有本质区别
很多读者会问“C# 里 TCP 连接数量是多少”“QT TCP 通信怎么写”“LabVIEW TCP 通信例程怎么调”“Modbus TCP 和 PLC 相机通讯怎么排查”。这些问题看起来技术栈不同,但底层都是同一个 TCP 三次握手。
无论是 Java、Python、C#、QT、LabVIEW,还是 Modbus TCP、PLC、工业相机,只要走 TCP 协议栈,操作系统都会在内核里完成三次握手。应用层只是设置 IP、端口、监听、连接、读写数据。不同的只是上层协议怎么解析数据、怎么处理粘包、怎么定义报文格式。
以 Modbus TCP 为例,它是在 TCP 之上定义了一套应用层请求响应报文。通信之前仍然要先建立 TCP 连接。如果连不上 PLC 或相机,先用nc -vz 192.168.1.100 502测试端口是否可达,再看应用层协议是否能正常收发。很多现场问题不是 Modbus 解析错误,而是 TCP 连接根本没建立起来。
| 应用层协议 | 正常使用的端口 | 底层是否走 TCP 三次握手 |
|---|---|---|
| HTTP/HTTPS | 80/443 | 是 |
| Modbus TCP | 502 | 是 |
| WebSocket | 任意监听端口 | 先经过 TCP 握手,再升级 |
| 自研上位机协议 | 自定义端口 | 是 |
| 普通 UDP 通信 | 自定义端口 | 否,无连接 |
5.4 生产环境连接管理建议
学习环境里可以不停起停连接,生产环境不能这么随便。服务端和客户端都要对连接生命周期做管理。
服务端建议:
- 监听 socket 设置
SO_REUSEADDR,避免重启时因 TIME_WAIT 导致 bind 失败。 - 根据业务并发量设置合理的 backlog,不要盲目调大。
- 对每一条已连接 socket 设置读写超时,避免个别异常连接长期占用资源。
- 监控 ESTABLISHED、SYN_RCVD、TIME_WAIT 数量,和系统文件描述符上限。
- 应用层增加心跳或空闲检测,主动清理死连接。
客户端建议:
- connect 必须设置超时时间,不要依赖系统默认等待。
- 读写也要设置超时,避免对端不再发送数据时线程被永久阻塞。
- 高频请求尽量使用连接池,减少频繁握手和挥手带来的时延与 TIME_WAIT。
- 连接被服务端关闭后,客户端要能感知到并重建连接。
生产环境比学习环境多出来的并不是 TCP 本身,而是日志、监控、权限、容量、回滚策略。
6. 三次握手常见误解和隐藏坑
6.1 第三次握手能不能带数据
第三次握手本质上是一个 ACK 报文,但 TCP 协议允许它携带应用数据。很多教程说第三次握手必须是一个空 ACK,这是不严格的。
客户端在调用 connect 返回后,如果马上调用 send,内核可能把数据和第三次 ACK 合并发送。抓包时第三次握手的 Flags 会变成[P.]或[P. ACK],而不是单独的[.]。这是因为 ACK 和数据可以共用一个报文段。
所以实际抓包时,如果看到第三次握手带数据,不要怀疑抓错了。只要报文的确认号是正确的,连接建立和数据发送可以同时发生。
6.2 抓包看到 TCP Retransmission 不一定是丢包
Wireshark 会通过序号和确认号来判断一个包是否重传,但这种判断并不总是准确。抓包点选择不对时,报文顺序可能呈现乱序,被 Wireshark 标记为 Retransmission 或 Dup ACK。
在三次握手阶段看到多个[S],则说明客户端没有及时收到[S.],内核在重传 SYN。这是正常退避机制,不是程序 bug。此时需要看为什么[S.]没有回来。
排查时不要一看到重传就认为网络丢包。要结合时间间隔、报文到达顺序、抓包位置一起判断。真正确认丢包,需要同时在两端抓包,对比同一方向的报文是否都在另一端出现。
6.3 “连接已建立”不等于“应用层可以立即读写成功”
ESTABLISHED 状态只说明内核层面的连接已经建立,但应用层可能仍然无法正常读写。常见原因包括:
- 服务端 accept 后没有及时收数据,客户端发送缓冲区被填满。
- 服务端 backlog 或 accept 循环处理不过来,全连接队列堆积。
- 对端应用没有调用 recv,导致窗口变成 0,发送方无法继续发送。
- 中间设备、负载均衡器在连接建立后就切断了 idle 连接。
排错时,如果 TCP 状态是 ESTABLISHED 但业务请求没有响应,要从应用日志、读写超时、负载均衡策略继续查,不能停留在 socket 状态上。
6.4 TCP 和 UDP 选型决策速查
三次握手带来了可靠性,也带来了额外开销。TCP 和 UDP 的取舍经常被问,简单速查如下:
| 维度 | TCP | UDP |
|---|---|---|
| 连接状态 | 面向连接,需要握手 | 无连接,直接发包 |
| 可靠性 | 可靠,有序,重复数据会去重 | 尽力而为,可能丢包乱序 |
| 流量控制和拥塞控制 | 有 | 没有 |
| 报文边界 | 字节流,需要处理粘包 | 报文有边界 |
| 时延 | 握手增加 RTT | 握手少,时延低 |
| 典型场景 | HTTP、数据库、文件传输 | DNS、音视频、游戏同步 |
选择时先看业务是否要求可靠、有序、可追踪。可靠优先选 TCP;低时延优先选 UDP,但要在应用层自己实现可靠性。QUIC 就是基于 UDP 实现了可靠传输和低时延连接建立,说明“可靠”和“UDP”并不必然互斥。
6.5 面试里最常追问的第三次 ACK 丢失
第三个 ACK 如果丢失,会出现一个不对称状态:客户端认为自己已经 ESTABLISHED,服务端仍然停留在 SYN_RCVD。
服务端在 SYN_RCVD 状态下,会因为长期没有收到 ACK 而重传 SYN+ACK。重传次数超过系统阈值后,服务端会放弃这条连接,并释放半连接资源。
如果客户端在第三个 ACK 丢失后马上发送数据,这个数据包中通常也包含正确的确认号,服务端收到合法数据包后可以进入 ESTABLISHED 状态。但如果客户端一直不发数据,最终连接会被服务端超时回收。
这个细节在面试里很有区分度,但工程里很少主动复现。了解它的意义是理解“连接建立”并不是一个瞬间动作,而是两个端点状态逐步同步的过程。
7. 验证清单和下一步学习路径
7.1 学习环境验证清单
学完三次握手后,不建议直接背流程图,建议用下面清单验证自己是否掌握:
| 验证项 | 操作方式 | 通过标准 |
|---|---|---|
| 理解报文变化 | 自己画出三次握手并标注 seq、ack | 能解释为什么第二次同时带 SYN+ACK |
| 掌握抓包 | 用 tcpdump 或 Wireshark 抓本地握手 | 能区分 [S]、[S.]、[.] 三行 |
| 最小实现 | 用 Python/Java/C# 写 TCP Server/Client | 能在本机建立连接并收数据 |
| 模拟端口占用 | 起两个进程监听同一端口 | 能复现 bind: address already in use |
| 模拟连接超时 | 客户端连接一个不存在的 IP | 能解释 connect 超时发生在哪一步 |
| 读懂状态 | 用 netstat/ss 查看连接状态 | 能区分 LISTEN、SYN_SENT、ESTABLISHED |
这套清单基本覆盖了三次握手相关的常见实验,不需要真实服务器,一台开发机就能完成。
7.2 生产环境检查清单
如果要排查生产环境的 TCP 连接问题,可以按以下清单检查:
- 目标端口是否在监听:
ss -lntp | grep port。 - 安全组、防火墙是否放行端口。
- 从客户端做端口连通性测试:
nc -vz ip port。 - 抓包确认 SYN 是否有响应。
- 检查连接状态数量:SYN_RCVD 是否堆积、ESTABLISHED 是否过高、TIME_WAIT 是否异常。
- 检查系统文件描述符限制和内核参数。
- 查看应用日志,确认连接是否通过了 accept,以及读写在哪个阶段失败。
生产环境不要一上来就改内核参数,也不要只看一个方向抓包。先确认现象发生在哪一层,再做对应处理。
7.3 学完三次握手后再补哪些内容
三次握手只是 TCP 的入口,后续还有大量内容值得继续学习:
- TCP 可靠传输:累计确认、乱序处理、超时重传、快速重传。
- 滑动窗口和拥塞控制:为什么 TCP 有时候速度上不去。
- 半连接队列和全连接队列:服务端高并发下的 accept 模型。
- TCP keepalive 和应用层心跳的区别。
- HTTP/1.1、HTTP/2、HTTP/3 的连接建立差异。
- QUIC 和 TLS 1.3 如何减少握手 RTT。
- 用 Wireshark 完整分析一次 HTTPS 连接建立过程。
网络排错能力和理论记忆之间只差一次实际抓包。建议今天就在开发机上把上面的 Python 示例跑一遍,用 Wireshark 找到那三行报文,再故意占用一个端口复现 bind 报错。等你能从报错现象反推出链路位置,才算真正理解三次握手。