ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

TCP三次握手与四次挥手:从原理到抓包排查实战

TCP三次握手与四次挥手:从原理到抓包排查实战 很多做后端、嵌入式、物联网和网络运维的朋友第一次接触网络编程时都会卡在这三个词上TCP、三次握手、四次挥手。我当年也是这样文档翻了不少能背出 SYN、SYN-ACK、ACK 的顺序可真到用tcpdump抓包看到一堆重传包、Dup ACK、FIN 包时还是手忙脚乱。今天我把这一整块从头到尾捋一遍重点不是“背流程”而是“理解为什么是这样”顺便把实战里最常见的报错和坑也一起说清楚。这篇内容适合这几类人刚写客户端/服务端程序的新手搞 ESP01S、传感器网关、Modbus TCP 等嵌入式通信的开发者排查线上连接异常的运维还有准备面试需要把三次握手和四次挥手讲明白的人。无论你是哪种情况我会尽量用大白话讲原理再用抓包和真实报错讲实操。看完之后你至少能回答这几个问题为什么握手必须三次为什么挥手偏偏要四次TIME_WAIT 是什么为什么老有人卡在它身上以及“CLOSE_WAIT 堆积”“Address already in use”这些经典问题该怎么下手排查。1. 先把这一层关系摸清楚TCP 到底在解决什么问题1.1 TCP 在协议栈里的位置TCP/IP 协议栈从下往上大致是网络接口层、网络层IP、传输层TCP/UDP、应用层HTTP、Modbus TCP、MQTT 等。TCP 位于传输层它是整个互联网可靠传输的核心。所谓“可靠”并不是说底层网络不丢包恰恰相反IP 网络本身是一个尽力而为、不保证可靠的通路所以 TCP 要靠自己的机制来保证数据不丢、不乱、有序到达。握手和挥手就是 TCP 在这条不可靠链路上最重要的两个“开关”一个负责在任务开始前确认双方状态都准备好了一个负责在任务结束后干干净净地回收资源。你后面在排查问题时会发现很多故障其实都出在这两个开关上没有处理干净而不是应用层逻辑本身有问题。1.2 连接到底是什么很多人提到 TCP 连接第一反应是“一根专用的网络管道”。但实际上TCP 连接在物理上并不存在一根专属线缆它是通信两端各自维护的一组逻辑状态源 IP、源端口、目标 IP、目标端口再加发送序号、接收序号、窗口大小、连接状态等。握手和挥手的本质就是让两端的这些状态从“不知道对方”变成“状态一致”。这一点很重要。我调嵌入式设备时经常强调千万别把 TCP 连接当成“插电话线”它只是一个双方协商出来的逻辑关系中间任何一层设备重启、路由切换、Wi-Fi 信号抖动都可能让这条“连接”外部中断但两端状态机却还懵然不知。这也是后面要提到“应用层心跳”的原因。2. 三次握手建立连接为什么必须“三”次2.1 先看一次完整的握手客户端主动connect()服务端listen()之后握手过程分三步。第一步客户端发送一个 SYN 报文报文里带有客户端的初始序列号记为seq ISN_c此时客户端进入SYN_SENT状态。第二步服务端收到 SYN 后回复一个SYN ACK报文既确认了客户端的 SYN又携带了服务端的初始序列号ISN_s同时ack ISN_c 1。服务端此时进入SYN_RCVD状态。第三步客户端收到SYN ACK后再回复一个 ACK 报文seq ISN_c 1ack ISN_s 1。客户端进入ESTABLISHED服务端收到这个 ACK 后也进入ESTABLISHED。需要留意一个细节第三步的 ACK 在 TCP 规范里完全可以不带载荷数据但实际实现中允许“捎带数据”。也就是说在一些优化场景下第三个握手包后面可能直接就跟着应用层数据比如 HTTP 请求。这也是为什么你在 Wireshark 里偶尔能看到一个看起来不大的高负载包把握手和首包请求一起拿下了。2.2 为什么两次握手不行核心是老问题过期的连接请求很多教材会说“三次握手是为了双方都能确认对方的收发能力”这话没错但没说到最痛的点。按收发能力来看其实第二次握手之后客户端已经知道“服务端能发也能收”服务端也知道“客户端能发”好像可以开始干活了。问题是服务端在收到客户端 ACK 之前并没有证据证明“客户端现在真的想建立这条连接”。我举个例子。客户端第一次发 SYN因为网络拥塞这个包迟迟没到服务端客户端超时后重传了新的 SYN服务端正常回包新连接很快就建立了。可是后来那个老 SYN 包又“复活”了慢慢漂到服务端。如果只有两次握手服务端收到这个过期 SYN会误以为客户端要再建一条连接于是分配资源、返回 ACK自己进入ESTABLISHED。可客户端那边压根不知道有这条连接自然不会发数据。服务端就一直白等这就是资源浪费。三次握手的第三包 ACK 就是用来做“最终确认”的只有客户端主动确认过的连接服务端才认可。如果那个过期的 SYN 又来了客户端收到对应的 SYNACK 后发现自己根本没有这条连接会直接丢弃或发 RST服务端收到 RST 就知道对方不要赶紧释放资源。这是三次握手最关键的价值。2.3 第二个关键原因同步初始序列号除了处理过期包三次握手还承担一个非常实在的任务同步初始序列号 ISN。TCP 是可靠字节流协议它靠序列号确认每个字节有没有收到。如果双方初始序列号没对齐后面 ACK、重传、排序全都会乱。握手时双方必须把各自的 ISN 交给对方并且要确认“对方已经知道我的 ISN 了”。假如只有两次握手客户端知道服务端的 ISN服务端也知道客户端的 ISN但服务端没法确认“客户端是否真的知道自己给了什么序列号”。第三次 ACK 就是客户端明确告诉服务端我已经收到你的序列号了下面报文的序号不会有歧义。所以说三次握手既清算了历史遗留的重复 SYN又完成了序列号同步这事确实得做三轮。为什么四次不需要因为服务端的 SYN 和 ACK 完全可以合并到一个报文里发出节省一个 RTT。如果拆成两步除了多一次往返没有任何额外收益。2.4 握手阶段零散状态和队列握手过程中有几个状态和队列面试问答和线上排查都绕不开。客户端状态CLOSED - SYN_SENT - ESTABLISHED。服务端状态LISTEN - SYN_RCVD - ESTABLISHED。Linux 内核为每个监听套接字维护两个队列半连接队列SYN 队列和全连接队列accept 队列。服务端收到 SYN 后把连接信息放入 SYN 队列收到第三步 ACK 后再把连接移入 accept 队列等待应用调用accept()。如果你用ss -ant看到大量SYN_RCVD说明很多握手请求卡在第二步和第三步之间要么是 ACK 丢包要么是 accept 队列满、内核开始丢包。这时候只看“有没有收到 SYN”是不够的还得看应用层有没有及时accept()。提示排查握手异常时先看三项ss -ant里的状态分布、服务端 backlog 配置、内核参数net.ipv4.tcp_max_syn_backlog。不要一上来就怀疑网络。3. 四次挥手为什么断开要用“四”次3.1 先说透“半关闭”这个概念TCP 是双工协议意味着每一端既可以发数据也可以收数据。断开的时候绝不能只靠一个 FIN 就把整个连接判死刑。主动关闭的一方发出 FIN意思是“我的数据发完了我不会再往对面发东西了”但它仍然可以继续接收数据。至于对面可能还有数据没发完。所以挥手必须一个方向一个方向地关第一步A 发送 FIN 报文表示 A 要关掉自己的发送方向。第二步B 回 ACK告诉 A你的 FIN 我收到了。此时 A 这边的发送方向关闭了但 A 还在继续接收数据。第三步B 把剩下的数据全部发完然后也发一个 FIN 报文关上 B 自己的发送方向。第四步A 回 ACK确认收到 B 的 FIN。有人会问第二步和第三步为什么不能像握手那样合并成一个 FINACK因为 B 收到 FIN 的时候它自己可能还有数据要发不能立刻关闭发送方向必须先把数据发完才能补发 FIN。这个中间间隔可能很长没办法一次完成。3.2 挥手中的状态迁移以及 TIME_WAIT 的由来整个挥手过程里状态切换是排查问题的重点。主动关闭方 AESTABLISHED - FIN_WAIT_1 - FIN_WAIT_2 - TIME_WAIT - CLOSED。被动关闭方 BESTABLISHED - CLOSE_WAIT - LAST_ACK - CLOSED。A 发出 FIN 后进入FIN_WAIT_1收到 B 的 ACK 后进入FIN_WAIT_2等着 B 后续发 FIN。B 收到 FIN 后进入CLOSE_WAIT在这个状态里 B 可以继续发剩余数据发完以后才调用close()发出自己的 FIN进入LAST_ACK。A 收到 B 的 FIN 后回一个 ACK进入TIME_WAIT。TIME_WAIT是主动关闭方独有的状态要等 2 个 MSL报文最大生存时间才会变成CLOSED。为什么要等那么久两个理由。第一确保最后这个 ACK 能到达对方。如果最后这个 ACK 丢了B 会重发 FINA 必须还在那里才能再回一次 ACK。如果 A 立刻变成CLOSED然后消失B 会一直收不到 ACK超时后还可能报异常。第二让这条连接上已经传输的报文在网络上彻底消失。防止这些老的重复报文被误认为属于一条新的、使用相同四元组的连接。等 2 个 MSL足够让这些包在网络中过期。TIME_WAIT持续时间默认大约是 60 秒不同内核略有差异。这个机制非常保守但别乱调尤其不要一看到大量TIME_WAIT就想着关掉它后面我会专门讲怎么合理处理。3.3 谁主动关闭谁承担 TIME_WAIT在实际开发中有一个很常见的认知客户端主动断则客户端承担TIME_WAIT服务端主动断则服务端承担TIME_WAIT。所以很多 HTTP 短连接场景服务端为了控制连接资源会主动关闭空闲连接结果服务端大量堆积TIME_WAIT然后被运维同学当成“洪水”去调内核参数。很多线上服务用短连接模型客户端请求一次服务端回完数据就主动close()。这样服务端会比客户端更早进入TIME_WAIT高并发下就会出现大量TIME_WAIT。大量TIME_WAIT不一定是坏事这是 TCP 早就在设计里预留的清理时间。但如果你实在受不了常见的做法是开启net.ipv4.tcp_tw_reuse让内核在建立新连接时可以复用处于TIME_WAIT状态的连接这比粗暴地缩短TIME_WAIT更稳妥。注意tcp_tw_reuse只作用于客户端发起新连接时的四元组复用服务端被动接入场景作用有限。不要照抄网上一堆参数就乱调改完以后一定要压测验证。4. 从抓包和真实场景感受三次握手和四次挥手4.1 用 tcpdump 看一次真实的握手挥手理论讲太多容易飘我习惯用工具抓一遍自己也更容易记住。以 Linux 环境为例。sudo tcpdump -i any -nn tcp port 80另开一个终端执行curl -v http://example.com/你会在 tcpdump 输出里看到类似下面的序列IP 172.16.0.2.52340 93.184.216.34.80: Flags [S], seq 1234567890 IP 93.184.216.34.80 172.16.0.2.52340: Flags [S.], seq 999999999, ack 1234567891 IP 172.16.0.2.52340 93.184.216.34.80: Flags [.], ack 999999999标志位解读[S]代表 SYN[S.]代表 SYN ACK[.]代表纯 ACK[F]代表 FIN[R]代表 RST接着你会看到 HTTP 请求和响应数据最后出现两个方向各一组 FINACK构成完整的四次挥手。如果你开启-S参数能看到绝对序列号默认看到的是相对序列号不影响判断方向因为序列号本身只是相对偏移。4.2 用 ss 看连接状态分布排查线上连接问题时我第一步永远是ss -ant state established | wc -l ss -ant state time-wait | wc -l ss -ant state close-wait | wc -l如果有大量CLOSE_WAIT说明服务端收到了客户端的断开请求但应用层一直没调用close()。这个通常就是代码 bug不是网络问题。如果有大量TIME_WAIT要分清是服务端主动断开还是客户端主动断开。如果是服务端主动断并且短连接高频出现需要检查业务设计而不是一味加大并发线程数。4.3 嵌入式与工业场景ESP01S、Modbus TCP、C 语言、LabVIEW嵌入式场景里很多人用 ESP01S 这类 WiFi 模块做 TCP 客户端往手机或服务器上发消息。这种模块本身资源很小TCP 协议栈由模块固件实现你只是发 AT 指令控制它。最常见的坑是服务端连接断开后模块状态还停留在CONNECT或不确定状态你的下一条指令可能一直不成功。我的建议是建立连接和发送消息之间留足够的状态查询失败必须重连重连前先显式CLOSE。Modbus TCP 是工业现场非常常用的协议它把 Modbus 应用报文直接承载在 TCP/IP 之上默认端口 502。很多时候大家只在应用层调试 Modbus 寄存器读写忘了底层依然是标准 TCP。如果通讯中断你要排查的其实是 socket 层的断线重连问题。用 C# 写 Modbus TCP 客户端时我习惯在底层封装一个重连方法收到 0 字节或异常后释放旧 socket重新BeginConnect并且设置连接超时否则断网后第一次connect可能卡几十秒甚至更久。LabVIEW 与 NI 实时目标做 TCP 交互时思路和标准套接字一致。想知道“实际交互的数据量”可以在应用层统计发送和接收的字节数或者用Network Stream的内置 buffer 统计。底层握手挥手对用户不可见但如果异常中断LabVIEW 的 TCP 节点有时会抛Timeout这种问题通常靠缩短超时、主动检测并重连来解决。C 语言层面TCP socket 编程的核心是一组系统调用这里我放一个极简客户端骨架帮助你理解int fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(80); inet_pton(AF_INET, 93.184.216.34, addr.sin_addr); connect(fd, (struct sockaddr *)addr, sizeof(addr)); send(fd, GET / HTTP/1.1\r\nHost: example.com\r\n\r\n, 31, 0); char buf[4096]; ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { // 对端关闭了连接对应收到 FIN } close(fd);这里最关键的一点TCP 是字节流协议send()发一包recv()不一定就收对应的一包。网络层可能把数据拆成多段也可能把多次 send 的数据拼在一起到达。所以应用层必须自己定义消息边界并处理“半包”和“粘包”。这也是很多嵌入式开发者第一次上手时最容易懵的地方。4.4 顺手把 TCP 和 UDP 的区别说清因为热词里也提到了 UDP我多说两句。TCP 面向连接有三次握手四次挥手提供可靠、有序、不重复的数据传输UDP 无连接没有握手挥手数据报直接扔出去不管对方能不能收到、顺序对不对、有没有重复。选型要看业务需要文件传输、数据库访问、HTTP、Modbus TCP 这种必须可靠的用 TCP实时音视频、局域网设备发现、游戏位置同步这类能容忍少量丢包但要求低延迟的通常用 UDP。再把范围缩小到本地如果你只是在自己电脑上调试客户端服务端UDP 没有握手所以很多“连接建立失败”的问题在 UDP 场景里根本不存在但你也得不到 TCP 那些可靠保障。5. 高频问题与排查我踩过的那些坑5.1 客户端重连时报“地址已在使用”有一次我用 Java 写一个 TCP 客户端断线后立刻重连就碰到Address already in use: connect。原因并不复杂旧 socket 进入TIME_WAIT后四元组源 IP、源端口、目标 IP、目标端口在短时间内被内核认为还没有完全释放如果新连接要完全复用它就会被拒绝。如果客户端显式绑定了本地端口或者你用的是同一个 socket 反复 connect就会撞上这个限制。解决办法不要绑定固定的本地端口让内核自动选临时端口冲突概率很低。重连间隔错开一点等TIME_WAIT过期或者加退避重试。在 Java 里可以通过Socket.setReuseAddress(true)在绑定前允许地址复用但这个主要是针对监听 socket 和服务端快速重启场景客户端重连不要指望它神挡杀神。服务端在处理大量TIME_WAIT时考虑开启net.ipv4.tcp_tw_reuse或使用连接池复用长连接避免短连接反复建立和断开。总结一句连接管理代码里close()的时机和重连逻辑才是真正的工程难点别小看这个报错。5.2 Docker 报“ports are not available”热词里有一条类似error response from daemon: ports are not available: exposing port tcp 0.0.0.0:xxx。这不是 TCP 三次握手的问题而是 Docker 在绑定宿主机端口时发现端口已经被占用或者端口不在可用的映射范围内。排查步骤netstat -tlnp | grep :8080 lsof -i :8080先看是谁占用了宿主机端口。有时候端口被另一个容器占用有时候是 docker-proxy 进程处于TIME_WAIT不能马上绑定。旁边再确认一下 docker 命令里是不是写了-p 8080:80把主机端口和容器端口写反。这类报错和协议握手层无关但经常和 TCP 端口、连接状态搅在一起所以列在问题里提醒一句。5.3 nginx 作为反向代理TCP 最大连接数上不去有段时间我帮同事排查 nginx stream 模块做 TCP 反代压测时连接数始终上不去。看配置没有明显错误后来发现是文件描述符限制太小。影响 TCP 连接数的常见瓶颈ulimit -n限制进程能打开的文件描述符数量TCP 连接占用 fd这个数不够连接数必然受限。nginx 配置里的worker_connections太低每个 worker 可处理的并发连接不够。没有配置proxy_timeout或proxy_socket_keepalive连接因为长时间空闲被系统回收。内核参数net.core.somaxconn、net.ipv4.tcp_max_syn_backlog太小在高并发握手阶段就开始丢包。排查时先看系统软硬限制ulimit -n cat /proc/sys/net/core/somaxconn然后看 nginx 的worker_rlimit_nofile配置。很大一部分所谓“TCP 连接数上不去”本质都不是握手协议的问题而是资源配置不足。5.4 大量 Dup ACK 和重传怎么看热词里有“tcp dup ack机制”这里展开说。Dup ACK 是 TCP 的正常机制不是故障本身。当接收方收到一个乱序的包时它会在下一个 ACK 里重复确认之前期望的序号这就产生了 Dup ACK。正常情况下发送方收到少量 Dup ACK只是提示可能有包乱序。如果持续出现 3 次或以上相同的 Dup ACK触发快速重传那就要警惕丢包了。我在排查一个远程服务时tcpdump里看到大量 Dup ACK同时伴随 RTT 抖动最后定位是网卡协商速率异常和 Wi-Fi 信号干扰。排查命令ss -sti可以看 TCP 连接的rtt、rto、retrans。如果 retrans 数值一直增长网络丢包基本跑不掉。对于无线环境来说这个现象尤其常见不要一上来怀疑应用代码。5.5 握手包被丢弃、SYN 洪水防护在搞鬼如果线上偶尔出现“第一次连接超时重试后马上成功”很可能是握手包被丢或者服务端应对外部攻击开启了 SYN Cookie 保护导致某些握手流程被延迟。Linux 下看 SYN Cookie 的启用情况sysctl net.ipv4.tcp_syncookiestcp_syncookies1是常见默认值在有 SYN 洪水风险时内核会自动进入 Cookie 模式牺牲部分握手性能来换安全性。如果你确认来自可信网络可以关闭或调大tcp_max_syn_backlog。不过如果防护设备或云安全策略本身在链路中间丢包你在这里怎么调内核都没用。所以排查顺序应该是先抓包确认是 SYN 没到服务端还是 SYN 到了但服务端没回 SYNACK再决定动哪一层。5.6 服务端卡在 CLOSE_WAIT大量连接不释放这是我见过最多的一类事故。服务端收到了客户端的 FIN说明客户端已经发起关闭服务端则进入CLOSE_WAIT。正常代码应该立刻close()但很多程序只在read()返回 -1 时才关闭 socket忽略read()返回 0 的情况。read()返回 0 意味着对端已经关闭了写端此时如果不关闭自己的 socket连接就永远挂在CLOSE_WAIT。一个简单的 C 语言写法ssize_t n recv(fd, buf, sizeof(buf), 0); if (n 0) { // 对端关闭必须在这里 close(fd) close(fd); }Java、Python、Go 的 socket 层同理。检查CLOSE_WAIT堆积时先找到对应进程和线程看看是不是没有处理“EOF”这个语义。TCP 连接断开不是靠异常通知的很多时候它只是安静地告诉你我这边收到 0 字节。5.7 应用层要不要做心跳最后提一句应用层守护。TCP 协议本身不包含“应用层心跳”虽然内核有 TCP keepalive但它默认探测周期很长默认 2 小时才发一次探测包在嵌入式、物联网、工业现场这种环境里根本不够用。在嵌入式设备与服务端之间我最常用的做法是应用层每 30 到 60 秒发一个带序号的心跳包服务端超过两个心跳周期没收到就主动断开重建。这个“主动判断并断开”的操作会在底层走一次四次挥手但总比一个失灵的半开连接挂在那里强得多。6. 最后说点我的实际体会把三次握手和四次挥手从“要背的面试题”变成“工具箱里的常识”我个人的经验是不要只看报文顺序要反复问自己双方为什么需要这个状态这个状态卡着不动会导致什么问题。TCP 真正难的不是那几条线而是它处理不可靠网络时那种“怀疑一切”的设计哲学。我建议你今天就做一件事在自己电脑上起一个最简单的 Pythonhttp.server然后开tcpdump再用 curl 访问一次。把 SYN、SYNACK、ACK、HTTP 响应、FIN、ACK、FIN、ACK 完整看一遍。看完之后再去读读ss -ant的状态八成你对 TCP 的理解会立刻上一个台阶。实操中有一次我调一个嵌入式网关设备上报数据总是偶发失败代码逻辑看着没问题最后用抓包定位到是 Wi-Fi 环境下广播流量过多导致 ACK 包频繁丢失触发重传和 RTO 超时。从那之后我在嵌入式设备里都加了一层比系统默认更快的 TCP keepalive并在应用层做了心跳和重连状态机。TCP 的握手和挥手不是只存在于面试卷上的名词它们每时每刻都在你的每一台设备之间运转。遇见问题别慌先抓包再顺着状态机一步步看答案一般比自己拍脑袋想出来的更快。
返回列表