1. 为什么UDP和TCP是两种生物:先撕开"数据报"这层皮
接触Linux网络的人,几乎都听过一句话:TCP是流式协议,UDP是数据报协议。教材上写得很轻巧,但到了工程里,这句话背后的差距能直接决定你的服务能不能撑住峰值。我见过不少后端同学,用TCP的思维去写UDP代码,结果压测一上来就各种丢包、乱序、性能腰斩,最后还把锅甩给"UDP不可靠"。
实际上,UDP的"面向数据报"不是一种简单的风格偏好,它是内核里一整套设计逻辑的出发点。你从socket接口层看下去,到协议栈,再到驱动,每一个环节都在维护"一条消息的完整性"。这一点和TCP完全不同:TCP的发送方只负责往内核丢字节流,接收方也只负责按顺序捞字节,至于一条应用消息从哪里开始、到哪里结束,TCP完全不关心。
1.1 从connect和sendto的差异说起
先看最直观的接口层。UDP的socket可以不用connect就直接sendto,这是大家都知道的事。但很多人没细想过为什么:因为UDP没有连接状态,内核不需要维护对端信息;每次sendto的时候,你把目标地址带进来,内核用完即走。
对应的,TCP在调用send之前必须connect,内核要维护一堆状态:三次握手、序号窗口、拥塞控制、重传队列。这些状态的存在,本质上是因为TCP承诺了"不丢、不乱、不重",所以要追踪每一个字节的去向。
而UDP的sendto路径走到内核里,做的事情极其干脆:把用户缓冲区拷进一个skb,设置好IP头里的源地址和目的地址,然后直接扔给IP层。没有任何序号,没有确认机制,没有重传定时器。
1.2 数据报的语义基础:一次sendto就是一个完整消息
所谓"面向数据报",含义就是:应用层一次sendto投递的数据,对端一次recvfrom必须完整收到。这个语义是UDP协议设计的第一原则。
举个例子,我sendto一个长度为2000字节的包。接收方调用recvfrom,缓冲区准备的是1500字节,那这2000字节会被截断,剩下的直接丢弃。注意,不是分两次给你,而是"截断+丢剩余"。
反过来,我sendto了两次,第一次100字节,第二次200字节。接收方如果用一个300字节的缓冲区去recvfrom,它只会拿到第一次的100字节,不可能把两次的数据拼成一个300字节的包返回。因为在内核里,这是两个独立的skb,接收路径会把每个skb单独交付一次。
这个语义在传输层代码里是怎么保住的?关键在于skb的独立性。TCP的数据可以被拆散、重组、甚至和上一个包合并,因为TCP不管消息边界。UDP的每个数据报必须单独挂在接收队列上,一个个来,不允许合并。这就是为什么UDP的接收队列里,每个skb就是一个完整的数据报。
1.3 从"面向记录"与"面向流"的差异看协议设计取舍
TCP和UDP的取舍,说到底是在"可靠性"和"简单性"之间做权衡。TCP为了保证可靠,付出了头部开销、状态维护、时延增加的代价。UDP放弃了可靠性,换来了极低的协议开销和处理延迟。
但有一个关键点经常被忽略:UDP不是"不可靠的协议",而是"不承诺可靠的协议"。它仍然提供校验和,能检测出数据损坏;它仍然提供端口复用,能正确地分发到对应进程。只是它不"试图修复"丢失和错乱。
这就像寄平信和寄挂号信的区别。平信没有回执,但邮局还是会尽力送到,油墨会盖邮戳,地址不对也会退件。UDP的校验和就是那枚邮戳,它不保证你一定收到,但能告诉收件人"这封信在途中没破相"。
所以当你设计UDP应用时,不要在"可靠性"上抱怨协议,而是应该在应用层自己补上"确认+重传"的逻辑。很多游戏服务器、实时音视频系统都是这样做的:UDP负责快速传输原始数据,应用层自行维护关键数据的可靠性。
2. 发送缓冲区:UDP其实只有"扣留区",没有堆积仓库
很多从TCP转过来的同学,第一反应是问:UDP的发送缓冲区多大?要不要调?TCP遇到发送缓冲区满会阻塞,UDP会怎样?
答案可能让人意外:在Linux里,UDP的发送路径基本不依赖发送缓冲区。它更像是"过路中转站",数据包进来,立刻被装配成skb,随即交给IP层和网卡。如果你硬要查发送队列的占用长度,你会看到它长期是0或者极短。
2.1 走进内核:从sendto到ip_append_data的路径
我用源码视角大概描述一下sendto的路径。应用层调用sendto后,最终会落到内核的sock_sendmsg,在UDP场景下进入udp_sendmsg()。
在udp_sendmsg()里,有几个关键判断:
- 如果socket没有connect过,且此次sendto带了显式地址,就用这个地址作为目的地址;
- 如果socket之前connect过,则每次sendto必须不带地址,否则报错(EISCONN相关逻辑);
- 然后调用
ip_append_data(),准备skb和IP头。
ip_append_data()会判断当前发送队列长度和阈值。对于UDP大包,如果超过MTU,就需要分片。注意,这里的"发送队列长度"指的是skb占用的字节数,由sk_wmem_alloc跟踪。
关键来了:UDP没有像TCP那样的重传机制,也不需要积压大量待发送数据。TCP的发送缓冲区是为了在未确认之前保存数据的副本,以便超时重传;UDP根本不做重传,所以数据发了就发了,内核不需要保存副本。这就是为什么UDP的发送缓冲区远小于TCP——因为压根不需要那么大。
2.2 skb的"一次性"如何保证数据报边界
skb是Linux内核网络栈里最核心的数据结构,你可以把它想成一个"数据信封",里面装的是实际载荷,外层还有一系列元信息(协议头、长度、时间戳等)。
在UDP发送路径里,每个sendto调用都会生成一个独立的skb。如果数据大于MTU,IP层会把它拆成多个分片,但每个分片仍然属于同一个skb队列,接收方重组完再交给上层UDP。
关键设计在这里:每个skb携带完整的UDP头,头里的长度字段标明整个数据报的大小。接收方不需要猜测消息边界,直接看UDP头里的长度就够了。
这就意味着,数据报边界不是"约定俗成",而是被协议头里的字段强制的。TCP没有这个字段,它是纯字节流,所以只能靠应用层自定义消息格式。
2.3 所谓UDP发送缓冲区和真正瓶颈是什么
UDP发送缓冲区虽然存在,但它的作用更像是保护系统内存不被无限消耗。默认的wmem_default一般是几十KB到几百KB,如果你一次性sendto一个超大缓冲区(比如几MB),内核会判断是否超过sk_sndbuf上限,如果超过,sendto会返回EAGAIN。
但真正限制UDP吞吐的,往往不是发送缓冲区,而是这几个因素:
- 数据从用户态拷贝到内核态的耗时(一次大包拷贝比多次小包更划算);
- IP分片带来的重组负担;
- 网卡的发送队列/中断处理能力。
以我的实测经验,在万兆网卡上,用UDP发小包时能达到的PPS(每秒包数)远高于发大包时的吞吐量。因为小包的主要瓶颈是系统调用次数和中断次数,大包的主要瓶颈是内存拷贝和分片重组。
2.4 实测:为什么UDP发大包和发小包差别巨大
我曾经做过一个简单的压测:客户端用UDP发送一个socket,服务端只有一个recvfrom循环,不做任何处理。
- 发送缓冲区设为64KB;
- 第一种场景:每次sendto 4字节,连续发送100万次;
- 第二种场景:每次sendto 1400字节,连续发送100万次。
结果非常典型:
- 小包场景下,CPU几乎全部消耗在系统调用和锁竞争上,吞吐量上不去;
- 大包场景下,虽然单次拷入内耗更大,但总吞吐高出好几倍。
原因很简单:每一次sendto都是一次系统调用,syscall的固定开销不会因为包小而减少。理论上,最好的方案是把多个小消息拼成一个UDP数据报发送,减少syscall次数,但这又会牺牲"每条消息独立"的语义。这个取舍没有标准答案,完全取决于你的业务需不需要单条消息的天然边界。
3. 接收缓冲区:真实占比极小,一旦满就丢
UDP的接收侧和发送侧不对称,这也是很多工程师容易踩坑的地方。发送侧不保留副本,所以发送缓冲区可以小;接收侧必须在消息到达后,把数据先存下来,等待应用层读取,所以接收缓冲区必须存在,而且满了之后会直接丢包。
3.1 udp_queue_rcv_skb与sk_receive_queue
当网络包经过网卡、IP层、路由判断,最终进入UDP协议栈时,会调用udp_queue_rcv_skb()。
这个函数做的事情,简单来说:
- 校验UDP头和校验和;
- 检查目标端口是否有人监听;
- 检查接收队列是否有空间;
- 有空间就把skb挂到接收队列上,唤醒阻塞的recvfrom;
- 没空间就丢包,并更新统计计数。
这里的"空间"是由sk_rmem_alloc衡量的,它表示接收队列当前累计占用的内存。内核规定了一个上限,默认是net.core.rmem_max(可调),而UDP socket的sk_rcvbuf默认值往往只有数百KB。
注意:UDP接收缓冲区的大小通常远小于TCP,因为UDP不需要为无序数据提供乱序重排缓存。TCP的接收窗口可能很大,以支持窗口缩放和突发流量;UDP只能硬扛,满了就丢。
3.2 阈值逻辑:为什么收发不平衡时接收端先死
如果你在压测时发现"服务端处理不过来,然后客户端也不行了",很可能就是接收缓冲区满了。
看一个具体场景:我用一个UDP客户端以最大速率发包,服务端每个包只做简单的解析,然后丢进队列后由另一个线程处理。处理线程跟不上,接收线程仍然在疯狂recvfrom,这时候会发生什么?
- 接收线程每次recvfrom取走队列头部的一个skb;
- 处理队列堆积,接收线程来不及处理;
- 内核的接收队列里,skb越来越多,
sk_rmem_alloc不断上涨; - 一旦超过
sk_rcvbuf,udp_queue_rcv_skb直接丢弃新到的skb。
结果就是:应用层根本看不到这些被丢弃包,你只有在netstat或者/proc/net/snmp里看到UDP的RcvbufErrors计数在涨。
这个现象告诉我们:UDP接收端的瓶颈几乎总是在应用层的处理速度。内核已经尽力缓存,但缓存有限。要做高吞吐UDP,必须保证接收线程的处理速度足够快,或者直接用多线程/多队列方案,让每个socket或者每个队列都有独立的接收路径。
3.3 内核统计:UDP丢包计数怎么查
想要判断UDP到底丢了多少包,不要靠猜,直接看内核统计。
cat /proc/net/snmp | grep Udp:输出的字段里,有几个特别重要的:
InDatagrams:接收到的UDP数据报总数;InErrors:接收时出错的数量(比如校验和错误、长度错误);RcvbufErrors:因为接收缓冲区满被丢弃的包数;SndbufErrors:因为发送缓冲区满导致send失败或包被丢弃的次数。
如果你看到RcvbufErrors一直增加,说明应用层的接收速度顶不住了,赶紧优化用户态代码,而不是盲目调大内核缓冲区。
虽然调大net.core.rmem_max和socket的SO_RCVBUF能暂时缓解,但治标不治本。本质上,数据到达速率大于应用消费速率,缓冲区再大也只是延迟了丢包时间点。
3.4 背压之外:IP分片与重组对缓冲区的影响
还有一个被忽略的问题:IP分片。当一个UDP数据报大于MTU(典型1500字节)时,IP层会拆成多个分片,接收方必须等所有分片到齐后重组,再交给UDP层。
问题在于:分片是独立传输的,任何一片丢失,整个数据报就废了;而且重组时有超时机制,超时未到的分片会触发重组失败,内核丢弃整个数据报。
更麻烦的是,分片重组需要额外的内存缓冲区,这部分在Linux里有一个独立的限值,叫net.ipv4.ipfrag_high_thresh。如果系统里有大量分片包,且重组速度跟不上,就会触发ipfrag内存压力,导致分片被直接丢弃。
工程上,对UDP报文建议尽量控制在MTU以内,要么用UDP+应用层分帧,要么用GSO/GRO让网卡帮忙处理大包,别让IP层来做分片。IP分片不仅效率低、浪费带宽,还会让丢包率成倍增加。
4. "粘包"在UDP里到底存不存在:报文边界与截断问题
"粘包"这个词,通常出现在TCP新手教程里。TCP是字节流,两次send的数据可能在下一次recv时一起出现,需要应用层自己切分。UDP则不存在这个问题,每个recvfrom返回一个完整的数据报。但我发现,很多人在实际写UDP代码时,还是会犯"粘包思维"的错误——他们总担心收到的数据不是一个完整逻辑消息,于是额外做一层解析,反而把事情搞复杂。
4.1 为什么TCP需要粘包处理而UDP不需要
TCP的粘包,根源在于TCP不维护消息边界。应用层send的字节到了内核,会按照MSS、拥塞窗口等参数被拆成段,接收方再按照自己的节奏把字节交给应用层。两次send可能合并成一次recv返回,也可能一次send被半个recv取走。
UDP没有这个问题。每个数据报在发送端就是一个独立的单元,接收端每次recvfrom只会返回一个数据报。这个边界由UDP头里的长度字段保证,内核在接收路径上根本不会合并两个skb。
如果你在写UDP消费端时,准备一个大缓冲区,然后用"加上收到的字节数"来拼接消息,基本就是多此一举。一个recvfrom返回的长度,就是当前数据报的完整长度,不需要你再去看什么\r\n分隔符。
4.2 recvfrom(recv)的最长行为与MSG_TRUNC
这里有一个容易踩的坑:如果我调recvfrom时,缓冲区小于数据报的真实长度,会发生什么?
答案是:数据报被截断,多余部分直接被内核丢弃,而且不会通知你"有截断"。有些人的代码里,缓冲区只准备了1480字节,但发来的数据报是3000字节,他们发现收到的数据总是不完整。
正确的做法是:在recvfrom时传入MSG_TRUNC标志(Linux特有),并在调用后检查返回值。
如果设置了MSG_TRUNC,recvfrom的返回值是数据报的真实长度,即使你的缓冲区不够大。没设置MSG_TRUNC时,返回值是实际拷贝到缓冲区的字节数。通过对比返回值和你传入的缓冲区长度,就能判断是否有截断。
提示:在Linux上写UDP接收代码,建议缓冲区至少分配65536字节,这样能接住最大65535字节的数据报(UDP头的长度字段是16位的,算上头部,最大载荷是65507字节)。
4.3 16位长度字段与65507的由来
UDP头里有个16位的长度字段,最大表示65535。这个长度包括8字节的UDP头和载荷,因此最大载荷是65535-8=65527字节。但还要减去20字节的IP头,才能放进IPv4的65535长度限制里,所以对IPv4来说,UDP最大载荷是65507字节。
如果发送端尝试sendto一个超过65507字节的缓冲区,Linux会返回EMSGSIZE错误——在内核里,这个检查在udp_sendmsg()里就会触发,不会等到IP层。
顺便提一句,UDP数据报长度的上限是协议本身决定的,不是socket缓冲区决定的。所以即使你把socket接收缓冲区调到2GB,单个数据报也不可能超过65507字节。
这个限制,意味着一个UDP数据报在单次传输里可以承载的数据量很小。想在UDP上传输大文件或大消息,必须自己做拆分和重组,而且要在应用层处理丢片重传。
4.4 UDP+L4校验和的弱校验问题与零拷贝情况说明
UDP的校验和是16位,覆盖IP头里的伪首部(源IP、目的IP、协议号、UDP长度)以及UDP头和载荷。但IPv4下,UDP头里的校验和可以设置为0,表示不校验,虽然大多数实现会默认校验,但你在某些嵌入式或者跨网络场景下,可能会碰到校验和设为0的包。
更值得关注的是UDP校验和的强度。16位校验和,本质上是对数据做简单的二进制补码和,检出连续多位错误的概率不算高。对可靠性要求高的业务,应用层应该再加一层CRC或消息认证码。
零拷贝的话题这里顺带提一下。Linux下UDP收发可以通过PACKET_MMAP、AF_XDP等机制实现零拷贝,但常规的socket API无法避免用户态和内核态之间的数据拷贝。接收路径上,数据从网卡DMA进内存,再从内核skb拷到用户缓冲区,全程至少一次拷贝。这也是高吞吐UDP场景中CPU占用高的重要原因之一。
5. 从源码再到应用层:那些被误解的"UDP特性"
前几节我们把UDP的内核路径和数据报语义都过了一遍,这一节聊聊工程上最常见的几个"误解",这些误解几乎每天都能在技术社区和线上事故分析里看到。
5.1 对方不回应,UDP不会有任何错误提示
这是我见过最多的一个坑:客户端用UDP发了一个包到服务器,对方服务没起来,这个包会怎样?答案是:不会被通知。
在IPv4下,如果目标端口没有进程监听,对方主机会回一个ICMP Port Unreachable。这个ICMP到达你的机器后,Linux会查找对应的UDP socket,然后把这个错误信号挂到这个socket上。但问题来了:如果你的程序一直不去recv或者send这个socket,这个错误永远不会被主动告知。
也就是说,UDP的"错误提示"是异步的、被动的、且可能丢失的。如果你指望UDP像TCP的RST一样立刻让send返回错误,那会大失所望。
处理方式是:应用层自己维护超时机制。发完包后,如果一定时间内没收到对端的应用层ACK,就判定为失联,该重发就重发,该换路就换路。
另外,ICMP错误在Linux下很多情况下会被
/proc/sys/net/ipv4/icmp_ratelimit限制,频繁触发时可能被抑制,所以更不能指望它。
5.2 有连接UDP的设计初衷(connect只是本地"白名单")
我在不少代码里见过,用UDP的人都坚持不调connect,理由是"UDP是无连接的,connect会破坏语义"。这个理解其实不准确。
对UDP调用connect,并不会真的"建立连接",不会向对端发任何包。它只是在本地为socket指定一个默认目的地址,内核后续send时可以直接用这个地址,不用每次再传入。
connect后的UDP有几个实际好处:
- send时可以直接调send/sendmsg,不用带地址参数,减少了一次地址拷贝和参数校验;
- 内核只接收来自这个对端的包,其他来源的包会被丢弃(相当于一个内核态"白名单");
- 某些场景下可以获得异步错误通知(ICMP错误更有机会传递到应用层)。
但要注意,connect后的UDP不能再用sendto指定其他地址,必须用connect重新指定。
在需要固定对端的高频通信场景(比如游戏服务器和客户端之间、DNS客户端请求固定服务器),我都推荐调用connect,性能上会有可感知的提升。
5.3 UDP的时延优势为什么在Nagle之外还有更多原因
TCP的时延高,很多人第一反应是Nagle算法,关掉TCP_NODELAY就行了。但Nagle只是原因之一。TCP还有确认机制、慢启动、拥塞窗口的逐步扩大,这些都会拖慢前几个包的发送。
UDP压根没有这些机制,它是"即发即走"的。如果你对时延极度敏感(实时对战、VoIP、直播推流),UDP天然比TCP更合适,因为在协议层它不需要任何握手和窗口管理。
但时延低不等于体验好。UDP丢包后不会重传,如果网络本身有丢包率,你可能需要应用层重传最关键的包。这里要掌握的平衡是:哪些数据走UDP、哪些数据走TCP,或者同一个UDP流里区分可靠通道和不可靠通道。
5.4 应用层补救手段的取舍总结
根据我做过几个UDP项目的实际经验,给大家一个应用层的"补救菜单":
- 关键小数据(登录、支付、重要状态):应用层ACK+超时重传;
- 海量实时数据(音视频帧、位置上报):允许丢旧帧,不重传,但带上序号,接收方做乱序丢弃和去重;
- 大文件可靠传输:在UDP之上做分片、序列号、滑动窗口、ACK和重传——这基本是重造TCP,除非有特殊需求,否则建议直接用TCP或者QUIC;
- 需要权衡吞吐和时延时:可以尝试自行调节发送节奏,避免突发风暴,减少接收端缓冲区溢出。
提到QUIC,它本质上就是UDP之上实现了可靠传输和拥塞控制,同时保留UDP的灵活性和头部开销。如果你的服务既要低时延又要可靠传输,QUIC已经帮我们把重传和乱序问题解决好了,没必要再造轮子。
6. 实战中的一点体会
写UDP代码,我的经验总结起来就是"三不":不指望内核帮你维护状态,不指望丢包能被协议层发现,不指望接收队列是无穷无尽的。
最开始做UDP服务时,我也犯过错:把接收缓冲区调得巨大,以为就永远不会丢包了。结果压测一上,RcvbufErrors照样疯涨。后来才明白,UDP的根本矛盾在于应用层的消费速度和数据到达速度之间的差距,缓冲区只是把矛盾往后推了一点点。
真正解决问题的方式,永远是让应用层的接收和处理更高效:减少每个包的解析开销,用多线程分担负载,或者用内核旁路(如AF_XDP)把数据直接交给用户态处理。
此外,网络观测的习惯要建立起来。收到任何异常现象,第一时间去看/proc/net/snmp里的UDP统计,再去看网卡统计,最后才猜测应用层问题。排查顺序一旦搞反,很容易被表象带偏。
最后说一个很多人没注意到的细节:UDP的校验和可以在socket选项里关闭(SO_NO_CHECK),虽然不推荐,但在某些数据完整性要求不高的场景下能省一点CPU。如果要做这个优化,建议只在单跳可信网络里用,并且要在架构上接受"数据可能损坏"的风险。
写这篇文章的时候,我脑子里全是这些年踩着各种坑慢慢梳理出来的经验。UDP的源码并不复杂,复杂的是你要理解它为什么这样设计,以及在这些设计约束下怎么写出能抗住压力的应用。希望这篇内容能帮你少走一些弯路。