1. 从真实报错讲起:为什么要反复理解TCP三次握手和四次挥手
做后端开发这些年,我在工位上听得最多的抱怨不是哪段业务逻辑写错了,而是“连接怎么又断了”“端口怎么又起不来了”“为什么上游总是 Connection reset by peer”。你随手搜索,满屏都是 TCP 三次握手、四次挥手的面试答案,可真到了线上,问题往往比考试复杂得多。比如容器启动时提示error response from daemon: ports are not available: exposing port TCP 0.0.0.0:xxxx,比如本地起服务时直接报bind: only one usage of each socket address,再比如 curl 一个 HTTPS 接口突然给你来个curl: (35) TCP connection reset by peer。这些看起来八竿子打不着的现象,最后追根溯源,几乎都落到同一个基础知识点上:TCP 的连接到底是怎么建立的,又是怎么优雅释放的。
如果你只是背过“三次握手、四次挥手”的流程,但不知道它和真实故障怎么挂钩,那这篇文章值得认真看一遍。我会从报文细节、连接状态、半关闭模型、TIME_WAIT 机制、双方队列这几个角度把连接管理讲透,再用实际排查经验把它和端口占用、连接重置、握手超时这类现象串起来。内容侧重 Linux 环境,但里面的原理在 Windows、嵌入式 TCP 协议栈上同样成立。适合刚接触网络协议的后端工程师、运维、嵌入式开发,也适合那些已经在用 Socket 编程,却一直没太明白抓包里 Seq、Ack 为什么这样跳的人。
1.1 八股文的终点,其实是排障的起点
三次握手是客户端和服务端确认“你能听我说、我能听你说,并且咱们的序号对得上”的过程。四次挥手则是双向数据通道分别关闭的过程。但实际开发中,绝大多数人不会真的手写 Socket 去摸这些细节,直到某个高并发服务出问题,你在ss -ant里看到几千个 CLOSE_WAIT 或 TIME_WAIT 才意识到,原来连接状态不是考试专用题目,而是服务能不能撑住线上压力的关键。
我在排查连接类故障时,第一步永远是看状态统计,不是看应用日志。因为 TCP 状态机非常诚实:建连建到一半卡住,会停留在 SYN_SENT 或 SYN_RCVD;对端发了 FIN 但本地程序没有关闭 socket,会停留在 CLOSE_WAIT;主动关闭后等待旧报文消散,会停在 TIME_WAIT。只要你能读懂这些状态是怎么迁移的,很多问题就已经定位了一半。所以这篇文章表面讲的是协议流程,实际是在帮你建一张“报错现象到 TCP 状态机”的映射表。
1.2 你需要记住的最小TCP连接模型
要进入三次握手和四次挥手的细节,先要在脑子里建立三个基础模型。第一,TCP 是面向连接的、全双工的字节流协议。全双工意味着数据可以同时在两个方向上传送,不是一个人说完另一个人才能说,所以关闭连接时需要两个方向分别处理。第二,TCP 用序列号来管理字节流顺序和可靠性,建立连接时必须让双方知道对方初始序号,所以握手过程必然涉及 ISN 的同步。第三,一个连接由四元组唯一标识:源 IP、源端口、目的 IP、目的端口。任何一端想要复用某个地址端口,都必须考虑另一个正在使用该四元组的连接是否已经彻底消失。
可以用一个生活化类比帮助记忆:TCP 连接像两个人用对讲机通话,而且是双工对讲机。三次握手相当于“你听到我了吗?我听到你了,你能听到我吗?可以,开始说话”。四次挥手相当于一个人先说“我说完了”,另一个人回答“收到,我还有话要讲”,等另一个人讲完,再说“我也说完了”,第一个人必须回一句“收到”。如果省掉最后一次确认,先说完的人不知道对方有没有听到自己的“收到”,可能还会再问一次,造成混乱。这个模型足够解释后面大部分问题。
2. 三次握手绝不仅仅是一个流程:报文、状态与队列
2.1 三次握手的过程拆解
标准的三次握手是这样的:客户端先发送一个 SYN 报文,里面带有自己的初始序列号,记作seq=x;服务端收到后回复 SYN+ACK 报文,里面带有服务端自己的初始序列号seq=y,同时确认收到客户端的 SYN,所以ack=x+1,表示“我期望你下一个字节的序号是 x+1”;客户端再发送一个 ACK 报文,seq=x+1,ack=y+1,表示“我收到你的 ISN 了”。这个 ACK 发出后,客户端进入 ESTABLISHED,服务端收到后才能进 ESTABLISHED。
容易搞混的一个细节是:SYN 报文会消耗一个序列号。因为 SYN 本身代表一个虚拟字节,所以服务端发送的 ACK 是 x+1,而不是 x。第四个普通 ACK(第三个报文)不消耗序列号,所以后续数据报文的 seq 仍然从 x+1 开始,除非客户端继续发了数据。
从这个流程能看到两个隐蔽点。第一,服务端在收到第一个 SYN 后,进入的是 SYN_RCVD 状态,此时连接还没真正建立,但服务端已经需要为这个半连接分配资源。第二,客户端在收到 SYN+ACK 后,连接立刻处于可用的 ESTABLISHED 状态,它不会等什么确认,因为从逻辑上讲,服务端既然能回 SYN+ACK,说明服务端的发送和接收路径都通。如果第三个 ACK 丢了,服务端会一直停在 SYN_RCVD,直到超时重传 SYN+ACK。这个现象在弱网环境下很常见,后面会展开。
2.2 为什么是三次而不是两次、四次
很多人会问:为什么不是两次握手?因为两次握手只能保证服务端确认了客户端的发送能力,却无法让服务端确认客户端的接收能力,也不能阻止历史失效连接请求突然到达服务端。
举个典型场景:客户端发起一个连接请求,由于网络拥堵,这个 SYN 报文在路由器里滞留了很久,客户端等不到响应,于是超时重传,第二个 SYN 到达服务端,连接正常建立并关闭。可就在这时,第一个旧的 SYN 才慢悠悠到达服务端。如果只有两次握手,服务端收到旧 SYN 后会立刻分配资源、建立连接、等待客户端发数据,而客户端心里根本没有这个连接,于是服务端白白占着资源空等,还可能把数据发给一个已经不存在的连接对象。三次握手就不一样了:客户端收到服务端对旧 SYN 的 SYN+ACK 后,会判断这个 ACK 关联的序号不是自己当前正在发起的连接,于是发送 RST 中断,服务端收到 RST 后就会清理掉这个废弃半连接。
那为什么不用四次?因为网络通信中每一轮都增加时间成本,能合并的确认就应当合并。服务端在收到客户端 SYN 后,既需要确认客户端的 ISN,也需要让客户端知道自己的 ISN,这两个信息放在同一个 SYN+ACK 报文里完全够了。所以 TCP 选择了三次,这是在“防历史连接”和“握手效率”之间取得的最优平衡点。
2.3 从抓包看握手时 Seq/Ack 的推进
光看状态图不如亲手抓一次包。假设客户端 IP 是 192.168.1.10,用随机端口 50780 访问服务端 192.168.1.20 的 443 端口,在服务端执行 tcpdump 可以抓到三个关键报文:
tcpdump -nn -i eth0 tcp port 443 and 'tcp[tcpflags] & tcp-syn != 0'抓到结果大致长这样:
1 0.000000 192.168.1.10.50780 > 192.168.1.20.443: Flags [S], seq 3351902976 2 0.000045 192.168.1.20.443 > 192.168.1.10.50780: Flags [S.], seq 1999605120, ack 3351902977 3 0.000071 192.168.1.10.50780 > 192.168.1.20.443: Flags [.], ack 1999605121Wireshark 里默认显示的是相对序列号,所以看起来是 seq=0、ack=1,实操时很多人因此误会,以为序列号真的都从 0 开始。其实绝对序列号是内核随机生成的 ISN,相对序号只是为了方便人观察。排查问题时应打开 Wireshark 的“Relative sequence numbers”选项,或者在命令行输出里区分绝对序号,否则对端抓包和本端抓包经常衔接不上。另外,第三个报文的 Length 为 0,纯 ACK 不消耗序列号,所以后面客户端发数据时 seq 依然停留在 x+1,而不是 x+2。
3. 四次挥手为什么是四次?主动关闭方与被动关闭方的命运不同
3.1 四次挥手的完整状态流转
正常关闭连接的流程可以拆成四个报文。主动关闭方先发 FIN,seq=u,表示“我的数据发完了,我不会再发送新数据了”,同时携带前面数据的 ACK 信息;被动关闭方收到后回 ACK,ack=u+1,表示“我收到了你的 FIN”。到这里,主动关闭方向的半连接关闭,但被动关闭方如果还有数据要发,就继续发,主动关闭方仍然要接收。等被动关闭方把数据发完,也调用 close,发送自己的 FIN,seq=v;主动关闭方收到后回 ACKack=v+1,被动关闭方关闭连接,主动关闭方进入 TIME_WAIT。
状态变化网上很多图,但最好能自己手推一遍:
主动关闭方: ESTABLISHED -> FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> (2MSL后) CLOSED 被动关闭方: ESTABLISHED -> CLOSE_WAIT -> LAST_ACK -> CLOSED注意一个容易踩坑的细节:主动关闭方在发出 FIN 后进入 FIN_WAIT_1,收到对端 ACK 后进入 FIN_WAIT_2。在 FIN_WAIT_2 状态下,它仍然可以接收对端数据,直到对端也发 FIN。很多长连接应用会把 FIN_WAIT_2 和 CLOSE_WAIT 混淆,以为只有被动方会出问题。实际上如果主动关闭方发完 FIN 后,对端一直不发 FIN,本地长期堆积 FIN_WAIT_2 同样说明被动方没有正确关闭 socket。Linux 内核里有tcp_fin_timeout用于处理这种孤儿连接,但应用层还是要查哪条链路没关干净。
3.2 半关闭模型决定了 FIN 不能和 ACK 合并
为什么挥手要四次,不能是三次?关键在于 TCP 连接是全双工的,收到 FIN 只代表对方放弃了自己的发送方向,不代表我不允许再发送。被动关闭方在收到 FIN 并回 ACK 后,进入 CLOSE_WAIT 状态。这时候被动方还可能在等应用程序把剩余数据写完、把缓冲区冲掉、或者做业务上的收尾操作。内核无法替用户提前决定:也许下一次 write 还能写出几十 KB 数据,所以不能立刻回复 FIN。
如果三次挥手,就意味着被动关闭方把对 FIN 的 ACK 和自己方向上的 FIN 合并成一个报文,那应用的数据只能在收到 FIN 之前全部写完,这在现实中没有可操作性。服务端收到客户端的关闭请求后,完全可能还需要 flush 响应数据、写访问日志、关闭数据库事务,这些都需要时间,FIN 必须推迟到用户态主动 close。所以四次挥手不是设计者非要绕弯路,而是“关闭确认”和“关闭发起”在语义上就是两件事,无法永远合并。
3.3 TIME_WAIT:为什么主动关闭方要“赖着不走”
主动关闭方发送最后一个 ACK 后,不会立刻进入 CLOSED,而是进入 TIME_WAIT,并且要等 2MSL(Maximum Segment Lifetime),也就是报文在网络中存活时间上限的两倍。Linux 里的默认时常通常约 60 秒,RFC 建议值可以到 2 分钟。这个等待期有两个核心目的。
第一个目的,确保最后一个 ACK 能到被动关闭方手中。如果这个 ACK 丢了,被动关闭方在 LAST_ACK 状态会超时重发 FIN,主动关闭方如果已经关闭,就不可能再回复 ACK,被动方只能一直重传,最终异常关闭。有了 TIME_WAIT,主动关闭方在 2MSL 内仍能收到重传的 FIN,并重新发送 ACK,保证双方都能干净关闭。第二个目的,让本连接旧报文在网络中自然消亡。四元组相同的新连接可能很快建立,如果旧连接的残留数据包迟到,新连接会收到脏数据。等待 2MSL 就是给旧报文留出充分时间消失,避免影响到后续复用同一四元组的连接。
线上看到大量 TIME_WAIT 时,不要第一反应是“内核有问题”。它恰恰说明 TCP 正在按照协议要求保护你的连接。真正要思考的是:为什么应用会产生这么多短生命周期连接,能不能用连接池或长连接复用,让 TIME_WAIT 数量降到合理范围。
4. 三次握手与四次挥手异常场景的排查实录
4.1 建连卡住的真正原因:SYN 队列与 Accept 队列
三次握手看起来只有三次报文交换,但服务端内核在其中维护了两个队列,学名叫 SYN Queue 和 Accept Queue。收到第一次握手的 SYN 后,连接先进入 SYN Queue,也就是半连接队列;收到第三次握手的 ACK 后,连接从半连接队列移到 Accept Queue,也就是全连接队列,等待应用调用 accept() 取走。如果应用 accept 太慢、backlog 设置太小,或者进程被阻塞,Accept Queue 会溢出,新连接可能被丢弃,严重时直接触发 RST。
判断方法很简单。Linux 下用ss -lnt看监听 socket:
ss -lnt如果某个 LISTEN 状态的 Recv-Q 持续接近 Send-Q 数值,说明 Accept Queue 已经堆满了。常见原因有三类:应用线程池打满,没人调用 accept;backlog 参数被调得太小;负载均衡或网关转发过来的连接本就超过应用处理能力。碰到类似现象,不要先骂网络,先在服务端抓包看看是不是握手第三包到了但应用没取走连接。用ss -lnt看到的 Send-Q 代表 listen backlog 上限,Recv-Q 代表已建立但还没被 accept 的连接数,是一个很直观的早期预警。
半连接队列溢出则不同。当 SYN Flood 或大量无效握手请求把 SYN Queue 塞满,正常客户端的 SYN 会被内核丢弃,客户端表现为 SYN 重传超时。线上防护一般会开启net.ipv4.tcp_syncookies,让服务端在 SYN Queue 满时也能通过 SYN Cookie 机制维持正常连接。这个开关默认在许多发行版是开启的,不建议关掉。如果服务端日志或抓包显示大量 SYN 到了但服务端不回复 SYN+ACK,别急着怀疑网络厂商,先检查是不是内核 SYN Queue 满了,观察netstat -s里SYNs to LISTEN sockets dropped是否在增长。
4.2 一抓包就出现 Connection reset by peer
RST 是 TCP 里的异常终止信号,它和 FIN 有本质区别:FIN 代表“我把话说完了,正常告别”,RST 代表“我这边出错或者不想理你,直接切断”。排查curl: (35) TCP connection reset by peer这类报错时,关键不是背错误码,而是搞清楚是谁发的 RST,以及为什么发。
几种常见情况:连接建立成功后又向已关闭的 socket 发送数据;服务端进程退出,端口不再监听,此时新连接会立刻收到 RST;服务端设置了 SO_LINGER 并选择立即重置,close 时不再走四次挥手;中间防火墙或负载均衡设备主动注入 RST。我在实际处理过的案例里,最常在服务端代码里犯的错是:一个 socket 还有内核接收缓冲区没读完就调用 close。TCP 协议栈发现接收队列里有未读数据,会认为应用程序丢弃了对方数据,直接发 RST 而不是 FIN。这种连接对客户端来说表现为“正常读写中突然 connection reset”。
排查 RST 建议两端同时抓包,单端抓包经常会误判。比如客户端看到服务端直接发 RST,但服务端抓包发现它根本没收到任何请求,那问题大概率在中间网络设备。另外别忽略 SO_LINGER,很多框架在超时关闭时会故意用 RST 替代 FIN,目的是不进入 TIME_WAIT、快速释放本地端口。这种设计牺牲了优雅性,在高性能内部 RPC 中很常见,但在公网服务中容易让客户端产生困惑。
4.3 bind报端口占用?大部分不是玄学
开发中总能看到下面这类错误:
bind: only one usage of each socket address error response from daemon: ports are not available: exposing port TCP 0.0.0.0:xxxx本质都是同一个:某个进程已经把这个 IP 和端口占用了,另一个进程无法以相同地址和端口执行 bind。可能原因有四种。第一种最普通,另一个进程还活着,用lsof -i :端口或ss -lntp | grep 端口能看到监听者,处理掉旧进程即可。第二种是自己程序调试时 fork 出的子进程还持有 fd,主进程退出但子进程没退出,端口依然被占。第三种是旧连接处在 TIME_WAIT 状态,没有设置 SO_REUSEADDR 的监听 socket 重启时绑不上。第四种是 IPv4 和 IPv6 监听冲突,程序绑定了0.0.0.0:port,另一个程序绑定了[::]:port,端口看似一样但在不同协议栈上,依然会冲突。
服务端程序应在 listen 前设置 SO_REUSEADDR,这是解决端口占用实践里最值得养成习惯的一步。它能允许监听 socket 绑定到 TIME_WAIT 状态连接使用的端口,不影响系统安全性。但客户端不要依靠 SO_REUSEADDR 来复用 TIME_WAIT 里的本地随机端口,那部分场景更适合用连接池和 keep-alive 解决。
4.4 TIME_WAIT 过多与端口耗尽
高并发短连接最容易把临时端口打光。客户端每次发起新连接时,会从/proc/sys/net/ipv4/ip_local_port_range指定的范围里选一个本地端口,例如 32768 到 60999,总共约两万八千个端口。如果大量连接由客户端主动关闭,每个关闭的连接都会进入 TIME_WAIT,在 TIME_WAIT 期间不能用于相同四元组的新连接,等这些 TIME_WAIT 攒满,新的 connect 就会失败,错误通常是Cannot assign requested address。
有人喜欢希望通过修改tcp_tw_reuse来“回收” TIME_WAIT 端口。这个参数只对主动发起连接的一方有效,并且依赖 TCP 时间戳选项,在 NAT 环境下容易造成排查困难。新版内核已经移除了tcp_tw_recycle,因为它对公网环境的负面影响远大于收益。我自己的处理优先级是:先看能否减少不必要的新连接,比如 HTTP 使用 keep-alive、内部调用改 gRPC 长连接;再看端口范围是否足够;最后才考虑tcp_tw_reuse。排查时可以用下面命令观察:
ss -tan state time-wait | wc -l cat /proc/sys/net/ipv4/ip_local_port_range如果 TIME_WAIT 都集中在某个下游服务的端口,大概率是你所在进程主动关闭了到该服务的连接。这种情况长连接改造收益最明显,我曾经只把内部 RPC 从每次请求新建连接改成连接池,TIME_WAIT 数量从二十多万直接降到几百。
5. 常用排查命令与避坑清单
5.1 连接状态与队列指标速查表
为了方便实战中快速对照,我把排查中最常用的现象、看点和处理思路整理成了一张表。每次出问题先别急着改代码,按表里顺序过一遍往往几分钟就能定位方向。
| 现象 | 关键指标 | 可能原因 | 第一步处理 |
|---|---|---|---|
| 客户端 connect 超时 | 抓包发现 SYN 一直重传,无 SYN+ACK | SYN 被丢弃、防火墙拦截、服务端半连接队列满 | 检查服务端netstat -s,看 SYN drop 计数,关掉 Syncookies 后观察 |
| 服务端日志大量握手超时 | ss -lnt中 Recv-Q 接近 Send-Q | Accept Queue 溢出,应用取连接太慢 | 查看应用线程池/阻塞点,调大 backlog |
| CLOSE_WAIT 持续增长 | 进程里 CLOSE_WAIT 数量只增不减 | 收到对端 FIN 但没调用 close | 用lsof -p PID定位泄漏连接的 fd,修业务关闭逻辑 |
| FIN_WAIT_2 堆积 | 主动关闭方收不到对端 FIN | 对端应用没有关闭 socket | 查对端进程是否阻塞在 IO 上,或内核tcp_fin_timeout是否合理 |
| TIME_WAIT 上万 | 单条命令后 TIME_WAIT 很多 | 客户端短连接主动关闭过快 | 优先改长连接或连接池,其次评估tcp_tw_reuse |
| Connection reset by peer | 抓包出现 RST,而非 FIN | 端口未监听、SO_LINGER 异常、接收缓冲有未读数据 | 两端抓包定位 RST 发起方,再查应用代码 |
| bind 提示 only one usage | ss -lntp能看到占用方 | 端口被进程占用、TIME_WAIT、IPv4/IPv6 冲突 | 设置 SO_REUSEADDR,清理旧进程,区分协议栈 |
5.2 改善连接健康度的几件小事
很多连接问题最终是编码习惯问题。服务端监听 socket 一定要设置 SO_REUSEADDR,这是 C/C++、Python、Java、Node 都能做到的通用措施。处理长连接时,要给空闲连接设置合理的探活机制,TCP 自带的 keepalive 默认可能要两个小时才发第一个探测包,业务上完全等不起。更可靠的做法是设计应用层心跳或者使用成熟框架的 idle timeout,超时后主动关闭并清理连接对象。
客户端断线重连也要有策略。不要每次失败都立刻新建一个 Socket,这会让 TIME_WAIT 雪上加霜。推荐使用指数退避重连,并在重连前检查旧 Socket 是否真的处于可复用状态。很多 C# 和 Java 开发者踩过的坑是:Socket 抛异常后没有 Dispose,连接对象被 GC 回收时内核才关闭 fd,表面上看是“断线后重连失败”,实际是旧连接占着本地端口不放。让开发环境用短连接做单元测试没问题,生产环境则优先考虑连接池,否则 TCP 握手和挥手的开销也会变成明显的性能瓶颈。
5.3 我自己的排障顺序参考
最后分享一套我常用的排障顺序,不一定适合所有场景,但至少能避免瞎抓瞎猜。第一步,先看全局连接状态分布:
ss -ant | awk '{print $1}' | sort | uniq -c如果 CLOSE_WAIT 多,问题大概率在应用不关 socket;如果 TIME_WAIT 多,大概率是连接生命周期设计不合理;如果 SYN_RCVD 多,则去检查服务端半连接能力和 syncookies。第二步,看网络层是否有丢包和重传,用netstat -s看重传计数,抓包看是否有 SYN 重传。第三步,结合代码查端口占用和 fd 泄漏,优先看哪个进程持有最多 TCP socket:
ss -antp | awk '{print $6}' | sort | uniq -c | sort -rn | head这里请去掉表头再统计,否则第一行永远是最多的进程信息。第四步才考虑内核参数调优,而且每次只改一个参数,观察一段时间再决定是否保留。网络排查最怕一次改一堆参数,出了问题都不知道是哪个改动引起了回归。
我个人的体会是,TCP 三次握手和四次挥手看起来只是几行状态图,但它几乎串起了网络编程里最常遇到的排障线索。好好把这两个流程的报文细节、状态迁移和时间等待机制弄清楚,再回到线上问题时,你会发现很多报错不再陌生,而是变成了可以按图索骥的状态机反馈。下次再看到端口占用、连接重置、握手超时,先别急着重启程序,跑一遍ss,抓一次包,往往比盲目重试有效得多。