
有一年夏天我在工控现场排查一个“设备掉线又自动重连”的问题服务器上netstat一刷TIME_WAIT一片CLOSE_WAIT还有几十个。如果只看业务日志基本只能靠猜后来把TCP连接建立和断开那套状态机捋清楚五分钟就锁定了方向。TCP三次握手与四次挥手是所有后端、网络、嵌入式开发者绕不开的一道基础题。表面上是三个报文、四个报文背后其实是两个“社恐”程序在不可靠链路上的谨慎社交先确认对方能听到、能说话再开始正式传输结束时也要一个方向一个方向地告别绝不留下悬念。这篇文章我会从握手到挥手拆开讲补充状态机、常见坑、实战排查最后把TCP和UDP的选型差异也说清楚。新手可以照抄思路老手也可以当一次查漏补缺。1. 三次握手两个“社恐”程序的破冰仪式1.1 为什么连接要先“试探”TCP是面向连接的协议但在真正开始传数据之前通信双方根本不知道对方是不是活着、网络通不通、对方内核协议栈有没有准备好接收数据。这种场景很像两个社恐的人第一次见面谁都不敢直接开讲必须先确认“你在听吗”“我能说吗”“你那边方便吗”来回确认几次才敢进入正题。UDP就没有这个负担发出去就不管了。TCP之所以要建立连接是因为它提供了可靠性、有序性、重传、流量控制这些能力而这一切的前提是双方必须协商好初始序号、接收窗口大小、最大报文段长度等参数。这些信息不是凭空来的需要通过握手报文逐项确认。我在新手期犯过一个错误直接调用connect然后立刻写数据结果偶尔丢数据。后来才明白connect返回成功只代表三次握手完成不代表对端应用已经read了数据。连接建立的确认和业务数据的确认是两回事。搞清这个区别后续处理超时重试会少掉很多头发。1.2 三次握手的报文细节与序号规则标准的三次握手流程是这样的客户端发送SYN报文携带初始序号seqx。x是一个随机生成的ISN不是固定的0或1。服务端收到后回复SYNACK报文携带自己的初始序号seqy同时确认号ackx1。客户端收到SYNACK后再回复一个ACK报文确认号acky1序号seqx1。这里有两个细节值得反复琢磨。第一SYN和FIN标志位虽然不携带应用数据但各自会消耗一个序号。所以客户端发SYN时seqx等到发ACK时序号已经变成x1。同样的道理四次挥手中FIN也占一个序号。这就是为什么ack永远是“对方起始序号1”而不是“对方起始序号”。第二seq和ack的配合逻辑贯穿TCP一生。seq表示“这个报文里第一个字节的序号”ack表示“我已经正确收到对方发来的、序号小于等于ack-1的所有字节下一个请从ack开始发”。如果你用Wireshark抓包看到Seq100Ack300翻译过来就是我发的数据从100编号开始同时我期待你下一个包从300编号开始。这个机制比想象中重要。线上数据错乱、重复投递很多都能从seq和ack的跳变里找到线索。我曾经定位过一个诡异问题客户端一直重传某个包对端一直不回ACK抓包发现服务端进程阻塞在磁盘IO上内核接收缓冲区满了根本没机会处理网络层。这种“握手之后连接正常但数据传输卡死”的场景最终还是要回到报文细节里找答案。1.3 为什么是三次而不是两次或四次这个问题几乎是面试必问但很多答案都停留在“两次不够可靠”的层面。我尝试用一个更具体的场景讲透。假设只有两次握手客户端发SYN服务端回SYNACK然后服务端就认为连接建立了开始分配资源、发送数据。问题来了如果这个SYN是一个网络延迟了很久的旧报文比如客户端上一次连接的SYN被卡在网络里现在才到达服务端服务端并不知道这是旧的于是照样回复SYNACK照样建立连接照样等数据。客户端收到服务端的SYNACK之后发现这个连接根本不是自己想要的可能直接丢弃。结果就是服务端白白挂着一个半废的连接浪费资源严重的还会被这种机制拖垮。三次握手就规避了这个问题客户端收到服务端的SYNACK后会判断这个连接是不是自己发起的。如果是旧SYN引发的那次连接客户端根本不会回复第三次ACK服务端迟迟等不到确认就知道这次连接没有被接受可以安全释放。至于为什么不是四次道理更简单三次已经完成了双向确认。第一次SYN证明客户端能发第二次SYNACK证明服务端能收能发第三次ACK证明客户端收到了服务端的应答。每一个方向都得到了对方确认再多一次握手并不会增加任何新的信息只会白白增加一个RTT的建连延迟。我自己的理解方式是这样的三次握手的本质是让双方都确认“我能发、你能收你能发、我能收”。两个方向都验证一遍正好需要三个报文。1.4 别忘了握手队列连接建立后的事三次握手的报文在系统层面还牵扯两个队列半连接队列和全连接队列也叫SYN队列和Accept队列。客户端SYN到达服务端服务端进入SYN_RCVD状态此时连接放在半连接队列里等第三次ACK到达服务端把连接移到全连接队列等待应用调用accept取走。如果全连接队列满了多余的完成握手连接会被丢弃或拒绝表现就是客户端connect成功但应用层一直accept不到新连接看起来像“连接建立不了”。判断这两个队列是否溢出有个常用命令ss -lnt重点关注Send-Q和Recv-Q这两列。如果Recv-Q一直有堆积说明有完成了三次握手、但应用没有及时accept的连接如果Send-Q的数值接近上限说明半连接队列可能被打满常见于SYN Flood攻击或者服务端accept太慢。很多人一遇到“客户端连不上”就怀疑防火墙但有时候就是Accept队列满了。在这种场景下光靠增加accept线程不一定有效可能要看应用是否卡在IO或者锁上也可能要调大内核参数net.core.somaxconn。先看清楚队列状态再动手改配置才是正确的排查顺序。2. 四次挥手体面的告别仪式2.1 双工连接为什么要挥四次手TCP连接是全双工的两个方向的数据通道互相独立。发送方可以A到B发数据同时B到A也在发数据。所以关闭连接的逻辑也必须照顾到两个方向A不再向B发数据这是一个方向B不再向A发数据这是另一个方向。两个方向各自关闭自然需要四个报文。如果想理解得更生活化一点可以把四次挥手想成两个人告别。A先说“我这边事情说完了准备走了”B回一句“知道了”。但B手里可能还有活没干完等B把最后的东西交代清楚才说“我也准备好了走了”。A最后回一句“好再见”。整个流程中B从收到FIN到发出自己的FIN之间是有一段“缓冲时间”的可能用来发最后的剩余数据。如果强行合并成三次就意味着B收到FIN那一刻必须立刻关闭自己的发送通道这会破坏TCP的全双工特性。2.2 挥手过程的状态迁移与报文顺序标准四次挥手流程如下从主动关闭方开始讲主动关闭方发送FIN报文seqp进入FIN_WAIT_1状态。被动关闭方收到FIN回复ACKackp1进入CLOSE_WAIT状态。主动关闭方收到ACK后进入FIN_WAIT_2状态。被动关闭方在完成剩余数据传输后发送自己的FIN报文seqq进入LAST_ACK状态。主动关闭方收到FIN回复ACKackq1进入TIME_WAIT状态。被动关闭方收到ACK后进入CLOSED状态。这里有个常见误区很多人以为挥手一定是客户端发起实际上谁都可以先挥手。客户端可以主动断开服务端也可以。真正的区分是“主动方”和“被动方”不是“客户端”和“服务端”。我在一个项目里见过服务端因为内存压力主动关连接结果客户端那边报一堆异常。后来约定好业务层有明确的“谁先断、何时断”规则才消停下来。另一个需要留意的点第二步的ACK和第三步的FIN之间可能间隔很久。如果被动关闭方一直不发第三个报文主动关闭方就卡在FIN_WAIT_2状态。这个状态并不少见如果应用逻辑没处理好能挂很久。2.3 TIME_WAIT主动关闭方要等的那“两分钟”四次挥手里最容易被忽略、也最影响线上稳定性的就是TIME_WAIT状态。主动关闭方在发出最后一个ACK之后并不会立刻进入CLOSED而是进入TIME_WAIT等待2MSL后才关闭。MSL是最大报文段生存时间常见的系统实现里2MSL通常是60秒到4分钟不等。为什么要等这么久原因有两个。第一确保最后一个ACK能被对方收到。如果这个ACK在网络中丢失了被动关闭方会认为自己的FIN没被确认于是重发FIN。主动关闭方还在TIME_WAIT状态里就能再次回ACK。如果主动关闭方直接进入CLOSED被动方重发的FIN就无人回应了。第二防止旧连接的延迟报文干扰新连接。假设客户端主动断开后立刻用相同的四元组建立新连接旧连接在网络中残留的报文如果还没消失就可能被新连接误收。等待2MSL可以让网络中所有旧报文都过期消失保证新连接是在干净的信道上建立的。TIME_WAIT是最消耗端口的一个主动关闭的短连接会占用一个本地端口2MSL时间。高并发短连接场景下客户端或服务端会出现大量TIME_WAIT导致端口不够用。有人为了“解决”这个问题直接把TIME_WAIT时间改短或者开启SO_LINGER强制RST关闭。这两种做法我都试过确实能减少TIME_WAIT但并不推荐。RST关闭意味着不按正常挥手流程走未发送的数据可能直接丢弃对端看到的是连接异常重置而不是优雅关闭有些框架会把它当作严重错误处理。我现在的处理原则是TIME_WAIT多优先考虑是不是连接复用做得不够好而不是急着调内核参数。2.4 实战案例短连接重连报“地址已在使用”这个报错出现的典型场景就是Java客户端频繁connect同一个服务端某次connect失败异常信息里有“Address already in use”或者“Cannot assign requested address”。原理很简单客户端主动关闭一个连接后本地端口进入了TIME_WAIT状态。在这个端口还处于TIME_WAIT的期间如果客户端又尝试用相同本地端口、相同远端IP、相同远端端口去建立新连接四元组完全一致内核就会阻止这个连接报地址已被占用。排查此问题先把本地端口范围看清楚cat /proc/sys/net/ipv4/ip_local_port_range如果范围内可用端口不多而TIME_WAIT又堆积得很快确实容易撞上。常见优化手段有三个方向。第一程序层面尽量复用连接而不是每次都connect。一个长期运行的客户端维护一个连接池比反复建立短连接健壮得多。第二服务端支持TCP keepalive或者应用层心跳让空闲连接不要被过早回收。第三如果确实无法避免短连接可以考虑调整系统参数比如开启tcp_tw_reuse它允许客户端在某些条件下复用处于TIME_WAIT状态的连接但要注意这个参数影响的是出方向连接而且需要在保证网络环境可预测的前提下使用。这里要特别提醒一个坑不要动tcp_tw_recycle这个参数在NAT环境下会导致大量连接异常在老内核上还会引发随机丢包。我见过有人照搬网上的优化命令把tcp_tw_recycle开启后整个服务的连接稳定性反而下降了这属于典型的“为了优化而优化”。3. TCP状态机从连接建立到关闭的完整地图3.1 状态速查表把三次握手和四次挥手串起来看TCP连接的一生就是一张状态迁移图。我整理了一份常用状态速查表排查时对照着看能省很多时间。状态所在端触发条件常见含义CLOSED任意初始状态无连接LISTEN服务端调用bindlisten等待客户端连接SYN_SENT客户端发送SYN后等待服务端SYNACKSYN_RCVD服务端收到SYN并回复SYNACK半连接存在ESTABLISHED双方握手完成正常传输数据FIN_WAIT_1主动关闭方发送FIN后等待对方ACKFIN_WAIT_2主动关闭方收到对方ACK后等待对方FINCLOSE_WAIT被动关闭方收到FIN并回复ACK等待应用closeLAST_ACK被动关闭方发送FIN后等待对方ACKTIME_WAIT主动关闭方回复最后ACK后等待2MSLCLOSING双方同时关闭罕见状态用ss命令可以快速观测这些状态ss -ant | awk {print $1} | sort | uniq -c这个命令能打印出当前所有TCP连接的状态统计。哪一类状态突然变多排查方向往往就清楚了。3.2 连接异常时的状态诊断线上最常见的异常状态是CLOSE_WAIT堆积。CLOSE_WAIT表示被动关闭方已经收到对方的FIN回复了ACK但应用程序还没有调用close关闭本地socket。如果这个状态的数量持续上涨几乎可以断定是应用层没有正确释放连接。典型的代码问题有几种读取到EOF之后直接break但忘了close异常分支里没有把socket关闭连接池中的空闲连接没有清理。排查CLOSE_WAIT堆的时候一个有效手段是看Java线程栈或者C/C程序的调用栈定位到哪个模块一直占着socket不释放。我有一个比较深刻的案例某个服务处理消息时会调用一个慢速第三方接口第三方超时时间设得特别长导致服务端线程全部阻塞在这个调用里。客户端等不及开始主动断开服务端收到FIN却没有线程执行closeCLOSE_WAIT一路飙升。最终服务端自己的连接数也打满了新请求完全进不来。解决方式很直接给第三方调用加超时同时给socket设置合理的读写超时确保异常路径也能关闭连接。FIN_WAIT_2长期不消失是另一个常见现象。主动关闭方发出FIN收到ACK后进入FIN_WAIT_2此时如果被动关闭方一直不发FIN这个状态就一直挂着。它背后往往是CLOSE_WAIT状态的另一面被动关闭方应用一直没有关闭连接。看到客户端大量FIN_WAIT_2同时服务端大量CLOSE_WAIT基本可以确定问题根因在服务端代码。3.3 稳定传输的秘密Dup ACK与快速重传三次握手之后TCP进入数据传输阶段这里最值得掌握的重传机制就是Dup ACK。正常情况下接收方每收到一个报文段会回复一个包含下一个期望序号的ACK。如果数据包在网络中丢失接收方会连续收到多个“序号不连续”的报文每收到一个乱序包就会重复回复期望序号的ACK。发送方如果连续收到3个相同的ACK就能推断某个段丢了不用等超时定时器立刻重传丢失的段。这就是快速重传。抓包时看到“TCP Dup ACK”并不是异常它只是接收方在说“我还在等序号为X的数据后面发来的我都看到了但都不是我等的”。真正需要警惕的是Dup ACK大量出现说明网络中出现了丢包或者乱序。常见原因有链路拥塞、网卡队列溢出、中间设备缓存不够、多路径传输导致乱序。有一次我排查延迟抖动抓包看到大量Dup ACK和快速重传。一开始怀疑是带宽不够后来把网卡队列长度调大同时确认了交换机没有开启EEE节能以太网抖动就消失了。这类问题如果不是亲眼看报文很难从应用日志里找到头绪。理解Dup ACK还有一个好处写应用层协议时你可以自己设计“确认机制”来模拟TCP的重传逻辑。比如在UDP上做可靠传输时就可以借鉴ACK、序号、快速重传的思路。TCP的很多设计并不是只能用在TCP里它是通用可靠传输算法的最佳教材。4. 真实世界里的TCP从协议栈到服务器配置4.1 协议栈与三次握手的“幕后关系”很多人写应用层代码感觉不到三次握手的存在因为协议栈已经把一切都封装好了。但理解协议栈的层次关系能让你在定位问题时少走弯路。TCP协议栈负责收发报文、维护状态机、控制重传与拥塞。应用层调用的connect、accept、read、write最终都会映射到这套状态机上。应用层看到的是文件描述符内核看到的是一个个状态以及挂在各个队列里的sk_buff。举个例子connect返回成功只代表三次握手完成。但从那一刻到你真正调用write中间可能还有若干毫秒甚至更长的延迟。如果这时对端accept队列已经满了connect照样可以成功因为第三次ACK已经被内核处理了应用层的accept只是从全连接队列里取走一个已经“建好”的连接。所以“connect成功”并不等于“对端应用已经准备好接收业务数据”。另外TCP协议包在网络中是可以用工具修改和重放的。调试协议交互时Wireshark可以直接编辑报文tcpreplay可以重放pcap文件。这种方式在排查协议兼容性、复现异常场景时非常有用。我做过一个实验把正常的SYN报文改掉序列号再重放观察服务端的反应能快速验证半连接队列是否足够健壮。4.2 Nginx反向代理最大连接数排查Nginx作为反向代理最常被问到的就是“最多能支持多少TCP连接”。答案不能只看一个参数要串联几个环节。工作进程数量对应worker_processes每个工作进程能同时处理的连接数由worker_connections决定两者相乘再除以2才是理论上的并发请求数。因为反向代理场景下一个用户请求进来Nginx要向后端发起一个新连接一个请求占用两个连接。但这只是Nginx自身的容量。系统层面还有限制ulimit -n文件描述符不够连接数再高也上不去。内核参数net.core.somaxconn会影响全连接队列长度net.ipv4.tcp_max_syn_backlog会影响半连接队列。Nginx的listen指令里的backlog参数也要和内核参数配合起来看。我在压测时遇到过一个典型问题Nginx配置看起来没问题压测到几千并发后新连接开始大量超时。后来用ss查发现全连接队列的Drop数一直在涨原因就是somaxconn太小backlog队列被打满。调大net.core.somaxconn并同步修改Nginx的listen backlog后问题立刻缓解。如果反向代理和后端之间大量出现短连接TIME_WAIT也会成为瓶颈。优化方向是开启Nginx与后端之间的keepalive让代理到后端的连接可以复用而不是每来一个请求就新建一个TCP连接。这个优化对高并发场景的收益非常明显值得优先做。4.3 用ASIO写一个TCP Server很多人在学习TCP服务端时会接触到ASIO库。ASIO本身是一个跨平台的异步IO库封装了底层socket接口但它的行为仍然严格遵循TCP三次握手和状态机的规则。一个最简单的ASIO TCP Server核心结构大概是这样的#include asio.hpp #include memory using asio::ip::tcp; class Session : public std::enable_shared_from_thisSession { public: Session(tcp::socket socket) : socket_(std::move(socket)) {} void Start() { DoRead(); } private: void DoRead() { auto self shared_from_this(); socket_.async_read_some(asio::buffer(buffer_), [this, self](auto ec, std::size_t length) { if (ec) { // 连接关闭或出错这里会触发四次挥手或直接RST return; } // 处理业务数据然后继续读 DoRead(); }); } tcp::socket socket_; std::arraychar, 1024 buffer_; }; class Server { public: Server(asio::io_context io, short port) : acceptor_(io, tcp::endpoint(tcp::v4(), port)) { DoAccept(); } private: void DoAccept() { acceptor_.async_accept([this](auto ec, tcp::socket socket) { if (!ec) { std::make_sharedSession(std::move(socket))-Start(); } DoAccept(); }); } tcp::acceptor acceptor_; };有几个细节需要说明。第一async_accept返回的socket是内核已经完成了三次握手的连接。我们拿到的每一个Session背后都经历了一次完整的SYN、SYNACK、ACK过程。第二错误处理非常重要。读操作返回EOF或者Connection reset通常意味着连接进入关闭流程。此时如果不把socket对象释放掉连接就会挂在那造成句柄泄漏或者CLOSE_WAIT堆积。第三单线程还是多线程取决于是否需要在多线程环境下调用io_context.run()。ASIO的设计可以让你用很少的线程支撑大量并发连接但也要注意读操作的回调里绝不能做耗时IO否则会阻塞事件循环导致所有连接假死。我在项目里用ASIO重构过一个聊天服务端从多线程阻塞socket改成单线程异步事件驱动连接数从几百提升到了几千。但期间也踩过回调里调用数据库导致事件循环卡住的坑。异步编程对代码纪律要求更高每一条回调链路都必须保证不阻塞。4.4 工控与移动场景中的TCP连接TCP的应用远不止Web服务。工控领域最常见的Modbus TCP底层就是在TCP之上承载Modbus应用协议。写C# Modbus TCP客户端时如果一个读写操作就建一个TCP连接性能会很差。Modbus单条指令的响应时间本身很快但TCP握手和挥手需要几十毫秒甚至更多建连开销可能比业务逻辑还大。更合理的做法是维护一个TcpClient对象池多个业务请求复用同一个连接同时通过应用层请求ID来区分响应。LabVIEW上位机与NI实时机的TCP交互也常会碰到“怎么查询信息量”这种问题。想精确知道每个周期传输了多少字节其实不用猜直接在LabVIEW的TCP Read节点里统计返回值长度或者用抓包工具抓取指定端口的流量查看payload字节数即可。NI自家的实时机通常也带网络日志能粗略统计收发数据量。这类场景的排查重点反而不是速度而是要确认上位机与实时机之间的读操作超时时间是否合理避免因为一个读写超时导致整个控制循环卡住。移动端场景也一样比如ESP01S模块给手机发TCP消息本质上ESP01S扮演TCP客户端手机上的接收端扮演TCP服务端。两者之间必须先完成三次握手才能传数据。如果模块频繁重启或者网络信号不稳定会出现SYN重传、连接超时等问题这时候排查方向还是握手的三个报文是否完整而不是只看应用层有没有收到消息。5. TCP和UDP该握手时别偷懒该直送时别啰嗦5.1 一句话说清TCP与UDP的差异TCP和UDP最大的区别可以概括成一句话TCP是可靠的、面向连接的、有序的字节流UDP是尽力的、无连接的、不保证顺序的数据报。维度TCPUDP连接性面向连接需要三次握手无连接直接发送可靠性可靠有确认和重传尽力交付不保证有序性有序按序号重组可能乱序数据边界字节流无消息边界数据报保留边界开销高头部20字节起低头部8字节典型应用HTTP、数据库、文件传输、Modbus TCP音视频、DNS、游戏状态同步我经常拿“挂号信”和“明信片”来做类比。TCP是挂号信发出之后要回执、要确认、丢了要补发UDP是明信片寄出去就不管了能不能到全看邮路心情。5.2 如何按场景选协议选TCP还是选UDP本质上是在回答一个问题数据丢了你的业务能不能忍。不能忍就选TCP。比如支付请求、数据库同步、控制指令、文件上传任何一个包丢失都可能导致业务出错或者状态不一致TCP的可靠传输能帮你省掉大量“补数据”的逻辑。能忍或者对延迟极其敏感就考虑UDP。比如语音通话偶尔丢几百毫秒的音频人耳几乎感觉不到但如果是TCP重传延迟可能瞬间飙升通话质量反而更差。游戏里玩家位置的同步也类似房间里的其他玩家只需要知道“最新状态”旧状态丢就丢了用TCP反而会因为延迟重传导致画面滞后。还有一种情况是“应用层自建可靠机制”。很多实时传输方案之所以基于UDP实现是因为UDP没有头阻塞收发灵活可以把可靠性的逻辑放在应用层定制。比如QUIC就是在UDP之上实现了类似TCP的可靠性、加密和多路复用又规避了TCP队头阻塞的问题。这类方案适合追求极致性能、且团队有能力维护复杂协议的场景。5.3 TCP连接中的“资源边界”意识不管选TCP还是UDP有一点要时刻记住TCP连接是系统资源不是无限的数字。每个连接至少要占用一个文件描述符、一部分内核缓冲区、一套TCP控制块。Linux系统默认文件描述符上限可以通过ulimit查看连接数太多会导致“Too many open files”。为了保证连接数可控代码层面要注意连接池的复用运维层面要监控TIME_WAIT和CLOSE_WAIT的曲线网络层面要合理设置backlog。在我自己的实践里一条TCP连接的“生命周期管理”优先级高于业务代码。每次建立连接前先想清楚三个问题谁创建、谁关闭、如果异常谁负责兜底。有了这套答案报错会少很多排查也会快很多。与其等线上报警了再去翻状态不如在写代码时就把连接的归属、超时、重试策略都定清楚。这些年排查连接问题我最深的体会就是不要死记状态迁移图而是用抓包工具结合业务日志去理解每一个状态背后的真实场景。当你亲眼看到一连串SYN重传、Dup ACK、TIME_WAIT堆积时协议设计者的每个决策都变得合理起来。如果初学者想快速理解三次握手和四次挥手我强烈建议在本地写一个最简单的TCP服务端用telnet连上去再用Wireshark抓一遍包把SYN、SYNACK、ACK和FIN这一整套报文看明白了会比看十遍教程都有效。TCP像极了两个社恐程序第一次破冰要足够谨慎最后告别也要足够体面中间所有的确认和等待都不是多余的动作。