ARTICLE DETAIL

资讯详情

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

TCP可靠还是UDP不可靠?传输协议可靠性边界与实战解析

TCP可靠还是UDP不可靠?传输协议可靠性边界与实战解析 很多人刚接触网络时都背过一句话TCP是可靠传输UDP是不可靠传输。我当年也把这句话当金科玉律考试写、面试说直到自己上手做公网联机、音视频传输、嵌入式Wi-Fi模块调试被各种诡异现象反复毒打之后才明白这句话充其量算一句被严重简化的科普结论甚至可以说是一个流传很广的hoax。它把“协议层承诺”和“业务层结果”搅在了一起让很多人对网络传输产生了不切实际的期望也让不少人在该用UDP的场景里死守TCP踩了坑还以为是自己的代码写得不对。这篇文章我想把TCP/UDP可靠性的边界彻底讲清楚TCP到底保证了什么哪些情况下它照样不可靠UDP是不是真的一无是处又如何通过应用层设计让它“变可靠”。后面还会带上我实际抓包、用iperf3打流、调各种调试工具时沉淀下来的排障经验希望对正在做网络开发、调试的同学有参考价值。1. “可靠”和“不可靠”的真相和你想的不太一样1.1 TCP到底保证了什么先给TCP说句公道话。TCP是一个“面向连接、可靠传输”的协议它做的事情确实很多建立连接时通过三次握手同步初始序列号和数据窗口发送过程中给每个字节编号接收方确认收到哪些序列号发送方一旦发现超时或连续收到多个重复确认就会重传中间还带流量控制和拥塞控制。这套机制的结果是如果发送方内核把一段字节流交给TCP协议栈TCP会尽最大努力把这段字节流按顺序、不重复地交付到接收方内核。注意我的措辞是“内核到内核”。TCP能承诺的是两端协议栈之间的传输过程不是你的应用进程从socket里读到什么。很多人把“TCP可靠”理解成“我send出去的数据对方业务一定能收到并处理”这是最大的误区。从实际效果看TCP在大多数正常网络环境下确实很省心。但在真实网络里数据包会被中间设备静默丢弃、会超时、链路会闪断、对端设备会掉电。TCP应对这些情况的唯一手段是重传如果重传一直不成功它最终会放弃连接。所谓“可靠”准确说是“尽力可靠并告诉你失败结果”——它保证不了任何绝对的结果。1.2 UDP不保证的东西到底有哪些UDP的定位完全不同。它是一个近乎“裸奔”的传输协议发送方把数据报丢给IP层后续的事情它一概不管。不重传、不排序、不去重、不维护连接状态也不保证接收方一定收到。如果接收方收到了它就交给应用如果丢了、错了它也不吭声发送方完全不知道。但“不保证可靠”不等于“每次都丢”。在稳定的局域网里UDP丢包率可能极低在Wi-Fi环境、无线公网、弱网链路上丢包和乱序才会变得非常明显。UDP把这些问题全部保留给应用层处理好处是协议开销极小、延迟极低、行为可控坏处是应用层必须自己承担很多本来可以交给协议栈的事情。所以更准确的说法应该是TCP把可靠性机制做进了协议栈UDP把可靠性机制选择权交给你。前者面向通用场景后者面向需要“自定义可靠性规则”的场景。理解了这层差异很多技术选型就不纠结了。2. TCP“不可靠”的五个瞬间线上踩坑实录2.1 三次握手之后对端可能早就不是你以为的那个人三次握手成功只能代表在握手那一刻双方网络可达、收发能力和初始序列号已经同步。它并不能证明这条连接在后面几小时、几天内一直健康。我在实际开发里遇到最多的是“半开连接”问题。服务端进程跑着跑着突然崩溃或者某台交换设备在中间静默丢掉了重置报文客户端这边完全感知不到它还傻傻拿着一个“看起来还活着”的socket继续发送。TCP协议栈默认的keepalive探测要到两个小时左右才触发一次而且触发后还要多次失败才会通知应用绝大多数业务根本等不起。这种事在嵌入式设备上尤其常见。比如设备没有主动断开就断网了再上线时TCP连接大概率已经失效但设备端协议栈还认为连接正常。后来我学乖了凡是长连接应用层必须有自己的心跳和超时判定不能指望TCP自带的keepalive。这和打电话一样接通后对方晕倒没挂断你从“通话还在”根本判断不了对面还清醒必须定期喊一喊确认。2.2 四次挥手不是每次都能体面收场正常断开连接要走四次挥手主动方发FIN被动方回ACK被动方再发FIN主动方回ACK。但真实环境里大量连接根本不是这么关的。有一方进程直接崩溃、设备断电、网络闪断FIN包发不出去下面的人只能靠超时等待还有一些场景直接发RST粗暴重置连挥手都省了。我见过最多的问题是服务端出现大量CLOSE_WAIT本质是代码里没有正确关闭连接可能只是关闭了读方向没关闭写方向或者线程卡在那里没走到close。CLOSE_WAIT一旦堆积句柄耗尽新连接进不来服务整体假死。反过来客户端出现大量TIME_WAIT也是常见坑短连接一多TIME_WAIT会占住五元组两分钟长连接复用做得不好就容易把端口池打满。排查这类问题最实用的方式就是在服务器上抓到CLOBE_WAIT、TIME_WAIT的数量然后对着代码看socket生命周期。别指望挥手协议能帮你兜底应用层该主动关闭就主动关闭必要时还要设置SO_LINGER来控制RST行为。2.3 重传机制背后队列、窗口和缓冲区依然会卡死TCP有超时重传和快速重传但重传不是凭空变出数据来的。数据先要进入发送缓冲区然后协议栈才能按序列号发送和重传。发送缓冲一满你调用send/sendto时就不会把数据真正交给网络可能直接阻塞也可能返回EAGAIN。很多人看到send没报错就觉得数据“已经发出去了”实际上它还在当地内核队列里排队。接收方向也是一样。如果应用进程不及时从socket里read数据接收窗口会被占满发送方收到窗口为0的通告后就会停止发送。这时候从网络抓包看双方都很“正常”但业务层数据就是不动。这类问题的本质是应用层消费速度跟不上TCP的流量控制反而成了背压信号。我调过不少“TCP吞吐上不去”的报障最后发现要么是socket缓冲区太小、要么是接收线程没有及时读。要提升吞吐除了调节SO_RCVBUF/SO_SNDBUF还得保证应用层消费链路足够快。TCP的可靠性是建立在双方内核和应用都配合的前提下的任何一环卡住整条链路都会“看起来活着实际死了”。2.4 粘包问题说明字节流可靠也需要应用层做边界管理TCP是字节流协议本身没有“消息边界”概念。你连续发两条消息对端可能在同一次read里把两条一起读出来也可能一条消息被拆到两次read里。这就是大家常说的TCP粘包/拆包。它不算TCP缺陷但确实是“字节流可靠”的必然代价协议层保证字节顺序完整却不负责告诉你哪里是一条业务消息的边界。解决粘包没有银弹通用方案就那么几种固定长度消息、长度字段前缀、分隔符切分或者更复杂的TLV结构。我常用的是消息头里放4字节长度字段先读够头再根据长度字段读够整个消息体。注意不能指望recv一次就能拿到完整包必须循环读取直到达到目标长度。C#、C、Java里做TCP通信的几乎都会遇到这个点。这里顺便提一下C#里做UDP“分包组包”和TCP粘包不是一回事。UDP天然保留了数据报边界但IP层会为了传输把大UDP包分片所以UDP应用面向公网时还是得自己控制包大小和应用层重组策略。2.5 网络分区、交换黑洞和断电TCP照样丢数据TCP的重传机制只能应对“丢包但链路还通”的情况。如果物理链路完全断裂、路由器域间故障、光缆被挖断TCP能做的只是反复重传直到超时。在很多故障场景里TCP连接会一直卡在“卡”的状态用户看到的是持续转圈而不是立即报错。我做过一次公网联机服务的故障复盘机房一侧的交换设备出现了黑洞路由流量进得去出不来。客户端和服务端的TCP连接一整天都没有断开发什么数据都没回应双方都扔在超时重传里。直到应用层心跳判断超时才把连接标记为死亡并触发重连。后来又加了一个主动探测机制心跳连续三次没回应就主动断开不等待TCP自己的超时。TCP在“彻底断网”面前并不比UDP强多少两者同样依赖应用层的失败检测。3. 把UDP调教成“可靠传输”的正确姿势3.1 在UDP之上实现ACK、序号和重传没那么神秘UDP不可靠的本质是没有编号、确认和重传。那你自己加上不就完了很多自定义可靠UDP协议、RUDP、KCP的思想都是一样的发送方给每个数据包编递增序号接收方收到后回ACK发送方启动一个重传定时器超时未收到ACK就重发接收方根据序号排序和去重。写一个极简的演示逻辑思路是这样的# 发送端每包编号 超时重传 seq 0 while 有数据要发: msg f{seq}|{payload} udp_socket.sendto(msg.encode(), addr) 记录(seq, payload, 发送时间) while True: if 收到 ack 且 ack序号 seq: seq 1 break if 超过重传时间: udp_socket.sendto(msg.encode(), addr) # 重传接收端收到数据后不管重不重复都回一个ACK。发送端收到ACK就认为这个序号及之前的包已经到达。这只是最粗略的可靠UDP真实工程还要考虑拥塞控制、滑动窗口、乱序缓冲区、快速重传等。但原理摆在这里UDP完全可以变得“可靠”。这个过程很像快递运输TCP是物流公司提供“送货上门、签收回执、丢件理赔”的全套服务UDP是你把包裹扔进公共运输网自己盯物流单号、丢了再补寄一单。后者虽然麻烦但在你明确知道“哪些东西丢了无所谓、哪些必须补”时反而能做到更低延迟、更细粒度控制。3.2 三个场景为什么非UDP不可实时对战游戏是典型例子。每一帧的状态快照来得越快越好晚个几十毫秒就没用了。TCP一旦发生丢包重传重传的旧数据到达后不仅没有意义还会阻塞后续所有新数据队头阻塞。所以很多实时对战仍然选择UDP做主传输配合插值、快照、状态同步等手段“少量丢包可容忍延迟必须低”。实时音视频通话也是同一逻辑。VoIP、WebRTC普遍基于UDP音频和视频帧允许部分丢失缺失的帧可以通过前向纠错、丢帧隐藏来弥补但整体端到端延迟必须控制在几百毫秒内。如果强行用TCP一个丢包导致的重传等待可能让整段对话卡顿到无法接受。组播和广播场景更离不开UDP。比如工业控制里的西门子1200PLC发送UDP组播数据需要先加入组播组然后协议栈把组播流量分发给所有订阅者。TCP是点对点的根本没法表达“发给一群人”的业务模型。用UDP组播做局域网内多端同步比用TCP维护N条连接要高效得多前提是接受“偶尔丢一包没大事”。3.3 UDP分片与包大小一个被忽略的可靠性因素很多人写UDP程序一口价发一个几KB的字符串看起来没问题一旦跨路由器、跨运营商就频繁丢包。罪魁祸首大概率是IP分片。UDP数据报如果超过路径MTUIP层会把报文切成多个分片每个分片独立传输只要中间任何一个分片丢失接收方重组失败整个UDP数据报都被丢弃。更麻烦的是UDP没有TCP那样的MSS自适应也没有重传机制。所以设计UDP协议时要主动控制载荷长度。以太网链路通常MTU是1500减去IP头20字节、UDP头8字节最大载荷建议控制在1472字节以内如果路径上有PPPoE等额外开销还要再减去8字节取1280甚至1200更安全。我见过不少C#、Java写的UDPServer发送端能发出几十KB的数据本地测试一切正常到了公网就丢包率飙升。后来把应用层数据切成小于1400字节的分片接收方按序号重组丢包率直线下降。所谓“UDP可靠性靠应用层”第一课就是控制包大小别让协议栈替你分片。4. 用工具拆穿协议本相抓包、打流和调试4.1 Wireshark看TCP重传别被“可靠”蒙住眼想真正理解TCP的可靠性边界抓包是最好的方式。Wireshark里几个最实用的过滤器tcp.analysis.retransmission超时重传的包tcp.analysis.fast_retransmission快速重传的包tcp.analysis.duplicate_ack重复ACKtcp.analysis.acknowledged_unseen确认了没见过的序列号看到重传别急着骂TCP“不可靠”先看是谁触发的。重传一般意味着网络里确实丢包了或者对端返回了重复ACK。我调一个传输大文件的程序时抓包发现同一序列号反复出现而且每次间隔基本恒定基本可以判定是某个中间设备在稳定丢包。这时候TCP已经尽力重传了但吞吐被拖累得很低应用的瓶颈在网络不在协议。抓包时还要注意过滤方向的陷阱。只抓客户端、不抓服务端容易把服务端的确认包当成“异常报文”。两个端都抓才能真正还原丢包发生在哪一段。4.2 iperf3 UDP打流一跑就现原形验证网络链路质量我习惯直接用iperf3跑一组UDP打流。服务端先启动iperf3 -s客户端向指定IP发送UDP流量iperf3 -u -c 192.168.1.100 -b 10M -t 10 -l 1400跑完后客户端输出里会给出接收的总包数、丢失包数和乱序包数。丢包率就是lost / (lost received)。如果丢包率在无线环境下超过百分之几就要认真考虑应用层是否需要补偿机制了。我之前在两个办公点之间做一套数据同步方案一开始采用TCP延迟波动很大。后来用iperf3分别跑TCP和UDP打流发现UDP丢包率在高峰期能到15%而TCP看起来没丢包但吞吐骤降。原因就是TCP通过重传“掩盖”了丢包代价是延迟变大。这个实验很有说服力UDP丢包是明账TCP丢包是暗账你不能只看“没丢”就认定TCP更优还要看延迟和吞吐是否达标。4.3 TCP调试助手和UDP测试工具的使用习惯很多新人喜欢用图形化调试助手收发TCP/UDP数据这类工具本身很方便但有几个地方特别容易踩坑。第一是ASCII和HEX的切换。在TCP调试助手里输入纯文本工具默认按ASCII发送切到HEX后如果还按文本习惯输入可能发送一串无效字符。有些UDP测试工具还允许自定义ASCII命令你在输入框里写的末尾换行符到底算不算数据的一部分要提前确认。第二是本地端口绑定。UDP调试时如果不显式绑定发送端口操作系统会临时分配源端口对端回包时可能回到一个你没监听的端口上导致“发了收不到”。所以测试UDP回包接收端要绑定固定端口发送端最好也绑定成同一个端口或确保对端往正确端口回。第三是组播调试。类似西门子1200这类设备用UDP组播通信调试助手要加入组播组才能收到数据。组播地址通常是224.x.x.x到239.x.x.x加入后要确认本机的网卡是否与设备在同一网段IGMP报文是否被交换机放行。很多时候收不到组播不是代码问题是交换机没开组播泛洪或者网卡防火墙把入站组播给拦了。5. 常见网络问题排查速查看到报错别慌我在论坛和企业内网见过很多网络相关的报错有些问题反复出现先整理成一张速查表后面再挑几个重点展开。报错/问题常见原因排查思路read udp: unknown error (code10054)用connect方式UDP发送但目标没有进程监听收到ICMP端口不可达不调用connect发送或忽略10054或确认对端服务是否启动failed to listen tcp on 10808: bind失败端口被占用或权限不足用netstat找到占用进程换端口或用管理员权限运行adb server: tcp:5037 could not read ok5037端口被占用ADB进程异常杀掉adb.exe重启查找占用5037的进程ESP-01S连TCP服务器失败AT指令、波特率、Wi-Fi类型或服务器地址配置不一致重刷固件、确认路由器2.4G频段、检查AT握手和连接返回码C# UDP分包组包丢数据单包超过MTU导致IP分片分片丢失后整包作废控制载荷小于1400应用层按序号分包重组TCP粘包导致解析错误没有消息边界协议使用长度前缀或固定头部循环读取到完整消息Zabbix监控Windows TCP连接数异常取的计数器口径不一致或权限不够用netstat统计比对检查agent运行权限lwIP连不上TCP服务器MSS、阻塞超时、对端端口不通开调试日志用PC做服务器先排除底层网络这里的端口占用类问题其实是新学网络最常见的坎。Linux上可以用ss -lntp、Windows上可以用netstat -ano查端口归属拿到PID后去任务管理器看进程。如果确认是历史残留可以直接结束进程释放端口。端口监听失败看起来是系统问题很多时候是软件本身重复启动或者配置被改乱了。ESP-01S这类单片机Wi-Fi模块发送TCP消息核心是先把AT固件刷对再串口调通。我遇到过好几次“AT指令发过去没反应”最后发现是串口调试助手里勾了自动换行AT要求以\r\n结尾但实际发的是纯文本所以模块一直不响应。设置好波特率115200或9600连接路由器要选STA模式然后ATCIPSTART到TCP服务器这些步骤按顺序走基本不会卡太久。UDP的10054错误也值得单独说。Windows的UDP socket一旦执行connect就相当于在内核里把远端地址固定了。这时候如果对端主机返回ICMP“端口不可达”UDP socket会得到一个WSAECONNRESET错误错误码就是10054。很多人第一次见以为是自己代码问题其实是对方端口根本没在监听。解决办法很简单如果不是非得建立UDP关联就不要调用connect直接用sendto发送就不会收到这个错误了。6. 最后说点真心话我做网络传输相关开发这几年最大的体会是别把协议当信仰。TCP的“可靠”是协议栈尽力而为的结果它可以在95%的场景下省掉你很多心但剩余5%的极端情况还是得靠应用层兜底。UDP的“不可靠”同样不是洪水猛兽只要你理解了丢包、乱序、重复的可能就能在应用层用极小的代价把可靠性拉回来。选型时先问几个问题数据能不能容忍延迟丢一包会怎样连接数量是几个还是几百万个有没有组播需求网络环境是局域网还是公网这几个问题问完你会发现自己对TCP和UDP依赖程度并没有想象中那么绝对。TCP适合大多数请求响应和文件传输UDP适合实时性优先和自控力优先的场景两者之间还有QUIC这样的融合方案没必要二选一。希望这篇经验能帮你少走点弯路也欢迎在评论聊聊你被“TCP一定可靠”坑过的经历。
返回列表