UDP协议详解:报文结构、MTU分片与缓冲区排障
前阵子帮一个做实时语音的团队看线上问题,抓包文件摊开来,一半是UDP包,一半是TCP重传。服务跑了两周都算平稳,某天开始用户投诉“说话有回音、断断续续”,而监控面板上CPU、内存、带宽全是绿色。折腾到第三天,有人翻出团队内部网络笔记里标着“13.16、UDP”的那一节,才意识到问题根本不在应用层代码——是发送端一直在发超过链路MTU的UDP数据报,被IP层拆成了碎片,任何一片丢了整个语音帧就废了。这件事之后我把UDP相关的东西重新梳理了一遍,从报文结构到内核参数到应用层重传,整理成下面这些内容。
UDP(User Datagram Protocol,用户数据报协议)是传输层里最“省事”的那个协议,它只做多路复用和一点点差错检测,剩下的事全交给上层。这篇文章会从协议本身讲到代码落地,再讲到实际部署里最容易翻车的地方,适合已经写过socket但被UDP坑过的人,也适合刚接触网络编程、想知道“什么时候该用UDP”的读者。看完你至少能搞清楚三件事:UDP到底保证什么不保证什么、你的业务场景该不该选它、选完之后要在哪些地方自己补课。
1. 无连接这三个字,比你想的要重得多
很多人对UDP的第一印象是“快”,这个印象对,但不够准确。UDP真正区别于TCP的地方不是快慢,而是它压根不维护连接状态。理解这一点,后面所有的取舍才有依据。
1.1 无连接意味着两端互不知情
TCP连接建立之后,双方内核里各有一份连接控制块,记录着序列号、窗口大小、拥塞状态、重传队列。这些状态是有成本的:内存、CPU、以及连接建立和拆除的往返时间。UDP把这份账本整个删掉了,发送方调用sendto,数据报就被扔进网络,收不收到、什么时候到、到的时候顺序对不对,协议层面一概不管。
这个设计带来的直接后果是:服务端不需要为每个客户端分配资源。一个UDP服务端处理十万个客户端,内存占用和十个客户端几乎没区别,因为它只认“数据报里带的源地址”,不认“连接”。这对那些客户端数量巨大、单次交互又很轻的场景是决定性的优势。
代价也很明显。TCP的连接状态顺便承担了“对端还活着”的探测功能,UDP没有这层,你的应用必须自己设计心跳、自己判断对端是不是已经消失。我见过不少项目在上线半年后才想起来补心跳,原因是NAT表项超时把映射删了,客户端还在发,服务端一个包都收不到,双方都以为对方还活着。
1.2 快慢只是表象,责任划分才是本质
拿UDP和TCP比吞吐,在理想网络下差距其实没想象中大。真正的差距出现在网络变差的时候:TCP会因为丢包触发重传和拥塞窗口收缩,吞吐断崖式下跌;UDP该发多少发多少,丢的包就丢了,应用层看到的是“质量下降”,而不是“延迟飙升”。
这两种降级方式没有绝对优劣,取决于业务对“延迟”和“完整性”的偏好。传文件、传配置、传订单,一个字节都不能错,宁可慢一点也要完整,那就该用TCP或者基于UDP自己实现可靠传输。传实时语音、传视频帧、传游戏里的位置更新,晚到一秒钟的数据等于废数据,那丢掉反而比等待更划算。
我通常用一个判断标准:如果这份数据晚到500毫秒就失去意义,那它是UDP的候选;如果这份数据晚到5秒钟但必须完整,那老老实实用TCP。这个标准粗糙但好用,能挡掉大部分选型错误。
1.3 八字节报头里塞下的全部信息
UDP头部固定8字节,四个16位字段:源端口、目的端口、长度、校验和。就这么多,没有选项字段,没有可变长度部分。对比TCP头部最短20字节、带选项能到60字节,UDP的极简程度一眼可见。
这8字节的结构决定了UDP的上限和下限。上限是单个数据报最大65535字节(受长度字段16位限制),扣掉IP头和UDP头之后,IPv4下实际能承载的载荷是65507字节。下限是它什么额外能力都没有,序号、确认、窗口、拥塞控制、连接管理,全都不存在。
有人会问,既然这么简陋,为什么还要设计它?因为在某些场景下,“简陋”本身就是需求。把复杂度从协议层挪到应用层,意味着应用可以根据自己的业务特点做最合适的取舍,而不是被协议层的通用策略绑住。QUIC就是这条思路的极致体现,它把TCP那一整套可靠性机制在用户态重写了一遍,为的就是能针对HTTP场景做更激进的优化。
2. 把一个UDP报文拆到字节级
光知道有四个字段不够,实际排查问题时经常需要对着十六进制看报文。这一节把每个字段的作用和容易踩的边界说清楚。
2.1 端口字段:十六位撑起的多路复用
源端口和目的端口各16位,范围0到65535。目的端口决定数据报交给哪个应用进程,源端口让接收方知道该回给谁。这两个字段是多路复用的全部基础,一台机器上跑几十个UDP服务,靠的就是端口区分。
有个细节经常被忽略:源端口可以是0。当发送方不需要接收回复时,可以把源端口设成0,表示“别回我”。某些扫描工具和单向通知场景会用这个技巧。反过来,服务端在回包时必须用recvfrom拿到的完整地址结构,而不是自己拼一个,否则在多网卡机器上很容易回错地址。
还有一类问题出在端口复用上。TCP有SO_REUSEADDR处理TIME_WAIT,UDP没有TIME_WAIT状态,但多个进程绑定同一端口的场景下,内核的行为取决于是否设置了SO_REUSEPORT。在Linux 3.9之后,SO_REUSEPORT可以让多个进程各绑一个socket到同一端口,内核按四元组哈希分流,这是做UDP服务横向扩展的常见手段。不设这个选项的话,第二个进程直接报EADDRINUSE。
2.2 长度字段与那个绕不开的65507
长度字段是16位,包含UDP头部本身,所以单个UDP数据报理论最大值是65535字节,载荷最大65535减8等于65527字节。再往下扣IP头,IPv4头最短20字节,得到65507字节;IPv6头固定40字节,得到65487字节。
但这是理论值,实际能发多大取决于路径MTU。以太网默认MTU是1500,扣掉20字节IP头和8字节UDP头,单个UDP数据报的安全载荷是1472字节。超过这个数,IP层要么分片,要么在DF位(Don't Fragment)置位的情况下直接丢弃并回一个ICMP“需要分片”消息。
我见过太多项目在这里翻车。发送端在本机测试,走回环接口MTU是65536,怎么发都没事;一上生产走真实链路,超过1472字节的包全部被分片或者丢弃,表现就是“小包正常、大包不通”。所以应用层的UDP协议设计,单包载荷最好控制在1200字节以内,这个数留出了IPv6头、各种隧道封装(VXLAN、GRE)的余量,是目前业界比较通行的保守值。
2.3 校验和:一个经常被关掉的字段
校验和字段16位,覆盖UDP头部、载荷,以及一个“伪首部”——包含源IP、目的IP、协议号、UDP长度。伪首部的存在是为了验证数据报确实送到了正确的目的地址,而不只是端口号对上了。
这里有个历史遗留的坑:IPv4下UDP校验和是可选的,填0表示不校验;IPv6下是强制的。早年有些网卡和驱动为了省CPU,会把校验和计算卸载到硬件,或者在驱动层直接关掉,导致IPv4环境下大量UDP包的校验和是0。这在纯内网环境问题不大,一旦跨公网、经过中间设备,某些实现会直接丢弃校验和为0的包。
提示:如果你在做跨公网的UDP服务,务必确认发送端开启了校验和。用
tcpdump -vv看包的时候留意一下cksum 0x0000,出现这个值就要警惕。
校验和是16位反码求和,只能检测差错,不能纠错,也不能防篡改。别指望它承担任何安全职责,需要完整性保护就上应用层的HMAC或者直接走TLS。
2.4 抓一次DNS查询看看真实结构
DNS是最典型的UDP应用,用它举例最直观。查询example.com的A记录,UDP载荷大概是这样的结构:前2字节是事务ID(比如0x1a2b),接着2字节标志位(0x0100表示标准查询、期望递归),然后2字节问题数(0x0001),2字节回答数(0x0000),2字节权威数(0x0000),2字节附加数(0x0000),这12字节是DNS自己的头部。
后面跟着查询名,格式是“长度+标签”的序列:0x07加example七个字节,0x03加com三个字节,最后0x00结尾。再往后2字节查询类型(0x0001表示A记录),2字节查询类(0x0001表示IN,互联网)。
用tcpdump -X -s0 udp port 53能看到这串十六进制。之所以要熟悉这个结构,是因为线上排查DNS问题时,你经常会看到“查询发出去了但没响应”,这时候需要判断是查询本身构造有问题,还是响应在路上丢了。DNS默认超时是5秒,重试2次,总共3个查询包,这个行为在/etc/resolv.conf里可以调,但很多人不知道options timeout:1 attempts:2这行的存在。
3. 这些场景里,UDP是正确答案而不是妥协
选UDP不是“退而求其次”,在下面这些场景里,它才是那个对的选择。判断依据始终是:业务能不能容忍丢包,以及能不能接受自己补齐协议层的能力。
3.1 实时音视频:晚到的包等于坏包
语音编码器每20毫秒产生一个数据包,播放端有一个几十毫秒的抖动缓冲区。如果某个包在路上多耽搁了200毫秒才到,它到达时播放位置早就过去了,只能被丢弃。这种情况下TCP的重传机制完全是负资产:它不仅让这个包更晚到,还因为队头阻塞把后面本来能按时到的包一起卡住。
所以实时音视频清一色走UDP,应用层自己做前向纠错(FEC)、丢包隐藏(PLC)、以及基于NACK的选择性重传。丢1%到2%的包,人耳基本听不出来;但如果因为等一个重传包导致整体延迟增加300毫秒,通话体验会直接崩掉。
3.2 DNS和DHCP这类一问一答的短事务
DNS查询是典型的“一个小请求换一个小响应”,交互轮次少、数据量小、对延迟敏感。用TCP的话,三次握手就要一个往返,查询本身再一个往返,开销翻倍。UDP把这个过程压缩到一个往返,代价是响应丢了要重试,但重试的成本比握手低。
DHCP更典型,客户端在还没拿到IP地址的时候,根本没条件建立TCP连接,只能用UDP广播。这类“协议引导阶段”的场景,UDP几乎是唯一选择。类似的还有NTP时间同步、SNMP设备管理、syslog日志上报,都是一问一答或者单向推送,用UDP最经济。
3.3 游戏状态同步:新状态永远比旧状态值钱
射击游戏里,服务端每秒给客户端推送20到60次玩家位置。某个位置包丢了,下一个包带来的新位置会覆盖它,所以丢包的影响只是“画面稍微顿一下”,而不是“角色瞬移”。如果为了不丢包去用TCP,队头阻塞会让所有后续的位置更新都卡在重传队列后面,表现出来就是“人物卡住不动然后突然跳过去”,体验比丢包差得多。
这类场景的设计要点是:每个状态包都是自包含的、幂等的、可丢弃的。客户端收到任何一个包都能独立还原出当前状态,不需要依赖之前的包。这个设计原则反过来也约束了协议格式,不能设计成“增量更新”,必须是全量快照或者带足够上下文的增量。
3.4 隧道与覆盖网络:把可靠性推给上层
各种Overlay网络、容器网络、服务网格的数据面,底层通常是UDP封装。原因是隧道里跑的可能本来就是TCP流量,如果隧道本身再用TCP,就出现了“TCP over TCP”,两层重传和拥塞控制互相干扰,性能会急剧恶化。
用UDP做封装,内层流量的可靠性由内层协议自己保证,隧道层只负责搬运。这个思路在VXLAN、Geneve、以及各种私有隧道协议里都能看到。做这类系统时要注意封装开销:外层IP头20字节、UDP头8字节、隧道头8到16字节,加起来三四十字节,MTU要相应调小,否则内层的大包会被分片。
3.5 什么时候老老实实用TCP
说完UDP的好,得说说什么情况别硬上。如果你的业务是下面这几种,选UDP基本是给自己找麻烦:
- 数据完整性要求高,丢一个字节就出错,比如文件传输、配置下发、数据库复制
- 交互模式是长连接、请求响应严格配对,比如HTTP API
- 团队里没人有精力维护重传、拥塞控制、连接管理这套逻辑
- 网络环境复杂,中间有大量NAT和防火墙,UDP穿透成功率不保证
最后这条尤其值得强调。很多企业网络对UDP的限制比TCP严格得多,某些防火墙直接封掉除53端口外的所有UDP。你的服务在测试环境跑得好好的,一到客户内网就不通,排查半天发现是被策略挡了。做选型时要把这个因素算进去。
4. 把服务跑起来:UDP的socket用法与并发模型
协议层面聊完,该动手了。UDP的socket API比TCP简单,但“简单”不等于“没坑”,几个参数设不对,性能能差一个数量级。
4.1 socket创建时那几个容易漏的参数
先看Python版本的最小服务端:
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 关键:接收缓冲区要在bind之前设置,且内核会把值翻倍 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * 1024 * 1024) sock.bind(("0.0.0.0", 9000)) while True: data, addr = sock.recvfrom(65535) sock.sendto(data.upper(), addr)SOCK_DGRAM这个参数决定了是UDP,SOCK_STREAM是TCP,写错了后面全错。SO_RCVBUF值得单独说:它控制内核为该socket排队的最大字节数,默认值在Linux上通常是212992字节(约208KB),这个量在现代服务里远远不够。缓冲区一满,后续到达的包会被内核直接丢弃,而且不会通知应用层,你只能通过/proc/net/snmp里的RcvbufErrors计数发现问题。
设置这个值有两个约束:一是必须在bind之前设,二是实际生效值是设定值的两倍(内核会翻倍),且不能超过net.core.rmem_max。想设成4MB,得先确认系统参数够大,否则设了也白设。
4.2 服务端:单线程循环能撑到多少
上面那个单线程循环,在本地回环上跑小包echo,实测能到每秒十几万次请求。这个数字看起来不错,但要注意它成立的前提:每个包的处理逻辑极短,且没有阻塞操作。一旦处理逻辑里加了一次数据库查询或者一次磁盘写,整个循环就被卡住了,后面排队的包只能等。
UDP服务端的并发模型大致有三条路:
| 模型 | 适用场景 | 注意事项 |
|---|---|---|
| 单线程循环 | 处理逻辑极轻,包量中等 | 任何阻塞操作都会拖垮整体 |
| 多线程/线程池 | 处理逻辑有IO等待 | 线程数别超过CPU核数太多 |
| epoll事件循环 | 高并发、多socket | 需要处理边缘触发和缓冲区读取 |
我倾向于先用单线程跑,用压测数据说话。如果单核跑不满,说明瓶颈在业务逻辑而不是IO模型,加线程也没用。真正需要上epoll的,是单机几十万QPS并且要管理多个socket的场景,普通业务到不了这个量级。
Go的实现更省心一些,运行时帮你处理了调度:
package main import "net" func main() { addr, _ := net.ResolveUDPAddr("udp", ":9000") conn, _ := net.ListenUDP("udp", addr) // 加大读写缓冲区 conn.SetReadBuffer(4 * 1024 * 1024) conn.SetWriteBuffer(4 * 1024 * 1024) buf := make([]byte, 65535) for { n, remote, err := conn.ReadFromUDP(buf) if err != nil { continue // 别因为一个错包退出循环 } conn.WriteToUDP(buf[:n], remote) } }这里有个细节:ReadFromUDP返回错误时不要return或者panic。UDP的错误大多数是单个包级别的(比如ICMP端口不可达回传),不影响后续收包。我见过一个服务因为对端发了个畸形包触发错误,代码直接退出循环,整个服务假死。
4.3 客户端:超时、重试与幂等
UDP客户端必须自己处理超时。settimeout之后recvfrom会抛异常,这是唯一能感知“对方没回”的手段:
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(0.3) for attempt in range(3): try: sock.sendto(b"ping", ("127.0.0.1", 9000)) data, _ = sock.recvfrom(65535) print("got:", data) break except socket.timeout: print(f"attempt {attempt} timeout")重试次数和超时时间要一起设计。超时设太短,网络稍微抖一下就重试,会造成服务端收到大量重复请求;设太长,用户等待时间不可接受。经验值是超时取预估RTT的2到3倍,重试2到3次,整体等待控制在秒级以内。
重试带来的直接问题是幂等性。同一个请求可能被服务端处理两次,如果你的处理逻辑是“扣库存”或者“创建订单”,重复处理就是事故。解决办法是在请求里带一个唯一ID,服务端维护一个短期的去重窗口。这个窗口不用太大,覆盖最大重试时间就够了,比如10秒。
4.4 本地实测的一组数据
在四核机器上跑回环echo,收发各100万次,不同包大小下的结果大致是这样:
| 载荷大小 | 单线程QPS | 平均延迟 |
|---|---|---|
| 64字节 | 约14万 | 7微秒 |
| 512字节 | 约12万 | 8微秒 |
| 1200字节 | 约9万 | 11微秒 |
| 8000字节 | 约2.5万 | 40微秒 |
这个数据能看出两件事。第一,小包的处理成本主要花在系统调用上,不是数据拷贝,所以包大小从64涨到1200,QPS只掉了三分之一。第二,超过MTU之后性能断崖式下跌,因为触发了分片,每个大包变成六个小包,收发两端的软中断次数翻了好几倍。这也是为什么前面强调要把应用层载荷控制在1200字节以内。
注意:回环测试的数字只能用来对比相对性能,不能当作线上容量规划的依据。真实链路上还有网卡中断、协议栈处理、交换机转发等开销,实际QPS通常只有回环的三到五成。
5. 问题最集中的地方:MTU、分片和缓冲区
UDP的线上问题,八成出在这三个地方。它们的共同点是:本地测试一切正常,上生产就出问题,而且报错信息往往模棱两可。
5.1 MTU与IP分片:1500这个数字的分量
以太网默认MTU 1500字节,这是链路层能承载的最大帧载荷。IP层要把一个超过MTU的数据报发出去,只能分片:拆成多个不超过MTU的片段,每个片段带自己的IP头,接收端再重组。重组需要所有片段都到齐,缺一片就整个数据报作废,而且接收端要等一个重组超时(Linux默认30秒)才能释放缓冲区。
这个机制对UDP极其不友好。TCP的路径MTU发现机制能自动调整分段大小,UDP如果应用层不注意,就是源源不断制造分片。更麻烦的是,某些中间设备(防火墙、NAT网关、负载均衡)对分片包的处理策略不一致,有的直接丢,有的只放行第一个片段,导致“小包能通、大包不通”的诡异现象。
判断方法很简单,发不同大小的包做二分:
# 配合前面的echo服务端 for size in 500 1000 1400 1472 1473 2000; do payload=$(head -c $size /dev/zero | tr '\0' 'a') timeout 1 bash -c "echo -n '$payload' > /dev/udp/127.0.0.1/9000" && echo "$size ok" || echo "$size fail" done如果在1472和1473之间出现分界,基本可以确认是MTU问题。解决方式不是去调大MTU(链路设备不受你控制),而是把应用层协议的单包大小降下来,或者自己在应用层做分片和重组。
5.2 接收缓冲区满了之后,内核做了什么
这是UDP最“沉默”的一类故障。应用层处理速度跟不上收包速度,缓冲区逐渐填满,之后到达的包会被内核直接丢弃。整个过程没有任何异常、没有返回值、socket上也不会报错,应用层完全无感。
唯一的线索是内核计数器。netstat -su或者读/proc/net/snmp,看RcvbufErrors这一项:
netstat -su | grep -A2 "Udp:" # Udp: # 1234567 packets received # 890 in-errors # 4321 receive buffer errorsreceive buffer errors持续增长,就说明在丢包。这时候有两条路:一是加大缓冲区(SO_RCVBUF配合net.core.rmem_max),二是提升消费速度(多线程、批量读、减少单包处理耗时)。
加大缓冲区是有极限的,它只是把“丢包”推迟到流量峰值之后。真正的解法还是让消费速度跟上生产速度。我的经验是:缓冲区别超过8MB,再大就是在掩盖问题,而且会显著增加内存占用和延迟(包在缓冲区里排队也是有时间的)。
5.3 UDP没有粘包,但会截断
TCP有粘包问题,UDP没有,因为UDP保留了消息边界,一个recvfrom对应一个数据报。这个特性让协议解析简单不少,但也带来另一个坑:如果传入的缓冲区比数据报小,多出来的部分会被直接丢弃,而且recvfrom只返回缓冲区大小的字节数,你根本不知道原来那个包有多大。
# 错误写法:缓冲区太小,大包会被静默截断 data, addr = sock.recvfrom(1024) # 收到一个1500字节的包,只拿到1024字节 # 正确写法:用足够大的缓冲区 data, addr = sock.recvfrom(65535)这个坑隐蔽的地方在于:对某些协议来说,截断后的数据仍然“看起来合法”。比如一个JSON包被截断,解析会失败,你能发现;但如果是一个定长二进制协议,截断后字段错位,可能解析出一个完全错误但结构合法的结果,问题会飘到很远的地方才暴露。
5.4 一次“发了但没收到”的完整排查链
回到开头那个语音团队的问题,把当时的排查过程完整还原一下,这个链路对大多数UDP故障都适用。
第一步,确认包到底发出去没有。在发送端抓包,tcpdump -i eth0 -n udp port 9000 -c 100。如果发送端能看到包,说明应用层和socket没问题,问题在链路或接收端。
第二步,看包的大小和分片标志。tcpdump -vv输出里如果看到flags [DF]或者frag字样,就说明涉及分片。当时抓到的包是1600多字节,明显超过MTU。
第三步,在接收端抓包对照。接收端只看到了一部分包,缺失的正好是那些大包。这时候方向基本确定了:大包在路径上被丢弃。
第四步,验证假设。把发送端的包大小临时改成1000字节,问题消失。确认是MTU/分片问题。
第五步,定位根因。查代码发现,语音帧的打包逻辑在检测到网络状况好时会自动增大帧长以提升音质,但打包逻辑用的是“理论最大载荷”,没考虑链路MTU。这是个典型的设计缺陷:协议设计时假定链路能承载任意大小的数据报。
修复方案是双管齐下:打包层加一个1200字节的硬上限,超过就拆成两帧发;同时在接收端增加对分片的容忍度,即使偶尔收到分片也能正确重组。上线后问题消失。
这个案例的经验是:UDP问题一定要在两端同时抓包,单看一端永远只能看到一半的事实。发送端看到的是“我发了”,接收端看到的是“我没收到”,中间那段才是真相所在。
6. 自己造可靠性:从应用层ACK到QUIC
当你决定用UDP,又需要一定程度的可靠性时,就得在应用层把这套机制补上。这一节讲最小可行的实现思路,以及现成方案能做到什么程度。
6.1 序号、确认与重传定时器
最小可靠的UDP协议需要三个东西:每个包带序号、接收方回ACK、发送方超时重传。
序号用32位足够,从0开始递增,回绕也没关系,只要比较时用模运算就行。ACK可以设计成累积确认(“我收到序号N及之前的全部包”)或者选择确认(“我收到了这些序号”)。累积确认简单但效率低,一个包丢了后面全得重传;选择确认复杂但省带宽,实际系统大多用两者的结合。
重传定时器的取值是个技术活。固定超时(比如200毫秒)实现简单,但在高延迟链路上会造成大量不必要的重传。自适应超时的做法和TCP一样:测量RTT,用指数加权移动平均算平滑RTT,再乘以一个系数得到超时时间。RFC 6298给了参考算法,直接照抄就行,别自己发明。
# 简化版的自适应RTO计算 srtt = None rttvar = None def update_rto(rtt_sample): global srtt, rttvar if srtt is None: srtt = rtt_sample rttvar = rtt_sample / 2 else: rttvar = 0.75 * rttvar + 0.25 * abs(srtt - rtt_sample) srtt = 0.875 * srtt + 0.125 * rtt_sample return srtt + max(4 * rttvar, 0.01) # 下限10毫秒6.2 去重与乱序重排
网络不保证顺序,同一个包也可能因为重传而到达两次。接收端需要一个滑动窗口来管理“已收到但还没交付给应用”的包。窗口大小决定了能容忍多少乱序,太小会导致频繁等待,太大占内存。
窗口的实现通常用一个位图或者一个有序集合。收到序号N的包,检查窗口内是否已存在,不存在就插入并回ACK,然后检查窗口左边界能不能往前推进,能推进多少就把连续的包按序交付给应用。
去重的关键是ACK必须带序号,而且接收端要维护一个“已确认的最大序号”。对于重复包,直接丢弃但重新回一次ACK,因为原ACK可能在路上丢了,发送方正在等。
6.3 拥塞控制为什么躲不掉
这点很多人想不通:我用UDP就是为了避开TCP的拥塞控制,为什么自己实现的时候还得加回来?
因为拥塞控制不是TCP的“额外功能”,而是网络稳定的必要条件。如果所有UDP应用都无节制地全速发送,中间路由器的队列会被填满,所有人都开始丢包,整个网络进入拥塞崩溃。TCP的拥塞控制本质上是一种“社会契约”,UDP应用不遵守,短期能抢到带宽,长期会把链路搞垮,包括自己。
业界的做法是参考TCP的实现,做简化版:慢启动阶段指数增长,到达阈值后线性增长,检测到丢包时减半。QUIC用的是类似的CUBIC算法。如果你的应用是在可控的内网环境,可以简化一些,但外网场景一定要有拥塞控制,否则对己对人都是灾难。
6.4 QUIC把哪些事替你做了
如果不想从头造轮子,QUIC是目前最成熟的选择。它在UDP之上实现了可靠传输、拥塞控制、加密握手、多路复用,而且把连接建立和加密握手合并,比TCP加TLS快一个往返。
QUIC相比自己造轮子的优势在于:经过了大规模线上验证,各种边界情况都处理过;有成熟的库(比如各种语言的quic-go、quiche、msquic封装);支持连接迁移(客户端换IP连接不断)。
代价是复杂度高,调试困难。QUIC的流量全是加密的,中间设备看不到内容,抓包分析基本靠应用层日志。如果你的场景只是简单的请求响应,用QUIC有点杀鸡用牛刀;但如果是移动端长连接、需要多路复用、需要连接迁移,那QUIC几乎是目前最优解。
7. 上线前值得逐条过一遍的东西
前面讲的都是原理和设计,这最后一节是实操层面的检查清单,都是我实际部署时踩过或者见过别人踩的。
7.1 内核参数与缓冲区设置
UDP服务的性能对这三个参数极其敏感:
# 接收缓冲区上限,应用层设置的值不能超过这个 net.core.rmem_max = 16777216 # 发送缓冲区上限 net.core.wmem_max = 16777216 # 网卡接收队列长度,高流量下小了会丢包 net.core.netdev_max_backlog = 5000rmem_max不调大,应用层setsockopt设多大都没用,会被截断到系统上限。这个问题特别隐蔽,因为setsockopt不报错,返回成功,但getsockopt读回来是个小值。养成设置完之后读一遍确认的习惯。
另外,netdev_max_backlog在万兆网卡、小包高吞吐的场景下很容易成为瓶颈。网卡收包速度超过内核处理速度时,包会堆在这个队列里,队列满了就丢。这个计数在/proc/net/softnet_stat里能看到,第二列持续增长就是丢了。
7.2 该盯的监控指标
UDP服务的监控和TCP不太一样,TCP有连接数、重传率这些天然指标,UDP什么都没有,得自己造。至少要盯这几个:
| 指标 | 来源 | 异常含义 |
|---|---|---|
| 收包速率 | 应用层计数 | 突然归零可能是链路或端口问题 |
| 缓冲区溢出计数 | /proc/net/snmp | 消费速度跟不上 |
| 发送失败计数 | 应用层sendto返回值 | 本地资源或路由问题 |
| 超时重试率 | 应用层统计 | 网络质量下降或对端异常 |
| 包大小分布 | 应用层统计 | 出现大包要警惕分片 |
其中“超时重试率”最能反映真实体验。这个值通常在0.1%以下,升到1%以上就说明网络有问题了。我们当时设的告警阈值是0.5%,触发后自动降级到更保守的发送策略。
7.3 反射放大与源地址校验
UDP有一个TCP没有的安全隐患:源地址可以伪造。发送方随便填一个源IP,包照样能发出去,接收方如果按这个地址回响应,就变成了对第三方的攻击。放大攻击就是利用这一点,伪造受害者IP发大量请求,让服务端把响应全打到受害者身上。
防御的核心是源地址校验。最简单的方式是要求客户端在第一个包里带一个token,服务端回一个带token的响应,客户端第二个包必须带上这个token才被处理。这样伪造源IP的攻击者收不到第一个响应,也就拿不到有效token。QUIC的握手设计里就内置了这套机制。
除了协议层校验,还要在运维层面做限速:对单IP的请求速率做限制,对异常大的响应做限制(DNS的ANY查询、NTP的monlist这类历史问题都是响应远大于请求)。这些措施不需要做到极致,挡住大部分自动化攻击就够了。
我在实际做UDP服务这些年,最深的体会是:UDP给你的自由,最后都会变成你的工作量。TCP帮你把重传、顺序、拥塞、连接管理全处理了,你不需要想这些;换成UDP,这些账全部记在你头上。所以选型的时候别只看“UDP快”,要看你的团队有没有能力把省下来的这部分补回去。
另一个常被忽略的点是测试环境的保真度。回环测试跑得再顺,也测不出MTU、NAT超时、防火墙策略这些问题。我的做法是至少搭一个跨网段的测试环境,中间放一台能做丢包和延迟注入的机器,用tc netem模拟5%丢包、100毫秒延迟,能提前暴露八成的问题。
最后分享一个小技巧:给每个UDP服务加一个/debug/udpstats之类的内部接口,把收发包数、缓冲区溢出、超时重试、包大小分布这些计数器吐出来。线上出问题的时候,这个接口的价值比任何日志都高,因为它反映的是“协议层发生了什么”,而不是“业务代码以为发生了什么”。