聊TCP三次握手和四次挥手是绕不开的两个关。不管你是面后端、面网络还是平时排查线上故障这两个概念几乎必考也几乎都是“背下来容易讲明白很难”的代表。我第一次真正把它们弄懂不是靠八股文而是在一次线上偶发断连的排障里抓包看到SYN、ACK、FIN的来回交错才意识到协议栈的状态机设计得比我想象中严谨得多。这篇文章不打算复述教科书而是以应用开发和运维的视角把TCP连接从建立到拆除的完整生命周期拆开讲清楚为什么会是三次、为什么会是四次、状态机怎么转、线上异常怎么对应最后用几张对比表格把知识点固化下来。不管你是刚入门的学生还是被TIME_WAIT、CLOSE_WAIT困扰过的后端工程师都能从中找到可以直接上手的结论。1. 三次握手的系统性拆解1.1 第一次握手到底在做什么先说结论三次握手的本质是通信双方在正式传输数据之前先完成两件事——确认对方在线并且交换彼此的初始序列号。第一次握手的报文长这样客户端发送一个SYN报文SYN标志位置1同时携带一个随机生成的初始序列号我们叫它seqx。这个x不是从0开始的固定值而是由操作系统根据时间、随机数等因子生成的目的很简单防止攻击者猜出序列号也避免新旧连接的报文混淆。这个随机初始序列号是很多人看抓包时容易忽略的细节。你去抓一次包会发现同一个IP上新建的连接seq基本都是不同的这就是ISNInitial Sequence Number在起作用。服务端收到这个SYN之后不会立刻回复一个单独的ACK而是会在内存里为这个半连接分配一个控制块记录客户端的ISN然后进入SYN_RCVD状态。这里有个容易踩的坑很多人以为三次握手是“客户端SYN服务端ACK客户端再ACK”但第三步的ACK其实不是对第二步的确认而是对第一步SYN的确认同时也可以看作对服务端“宣告自身序列号能力”的应答。严格讲第二步是SYNACK它是把“确认”和“同步”两个语义合并到了一个报文里。抓包验证很简单tcpdump过滤一下SYN标志tcpdump -i eth0 -nn tcp[tcpflags] tcp-syn ! 0 and tcp[tcpflags] tcp-ack 0这时候你看到的基本都是SYN报文。如果把这个过滤条件改成syn和ack同时存在你会看到握手阶段的第二个报文几乎一模一样是46字节左右的包。这个“报文大小接近”的特点在排查防火墙丢包时很有用——很多防火墙策略会把SYN和SYNACK当作两类流量分别控制。1.2 为什么不能是两次握手三个硬理由这个问题面试必问也是真正理解三次握手的关键。我见过很多人背答案说“因为要防止失效的连接请求”但只答这一句是远远不够的至少有三个层面要考虑。第一防止历史重复SYN干扰。假设客户端先发了一个SYN因为网络拥塞卡了很久客户端超时后重新发起新连接这时旧SYN才姗姗来迟。如果是两次握手服务端收到这个旧SYN就直接建立连接、分配资源但客户端早已关闭了这个端口根本不会理它。结果就是服务端白白维护一个“假连接”直到超时才能回收。而在三次握手中客户端收到服务端对旧SYN的响应后会通过序列号判断这不是本次握手的回应于是发送RST报文把这个假连接干掉服务端就能及时释放资源。第二双向确认。TCP是全双工协议数据要双向传输所以双方的“发送能力”和“接收能力”都必须被验证。两次握手只能确认“客户端能发、服务端能收”这个单向通道是通的无法确认“服务端能发、客户端能收”这个反方向是否正常。第三次握手时服务端收到客户端发来的ACK才能确定自己的发送通道对客户端是可达的。这个理由特别适合用来解释为什么挥手必须是四次——因为两个方向的通道要分别关闭。第三序列号同步。只有双方都确认了对方的初始序列号后续数据包的编号才能对得上。客户端通过第二步SYNACK拿到服务端的ISN服务端通过第三步ACK确认客户端已经收到了自己的ISN这样双方才建立起一致的“数据编号上下文”。我用一个生活中的例子来比喻你约朋友去咖啡店碰头不会刚喊一声“我到门口了”就直接开始点单。得先对方回应“我也到了”你再确认一句“好我看到你了”双方才会开始交流。前两句只能证明你俩都在附近第三句才确保双方都完成了信息对齐。1.3 握手状态链与服务端半连接队列聊完理论看状态流转。三次握手过程中客户端的连接状态变化是CLOSED → SYN_SENT → ESTABLISHED。服务端则是LISTEN → SYN_RCVD → ESTABLISHED。这里有个关键点服务端的SYN_RCVD状态不是孤立存在的它和内核里的半连接队列绑在一起。在Linux上服务端收到SYN后内核会把这个连接放进两个队列之一半连接队列syn queue和全连接队列accept queue。半连接队列里放的是已经收到SYN、还没完成三次握手的连接全连接队列里放的是已经完成握手、等着应用层调用accept()取走的连接。很多“connection reset by peer”、“握手超时”问题根源不在应用代码而是这两个队列满了。控制这两个队列的内核参数分别是# 半连接队列上限 net.ipv4.tcp_max_syn_backlog 65535 # accept队列上限 net.core.somaxconn 65535注意应用层监听socket里的backlog参数也会限制accept队列大小最终的有效值是min(somaxconn, backlog)。也就是说你光在nginx配置里写了listen 80 backlog65535但内核的somaxconn还是默认的4096队列照样上不去。这是我在排查Nginx高并发连接时踩过的坑后面会展开说。半连接队列最容易受SYN Flood攻击。攻击者不断发送SYN但不回应第三步ACK服务端半连接队列很快被占满正常用户的SYN就进不来了。Linux的应对机制是tcp_syncookies不分配半连接队列资源直接把“序列号时间戳源地址”等信息加密成一个cookie放在SYNACK里等客户端回ACK时再重构连接信息。这个机制保证极端情况下正常用户仍能完成握手代价是稍微增加CPU计算量。生产环境一般建议开启net.ipv4.tcp_syncookies 1日常查看半连接队列积压情况可以用ss -tan state syn-recv ss -lnt如果syn-recv状态的连接数长期居高不下或者ss -lnt里某个监听端口的Recv-Q队列持续非零就要警惕是不是有人在打SYN包或者backlog配置太小。2. 四次挥手的完整生命周期2.1 为什么挥手是四次半关闭设计四次挥手比握手多一次原因在于TCP是全双工协议建立连接时一个SYNACK报文可以同时干两件事但断开连接时不行——两个方向的关闭动作必须独立完成而且不一定同时发生。具体拆开看客户端要关闭“自己发送数据”这个方向发一个FIN报文服务端收到后回ACK表示“知道了”但服务端可能还有数据没发完所以不能立刻回FIN。等服务端把最后一个字节发完它也要关闭“自己发送数据”的方向于是发FIN客户端收到后回ACK整个连接才能关闭。你可以类比成两个人打电话道别先提出挂电话的人说“我要挂了”FIN对方应一声“好的我知道了”ACK但对方可能还有事没说完于是继续说等说完了对方再说“我也说完了挂吧”FIN先前提出挂电话的人再应一句“好挂吧”ACK。这个过程天然就是四次交互不是三次。为什么不能像握手一样把两步合并成一步因为在第三次挥手时被动关闭方不一定能立刻close。它可能还在处理业务逻辑或者还有积压数据要写。如果强行规定收到FIN就必须马上FINAL那就要求应用层和内核协议栈完全同步关闭这在现实中是不可能的。所以协议设计上必须允许“半关闭”状态让一方先关闭发送另一方继续发送。2.2 四次挥手的四个报文与状态流转挥手过程看起来比握手复杂状态也多不少。我按主动关闭方通常是客户端但也可以是服务端的视角走一遍第一步主动方调用close()内核发送FIN报文主动方进入FIN_WAIT_1。这个状态很短暂如果网络通畅马上就会收到对方的ACK。第二步被动方收到FIN内核立刻返回ACK被动方进入CLOSE_WAIT状态。这时候被动方的应用程序会感知到EOF如果程序逻辑写得没问题它应该着手释放资源、关闭自己的套接字。主动方收到ACK后从FIN_WAIT_1进入FIN_WAIT_2。第三步被动方处理完所有数据调用close()内核发送FIN被动方进入LAST_ACK状态。第四步主动方收到FIN发送最后一个ACK然后进入TIME_WAIT状态。被动方收到这个ACK后从LAST_ACK进入CLOSED。这里有一个非常关键的细节很多人忽略如果最后一个ACK丢了被动方会一直处于LAST_ACK状态等待超时后重发FIN。而主动方因为处于TIME_WAIT收到重发的FIN后还会再次回ACK。也就是说TIME_WAIT状态的存在就是为了兜底处理“最后一个ACK丢失”这种情况。我在抓包时见过几次这种重发FIN的现场如果不理解状态机很容易误判为“连接异常”。顺手把状态流转用表格列出来方便对照阶段主动方状态被动方状态关键报文挥手前ESTABLISHEDESTABLISHED正常数据传输第一次挥手FIN_WAIT_1CLOSE_WAITFIN第二次挥手FIN_WAIT_2CLOSE_WAITACK第三次挥手TIME_WAITLAST_ACKFIN第四次挥手TIME_WAITCLOSEDACK2.3 TIME_WAIT的2MSL到底在等什么TIME_WAIT是面试频率极高的考点。等2MSL的原因主要有两个分开说。第一个原因是确保最后一个ACK能够到达对端。ACK报文在网络中可能丢失如果丢了被动方会重发FIN。主动方只有在TIME_WAIT期间收到重发的FIN才能重新回ACK。2MSL的时间足够一个报文在网络中“从一端传到另一端又传回来”所以在这个时间内即使ACK丢了也能等来重发的FIN并再次回应。第二个原因是让本次连接中所有“残留报文”在网络中彻底消失。TCP报文在网络里是有生存时间的超过MSL就要被丢弃。如果连接立刻释放端口立刻被新连接复用理论上旧连接里延迟到达的报文可能被误认为新连接的报文造成数据错乱。等待2MSL等于是给所有旧报文留出足够的时间“死干净”。TIME_WAIT状态下这个连接的四元组源IP、源端口、目标IP、目标端口还不能被新的连接使用。线上如果大量产生短连接会出现TIME_WAIT堆积占用大量端口和内存。我在用Java写定时任务时遇到过这个问题每5分钟new一个Socket连接跑一天就报“Address already in use”后面会专门讲怎么根治。TIME_WAIT持续时间是2MSLLinux上MSL默认30秒到60秒所以TIME_WAIT一般持续60秒到120秒。网上有文章建议改小MSL来快速释放端口短期能缓解端口耗尽但可能增加数据错乱风险不建议在生产环境乱调。更健康的方式是减少短连接、使用连接池、开启SO_REUSEADDR。3. 核心对比与状态速查3.1 三次握手和四次挥手的报文交互对照很多教材把握手的三个报文和挥手的四个报文分开讲但放到一起对比会更清晰。我用表格把两个过程的核心要素摆出来方便记忆也方便复习对比维度三次握手四次挥手报文数量3个4个标志位组合SYN、SYNACK、ACKFIN、ACK、FIN、ACK是否可能合并SYNACK必须合并成一步第二次ACK和第三次FIN不能保证合并参与方向双向同步序列号两个方向独立关闭是否携带数据标准握手不携带应用数据FIN之前的最后数据是合法的建连/断开从CLOSED到ESTABLISHED从ESTABLISHED到CLOSED关键状态SYN_SENT、SYN_RCVD、ESTABLISHEDFIN_WAIT_1、CLOSE_WAIT、LAST_ACK、TIME_WAIT何时主动发起客户端主动先关闭的一方主动这个表格里值得强调的是TCP允许在挥手阶段发送数据。比如被动方收到FIN后还可以继续发送剩余数据然后再发FIN。这一点和三次握手完全不同握手中如果携带数据很多协议栈会直接忽略或当作异常处理。3.2 两端状态残留速查表排障的时候最基础的本事就是看到ss -tan里的一堆状态能立刻知道连接处于什么阶段。我把常见状态和它们对应的阶段整理出来直接照着对状态属于哪一方含义如果大量出现表示什么SYN_SENT客户端客户端已发SYN正在等SYNACK服务端没响应或防火墙丢包SYN_RCVD服务端已收SYN正在等客户端ACK半连接队列积压、被SYN FloodESTABLISHED双方连接建立成功正常传输正常但也要关注数量是否超限FIN_WAIT_1主动关闭方已发FIN等ACK通常瞬态如果积压说明对方没回ACKFIN_WAIT_2主动关闭方已收ACK等对方FIN如果长时间不消失说明对方一直不closeCLOSE_WAIT被动关闭方已收FIN应用还没闭应用程序忘了close必须查代码LAST_ACK被动关闭方已发FIN等最后一个ACK通常瞬态积压说明最后的ACK丢了TIME_WAIT主动关闭方等待2MSL确保旧包消失短连接过密端口可能不够用CLOSED双方连接关闭正常记住一个规律看到CLOSE_WAIT先查应用代码看到TIME_WAIT先查连接的使用方式和端口规划看到SYN_RCVD先查队列参数和是否被攻击。这三个状态是线上排障中最常遇到也最能反映问题的。3.3 常见误区SYN、ACK、FIN、RST别搞混聊几个高频误区都是我实际见过有人犯错的点。第一个误区是分不清RST和FIN。RST不是正常的关闭方式是一次异常重置。端口没有监听、接收缓冲区有未读数据但连接被强制关闭、超时被协议栈判定异常时都会出现RST。线上看到大量RST优先怀疑应用异常退出或防火墙策略。FIN是“好聚好散”RST是“当场摔电话”两者的语义完全不同。第二个误区是以为断开一定是服务端先发FIN。其实谁先close谁就先发FIN。客户端可以主动断开服务端也可以。我们习惯上把“客户端—服务端”代入主动和被动但真正决定权在应用层谁先调用close谁就是主动方。很多新手看到服务端出现TIME_WAIT就惊讶其实如果服务端主动关闭连接它照样会进TIME_WAIT。第三个误区是不理解三次握手绝对不传应用数据。标准TCP握手第三个ACK报文里虽然可以带上数据比如HTTP早期的一些实现但绝大部分情况下应用数据都是握手完成、ESTABLISHED之后才发送的。如果面试或者做协议解析时看到握手阶段就带负载要么是TFOTCP Fast Open这种新特性要么就是伪装的扫描工具正常业务下少见。4. 开发与运维中的TCP排查实录4.1 案例一Java客户端重连时报“地址已在使用”这个案例几乎能映射到所有使用短连接的语言。现象是定时任务每隔几分钟就new一个Socket去连服务端跑一段时间后开始报java.net.BindException: Address already in use。根本原因很清晰每次Socket close后客户端端口进入TIME_WAIT状态2MSL内几十秒到两分钟这个端口不能被复用。如果任务跑得密集系统可用端口就越来越少最终耗尽。Linux上临时端口的默认范围在/proc/sys/net/ipv4/ip_local_port_range里一般是32768到60999总共也就两万多个短连接一多很容易见底。解决思路分几层。第一层能用长连接就尽量别用短连接每次查询复用同一个连接或使用连接池第二层如果确实需要频繁创建连接客户端Socket可以设置SO_REUSEADDR允许端口在TIME_WAIT阶段被复用第三层如果端口还是不够适当扩大ip_local_port_range但这属于治标。Java里设置SO_REUSEADDR很简单Socket socket new Socket(); socket.setReuseAddress(true); socket.connect(new InetSocketAddress(host, port), 3000);这里有个容易被忽略的技术细节客户端的SO_REUSEADDR作用是允许处于TIME_WAIT状态的本地端口被新的连接复用并不是像某些文章说的那样“服务端必须加这个才能重启”。服务端监听socket加SO_REUSEADDR的意义在于当服务进程重启、端口还在TIME_WAIT中时可以立刻重新绑定监听端口否则会直接绑定失败。所以说客户端和服务端的SO_REUSEADDR解决的是不同问题。网上很多教程把它们混为一谈实际排障时会误导人。4.2 案例二服务端CLOSE_WAIT堆积CLOSE_WAIT堆积是后端开发最常遇到的连接泄漏。现象很典型ss -tan一看几千个CLOSE_WAIT状态的连接挂在同一个服务上内存和文件描述符持续上涨最终所有新连接都建不进来。CLOSE_WAIT怎么产生的前文讲过被动方收到FIN后进入CLOSE_WAIT如果应用层没有调用close这个状态就永远停留在那里。Java里头最常见的场景是用了HttpClient、Socket、InputStream但没在finally里关闭资源或者业务线程阻塞在某个调用上finally代码没执行到或者连接池里的空闲连接被对端断开但池本身不检测失效连接。排障三板斧# 1. 看有多少CLOSE_WAIT ss -tan | grep CLOSE-WAIT | wc -l # 2. 看具体是哪些连接 ss -tan state close-wait # 3. 如果是Java进程配合看线程栈 jstack pid | grep -A 20 http-nio\|SocketRead从经验看CLOSE_WAIT爆炸往往是代码问题不是网络问题。修复思路是所有涉及连接、流、通道的代码用try-with-resources或者try-finally保证close设置合理的读超时避免线程一直阻塞在read上连接池要开启空闲检测和回收。4.3 案例三Nginx反向代理TCP最大连接数上不去一个真实场景内网某服务用Nginx做四层反向代理压测到几千并发连接时客户端开始大量报connection reset。当时第一反应是改Nginx配置把worker_connections从1024调成10240listen 80 backlog8192也写了结果还是不行。最后发现瓶颈不在Nginx而在系统参数。真正的限制链条是这样的客户端发起SYN经过Nginx时先经过全连接队列。accept队列的有效长度是min(net.core.somaxconn, backlog)。系统默认somaxconn只有4096虽然listen backlog写了8192但生效值还是4096。压力一上来全连接队列溢出内核直接丢包客户端重试无果后报错。正确的调整组合是# 内核层 net.core.somaxconn 65535 net.ipv4.tcp_max_syn_backlog 65535 # nginx层 worker_processes auto; events { worker_connections 65535; accept_mutex off; } listen 80 backlog65535;另外还要看文件描述符上限Nginx单进程承载的连接数不能超过ulimit -nulimit -n 655350这个案例说明一个规律TCP连接相关的坑十有八九不是单一因素而是应用配置、内核参数、资源限制三层堆叠造成的。排查的时候不能只看自己熟悉的层面。4.4 案例四抓包看到大量tcp dup ack怎么判断丢包还是乱序TCP的Dup ACK机制属于传输可靠性的一部分但线上看到它不代表一定出故障要结合上下文判断。我曾经在半夜接到报警说某个服务节点网络抓包出现大量TCP Dup ACK服务本身却没有任何报错。后来分析发现是上游设备短暂拥塞导致几个包乱序到达属于网络中的正常现象不用处理。判断方法是看抓包时间线和序列号。正常的乱序表现为某条连接里对端连续回了几个“Dup ACK”后缺失的包很快又到了然后继续正常传输整个过程RTT没有明显上升。真正的丢包则表现为同一个序号的重传包反复出现伴随RTT变长而且Dup ACK持续不休直到重传到达才恢复。实战中我习惯用Wireshark的Expert Information面板它会自动归类Retransmission、Dup ACK、Out-of-Order。看到大量Out-of-Order但重传很少基本可以判定是网络路径上的乱序问题看到Retransmission和Dup ACK成对出现且频繁就要去查对端网卡丢包、带宽打满或者防火墙策略了。排查丢包还有几个很务实的命令# 网卡丢包统计 ip -s link show eth0 # 系统协议栈丢包统计 netstat -s | grep -E segments retransmited|bad segments|resets received网卡的RX dropped如果持续增长基本可以锁定是物理层或驱动问题协议栈统计里retransmited偏高则大概率是网络拥塞或对端处理不过来。4.5 Windows下查看TCP全局参数不是所有环境都是Linux。Windows服务器也有对应的TCP参数查看方式先记住这个命令netsh interface tcp show global这个命令能一次看到Windows的TCP全局配置接收窗口自动调优级别、ECN能力、定时器设置、初始拥塞窗口等。我在维护Windows Server上的高并发长连接服务时发现Windows默认的TCP设置和Linux差异很大接收窗口的自动调优有时会限制大流量下载的吞吐。如果确认是Windows端带宽上不去可以手动关闭自动调优级别或者固定为normalnetsh interface tcp set global autotuninglevelnormalWindows上处理TIME_WAIT堆积不像Linux那样有tcp_tw_reuse通常要改注册表。两个关键值TcpTimedWaitDelay默认120秒可调小和MaxUserPort限制用户可用的端口数量默认很小。改注册表有风险建议在测试环境验证后再上生产不要盲目调低TIME_WAIT否则一样有旧包错乱风险。5. 从背概念到真会用的三个落地建议5.1 亲手抓一次包胜过看十篇文章学TCP协议最忌讳只看图不动手。抓包工具用tcpdump加Wireshark就够。我建议在一台Linux测试机上用一个简单的HTTP请求同时抓包tcpdump -i lo -nn -w handshake.pcap tcp port 8080然后用客户端发起一次HTTP请求。打开pcap文件后你会在Wireshark里清晰地看到三握手两挥手先是三条短的握手报文长度几乎一样中间夹着HTTP请求响应数据最后是四条短的挥手报文。把鼠标放上去看标志位SYN是0x002SYNACK是0x012ACK是0x010FINACK是0x011。这些十六进制标志位只要见过一遍以后看任何抓包都不会发怵。第二步是做一次“主动关闭”和“被动关闭”的对比实验可以用nc -l 8080开个监听然后从客户端连接后立刻关闭客户端观察服务端的CLOSE_WAIT会不会出现。如果你能在抓包和状态机里亲眼看到这个状态对TCP生命周期就建立了肌肉记忆。5.2 把连接生命周期纳入应用设计很多线上连接问题根子不在协议栈而在应用设计。连接什么时候建立、什么时候关闭、异常时怎么重试这些都应该在写代码时想清楚。我总结几个靠谱的做法。第一用连接池而不是临时建连。无论Java的HttpClient还是MySQL的Connector连接池都能避免大量TIME_WAIT堆积。第二重试逻辑要带退避不能无限快速重连否则客户端端口和系统资源都会被打爆。第三优雅关闭一定要实现。Java服务关闭时先停止接收新请求再等待存量请求处理完最后调用close释放连接不要直接在进程退出时强行断开否则对端会看到大量RST。第四长连接要有心跳。TCP本身没有应用层心跳如果中间设备静默丢弃空闲连接业务方可能一直以为连接是好的。用TCP keepalive时建议调短默认时间或者用应用层心跳包。心跳机制的本质是让连接周期性地产生数据包从而暴露那些“已死但未被发现”的连接。5.3 几个值得记住的TCP参数清单调优TCP连接不需要背一堆参数记住常用的几个就够了。我按使用频率列了一个清单参数作用适用场景net.core.somaxconnaccept队列上限高并发服务、Nginx调优net.ipv4.tcp_max_syn_backlog半连接队列上限抗SYN Flood、大量并发连接net.ipv4.tcp_syncookiesSYN Flood防护开关推荐开启net.ipv4.tcp_tw_reuse允许TIME_WAIT端口复用仅客户端出站大量短连接场景net.ipv4.tcp_tw_recycle旧内核的TIME_WAIT快速回收不推荐4.12后已移除net.ipv4.tcp_keepalive_time空闲多久开始探测调整长连接空闲踢除时间端口范围ip_local_port_range本地临时端口范围客户端大量短连接时调大注意一个很容易被误导的点tcp_tw_reuse只对客户端发起的新连接有效对服务端主动关闭后的TIME_WAIT基本没用。另外它依赖TCP时间戳选项tcp_timestamps如果系统里关了这个选项tcp_tw_reuse也不会生效。网上很多文章把这些细节混在一起讲用的时候容易踩坑。还有一个常见误区是拿tcp_tw_recycle当优化手段。这个参数在旧内核里确实能快速回收TIME_WAIT但它在NAT环境下会影响同NAT后多个客户端的时间戳校验导致连接被随机丢弃体验非常差。Linux 4.12内核已经把它移除了如果你的系统还在用建议尽早停掉。这几年的排障经历让我有一个很深的体会TCP的三次握手和四次挥手绝不是面试背完就扔的八股文。它们背后的状态机、超时机制、资源管理逻辑几乎每天都在和真实业务打交道。你在抓包里看到的每一个SYN、FIN、RST背后都是一次业务的建立、关闭或异常。把这些状态真正和线上现象对应起来比记住任何一张概念图都有用。
阅读完成 · 觉得有帮助?