ARTICLE DETAIL

资讯详情

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

TCP三次握手与四次挥手全拆解:从原理到抓包、排障与性能调优

TCP三次握手与四次挥手全拆解:从原理到抓包、排障与性能调优 做网络开发的这些年我越来越觉得TCP协议不该被当成天书它更像两个“社恐”程序之间的破冰与告别仪式。每次打开网页、每次点开App、每次发起数据库连接背后都有这么一出先是三次握手确认“你在、我在、能聊”然后才是数据搬砖最后四次挥手体面散场。这个标题之所以很形象是因为TCP里几乎所有难以理解的细节都可以从“两个端点互不信任、互不确认”这个角度重新理解一遍。这篇文章我会把TCP三次握手与四次挥手从头到尾拆一遍包括报文里的seq/ack怎么对、状态机怎么跳、握手为什么必须是三次、挥手为什么非要四次、TIME_WAIT又是哪来的麻烦。后面还准备了大量工程向内容tcpdump抓包怎么看、asio库怎么写TCP server、nginx反代TCP的最大连接数怎么算、Modbus TCP和LabVIEW上位机在实际项目里要注意什么以及“java客户端重连报地址已在使用”“TCP dup ack快速重传”这类真实排障案例。适用人群很广刚学TCP/IP的学生、写业务代码偶尔要调网络接口的开发、做嵌入式或者工控上位机的工程师、还有被线上连接故障折腾过的运维都能在这篇里找到自己能用上的东西。1. 三次握手破冰仪式的真实意图1.1 报文只看三个关键位SYN、ACK和序列号很多人第一次看TCP头的时候会被一堆字段吓到其实搞懂三次握手只需要关注三样东西flags标志位、sequence number序列号、acknowledgment number确认号。SYN发起连接相当于“我想加你好友”。ACK确认收到相当于“你的消息我看到了”。seq发送方告诉对方“这是我这次发给你的第一个字节编号”。ack接收方告诉对方“我期待你下一个字节编号是多少”顺带表示“你之前的字节我都收到了”。三次握手的原始报文可以浓缩成一个表格步骤方向标志位序列号确认号第一次客户端 - 服务端SYNseqx无第二次服务端 - 客户端SYNACKseqyackx1第三次客户端 - 服务端ACKseqx1acky1注意一个细节第二次握手不只是回一个ACK而是SYNACK两个标志一起置位因为服务端也要发起自己的同步。它把“确认你的SYN”和“我自己也想建连”这两件事打包在一个报文里所以这里省了一次往返这也是三次握手而不是四次握手的原因之一。用生活化类比理解第一次握手是你说“你好我是A我接下来想跟你说话这是我的起始编号1000”第二次握手是对方回“收到你的起始编号1001我的起始编号是5000我也准备好了”第三次握手是你再回一句“收到你的起始编号5001那我们开始吧”。三次之后双方都知道了“对方已经确认过我的起始序号”后续数据才可以按序号无歧义地拼接。实际抓包时tcpdump输出大概长这样$ tcpdump -i eth0 -nn -S tcp port 8080 18:21:33.100001 IP 192.168.1.10.50001 192.168.1.20.8080: Flags [S], seq 1000 18:21:33.100012 IP 192.168.1.20.8080 192.168.1.10.50001: Flags [S.], seq 5000, ack 1001 18:21:33.100020 IP 192.168.1.10.50001 192.168.1.20.8080: Flags [.], ack 5001我把seq、ack字段拉到你面前是因为后面所有状态问题、重传问题、抓包问题追根到底都是这两个编号在打架。1.2 为什么必须三次砍掉一次行不行这是面试问烂了、但很多人其实没想透的问题。核心答案不是为了“确认双方都在线”而是为了解决历史重复SYN的歧义。想象一个场景客户端发起一个连接第一个SYN因为网络拥塞丢了或者只是走了一条特别慢的路径迟迟没到服务端客户端等超时后重发了第二个SYN服务端先收到了第二个SYN。如果只有两次握手服务端收到第二个SYN后直接建连、发ACK它完全不知道还有没有更早的SYN会晚到。此时第一个迟到的SYN如果突然冒出来服务端会再建一个连接然后向客户端发送确认。客户端一看这个连接的起始序号跟我预期的不一样而且我根本没有再请求新连接——这时候客户端只能回一个RST把连接杀掉但服务端已经被浪费了资源甚至可能因为两个SYN都到达而错误地建立了两个连接后续数据互相串包。三次握手的存在让服务端不急着建立连接而是先发一个SYNACK等客户端对这个“具体SYN”做最终确认。如果客户端收到的是旧SYN对应的确认就会回RST终止掉这次连接如果确认号对得上才真正进入ESTABLISHED。这个“二次确认”设计本质上是把“历史重复报文能不能建立连接”的最终裁决权交给发起方。还有一个容易被忽略的点三次握手也是双方交换初始序列号ISN的唯一时机。TCP要保证可靠传输发送方和接收方必须对“从哪个字节开始编号”达成一致。如果砍成一次或两次有一方的ISN是未知的后续数据就无法排序、去重、确认。序列号不是从0开始的也不是随意选的现在内核里一般都有随机化算法目的就是防止old报文和新连接混淆。最后TCP还在握手报文里顺带完成了能力协商MSS最大报文段长度、窗口缩放因子、SACK选择性确认、时间戳等。这些参数不交换双方只能按保守默认值干活性能会明显下降。所以三次握手是一次低成本的“接口对接会议”不是寒暄。1.3 握手的工程含义SYN队列、长连接与连接复用知道了原理再往工程上看。每次失败/超时的握手都会在内核留下痕迹最典型的就是半连接队列和全连接队列。半连接队列SYN Queue服务端收到SYN、还没收到第三次ACK的连接存这里大小由内核参数net.ipv4.tcp_max_syn_backlog以及应用listen传入的backlog共同决定。全连接队列Accept Queue第三次握手完成后、还没被应用accept()拿走的连接大小由listen(fd, backlog)和net.core.somaxconn决定取两者较小值。如果应用层处理慢、accept不及时全连接队列被塞满新的握手即使三次都完成也会被内核丢弃客户端表现就是连接超时或直接RST。这三种队列用ss -lnt就能看到Send-Q和Recv-Q占用情况。再说到长连接。HTTP 1.1的Keep-Alive、数据库连接池、消息队列客户端本质都是“完成一次三次握手后别急着四次挥手多复用一段时间”。我之前算过一笔账内网RTT大约是0.2ms三次握手来回就要0.6ms如果每个请求都新建连接光握手开销就占了请求耗时的很大一部分。短连接模式下你发一个简单查询可能80%的时间都在破冰和告别。所以现在主流框架都是连接复用。这里顺带提一下TCP Fast Open。它允许客户端在第一次SYN里就携带数据省掉一个RTT。典型场景是HTTPS请求——TLS握手本身又要几轮往返配合TFO可以把首包延迟压得很低。不过这需要双方都支持而且只对真正重复访问的连接生效不建议新手一上来就开。2. 四次挥手社恐式告别的拖泥带水2.1 四个报文与连接状态的完整流转三次握手是建立连接四次挥手是拆除连接。很多人在这一步开始懵“都结束了为什么还要来回四次”原因是TCP连接是双工通道每个方向必须单独关闭。类比一下你和同事一起做项目收尾你这边把自己的活交接完了说“我这边结束了”同事得回一句“收到”。但这不代表同事那边的活也完了他可能还得继续交接自己的部分等他交接完他再说“我也结束了”你再回一句“收到”。丢掉任何一次确认都会有人蒙在鼓里。四次挥手的标准过程黑话版主动关闭方发送FINseqm表示“我的数据发完了”。被动关闭方回ACKackm1表示“我收到你的再见了”但此时被动方可能还有数据要发。被动关闭方数据发完后发送FINseqn。主动关闭方回ACKackn1然后进入TIME_WAIT等待2MSL后再彻底关闭。这里有个细节特别容易搞混即使是客户端先发FIN也不代表“客户端赢”或“客户端先断”。谁先发起无关紧要取决于业务是否需要主动关闭但谁先发FIN谁就要承担TIME_WAIT这个后面详细说。状态流转如下主动关闭方ESTABLISHED - FIN_WAIT_1 - FIN_WAIT_2 - TIME_WAIT - CLOSED被动关闭方ESTABLISHED - CLOSE_WAIT - LAST_ACK - CLOSED实操中你ss -ant看到的CLOSE_WAIT、FIN_WAIT_2、TIME_WAIT都是能直接对应到代码毛病的。比如服务端CLOSE_WAIT一堆几乎可以肯定是被动关闭方调了close()但应用没处理完或者根本没调close()。客户端FIN_WAIT_2长期不消失往往是对端一直不发FIN可能是对端半死不活卡在某处这时候只能靠应用层超时。2.2 TIME_WAIT不是bug是特性TIME_WAIT是主动关闭方在发完最后一次ACK之后进入的状态持续2MSLMaximum Segment Lifetime报文最大生存时间。MSL一般取30秒到2分钟Linux里相关常量可以在内核头文件里看到实际通常时长在60秒左右。为什么一定要等2MSL官方解释主要有两条。第一保证最后一个ACK如果丢了能重发。如果主动关闭方发完最后的ACK就立刻CLOSED网络波动把ACK弄丢了被动关闭方收不到确认会重传FIN可此时主动方已经把这个连接从内核里抹掉了重传的FIN会撞上一个根本没有的连接被动方只能看到RST这就会让对端认为“连接被异常终止”状态不干净。等2MSL的作用就是如果被动方重传FIN主动方还有时间重新回ACK。第二确保旧连接的所有报文在网络里彻底消失。报文在网络中可以被路由器缓冲、延迟、重放最长的存活时间就是MSL。一个方向花MSL、反方向再花MSL2MSL之后这个连接里的旧数据包理论上不会再出现在网络中也就不会混进下一个使用相同四元组的新连接里。没有TIME_WAIT你很可能打开一个新连接时突然收到上一个连接迟到的脏数据。但TIME_WAIT在工程上有个臭名昭著的副作用端口被占着不放。尤其在高并发短连接场景下主动关闭方如果总是固定用一个端口去连服务端TIME_WAIT堆积就会出现Java开发者最熟悉的一句报错Address already in use: connect。我在帮同事排查问题时见过最夸张的案例一个高频交易网关长期短连接发单由于每笔请求都新建Socket又没有用连接池TIME_WAIT直接把本机可用端口打满日志里一片Address already in use。当时处理不是简单调大端口范围了事而是从服务设计上把所有发单请求改成长连接复用TIME_WAIT数量立刻降了下来。关于参数调优我个人的建议是不要上来就把net.ipv4.tcp_tw_reuse或net.ipv4.tcp_tw_recycle乱开。tcp_tw_recycle在NAT场景下会导致对端时间戳错乱出现“间歇性连不上”的诡异问题很多线上故障就是它造成的。tcp_tw_reuse相对安全一些但也只适用于主动发起连接的场景而且要配合时间戳机制。更根本的做法是应用层复用连接让连接保持得久一点不要在每次请求后都挥一次手。2.3 CLOSE_WAIT堆积服务端的“忘记说再见”如果服务端是四次挥手里的被动方那最怕的就是CLOSE_WAIT堆积。CLOSE_WAIT的含义是你收到了对端的FIN并且已经回了ACK但你自己的业务代码还没调用close()。换句话说对端已经决定结束通话你却还在拖泥带水不挂电话。CLOSE_WAIT堆积的常见原因按我排查过的排序基本是业务线程被阻塞Socket没及时关闭。读了一半发现连接半关闭只处理了读事件忘了关写方向。异常路径上没有finally关闭资源比如数据库连接、HTTP响应流、自定义Socket。第三方库帮你管理连接生命周期但你没正确调用shutdown或close。排查命令很简单ss -ant | awk {print $1} | sort | uniq -c如果你发现CLOSE_WAIT数量只增不减可以直接看进程里打开的fd确认是哪只“手”没松开ls -l /proc/pid/fd | grep socket再结合lsof -p pid | grep TCP基本能定位是哪个线程、哪个IP端口没关闭。修复动作听起来简单在代码里确保每条连接都有明确的close路径。但实际代码里连接可能被多个线程共享尤其用异步框架时close()的时机得小心处理否则很容易误关一条还在传输数据的连接。3. 从协议到工程抓包、代码、配置三板斧3.1 用tcpdump把握手挥手拍成聊天记录看完理论不抓包等于白看。我最常用的抓包姿势是tcpdump -i eth0 -nn -S tcp port 8080 -w /tmp/tcp.pcap加-S的目的是显示绝对序列号而不是相对序列号。如果不用-S你会看到seq 1, ack 2这种被归一化过的数字排查时容易忘记它已经减了初始值用绝对序列号你和内核日志、应用日志对得上号。抓到之后导出成可读文本tcpdump -nn -S -r /tmp/tcp.pcap port 8080看输出里Flags字段[S]SYN第一次握手。[S.]SYNACK第二次握手注意这里的点表示ACK标志也置位了。[.]纯ACK第三次握手以及之后所有数据确认。[F]FIN第一次挥手。[F.]FINACK第三次挥手。[R]RST连接异常重置。实操心得看到[S]重传了两次那多半是服务端没监听或者防火墙丢包看到握手完成了但马上出现[R]要考虑双方协商能力失败比如MSS问题、TLS握手失败导致连接被应用关闭看到挥手时[F]发出去后一直等不到对方的[F]要检查对端是不是进CLOSE_WAIT卡住了。3.2 用asio库写一个最简TCP server很多C开发接触异步网络的第一站就是Boost.Asio或独立版Asio。只讲理论不讲代码不过瘾我写一个最小可用的TCP server去掉业务逻辑只保留框架骨架方便你照着搭#include asio.hpp #include iostream using asio::ip::tcp; int main() { try { asio::io_context io; tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080)); for (;;) { tcp::socket sock(io); acceptor.accept(sock); std::cout accept from sock.remote_endpoint() std::endl; std::string msg hello from tcp server\n; asio::write(sock, asio::buffer(msg)); } } catch (std::exception e) { std::cerr e.what() std::endl; } return 0; }这只是同步阻塞版本胜在直观。它做的事情就是创建io_context事件循环、创建acceptor监听8080、accept产生一个socket、把这个连接当作普通的流对象读写。你跑起来后用telnet 127.0.0.1 8080就能看到效果这个例子里三次握手和四次挥手都是内核帮你完成的应用层根本不需要知道细节。生产环境当然不会这么写因为同步accept一次只能处理一个连接。要支持并发常见做法是void handle(tcp::socket sock) { try { std::string msg hello\n; asio::write(sock, asio::buffer(msg)); } catch (std::exception e) { std::cerr e.what() std::endl; } } int main() { asio::io_context io; tcp::acceptor acceptor(io, tcp::endpoint(tcp::v4(), 8080)); std::functionvoid() do_accept []() { auto sock std::make_sharedtcp::socket(io); acceptor.async_accept(*sock, [](const asio::error_code ec) { if (!ec) { handle(std::move(*sock)); } do_accept(); }); }; do_accept(); io.run(); return 0; }核心变化是async_acceptaccept不阻塞连接到达时回调函数会被投递到io_context里执行。这样事件循环才能腾出手去处理多个连接、多个socket读写。这里踩过的坑提醒你Asio的异步回调不是多线程安全的如果你在多个线程里调io.run()回调会在不同线程执行共享数据要加锁否则连接数一上来就偶发崩溃。另外异步写入时要注意buffer的生命周期asio::buffer不复制数据如果buffer里的std::string在写入完成前被析构了你会看到随机数据或者崩溃。新手最容易在这两个地方翻车。3.3 nginx作为反向代理TCP最大连接数到底卡在哪Nginx不只做HTTP反向代理通过stream模块也能代理TCP流量比如MySQL、Redis、自定义协议。很多人问“nginx当TCP反向代理最大能支持多少连接”我的答案是先别问nginx问操作系统。连接数瓶颈的第一层是文件描述符。在Linux上一条TCP连接对应一个socket fd。nginx是事件驱动模型理论上一个worker进程就能支撑几万甚至几十万并发但前提是fd够用。先看进程fd限制ulimit -n如果只有1024那不用想了最多也就几百并发。修改方法是在systemd服务文件里加LimitNOFILE1048576或者直接在shell里ulimit -n 1048576再启动nginx。nginx自身的配置再看两层。第一层是worker进程数通常设为CPU核心数第二层是每个worker能处理的最大并发连接数由worker_connections控制同时受fd数量限制。经典的估算公式最大并发连接数 ≈ worker_processes × worker_connections ÷ 2为什么除以2因为一个TCP连接需要读写两端nginx作为反向代理时一条完整的流量路径涉及“客户端到nginx”和“nginx到上游服务”两条连接两条都会各占用一个fd。一个保守但实用的stream配置worker_processes auto; worker_rlimit_nofile 1048576; events { worker_connections 65535; } stream { upstream backend_mysql { server 127.0.0.1:3306; } server { listen 9000; proxy_pass backend_mysql; proxy_timeout 300s; } }在这个配置下理论上单机能支撑的连接数上限是worker数 × worker_connections / 2再乘以每个连接的资源开销结果相当可观。但实际压测时你会发现CPU、内存、网卡中断、内核的连接表锁都可能先扛不住。连接数上到几十万以后瓶颈往往不再是配置文件而是内核的路由缓存、conntrack表、软中断分配策略。还有一点要特别注意nginx对TCP反向代理默认不做会话保持如果上游服务依赖客户端IP或者需要session你得在stream模块里用proxy_bind或通过Lua脚本做哈希路由。别把HTTP反向代理的ip_hash直接套到stream上两者并不通用。3.4 工控场景Modbus TCP、ESP01S与LabVIEW上位机搜“TCP”关键词的人里有一大批是做工控和嵌入式上位机的。这里我多聊几句。Modbus TCP不是独立协议本质是Modbus报文穿上TCP的工装端口号固定用502。它的三次握手、四次挥手和HTTP没什么两样只是应用层报文格式不一样。你调试的时候用普通tcpdump抓包完全够用看到[S]、[S.]、[.]之后后面跟着的就是Modbus的MBAP头和功能码。C#开发商用Modbus TCP客户端时常见做法是引用NModbus库但这个库底层也是标准的TcpClient也就是说你依然要处理好连接超时、重连、半关闭回收这些问题。不要以为用了Modbus协议就能绕过TCP的脾气。很多用ESP01S这类WiFi模块做透传的人会在“ESP01S发送TCP消息到手机”时卡住。手机端没有公网IP模块主动连不上手机正确的做法是让模块连到局域网内的TCP Server比如PC上开一个SocketTool或者走云服务器中转。TCP必须有一方监听、一方主动连接这是握手的大前提不是设备功能问题。LabVIEW上位机与NI实时机RT Target之间我经常看到有人在问“交互的信息量怎么查询”。实际上如果你用的是NI的Network Streams可以调用属性节点读取当前发送队列里的元素数量如果走的是原生TCP Write/Read想看实时流量最直接的办法是在连接建立后用wireshark抓包统计每秒字节数。信息量这个词含义太广先分清你要看的是吞吐率、延迟、还是消息频率再选工具。我个人习惯是先做一轮基准上位机循环发一个128字节的心跳包测出吞吐和平均往返时间再谈监控。工控网络里有个通病设备会隔一阵子就“偶尔断连一次”排查时发现TCP重传一堆底层链路丢包严重。这时候别慌着优化握手参数先用ping测试丢包率再用mtr看每一跳的延迟抖动。很多时候问题根本不在TCP而在交换机的广播风暴或者光电转换器故障。4. 连接故障排查实录经典报错与抓包技巧4.1 “Address already in use”复盘先看Java客户端的典型错误栈java.net.BindException: Address already in use: connect这个问题的完整链条是这样的客户端每次new Socket连接服务端内核会分配一个本地端口。如果客户端在关闭连接时主动先发了FIN或者收到了RST该连接就要在TIME_WAIT里待60秒左右。如果程序短时间内反复创建同样的连接可用的本地端口被TIME_WAIT占满于是connect时内核找不到空闲端口抛出BindException。在高并发短连接场景下最有效的解法是彻底改成长连接。如果确实没法避免短连接可以按优先级做这几件事在客户端Socket上设置SO_REUSEADDR允许重用处于TIME_WAIT状态的本地端口。在Linux上开net.ipv4.tcp_tw_reuse 1注意这个参数只对主动发起连接的一方生效且需要TCP时间戳支持。增加本地端口范围net.ipv4.ip_local_port_range从默认的32768-60999扩大比如改成1024-65535。减少TIME_WAIT时长。net.ipv4.tcp_fin_timeout虽然不能直接改TIME_WAIT长度但通过配合FIN_WAIT_2超时也能变相降低占用。我的建议是先考虑架构再考虑内核参数。同一台机器上如果一个网关上连接数能到几万TIME_WAIT再调优也只是缓兵之计。真正稳的方案是让连接活得更久连接池、空闲复用、心跳保活。4.2 TCP dup ack机制与快速重传搜“TCP dup ack”的人多半是被wireshark里的“TCP Dup ACK”或者“TCP Retransmission”弄懵了。背景是这样接收方收到数据后必须回ACK确认。如果它发现某个段丢了但它还继续收到后续的乱序段它不能直接说“我丢包了”因为它没有专门的否定确认报文只能一遍遍重复发送当前期望的序列号这就是Dup ACK重复确认。发送方连续收到3个Dup ACK后会认为那个报文确实丢了触发快速重传不等超时定时器立刻重发丢失的段。这就是TCP的一个保命技能用重复ACK代替显式的NACK在拥塞避免机制里更快地恢复。wireshark里看到Dup ACK不一定是网络故障正常的乱序、重复包都会触发。只有当你看到大量Dup ACK并伴随重传、接收窗口异常时才需要警惕。排障思路是先看抓包里丢的是不是同一个序列号如果是看是不是发送端的大包被某个中间设备切碎后丢失如果不是看是不是接收端缓冲区设置不当导致乱序。有一次我排查一个跨机房同步服务的网络问题应用表现为写入延迟抖动抓包看到每5秒出现一次Dup ACK风暴。最后定位到问题是两条链路负载不均衡导致同一个TCP流的包走不同路径到达顺序乱了。接收方连续回Dup ACK发送方频繁快速重传带宽浪费得惊人。解决方式就是做基于会话的哈希分流保证同一条TCP连接始终走同一条物理链路。4.3 半开连接、SYN重传与保活机制TCP连接是状态性的但网络是不可靠的。如果一端突然断电、网线被拔、进程崩溃另一端不会立刻知道因为它没有收到任何FIN或RST。这时候这条连接就成了半开连接。内核为这种情况提供了保活机制TCP KeepAlive。它不是应用层心跳默认关闭打开后内核会周期性发送探测报文。Linux默认参数大约是7200秒无数据才启动探测对多数业务来说太慢了。所以应用层通常要自己设计心跳客户端定时发一个心跳包服务端如果在N秒内没收到任何数据就主动关闭连接。这个逻辑几乎是所有长连接代码的标配也是“社恐程序”保持默契的关键。握手阶段解决的是信任问题但长期相处的信任是靠心跳维系的。SYN重传则是另一种常见故障。如果服务端根本没监听端口客户端发SYN后可能收到RST如果被防火墙静默丢弃客户端会按net.ipv4.tcp_syn_retries的次数和退避时间重传SYN。默认值是6总耗时大约127秒也就是说一个被防火墙丢掉的连接请求应用层可能要卡一分多钟才报超时。这个时长对很多上层应用来说太长如果你明确知道对端应该是通的只是被防火墙策略挡了可以把SYN重传次数调低到3到4次让失败更快暴露。5. TCP和UDP别再看心情选了5.1 协议血统对比搜“udp和tcp协议的区别”的人极多我直接列一个清晰对比表。TCP是“有教养的可靠派”自己做连接管理、顺序保证、重传、流量控制、拥塞避免。UDP是“直接了当的实干派”只管发不管到没到、有没有乱序。维度TCPUDP连接状态面向连接三次握手四次挥手无连接发完即走可靠性可靠传输重传丢失段不可靠丢包要靠应用层处理有序性保证字节流的顺序不保证顺序应用自己排流量控制有滑动窗口无拥塞控制有拥塞窗口避免压垮网络无想发多少发多少数据边界字节流read/recv可能半包粘包报文边界一次recv对应一个UDP包开销头20字节连接维护有成本头8字节无连接状态典型场景HTTP、数据库、文件传输、消息队列音视频、游戏实时位置、DNS、广播纯看这句话“TCP用心UDP用命”。TCP靠一堆机制换可靠性UDP靠把包袱甩给应用换低延迟和简单性。5.2 用朴素逻辑做选型别让协议选择变成玄学实际选型我有一套朴素判断逻辑第一条你能容忍重传带来的延迟尖刺吗对于一个在线多人游戏位置消息迟到一秒比丢一帧还可怕所以位置同步优先UDP前排应用层带序号和状态快照。对于文件传输、转账请求、数据库查询丢一个字节都不可接受必然选TCP。第二条应用层愿意付出多少复杂度选UDP意味着排序、去重、确认、重传、拥塞控制全都要自己实现这些在TCP里是内核帮你磨好的。普通团队的业务系统不要轻易自研可靠UDP协议代码量指数级上升事故排查难度也指数级上升。第三条有没有中间设备在捣乱UDP包经过NAT、防火墙时容易被丢弃而且很多云厂商对UDP流量有特殊限速。跨公网的关键业务TCP往往更稳。即使像QUIC那样想改善性能也只是在UDP之上造了一个“类TCP”的可靠层难度并不低。举一个真实例子。我之前调一个HMI上位机原方案用UDP传实时曲线丢包时上位机画出的曲线偶尔会有毛刺。运维反馈“网络不好”后来我们改成TCP代码只改了socket类型和收包循环毛刺问题消失了大半。代价是延迟从原来的亚毫秒级变成偶尔几十毫秒但对HMI刷新率来说完全无感。这就是典型的选型权衡可靠性换低延迟是否划算完全看业务需要。再补充一个实用判断如果一条消息发出去后接收方必须给出业务层面的回执那么即使底层是UDP你的应用层也必须模仿TCP做确认。与其自己造轮子不如一开始就用TCP。反过来如果你的业务能容忍少量丢失又极度看重延迟那就不要被“TCP可靠”三个字绑架果断上UDP做好丢包统计和降级策略就行。在我个人的实际经验里连接管理问题几乎从不输在协议本身而是输在“没有充分理解连接的生命周期”。三次握手四次挥手的每个状态、每个定时器背后都对应着内核里那么一小段逻辑但这一段逻辑能产生的影响足以让一个高并发服务从顺畅变成雪崩。如果你打算深入这块最好的办法不是背文档而是搭一个最小环境亲自抓包看一遍握手和挥手再故意丢几个包看重传和状态变化比看十篇文章都管用。最后多说一句遇到连接问题先看状态下结论别一上来就怀疑内核参数很多“灵丹妙药”式的调优配置熬的不是连接问题是你自己的排查时间。
返回列表