前阵子排查一个跨机房的数据上报故障日志里清清楚楚记录着“发送成功”服务端那边却一条消息都没收到。抓包一看确认报文确实从发送端网卡出去了服务端物理机网卡也收到了可就因为防火墙把UDP回包ICMP端口不可达挡了发送端一直以为发得没问题——这就是UDP最典型的“假成功”。也正是从那次开始我一直想写一篇把UDP和TCP发送接收流程差异讲透的文章。两个协议虽然都跑在传输层但从调用API那一刻起后面的路径、保障机制、失败方式完全不一样。这篇文章会从发送路径、接收路径、实际场景、排查手段、选型判断五个角度展开适合刚接触网络编程的初学者也适合被线上问题折磨过、想系统理一遍思路的开发者。1. 为什么send函数已经返回对端却迟迟没有收到数据1.1 同样返回成功UDP与TCP的内核处理完全不同很多人的第一反应是send成功 数据到对端了这个误区的根源在于不理解“系统调用成功”和“传输层确认”是两码事。以UDP为例你调用sendto如果返回值等于发送长度只代表内核把数据报拷到了套接字的发送队列里之后所有事情基本和你无关了。内核会尽快把它封装成UDP数据报、交给IP层发出去但发出去之后有没有到对端、对端有没有接收、中间路由器有没有扔掉UDP一概不关心你也不会收到任何反馈。TCP则完全不同。当你调用send或write、sendmsg数据先进入套接字发送缓冲区但最终能发多少取决于发送窗口和发送缓冲区的剩余空间。返回值可能小于你请求发送的长度非阻塞模式下尤其常见也可能阻塞等待直到数据被内核接受。更重要的是即使send成功也不代表数据已经送达对端只是代表数据被纳入了可靠传输机制的管理范围——内核会负责后续的发送、确认、重传直到对端ACK为止。所以UDP的“发送成功”是一种表面成功TCP的“发送成功”则是“进入可靠管道”的成功二者对应用层的含义有本质差别。1.2 TCP发送路径的完整链路滑动窗口、序号、ACK与重传要把TCP的流程讲清楚最好的方式是从“双方各自维护什么状态”来看。TCP每个连接都有两套窗口发送窗口和接收窗口。发送窗口决定你现在可以一口气发出多少数据而不需要等确认接收窗口则是对端告诉你能发多少它缓冲还够接收多少。假设发送窗口是4096字节你连续send了10KB数据内核会先按MSS通常1460字节左右分成多个报文段填满发送窗口能容纳的部分先发出去剩下的存进发送缓冲队列等待。发出去的每个字节都有一个序列号Seq对端收完后会回一个ACK携带“下一个期望收到的字节序号”。发送端每收到一个ACK窗口就往前滑继续发送新数据。如果某一段数据超时没收到ACKTCP会通过RTO超时重传或快速重传机制重新发送同时会动态评估RTT来调整超时时间。这个逻辑保证了只要连接还活着数据最终会按序到达应用层。代价很明显一旦网络抖动、丢包发送流程会变慢甚至可能出现队头阻塞——后面的数据明明已经到达但对端因为前面丢了包不能把后续数据交给应用层。1.3 UDP发送路径的完整链路无连接、分片与静默丢弃UDP的发送路径在机制上比TCP简单太多。每调用一次sendto就相当于生成一个独立的数据报数据报有自己的边界内核不会帮你拆分或合并。它带着目标IP和端口直接进入IP层。这里暗藏一个非常容易踩的坑IP分片。当你的UDP数据报大于路径MTU比如1500字节时IP层会把它分成几个分片分别发送。问题是任何一个分片丢失整个数据报都无法重组会被接收端直接丢弃而发送端对此一无所知也没有任何重传机制。另一个坑是ICMP错误不一定会传到你的应用。比如对端端口没监听路由器或对端主机会回一个“端口不可达”ICMP报文。理论上这个错误应该反馈给发送端如果你用的是已连接的UDP套接字即对UDP调用过connect后续send可能会返回ECONNREFUSED错误但如果你用普通的sendto根本不带连接状态内核无法把错误和你某一次send对应起来于是这个错误就被吞掉了你永远不知道消息是否真的被处理了。可以做一个直观的对比对比维度UDPsendtoTCPsend调用返回含义数据进入发送队列数据被内核接受并纳入可靠传输是否存在ACK确认不存在存在且和序号绑定数据发送单位独立数据报字节流按MSS分段失败反馈通常无反馈或延迟丢失超时重传、对端RST、连接断开对包序的保障不保证严格保证2. 接收端的两种哲学UDP能收多少算多少TCP必须收全2.1 recv与recvfrom一个按字节流一个按数据报接收路径的差异比发送路径更常被忽略。TCP是字节流协议recv函数接收到的字节数和你之前send的字节数没有一一对应关系。你用send发了5000字节对端可能第一次recv就拿到5000字节也可能只拿到1460、再拿3540甚至可能出现你send两次对端一次recv全收的情况。这个特性是TCP面向流的本质决定的也正是粘包与拆包问题的根源。UDP则完全不同。recvfrom每次读取一个完整的数据报一次返回多少字节就是对方某一次sendto时写入的整个数据报长度边界由内核维护。哪怕接收缓冲区里攒了十个数据报每次recvfrom也只会取出其中一个。反过来如果你给的接收缓冲区比数据报小recvfrom会按标志位决定是丢弃剩余部分还是返回错误这个也要注意。使用层面有个很实用的经验TCP编写的接收代码几乎都要配合自定义协议解析比如包头带长度字段、按长度截断UDP则天然具备“消息边界”一个数据报就是一条消息。后者省了拆包逻辑却把丢包、乱序、重复的问题留给了应用层。2.2 粘包与拆包TCP逃不开的边界问题UDP由内核兜底粘包的形成原因很简单TCP为了效率可能把多个send写入的数据合并成一个报文段发给对端也可能把一个大数据拆分到多个报文段。接收方只看到一段连续的字节流不知道每条消息从哪里开始、到哪里结束。如果不做应用层协议约定接受方根本无法区分“这是一条数据还是两条”。最常见的解决方案是业务协议里每个消息前加4字节长度头。接收方先读长度头再按长度读消息体。另一个方案是使用固定长度的消息结构长度不符合就直接丢弃或报错。这就是所谓“自定义帧”。UDP则不需要这种处理。你send一次对方recvfrom一次拿到的就是一个完整消息。奇偶校验、顺序、去重这些工作内核不会帮你做——例如网络层可能因为迂回路径导致数据报乱序也可能因为底层设备重传机制导致同一个数据报重复到达这些都属于“通了但没完全通”的情况需要应用层自己判断。2.3 返回0的含义不同连接关闭与空数据报很多新手在看示例代码时会把recv返回0等同于“对方没发数据”这是错的而且在对UDP和TCP双协议做兼容轮子时会踩大坑。在TCP里recv返回0有非常明确的含义对端关闭了连接不会再发送数据此时你通常需要做半关闭或全关闭处理。对TCP编程来说“recv0”意味着这条连接的生命周期走到了终点。在UDP里recvfrom返回0却代表完全不同的意思对端真的发送了一个长度为0的UDP数据报。UDP允许发送空报文这是一个合法且可以实际观察到的情况很多心跳检测设计就是利用长度为0的数据报来探测“端口是否可达”。如果代码把UDP下recvfrom返回0当成异常去关闭套接字就会导致服务崩溃或逻辑错乱。2.4 接收缓冲区溢出TCP会减速UDP会直接丢接收端还有一层机制差异集中在接收缓冲区上。TCP的滑动窗口本质上是一种背压传导应用层来不及recv缓冲区满了内核会把接收窗口通告给发送端比如窗口变成0发送端就会停止发送新数据等窗口重新变大再继续。这样数据不会丢除非连接断开但会自然限速甚至触发零窗口探测。换句话说TCP用“等你”的方式保护数据。UDP没有这种能力。数据报到达时如果套接字接收缓冲区已满内核会直接丢弃新到达的数据报。应用层连影子都看不到发送端更不会知道。这在日志采集、传感器数据上报这类场景里特别致命——流量稍微一冲丢包率可以瞬间飙到两位数而且完全没有征兆。可以理解为TCP像一条有闸门的运河水多了就关闸蓄水UDP更像一个漏斗倒得太快溢出来的就流走了。3. 游戏帧同步、文件传输、日志上报从三个场景看真实差异3.1 帧同步对战宁可丢一帧不能等一秒实时对战类应用几乎清一色选择UDP核心原因是TCP的可靠传输机制在弱网条件下会引入不可控的延迟。想象一下双方都在进行帧同步每隔几十毫秒同步一次状态如果中间某个TCP包丢失触发重传整个数据流都会停住等待玩家看到的直接后果就是卡顿、瞬移。而丢一个UDP包下一帧数据马上会跟上旧数据丢了就丢了对整体体验的影响反而更小。当然选UDP不代表不做可靠性而是做有选择的可靠性。游戏引擎常在UDP之上叠加自定义序号、确认和重传策略只有关键数据比如玩家进入、道具拾取要求确认普通位置同步则允许丢弃。这样既避免了队头阻塞又兼顾了必要的可靠性。这个取舍逻辑恰恰说明了UDP/TCP的选择不是一个“谁好谁坏”的问题而是对延迟、有序性、可靠性不同权重的权衡。3.2 大文件传输为什么还是老老实实用TCP文件传输对完整性、有序性要求极高TCP几乎是天然匹配每个字节都有唯一的序列号对端会按序重组数据出错自动重传应用层只需要读写字节流。用UDP裸传文件应用层必须自己实现分片编号、确认、重传、乱序重排、流量控制几乎相当于把TCP重新造一遍。这也是为什么业界虽然有所谓的“UDP加速传文件”方案但大多数时候底层还是依赖QUIC或自研可靠UDP并不是直接用原始UDP。真实工程里文件传输一旦改用可靠协议复杂度会成倍上升。除非你的场景对“抢占带宽”有极端需求比如要在高丢包链路上榨干带宽否则用TCP是最稳妥、最省力的选择。3.3 日志上报发送端乐观接收端现实日志上报和监控数据采集是UDP的经典阵地因为每条日志之间没有强关联性丢个几条也不影响大局而且UDP开销小、无需维护连接尤其适合长时间运行的高频上报通道。可问题往往不出在“能不能丢”而出在“丢起来没数”。接收端一旦处理不过来缓冲区溢出数据报开始被静默丢弃而发送端按原有节奏持续发送丢掉的永远不会重来。有一次排查日志缺失发送端和接收端看代码都没问题最后用系统网络统计一看接收端UDP队列溢出次数一直在涨这才定位到是服务端采集线程太少、消费不过来。这类问题在TCP上几乎不会出现——TCP会通过接收窗口反向压发送端让生产速率自动降下来但UDP没有这个反馈回路。所以选择UDP做日志上报时接收端必须要有独立的缓冲、多线程消费和丢包统计最好再加一条监控告警一旦发现丢包率的统计值超过阈值就触发降级或扩容而不是让业务在无声中“丢数据”。4. 排查链路从“能ping通”到“UDP不通”有多远4.1 ping通不等于UDP端口通TCP能连也不等于UDP能连排查任何网络问题最忌讳的窗口就是“服务器能ping通”基本测出UDP也通。ping走的是ICMP回显和三层的连通性有关和四层的UDP/TCP没有直接关系。一个防火墙可以放行ICMP却拦掉所有UDP端口也一样TCP端口能连上UDP端口依然可能不可达因为UDP是无连接的三次握手这种探测手段根本用不上。TCP让你连不上端口的方式很明确——connect会直接被拒或在等待超时后失败错误类型也很清晰UDP则可能是“发送成功、对方默默丢弃”也可能对方回了ICMP端口不可达但发送端没感知。所以验证UDP通断正确做法是用专门的手段例如用nc -u命令去发特定报文同时在对端抓包确认报文是否到达、有没有回包或者直接写一个小客户端和服务端打一条约定好的消息两侧都做日志记录比对发送数量与接收数量。4.2 抓包对比一看序号二看重传三看耗时如果条件允许tcpdump加Wireshark是定位发送接收差异最快的路径。TCP抓包时重点看三个东西第一个是Seq和Ack变化看有没有重复ACK、有没有跳号第二个是有没有大量TCP Retransmission重传或Dup ACK这代表网络存在丢包或乱序第三个是Time列看RTT是否突然升高。TCP的报文头和序号机制把每个数据段的命运都交代得清清楚楚这是它“可排查性好”的体现。UDP抓包则要更加依赖统计。先确认对方网卡确实收到了UDP包再看有没有ICMP Port Unreachable报文回给发送端后面再确认对端进程有没有从套接字里实际读走。重点在于UDP抓包只能说明“报文到了本机”至于本机内核有没有丢弃丢包则要看接收缓冲区是否溢出。抓包工具本身是在数据链路层旁路的能看到不代表应用层能看到。4.3 本地回环测试为什么最容易给人错觉我见过太多项目在本地开发环境测UDP一点问题没有上线就崩。原因在于lo回环接口的特殊性回环不走真实物理链路不存在路由器拥塞、不会触发MTU分片问题数据从发送队列到接收队列几乎是瞬间完成丢包率趋近于零。再加上本机吞吐量巨大接收端稍微慢一点也未必能塞满缓冲区。在这种环境里测出的UDP表现完全无法代表公网或复杂内网的真实情况。正确做法是用两台真实机器最好中间再挂一层弱网模拟工具去制造延迟和丢包然后再看两条协议的表现差异。如果条件不允许至少心里清楚本机测试只是验证“代码逻辑没写错”不能用来评估“传输是否可靠”。4.4 用系统网络统计快速定位UDP丢包除了抓包系统自带的网络统计信息也很有价值。在Linux上可以通过netstat -s或cat /proc/net/snmp查看UDP协议统计其中有个UdpRcvbufErrors字段专门记录接收缓冲区溢出导致的丢包数量。如果这个数字在增长说明接收端消费能力跟不上。还可以看UdpInErrors代表所有UDP层丢包的总和。发送端则观察发送队列积压情况以及对端有没有大量端口不可达反馈。这些指标配合应用层统计使用排查效率会高很多。常规的做法是先在应用层统计发送和接收的数量差确认“有没有丢”然后用系统统计确认“在哪个层面丢”最后用抓包确认“物理链路上有没有收到”。三步走完问题基本就框定了。5. 选型判断结合发送接收差异决定你的业务用哪个5.1 一张表帮你快速决策把两类协议的发送接收机制梳理清楚之后选型就不再是拍脑袋了。我习惯从下面几个维度做评估业务需求更合适的选择原因对延迟极其敏感、能容忍少量丢包UDP避免队头阻塞和重传引入的延迟抖动数据必须完整、有序TCP内核已完成可靠有序的传输高频小消息、单条独立处理UDP无连接开销低自带消息边界长时间数据流、字节流模型TCP流式传输天然适合文件、下载、RPC长连接需要穿透复杂NAT/防火墙UDP或基于UDP的QUIC无连接特征有利于NAT打洞虽然不一定成功开发速度和通用性优先TCP生态成熟中间件和排查工具多这张表不是绝对标准真正的决策要回到你业务对“延迟、可靠性、成本”的权重上。5.2 如果要在UDP上补可靠性需要补哪些东西UDP之上做可靠传输本质上是把TCP的核心机制重新实现一遍。最基础的清单包括序列号序号与去重、确认包ACK、超时重传、乱序重排、流量控制与拥塞控制。序列号用来识别每个包、判断重复和乱序ACK让发送端知道哪些数据已经被确认重传保证丢失的数据重新发出乱序重排则让上层能够按顺序交付。流量控制防止接收端缓冲区被打爆拥塞控制则让发送速率自适应网络状况。实际动手之后会发现每个环节都有大量细节RTO怎么估算是否要支持选择性确认快重传的阈值如何配置这些正是TCP做了三十年才做到今天这个程度的原因。所以除非你有专门的研究场景否则直接在UDP之上重造轮子并不划算。现在更合理的路径是直接用成熟方案——比如QUIC它基于UDP实现但在应用层提供了可靠传输、多路复用、加密等能力相当于把TCP的优点搬到了UDP的灵活框架上。5.3 几条选择与维护的工程级建议最后分享几条我在工程实践中沉淀下来的建议。第一UDP收发双方一定要加应用层心跳和序号哪怕不做重传也要能检测“有没有断流、对方有没有死掉”否则UDP会无声无息地死掉而且很难察觉。第二TCP连接要关注半开连接与空闲保活问题一条连接如果长期空闲中间设备可能悄悄把它清理掉而任何一端都不知道需要定期应用层心跳或开启TCP KeepAlive。第三做UDP接收端时要用多线程消费、绑大缓冲区并且布一个丢包率监控不要等到数据缺失不可逆了再排查。第四无论用哪个协议都要在收发两端都记请求日志或计数器只在一端统计消息往往看不到全貌。把发送接收流程吃透之后看很多网络问题都会有豁然开朗的感觉。“为什么TCP慢”“为什么UDP丢包没人知道”这些问题的答案其实都藏在内核收发队列的行为里。协议选择没有银弹关键看你愿意让哪些数据可控、哪些数据可丢一旦厘清了这两类需求UDP还是TCP答案就自动浮出水面了。
阅读完成 · 觉得有帮助?