十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

TCP三次握手与四次挥手:从报文细节到线上排障实战

TCP三次握手与四次挥手:从报文细节到线上排障实战 写这篇文章之前我先说个事。干后端这两年我看过太多人背八股文三次握手、四次挥手画图画得比教科书还标准一问“为什么四次不是三次”“TIME_WAIT留着干嘛”“线上全是CLOSE_WAIT怎么查”当场卡壳。TCP这个协议最烦人的地方在于——你越觉得它基础它越会在线上给你挖坑。今天这篇不画大图不讲概念就是把握手和挥手这两个过程从报文层面拆开按真实事件顺序讲清楚再告诉你这些机制到底保护了什么、失效时会看到什么现象最后用手上的工具给你演示一遍从建立到断开的状态流转。中间会穿插大量一线排查经验看完你至少能解决掉一类线上连接问题。1. 三次握手从SYN到ACK连接建立的完整链路1.1 为什么是“握手”连接建立要解决的三件事很多人第一次接触TCP听到“握手”这个说法会觉得很抽象。其实换个场景就明白了假设你和另外一个人在完全陌生的环境里通话电话一接通你必须确认三件事——你说话对方能不能听见对方说话你能不能听见以及双方是否都在用同一套“语言规则”来理解这段对话。TCP的连接建立本质上就是干这件事。从协议层面拆解三次握手要完成三个目标确认双方的收发能力。A要证明自己能发能收B也要证明自己能发能收。同步初始序列号ISN。TCP是面向字节流的每个字节都有编号双方必须知道对方的起始编号后续数据才能按序拼接。协商选项参数。比如MSS最大报文段长度、窗口缩放因子等这些都在握手报文里捎带过去。这三件事少一件连接都不算真正建立。这里有个很多人忽略的细节第一次握手客户端发SYN只能证明“客户端能发、服务端能收”第二次握手服务端回SYNACK才能证明“服务端能发、客户端能收”同时让客户端确认自己发的SYN确实到了但此时服务端还不知道自己发的SYN-ACK有没有到所以必须由客户端再回一个ACK第三次握手的作用说白了就是让服务端确信自己的报文能到达客户端。这个逻辑链必须记清楚三握手里没有一个步骤是多余的。1.2 交互过程逐包拆解SYN、SYN-ACK、ACK 各自携带什么过程不多说直接上抓包视角的完整流程。假设客户端IP是192.168.1.10服务端是192.168.1.20端口8080第1步客户端 - 服务端SYNTCP 192.168.1.10:51000 - 192.168.1.20:8080 [SYN] Seq1000000 Win64240 Len0 MSS1460客户端状态从CLOSED进入SYN_SENT。Seq是一个随机生成的初始序列号不是从0开始——这是为了防预测和防历史报文串扰老版本内核如果使用可预测的序列号很容易被伪造RST攻击。第2步服务端 - 客户端SYNACKTCP 192.168.1.20:8080 - 192.168.1.10:51000 [SYN, ACK] Seq2000000 Ack1000001 Win65535 Len0 MSS1460服务端收到SYN后状态从LISTEN变成SYN_RCVD。它要做两件事一是为这个连接分配传输控制块TCB也就是内核里那棵socket树上的节点二是回一个SYNACK其中Ack1000001表示“我收到了你的序列号1000000下个字节请从1000001发给我”同时把自己的序列号2000000告诉客户端。这里的Ack为什么是Seq1因为SYN报文本身要占一个序号它属于流中的一个控制字节后面的数据字节才能从1000001接着排。第3步客户端 - 服务端ACKTCP 192.168.1.10:51000 - 192.168.1.20:8080 [ACK] Seq1000001 Ack2000001 Win65535 Len0客户端收到SYNACK后状态进入ESTABLISHED回一个ACK。服务端收到这个包后也从SYN_RCVD进入ESTABLISHED。此时握手完成双方可以开始正常收发数据。以上三段报文在wireshark里看就是三行但要注意第2步的报文是SYN和ACK两个标志位同时置1这不算“四次握手”这是把两步合并成一步的优化——因为SYN和ACK本来就是两个方向独立的确认既然能在同一个报文里装下就没必要拆开发两次。1.3 用抓包工具亲眼验证一次握手我不建议只看截图自己在服务器上抓一次会比什么都有用。最简单的方式用Python起一个迷你服务端再用curl发起请求import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8080)) server.listen(5) conn, addr server.accept() print(fconnection from {addr}) data conn.recv(1024) conn.sendall(bhello) conn.close()同一台机器上另开两个终端一个跑这个服务端另一个开抓包命令tcpdump -i any tcp port 8080 -nn -S然后在第三个终端执行curl -v http://127.0.0.1:8080/你会看到类似下面的输出21:31:04.123456 IP 127.0.0.1.51000 127.0.0.1.8080: Flags [S], seq 1000000 21:31:04.123489 IP 127.0.0.1.8080 127.0.0.1.51000: Flags [S.], seq 2000000, ack 1000001 21:31:04.123491 IP 127.0.0.1.51000 127.0.0.1.8080: Flags [.], ack 2000001第一行[S]是SYN第二行[S.]是SYNACK第三行[.]是纯ACK。实操的时候有个小经验如果抓到的第一个包不是你预期中的SYN而是[R]RST或者[RA]RSTACK先别去看协议栈大概率是服务端端口根本没监听或者防火墙把SYN包丢了。我遇到过很多次“连不上”的问题最后都是这一步定位出来的。另外补一个内核参数net.ipv4.tcp_syn_retries它控制SYN的重发次数默认配置在不同内核版本可能不同常见值是5或6。如果客户端发出SYN后一直收不到SYNACK会按指数退避重传直到超过这个次数才返回超时。你排查连接卡在SYN_SENT时可以先确认下这个值免得傻等太长时间。1.4 握手阶段的资源开销半连接队列与全连接队列三次握手不是只发三个包就完了内核还要为连接分配内存。服务端收到SYN后建立的连接叫半连接存放在半连接队列syn queue收到第三个ACK后连接转为全连接进入accept queue等待应用层调用accept()取走。这里有两个队列很可能成为线上故障点net.ipv4.tcp_max_syn_backlog半连接队列长度。net.core.somaxconn和 listen() 的 backlog 参数全连接队列长度两者取较小值。如果应用层accept()不够快全连接队列会满新来的连接即使完成了三次握手也会被内核丢掉或者延迟accept。现象是客户端显示连接已建立三次握手完成了但发数据没响应。很多人这时去查防火墙、查网络绕了一大圈结果问题出在业务代码处理连接的速度上。排查手段很简单ss -lnt看Recv-Q和Send-Q两列。在LISTEN状态下Recv-Q表示accept queue当前的连接数Send-Q表示队列最大长度。如果Recv-Q一直压着接近Send-Q说明上层服务消化连接的速度跟不上。2. 四次挥手断开连接为什么比建立多一次2.1 挥手和握手的不对称性建立连接时双方其实处于“都对对方一无所知”的状态所以核心是同步。但断开连接时情况完全不一样——两个方向的数据发送是互相独立的A可能已经发完所有数据B的发送却还没结束。打个比方。你在微信和别人聊天你单方面说“我要下线了”对方可能还有后半段话没打完。合理的方式是你告诉对方你要关了对方回复“收到你等我一下”然后等他把话说完再主动告诉你“我也说完了可以关了”最后你确认一句“好”。整个过程每一方都需要独立地表达一次“我说完了”并收到对方的确认。这就是四次挥手比三次握手多一次的根源。2.2 四次挥手的完整报文序列拆开看标准流程。假设客户端主动关闭连接第1步主动方客户端 - 被动方服务端FINTCP 192.168.1.10:51000 - 192.168.1.20:8080 [FIN, ACK] Seq1000100 Ack2000100FIN标志位置1表示客户端的发送方向关闭了不再发送新的数据。客户端状态从ESTABLISHED进入FIN_WAIT_1。这里有个不少人疑惑的点——为什么FIN通常带着ACK因为挥手前连接里可能有尚未确认的数据FIN报文顺手把之前收到的数据Ack一下是正常的。第2步被动方 - 主动方ACKTCP 192.168.1.20:8080 - 192.168.1.10:51000 [ACK] Seq2000100 Ack1000101服务端收到FIN后回一个ACK告诉客户端“你的FIN我收到了”。服务端状态进入CLOSE_WAIT客户端状态进入FIN_WAIT_2。但注意此时服务端仍然可以继续往客户端发数据。TCP是双向的客户端关闭了发送方向不意味着服务端也必须立刻关闭自己的发送方向。第3步被动方 - 主动方FIN服务端把剩余数据发完后发送FIN表示“我也发完了可以关了”TCP 192.168.1.20:8080 - 192.168.1.10:51000 [FIN, ACK] Seq2000200 Ack1000101服务端状态从CLOSE_WAIT进入LAST_ACK。第4步主动方 - 被动方ACKTCP 192.168.1.10:51000 - 192.168.1.20:8080 [ACK] Seq1000101 Ack2000201客户端确认了这个FIN回复ACK。服务端收到后进入CLOSED客户端则进入TIME_WAIT等待2MSL后彻底关闭。再次强调那个理解上的关键并不是说FIN一定只能一个单独报文发也不是说每挥手就一定是4个报文排得整整齐齐。实际上第2步的ACK和第3步的FIN经常合并成一个报文发出尤其是服务端已经没有待发数据的情况下此时从报文数量上看是3个但状态流转依然是“两个独立的关闭方向各自确认”逻辑上仍然是四次步骤。2.3 TIME_WAIT为什么要等2MSL四次挥手最容易让人困惑的是最后那个TIME_WAIT状态客户端明明已经收到服务端的FIN并回了ACK为什么不能立刻进入CLOSED非要等2MSLMaximum Segment Lifetime报文最大生存时间两个原因缺一不可第一保证被动方能够收到最后一个ACK。客户端发的第4步ACK可能丢失服务端在LAST_ACK状态下收不到ACK会重发FIN。客户端如果已经进入CLOSED就没人回应这个重发的FIN服务端永远关不掉这个连接。等2MSL是为了给重传留足时间窗口。第二让旧连接的报文在网络中自然消亡。2MSL约等于报文在网络里往返一趟再返回来能存活的最长时间。等待这个时间过后连接上的所有孤儿报文都消失了之后再建立一个使用相同四元组的新连接就不会把老连接里的残留数据当成新连接的数据。Linux上TIME_WAIT状态一般持续60秒这个值取决于系统对MSL的设定常见默认MSL是30秒2MSL即60秒。生产环境里如果短连接非常多而且由同一端主动关闭TIME_WAIT会堆积得很恐怖我见过一台机器几万个TIME_WAIT的。后面第4章会讲怎么处理。2.4 半关闭一个被忽略但真实存在的用法四次挥手之所以能拆成中间两段是因为TCP支持半关闭half-close也就是一端关闭发送后另一端仍能继续发数据。这个能力在实际应用里有重要价值。最典型的场景是HTTP的Connection: close 之后服务端一般会先发完响应数据再主动关闭连接此时“客户端发FIN - 服务端继续发数据 - 服务端再发FIN”这个顺序就体现了半关闭的意义。在socket编程里体现为shutdown()和close()的区别shutdown(sock, SHUT_WR)只关闭发送方向还能继续收数据。close(sock)彻底释放socket描述符收发两个方向都不能用了。如果你在写文件传输类程序发送方发完数据后调用shutdown而不是close能让接收方及时感知到“数据结束了”read返回0但接收方还能把结果回传。这个细节在面试和实际故障排查里都容易踩我确实见过有同事用close导致对端收到RST文件传输直接中断排查半天的。3. 协议设计边界为什么不能是两次握手或三次挥手3.1 两次握手的致命缺陷历史报文串扰先思考一个场景客户端发起TCP连接发出的第一个SYN因为网络拥塞被卡在路上很久客户端超时后重新发了一个新的SYN建立起了连接双方通信完毕并正常关闭。结果那个被卡住的旧SYN此时才到达服务端。如果TCP只有两次握手会发生什么服务端收到这个迟到的旧SYN会认为客户端要建立新连接于是分配TCB、进入连接状态并回一个SYNACK。可客户端根本不存在这样一个新的连接不会理它。服务端就白白浪费一个连接资源一直挂着直到超时。三次握手怎么解决这个问题因为客户端只有在确实发起连接时才会响应服务端的SYNACK也就是第三个ACK才是关键确认。服务端在收到ACK之前都处于SYN_RCVD不进入ESTABLISHED不会把资源当成正式连接。如果收到的是迟到旧SYN对应的SYNACK客户端不会回ACK服务端等不到第三个包就会释放半连接。网上有个很流行的说法是“三次握手的核心是确认双方收发能力”我觉得这只是表层的解释更本质的原因是通过一个额外的往返确认避免历史报文对新数据的污染。这一点在做协议设计时很关键不光是TCP很多自研协议在握手阶段也会加入自己的“挑战-应答”机制底层逻辑是一样的。3.2 握手的步数能不能再压缩为什么不把ACK捎带进数据里有朋友会问既然第三次握手只是ACK能不能省略掉——客户端收到SYNACK后直接开始发数据反正服务端看到第一个数据包就知道客户端收到了自己的SYN设计上的答案很明确不行。TCP不是磁带的“一发不可收拾”它要保证每个字节都被确认连接建立这个动作更不能依赖“后续数据包”隐含确认。如果客户端第一个数据包丢了服务端根本不知道对方有没有成功建立连接连接状态会一直悬着。更麻烦的是服务端在收到数据的瞬间其实是”未收到ACK“的状态此时如果数据包因为乱序或者丢失提前到达服务端会莫名其妙认为连接建立了从而分配资源这又回到上一节的资源浪费问题。说到底TCP追求的是一种“确定性的连接状态”三次握手里没有任何一步是依赖猜测或者捎带推断的每一步都有明确的ACK作为完成标志。这也是它和UDP带外传输的本质不同。3.3 如果挥手只挥三次会怎样先别急着回答我的建议是这个问题要先区分“报文数量”和“状态转移次数”两个概念。现实中确实经常出现只看到3个报文的挥手过程但状态依然是4次转移原因是第2步ACK和第3步FIN被合并了。所以“三次挥手”准确说应该是“把两个方向关闭确认的其中一个合并掉”这在有半关闭的场景下做不到逻辑自洽。假设一种极端情况客户端说“我不发了”FIN服务端立刻回一个“我也不发了”FINACK客户端回ACK看起来是三次。但服务端此时真的没有数据要发了吗不一定。如果服务端还有数据没发完这个合并就失败了必须拆开。标准四挥手的设计不假设双方数据发送同时结束所以独立保留两次FIN。这也是为什么很多教科书在讲挥手时一定会强调CLOSE_WAIT和FIN_WAIT_2这两个中间状态它们的存在就是给“服务端发送未完数据”留的时间。3.4 SYN Flood攻击者看准的就是握手的不对称性既然聊到握手设计顺便讲下网络安全里一个经典问题SYN Flood。半连接是TCP设计里相对昂贵的东西服务端收到SYN后会为它分配TCB虽然不像完整连接那样开窗口缓冲区那么大但一样要消耗内存和CPU。攻击者如果持续伪造源IP发送SYN不完成第三次握手服务端的半连接队列会被撑爆正常的SYN进不来这就是分布式拒绝服务的经典打法。防护手段说几个常见的调大tcp_max_syn_backlog给半连接队列扩容。开启net.ipv4.tcp_syncookies。开启后服务端在队列满时不再保存半连接信息而是把序列号通过hash算出来塞进SYNACK的Seq里等客户端回ACK时再通过Ack值还原信息。代价是丢失了一部分TCP选项协商能力但在被攻击时这是保命手段。利用SYN Proxy四层负载均衡设备常见功能由代理去完成握手验证验证通过的连接才转发给后端。用ss -lnt看的时候如果发现某个端口Recv-Q即半连接数量非常大而且源地址五花八门、数量巨大基本就要往这个方向怀疑了。4. 状态流转与线上排查把协议知识变成排障能力4.1 一张状态表定位连接卡在哪个环节TCP的所有状态变化都可以用netstat或ss观察到学TCP如果不是为了排障意义就少了一半。排查的第一步是你打开ss -ant能立刻知道每一行连接正处于什么状态。状态含义常见出现位置LISTEN服务端在监听端口accept队列等待中SYN_SENT客户端发了SYN等SYNACK客户端连接外网不通时SYN_RCVD服务端收到SYN等ACK半连接队列疑似SYN Flood时大量出现ESTABLISHED连接正常建立数据收发中FIN_WAIT_1主动方发了FIN等ACK短暂出现很难抓到FIN_WAIT_2主动方收到ACK等被动方FIN对端长时间不发FIN时会堆积CLOSE_WAIT被动方收到FIN等应用层close()业务代码没关socket时大量堆积LAST_ACK被动方发了FIN等最后的ACK很少见通常一闪而过TIME_WAIT主动方等2MSL短连接高并发场景常见这张表我打印过很多次每次排查连接相关故障先看状态分布基本能缩小一半怀疑范围。最典型的一类一大片CLOSE_WAIT不用多想就是业务层没释放连接。4.2 CLOSE_WAIT 堆积最常见也最好定位的服务端问题CLOSE_WAIT堆积的原因非常确定——对端发来FIN内核通知应用层“对端关闭了”但应用层没有调用close()把本端的socket也关掉。常见的坑有两个都是代码层面的一个是读取到EOF后忘记关闭。比如你封装了一个读数据函数recv返回0或者read返回-1就跳出循环却忘了关闭socket描述符。另一个是异常路径忘记关闭。try块里正常关闭了catch块里没有处理资源释放。这种最隐蔽平时测试很难触发一上线流量一冲就一堆CLOSE_WAIT。排查命令ss -ant | grep CLOSE_WAIT | wc -l ss -antp | grep CLOSE_WAIT第二行能显示进程PID然后用lsof看具体是哪些fd没关再回到代码里去对照。如果CLOSE_WAIT数量一直涨不用怀疑网络和内核问题一定在你的进程内部。4.3 TIME_WAIT 过多端口耗尽与优化策略TIME_WAIT多半出现在主动关闭连接的一方。高并发短连接场景里比如HTTP服务自己就是主动断开方TIME_WAIT堆积几百上千是常事通常问题不大最多占点内存。但极端情况下比如客户端或代理机器上也堆了大量TIME_WAIT可能出现端口不够用——因为TCP四元组里的客户端端口是有限的连接不能无限复用。处理方式我按优先级排一下最推荐的方案改造业务层减少短连接用长连接池。连接复用了TIME_WAIT自然少。开启net.ipv4.tcp_tw_reuse1。注意这个参数的作用是让客户端在发起新连接时如果本地有TIME_WAIT状态的连接且四元组不冲突可以直接复用该端口。它只对发起连接的一方有效服务端别指望靠它解决问题。别乱开tcp_tw_recycle。这个参数在新内核里已经不推荐甚至移除了它依赖时间戳在NAT后面会坑很多人因为不同的客户端时间戳可能不一致导致合法连接被丢弃。我碰到过不止一次开了tw_recycle之后线上间歇性连接失败关掉就恢复正常。调整net.ipv4.ip_local_port_range扩大可用端口范围治标不治本但能应急。一个微妙的地方是TIME_WAIT其实是协议为了保证可靠性而付出的代价未必全是坏事。之前在排查一个偶发连接异常问题时我甚至怀疑过是TIME_WAIT关闭得太快导致旧报文复用了新连接。所以处理这个问题时先确认它到底有没有影响业务再动手优化别为了指标好看把协议的保护机制给弄没了。4.4 面试常追问三个值得深挖的细节最后把面试和日常理解中很容易出错的几个点拎出来说一下。如果你要对同事讲或者面试这三个点讲清楚比背完整流程更能体现水平。第一为什么客户端最后进入TIME_WAIT而不是服务端因为主动关闭方是客户端主动方发送最终的ACK它需要承担2MSL等待被动方收到ACK后就可以进入CLOSED。如果你在服务端看到大量TIME_WAIT说明服务端自己在主动断开连接这种场景常见于HTTP短连接模式。第二初始序列号为什么不从0开始防猜也防历史报文串扰。如果序列号可预测攻击者可以构造一个合法的SYN或RST包把连接干掉这属于协议栈安全的基本要求。所以现代内核里ISN通常是用时间相关的随机算法生成。第三握手和挥手报文里的Seq/Ack换算关系。SYN和FIN标志位各占一个序列号所以Ack值总是对方的Seq1。很多文档里说“ACK是Seq1”但这段表述在纯数据报文里并不成立——纯ACK报文的Ack等于对方的下一个字节序号不等于对方本包Seq1。只有SYN和FIN会占号这算是细节中的细节理解了它你就能看懂wireshark里所有报文的编号逻辑。最后的个人建议别光看文章和图找两台虚拟机甚至两个容器在一台机器上tcpdump在另一台上telnet或curl把三次握手和四次挥手各抓一遍。看着终端里一行一行刷出来的报文再去对状态流转很多背不下来的东西就会变成肌肉记忆。之后遇到连接挂死、端口耗尽、连接泄漏你也不会只想到重启服务而是先打开ss看一眼状态分布很多问题五分钟内就能定位了。
返回列表