很多团队第一次把服务从 TCP 切到 UDP,都是奔着"快"和"省事"去的,觉得无连接、不用握手、不用维护状态,怎么算都划算。结果第一版上线,监控面板上接收侧丢包率稳定在 3%~8%,第一反应是机房网络有问题,拉着运维查路由、查交换机、查对端限速,折腾两天最后发现:发送端的sendto全部返回成功,一个错误都没有,包是在本机内核的接收缓冲区里被默默丢掉的。这个场景我见过不止一次,它几乎概括了 UDP 协议最容易被误解的地方——它把控制权交给你,同时也把责任甩给了你。这篇内容就把 UDP 从首部字段一路拆到工程落地,讲清楚它的边界在哪里、哪些事情必须自己做、哪些坑几乎人人都会踩一遍。适合已经写过 socket、但被 UDP 的各种"玄学丢包"折磨过的开发者,也适合还在犹豫该不该上 UDP 的技术选型人,看完你应该能自己判断手里这个业务到底该不该用它。
1. 实时链路为什么敢用UDP:一次"看起来像网络故障"的排查开场
1.1 从5%丢包说起:问题并不在网络上
那次排查的起点非常典型:一个实时数据上报的场景,客户端每秒发 200 个数据报,单个报文 800 字节左右,服务端用单线程recvfrom循环收。压测环境一切正常,上了真实流量之后,服务端统计到的收包数比客户端统计的发送数少了 5% 上下,而且这个比例和流量大小强相关——流量越大,丢得越多。
一开始所有人的注意力都在网络上,因为"丢包"这个词天然指向链路。但我当时坚持先在本机看了一眼中断和 socket 队列,用ss -u -a -n -i观察接收队列,发现Recv-Q在流量高峰时几乎一直是满的。这一步基本就定性了:包已经到达内核,只是应用没来得及取走,缓冲区满了之后,新来的包被内核直接丢弃,发送端完全感知不到。改用多进程加SO_REUSEPORT、把接收缓冲区调大之后,丢包率降到 0.02% 以下,剩下的部分才是真正的链路丢包。
这里的关键认知是:UDP 的"丢包"至少有四个完全不同的来源,从下往上分别是物理链路拥塞或错包、中间设备限速或策略丢弃、本机内核接收缓冲区溢出、应用层自己处理不及。它们的现象长得一模一样,但处理手段天差地别。不从下往上分层定位,就只能靠猜,而猜的代价通常是几天时间。
1.2 UDP到底放弃了什么,又换回了什么
把 UDP 和 TCP 摆在一起对比的文章很多,但大多数只停留在"可靠 vs 不可靠"这种口号层面,实际指导意义有限。我更愿意从"内核帮你做了什么"这个角度看:TCP 在内核里维护了一整套状态机,包括连接建立与拆除、序号与确认、超时重传、滑动窗口、拥塞控制、乱序重组、粘包拆包的处理。这些东西你一行代码都不用写,代价是内核要替你决策,而且很多决策的参数你改不动。
UDP 把这些几乎全部去掉了,只留下端口寻址和一层可选的校验。换来的是三样东西:第一是延迟的可预测性,没有重传等待,没有拥塞窗口爬坡,一个包发出去要么到要么不到,不会出现"因为前面一个包丢了所以后面全卡住"的队头阻塞;第二是控制权的完整性,什么时候重传、重传几次、要不要让路给别的流,全部由应用层说了算;第三是拓扑上的灵活性,广播、组播这些一对多的寻址方式,TCP 从设计上就不支持。
代价也同样明确:所有可靠性工作下移到了应用层,而且内核不再提供背压。TCP 发送缓冲区满了会把send阻塞住,逼着生产者降速;UDP 的sendto只要把数据塞进发送缓冲区就返回成功,缓冲区满了才返回EAGAIN,接收端更是彻底没有反馈通道。这意味着 UDP 链路上任何一个环节的处理能力不足,都会以丢包的形式体现,而不是以延迟上升的形式体现。理解这一点,后面所有的工程细节才有落脚点。
1.3 什么样的业务适合把可靠性交给上层
不是所有业务都值得为 UDP 付出这些额外成本。我的判断标准通常看三条:能不能容忍偶发丢包、延迟敏感度有多高、有没有必要做一对多。
能容忍丢包的典型是实时音视频和游戏状态同步。一帧视频丢了,解码器做个错误隐藏,人眼基本看不出来;一个位置同步包丢了,下一个包 20ms 后就到,插值一下就补上了。这类场景里,重传一个已经过时的包不仅没用,反而会占用带宽、加剧拥塞,所以 TCP 的重传机制在这里是负资产。
延迟敏感度高的典型是实时交互类业务,比如远程操作的指令下发。这类场景对丢包的容忍度其实不高,但对"确定性延迟"的要求极高,宁可快速失败让上层做降级,也不能出现几百毫秒的不可预测停顿。
至于一对多,组播和广播是 UDP 独占的能力,做局域网设备发现、行情推送、流媒体分发时几乎绕不开。
反过来说,凡是"每条消息都必须到达、顺序不能乱、数据量还不小"的场景,老老实实用 TCP 或者基于 UDP 的成熟可靠传输方案,自己从零写一套可靠层,大概率会在某个边界条件上翻车。
2. 拆开那8个字节:UDP首部字段与伪首部校验的完整推演
2.1 四个字段各自的职责与边界
UDP 首部固定 8 字节,四个 16 位字段,简单到可以背下来,但每个字段的边界条件都值得说清楚。
源端口和目的端口各 2 字节,取值范围 0~65535。源端口在多数客户端场景下由内核自动分配,走的是临时端口范围,Linux 上默认是net.ipv4.ip_local_port_range定义的那一段。这里有个容易被忽略的限制:单个客户端 IP 对同一个服务端IP:Port组合,理论上最多只能建立 65535 个不同的源端口会话,做大规模长连接或高并发 UDP 会话时,这个上限会先于带宽成为瓶颈。解决办法是多绑几个本地 IP,或者用多台机器分摊。
长度字段 2 字节,指的是"首部 + 数据"的总长度,最小值是 8(纯首部、无数据),最大值是 65535。注意它统计的是 UDP 层的长度,不含 IP 首部。IPv4 下 UDP 载荷最大是 65535 - 8 - 20 = 65507 字节,IPv6 下因为 UDP 长度字段可以为 0(走 Jumbogram 扩展),理论上能更大,但实际工程中几乎没人这么用。
校验和字段 2 字节,覆盖范围包括伪首部、UDP 首部和数据。在 IPv4 里它是可选的,填 0 表示发送端不计算校验和,接收端也不校验;在 IPv6 里它是强制的,填 0 是非法值。实践中我不建议关掉它,因为关掉之后错包只能靠上层协议自己发现,而很多上层协议并没有做这件事。
提示:判断一个 UDP 包是否"真的到了应用层",看的是应用有没有
recvfrom到它,而不是发送端有没有返回成功。sendto返回的字节数只代表数据进入了本机 socket 发送缓冲区,和对面是否收到没有任何关系。
2.2 校验和为什么要把伪首部算进去
伪首部这个概念初次接触时很反直觉——它并不在网络上真实传输,只是参与校验和计算时临时拼出来的 12 字节(IPv4)或 40 字节(IPv6)结构。IPv4 的伪首部包含源 IP、目的 IP、一个保留字节、协议号(UDP 是 17)和 UDP 长度;IPv6 的伪首部包含 16 字节源地址、16 字节目的地址、4 字节上层报文长度、3 字节零和 1 字节下一首部值。
为什么要多此一举?因为如果只校验 UDP 首部和数据,那么一个被错误路由到本机的包,只要它内部数据自洽,校验就能通过,应用会收到一个"看起来合法但不属于自己"的报文。把 IP 地址纳入校验范围之后,地址在传输过程中被篡改或出错的情况就能被检出。这是一个很朴素但有效的设计:用上层校验覆盖下层关键字段。
计算过程本身是标准的 16 位反码求和:把伪首部、UDP 首部(校验和字段先置 0)和数据按 16 位一组切开,逐组相加,进位回卷加到低位,最后取反码。有一个细节值得记住:如果计算结果恰好是 0x0000,发送时要写成 0xFFFF,因为全 0 在校验和字段里有"未计算"的特殊含义,两者在反码运算里是等价的,但语义不能混。
# 手工计算 UDP 校验和的最小示例,用于理解过程 def udp_checksum(src_ip, dst_ip, payload: bytes, src_port, dst_port): udp_len = 8 + len(payload) pseudo = src_ip + dst_ip + bytes([0, 17]) + udp_len.to_bytes(2, 'big') udp_hdr = src_port.to_bytes(2, 'big') + dst_port.to_bytes(2, 'big') \ + udp_len.to_bytes(2, 'big') + b'\x00\x00' data = pseudo + udp_hdr + payload if len(data) % 2: data += b'\x00' total = 0 for i in range(0, len(data), 2): total += (data[i] << 8) + data[i + 1] total = (total & 0xFFFF) + (total >> 16) total = ~total & 0xFFFF return total if total != 0 else 0xFFFF实际抓包时你会看到,校验和错误的包在大部分网卡上会被硬件直接丢掉,只有开了混杂模式或者网卡关闭了校验和卸载(checksum offload)才能抓到。这也是为什么用tcpdump在本机抓包看到"包明明在",但应用收不到——很可能就是校验和有问题被丢了。排查这类问题时,先看一眼/proc/net/snmp里的InCsumErrors计数,比盲猜快得多。
2.3 抓包对照:一个1472字节的包长什么样
理解协议最快的方式还是看真实报文。以太网 MTU 是 1500 字节,扣掉 20 字节 IPv4 首部和 8 字节 UDP 首部,UDP 载荷上限就是 1472 字节。这个数字在工程中出现频率极高,很多采集类系统就是按 1472 来切包,目的就是让每个 UDP 报文塞进一个以太网帧,避免 IP 分片。
下面是一个典型的 1472 字节载荷的 UDP 报文在tcpdump里的样子(十六进制部分略去数据区):
IP 10.0.0.12.54321 > 10.0.0.34.9000: UDP, length 1472 0x0000: 4500 05dc 1a2b 4000 4011 8f3c 0a00 000c 0x0010: 0a00 0022 d431 2328 05dc 1a2e逐段读:4500是 IPv4 版本 4 加首部长度 5(5×4=20 字节),05dc是总长度 1500,1a2b是标识,4000里高 3 位是标志(DF 位置 1,表示不分片),4011里40是 TTL 64,11是协议号 17 也就是 UDP,后面是首部校验和与源目 IP。再往后d431是源端口 54321,2328是目的端口 9000,05dc是 UDP 长度 1500,1a2e是校验和。
从这个报文能读出两件事:第一,UDP 长度字段填的是 1500,包含了 8 字节首部,所以载荷是 1492?不对,这里我做个小修正——如果 UDP 长度是 1500,那 IP 总长度应该是 1520,但以太网 MTU 只有 1500,所以这个包必然分片。真正不分片的情况下,UDP 长度字段应该是 1480(1472 载荷 + 8 首部),IP 总长度 1500。第二,DF标志位一旦置 1,任何加上了首部之后超过 MTU 的包都会被中间设备丢弃并回一个 ICMP 需要分片的消息,这反而是件好事——它让问题暴露出来,而不是被悄悄分片。
3. 无连接不等于无责任:把可靠性补在上层还是在中间件
3.1 可靠UDP的四种典型补全路线
既然 UDP 不保证送达,那"保证送达"这件事就得有人做。业界大致有四条路线,选哪条取决于你的业务边界和对可控性的要求。
第一条是自己写最小可靠层。核心就三件事:给每个报文加序号、接收端回确认、发送端维护重传队列。听起来简单,但一旦要考虑乱序、去重、窗口、超时估算,代码量就会迅速膨胀到几千行,而且几乎必然会漏掉某个边界条件。我只建议在报文语义非常简单、丢包容忍度较高的场景下自己写,比如局域网内的控制指令通道。
第二条是在 UDP 之上套一层成熟的可靠传输协议。QUIC 是最典型的代表,它在 UDP 之上重建了可靠流、拥塞控制、多路复用,还顺带把 TLS 1.3 集成进来,解决了队头阻塞问题。如果你的场景是"需要一个比 TCP 更快的可靠传输",QUIC 基本是首选,不用自己造。KCP 是另一个选择,它用牺牲部分带宽换取更低延迟的策略,在同等的丢包率下延迟表现通常优于 TCP,代价是流量放大约 10%~20%。
第三条是用 RTP/RTCP 这类面向媒体的协议栈。它们不强求每个包都到,而是通过序列号、时间戳和接收端报告来做丢包隐藏、抖动缓冲和码率自适应。做音视频的话,这条路是标准答案。
第四条是在应用层做幂等加补偿。传输层索性不保证,业务层通过消息 ID 去重、定时对账、定期全量同步来兜底。这条路线对基础设施改动最小,适合内部系统之间的数据上报。
| 路线 | 典型方案 | 适用场景 | 主要代价 |
|---|---|---|---|
| 自研最小可靠层 | 序号 + ACK + 重传队列 | 语义简单的控制通道 | 边界条件多,维护成本高 |
| 成熟可靠传输协议 | QUIC、KCP | 需要可靠且低延迟的数据流 | 需要引入额外依赖或改造接入层 |
| 媒体协议栈 | RTP/RTCP、SRTP | 音视频、实时流 | 需要配套的抖动缓冲与拥塞控制 |
| 应用层幂等补偿 | 消息 ID 去重 + 对账 | 内部数据上报、日志采集 | 有延迟,需要业务侧配合 |
3.2 重传、序号与拥塞控制的最小实现骨架
如果你确实要自己写一层,那么有几个设计决策必须先定下来,它们是后续所有问题的根源。
序号怎么编。单调递增的 32 位或 64 位整数是最常见的做法,回绕问题一定要提前想清楚,比较大小的时候用差值而不是直接比大小。另一种是包序号加时间戳的组合,好处是能同时支撑乱序重组和 RTT 估算。
ACK 怎么回。逐包 ACK 在包量大时会把上行带宽吃光,通常用累积 ACK 加选择性 ACK 的组合:告诉对端"我连续收到了到 N 为止的所有包,另外还额外收到了这几个"。ACK 本身也要做聚合,比如每收到 2 个包或者每 10ms 回一次。
重传超时怎么定。固定超时是最简单也最容易出问题的做法。稍微认真一点的做法是维护 RTT 的平滑值和方差,超时时间取平滑 RTT 加上若干倍方差,同时设一个下限(比如 50ms),避免在低延迟链路上因为抖动触发大量无谓重传。
拥塞控制要不要做。这一条最容易被省略,也是自研可靠层在生产环境翻车的主要原因。没有拥塞控制,一旦链路出现丢包,发送端还在全速发,只会让丢包率进一步上升,最终整条链路退化到几乎不可用。哪怕只做一个最简单的版本——丢包时把发送速率减半,一段时间无丢包再线性增长——也比完全没有强得多。
# 极简的 RTT 估算与重传超时计算,只演示思路 class RttEstimator: def __init__(self): self.srtt = None # 平滑 RTT self.rttvar = None # RTT 方差 self.min_rto = 0.05 # 50ms 下限 def update(self, sample): if self.srtt is None: self.srtt = sample self.rttvar = sample / 2 else: self.rttvar = 0.75 * self.rttvar + 0.25 * abs(self.srtt - sample) self.srtt = 0.875 * self.srtt + 0.125 * sample def rto(self): if self.srtt is None: return 0.2 return max(self.min_rto, self.srtt + 4 * self.rttvar)3.3 别自己造轮子:什么情况下该直接上成熟方案
有个很实用的判断原则:如果你的可靠层需要处理"连接迁移""多路复用""加密握手""路径 MTU 发现"中的任意两项以上,就别自己写了。这四件事每一项单独做都不算难,但组合起来的复杂度是指数级上升的,而且出问题的时候极难复现。
QUIC 已经把这些都做完了,而且有成熟的开源实现可以直接嵌入,语言覆盖也够全。真正适合自研的场景通常是:协议语义极简(比如只有固定 32 字节的状态包)、运行环境受限(嵌入式设备内存只有几十 KB)、或者需要和已有协议做深度耦合(比如自定义的私有协议栈已经有大量存量设备)。
还有一种情况值得单独提:只是想在局域网内做高可靠传输。局域网丢包率极低,链路 RTT 通常小于 1ms,这种情况下"可靠"的实现难度会大幅下降,简单的序号加超时重传就够用,不需要复杂的拥塞控制。但要注意,一旦这套东西要跨公网,前面的假设就全部失效了。
4. 缓冲区、MTU与IO模型:UDP工程化的三个硬约束
4.1 接收缓冲区溢出:UDP丢包最常见的一号元凶
回到开头那个 5% 丢包的故事,根因就是接收缓冲区。这里把机制讲透。
每个 UDP socket 在内核里有一块接收缓冲区,大小由net.core.rmem_default决定,可以用SO_RCVBUF调整。数据到达网卡之后,经过驱动、协议栈,最终挂到这个 socket 的接收队列上。应用调用recvfrom时从队列里取走一个数据报。如果队列满了,新到的包直接被丢弃,不通知发送端,也不通知接收端,最多在/proc/net/snmp的RcvbufErrors计数上加一。
这意味着两件事。第一,UDP 的丢包在缓冲区这一层是"静默"的,只能靠计数器事后发现。第二,能不能扛住突发流量,取决于缓冲区能装下多少个包。举个具体的数:假设单个报文 1200 字节,缓冲区设为 2MB,那么大约能缓存的报文数是 2097152 / 1200 ≈ 1747 个,扣除内核的结构体开销实际更少。如果应用处理能力是每秒 5 万包,那么 2MB 缓冲区只能吸收约 35ms 的处理停顿。一旦发生一次 100ms 的 GC 或者磁盘抖动,溢出就是必然的。
所以调缓冲区之前,先算清楚两个数:峰值收包速率和最长处理停顿时间,两者相乘就是最低需要的缓冲字节数,再乘个 1.5 的安全系数。
# 查看当前系统的 UDP 相关统计,重点看 RcvbufErrors netstat -su | grep -A 10 "^Udp:" # 或者直接读 proc cat /proc/net/snmp | grep "^Udp:" # 查看内核缓冲区上限 sysctl net.core.rmem_default net.core.rmem_max sysctl net.core.netdev_max_backlog # tcpdump 之外,用 ss 看接收队列积压情况 ss -u -a -n -i调优的时候有几个容易踩的坑。第一个是SO_RCVBUF设置的值会被内核翻倍(用于内部簿记开销),所以设置 4MB 实际得到 8MB,反过来说如果你读回来是 8MB 不要惊讶。第二个是设置值受net.core.rmem_max限制,超过部分会被静默截断,所以必须先把 sysctl 调大。第三个是netdev_max_backlog这个参数管的是网卡到协议栈之间的队列,很多人在收包瓶颈时只调了 socket 缓冲区,忘了这一层也会丢包,现象是RcvbufErrors不涨但包就是少了。
注意:一味把缓冲区调大并不是好事。缓冲区越大,应用处理不过来时积压的包越多,而这些包往往已经过时,处理它们只是浪费 CPU,还会掩盖真实的问题。合理的做法是调到能吸收正常的抖动就够了,然后通过监控
RcvbufErrors来持续观察。
4.2 MTU与IP分片:1472这个数字的来历和它的陷阱
前面提到 1500 减去 20 减 8 得到 1472,这是 IPv4 下的经典值。IPv6 的首部是 40 字节,所以对应的值是 1500 - 40 - 8 = 1452。除此之外还有几层封装会继续吃掉空间:PPPoE 要扣 8 字节,VXLAN 要扣 50 字节,GRE 要扣 24 字节,IPsec 隧道也要扣掉几十字节。这就是为什么在云上或者跨专线的环境里,按 1472 发包很可能直接被丢。
分片的机制是这样的:如果 IP 层的包超过了出接口 MTU,且没有设置 DF 标志,IP 层会把它切成多个分片分别发送,接收端根据 IP 首部的标识、偏移和 MF 标志重组。问题在于只要任何一个分片丢失,整个原始数据报就作废,接收端无法交付半个 UDP 报文。而且重组需要占用接收端的内存缓冲区,还有超时限制。在丢包率 1% 的链路上发一个 8000 字节的包,需要切成 6 个分片,只要其中一个丢了,整包重传,实际有效丢包率会放大到接近 6%。
所以工程上的原则很明确:尽量不让 UDP 报文触发 IP 分片。具体做法有几个层次:
一是把应用层的报文大小控制在 1200 字节左右。这个数字不是随便定的,QUIC 选它是因为 IPv6 的最小 MTU 要求是 1280,减去 40 字节 IPv6 首部和 8 字节 UDP 首部正好剩 1232,再留一点余量给隧道封装。按 1200 来切包,走绝大多数网络都不会分片。
二是开启路径 MTU 发现(PMTUD),也就是设置IP_MTU_DISCOVER为IP_PMTUDISC_DO,让内核在包超过 MTU 时返回EMSGSIZE而不是默默分片。这样问题会在发送端立刻暴露,而不是变成对端的神秘丢包。但 PMTUD 依赖 ICMP 反馈,而很多网络会屏蔽 ICMP,所以实践中通常还会配一个保守的兜底值。
三是如果真的需要传大块数据,在应用层做分片和重组,自己维护分片序号和超时丢弃,而不是依赖 IP 层。
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 禁止 IP 层分片,超过 MTU 直接报 EMSGSIZE sock.setsockopt(socket.IPPROTO_IP, socket.IP_MTU_DISCOVER, socket.IP_PMTUDISC_DO) # 设置发送与接收缓冲区 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1 * 1024 * 1024) try: sock.sendto(b'x' * 2000, ('10.0.0.34', 9000)) except OSError as e: # 在开启了 PMTUD 的情况下,超长报文会抛 EMSGSIZE print('send failed:', e)4.3 单socket还是多socket:并发收包的几种组织方式
UDP 是无连接的,一个 socket 天然就能处理来自任意客户端的报文,所以最直观的写法是单线程recvfrom加业务分发。这种写法在低流量下完全够用,但流量一大就会遇到两个问题:单核打满,以及前面提到的缓冲区溢出。
往下走有几条路,各有利弊。
多线程共享同一个 socket。多个线程同时recvfrom,内核会加锁串行化,实际吞吐提升有限,而且在高并发下锁竞争反而会拖慢。这条路我不太推荐,除非线程数很少。
多进程绑同一个端口加SO_REUSEPORT。内核会按四元组哈希把报文分发到不同的 socket,每个进程各自处理,互不干扰。这是目前 Linux 上最实用的方案,扩展性好,进程挂掉一个也只影响一部分流量。缺点是哈希固定,某个热点客户端可能一直落在同一个进程上,需要靠增加分片数来摊平。另外要注意,SO_REUSEPORT的负载均衡是按流做的,对于 UDP 来说"流"就是四元组,所以同一对 IP 端口之间的所有报文会固定落到同一个 socket。
单线程加epoll加批量收取。recvmmsg一次系统调用可以收多个数据报,能显著降低系统调用开销。在 10 万 pps 这个量级,批量收取带来的提升是肉眼可见的。配合sendmmsg批量发送,单核处理能力可以再上一个台阶。
内核旁路方案。当单机需要处理百万级 pps 时,常规协议栈的开销就成了瓶颈,这时候会考虑 DPDK、AF_XDP 这类方案,把网卡数据直接搬到用户态。代价是复杂度陡增,需要独占网卡、自己做协议栈,运维和调试成本都很高。除非确实撞到了性能墙,否则我不建议走这条路。
| 方案 | 适用规模 | 优点 | 主要限制 |
|---|---|---|---|
| 单线程 recvfrom | 万 pps 以下 | 实现最简单 | 单核上限,无冗余 |
| 多线程共享 socket | 万级 pps | 改动小 | 锁竞争,扩展性差 |
| 多进程 + SO_REUSEPORT | 十万级 pps | 水平扩展,容错好 | 哈希倾斜,需调分片数 |
| recvmmsg 批量收发 | 十万级 pps | 系统调用开销低 | 需要处理批量语义 |
| AF_XDP / DPDK | 百万级 pps | 极致性能 | 复杂度高,运维成本大 |
5. 广播、组播与穿透边界:UDP的拓扑能力与限制
5.1 单播之外的三种寻址方式
TCP 只能一对一,这是它的设计前提。UDP 支持三种额外的寻址方式,这是它在某些场景下不可替代的根本原因。
广播发往本地网段内的所有主机。IPv4 里有受限广播地址 255.255.255.255 和子网广播地址(比如 192.168.1.255)。使用前必须在 socket 上设置SO_BROADCAST选项,否则会返回权限错误。广播的局限很明显:不跨路由器,只在本地网段有效,而且所有主机都要消耗资源处理这个包,网段里设备一多就会造成明显的广播风暴。
组播是更精细的方案,发送方把包发到一个组播地址,只有加入了该组的主机才会收到。IPv4 的组播地址范围是 224.0.0.0/4,其中 224.0.0.0/24 是本地链路保留段,像 mDNS 用的 224.0.0.251、SSDP 用的 239.255.255.250 都属于约定俗成的固定地址。使用组播需要加入组(IP_ADD_MEMBERSHIP),并关注 TTL 设置——组播包的 TTL 决定了它能跨多少个路由器,默认值 1 意味着只在本地网段生效。
任播在 UDP 层面没有专门的支持,它是靠路由层面把同一个地址宣告到多个位置来实现的,客户端看起来是在单播,实际被路由到了最近的一个节点。很多公共 DNS 服务用的就是这套机制。
import socket, struct # 加入组播组并接收 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(('', 5353)) mreq = struct.pack('4s4s', socket.inet_aton('224.0.0.251'), socket.inet_aton('0.0.0.0')) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) while True: data, addr = sock.recvfrom(2048) print(addr, len(data))实践中组播最大的坑在于中间网络设备对组播的支持程度参差不齐。交换机需要开启 IGMP snooping 才能避免把组播泛洪成广播,路由器需要配置 PIM 才能跨网段转发,云环境里组播往往直接被禁用。所以组播方案在落地前一定要先在目标网络环境里做连通性验证,不能想当然。
5.2 NAT与连接跟踪:UDP会话为什么"放着放着就断了"
UDP 本身没有连接概念,但中间设备为了做地址转换和状态过滤,会给它维护一份"会话表"。这份表的条目是有超时时间的,一旦超时,表项被删除,后续的包就会被丢弃或者拒绝。
不同设备的默认超时差异很大。常见的家用路由器和运营商级 NAT 上,UDP 会话的空闲超时可能在 30 秒到 5 分钟之间。这意味着如果一个 UDP 会话超过这个时间没有任何报文往来,通道就会被回收。表现出来就是:客户端和服务端都觉得自己还活着,但包发不过去了。
解决办法很直接——保活。定期发一个小的探测包,让中间设备的状态表保持活跃。间隔要比最短的超时时间更短,实践中常用 15 到 25 秒。但保活本身也有代价:移动端上频繁唤醒无线网卡会显著增加耗电,所以在移动场景里需要配合应用层的前后台状态来动态调整。
还有一种更隐蔽的情况:NAT 设备重启或者表项被提前回收,但客户端仍然按原来的映射发数据,这时候包会被丢弃,而服务端完全不知道。所以客户端必须有一个"长时间收不到响应就重建会话"的机制,不能假设通道一直有效。
提示:Linux 上的
nf_conntrack_udp_timeout和nf_conntrack_udp_timeout_stream两个参数控制 UDP 连接跟踪的超时,前者用于没有回包的会话(默认 30 秒),后者用于双向都有流量的会话(默认 120 秒)。做网关或代理类服务时,这两个值往往要调大。
5.3 反射放大风险与必须做的收口
UDP 有一个天然的安全弱点:源地址可以伪造。因为不需要握手,攻击者可以伪造受害者的 IP 向服务发送请求,服务把响应发给了受害者。如果请求很小而响应很大,就形成了放大效应。DNS、NTP、SSDP 这些基于 UDP 的服务历史上都因为这个问题被滥用过。
作为服务提供方,该做的事情很明确,而且都是常规工程手段:
对无认证的请求做响应大小限制。如果协议允许,把响应控制在请求大小的一定倍数以内,最理想的是不超过请求本身的大小。
对来源做速率限制和异常检测。同一来源在短时间内发送大量请求时,降低响应优先级或者直接丢弃。注意这里要小心误伤,NAT 后面的合法用户共享一个出口 IP,限速阈值要留足余量。
能加认证就加认证。哪怕是最简单的 cookie 机制,也能让伪造源地址的攻击者收不到有效响应,因为第二个包需要带上第一个包里的随机值。
别暴露不必要的服务。开放的调试接口、状态查询接口如果响应很大又不需要认证,就是最理想的放大工具。这类接口应该只监听内网地址,或者干脆默认关闭。
做入口流量清洗。对于规模较大的服务,上游的流量清洗是兜底手段,但这是最后一道防线,不能替代前面的收口。
6. 选型决策与压测:把UDP用在正确的地方
6.1 一张对照表:什么时候选UDP,什么时候老实回TCP
做了这么多年前后端,我发现选型失误的根源往往不是技术能力不够,而是问题定义不清楚。下面这张表是我自己常用的判断依据,按场景归类。
| 场景特征 | 推荐选择 | 理由 |
|---|---|---|
| 音视频通话、直播推流 | UDP(RTP/WebRTC) | 丢包可隐藏,重传无意义 |
| 实时游戏状态同步 | UDP | 状态有时效性,旧包无用 |
| 高频行情推送 | UDP 组播优先 | 一对多,延迟敏感 |
| 局域网设备发现 | UDP 广播/组播 | TCP 不支持一对多 |
| 大文件传输 | TCP 或 QUIC | 需要可靠且有序 |
| 支付、订单类业务 | TCP | 不允许丢消息,语义强依赖顺序 |
| 内部服务间 RPC | TCP 或 QUIC | 生态成熟,调试工具全 |
| 指标采集、日志上报 | UDP 加应用层补偿 | 量大,允许少量丢失 |
| 跨公网的稳定长连接 | QUIC(基于 UDP) | 兼顾低延迟与可靠性 |
有个判断技巧值得分享:问自己"如果这条消息丢了,业务能不能自己发现并补救"。如果能,UDP 是可以考虑的;如果不能,而且补救成本很高,那还是老实上可靠传输。很多团队选 UDP 是觉得"性能好",但实际瓶颈根本不在传输层,换过去之后性能没提升多少,可靠性问题倒是接二连三。
6.2 压测怎么做才有意义:指标、工具与观测点
UDP 的压测和 TCP 差别很大,因为发送端收不到任何反馈,很容易测出"发送 100 万包全部成功"这种毫无意义的结论。有效的压测必须同时在发送端和接收端埋点,然后对账。
发送端要记录的是:应用层调用sendto的次数、成功次数、失败次数(区分EAGAIN、EMSGSIZE、ENOBUFS等错误码)、实际发出的字节数。接收端要记录的是:recvfrom收到的包数、字节数、丢包数(发送数减接收数)、乱序数、重复数、端到端延迟分布。
工具方面,iperf3的 UDP 模式可以做基础带宽测试,但它只能告诉你吞吐和丢包率,测不出应用层的处理能力。真正压业务逻辑还得自己写客户端,或者用现成的压测框架。我通常会用一个小脚本做发包,接收端跑真实服务并暴露 Prometheus 指标,这样能得到最贴近生产的结论。
观测点上,除了应用层指标,一定要看这几个内核计数器:
# UDP 层统计,重点是 InErrors / RcvbufErrors / SndbufErrors / InCsumErrors cat /proc/net/snmp | grep "^Udp:" # 网卡层丢弃,看 drop 和 fifo 两列 ip -s link show eth0 # socket 队列积压 ss -u -a -n -i | head -20 # 中断分布,如果集中在单个核上说明需要开 RSS 或者调整亲和性 cat /proc/interrupts | grep eth0压测的节奏也要注意。不要一上来就打满,应该从小到大分几档,每一档稳定跑够时间(至少 60 秒,让缓冲区进入稳态),观察各项指标随流量增长的变化曲线。如果丢包率在某个流量点突然从 0 跳到明显的百分比,那个点就是系统的实际容量上限,比理论计算值靠谱得多。
6.3 上线前的检查清单
按我自己的习惯,UDP 服务上线前会过一遍下面这些项,每一条都对应过真实的故障。
报文大小是否控制在 1200 字节以内,有没有针对隧道封装的余量。曾经有个服务在测试环境跑得好好的,上了生产走了 VXLAN 隧道,1500 的包全部被丢,因为隧道封装后又多了 50 字节。
缓冲区是否按峰值速率和最长停顿算过,rmem_max和netdev_max_backlog是否同步调过。这两个不一起调,光调前者没用。
保活机制是否存在,间隔是否短于目标网络的最短 NAT 超时。跨运营商、跨云厂商的链路,超时值差异会很大。
错误处理是否覆盖了EAGAIN、EMSGSIZE、ECONNREFUSED。特别是ECONNREFUSED,在对端没监听的时候,如果 socket 调过connect,recvfrom会收到这个错误,很多人没处理导致进程直接崩掉。
监控是否覆盖了接收侧丢包、缓冲区溢出、校验和错误、乱序和重复。只在发送端埋点是不够的,发送端的"成功"什么都不能说明。
限速与收口是否做了,特别是响应大小和来源速率。这一条经常被当成"以后再说",然后就没有以后了。
容量验证是否在真实网络拓扑上做过,而不是只在同机房的低延迟环境里跑。同机房 RTT 小于 0.5ms,跨公网可能 30ms 以上,缓冲区需求差了几十倍。
回退方案是否准备好。UDP 服务出问题时,能不能快速切回 TCP 或者降级到低频上报,这个开关要提前做好。
最后分享一个我自己踩过的坑。有次排查一个偶发的丢包问题,各种计数器都正常,缓冲区没溢出,网卡没丢包,链路也没问题,最后发现是应用层的线程池队列满了,任务被静默丢弃,但日志里只记了"处理成功"——因为丢弃发生在一个try/except的宽泛捕获里。这件事之后我改了一个习惯:凡是可能静默丢弃数据的地方,无论在哪一层,都必须有一个计数器,哪怕只是一个内存里的累加值,定期打到日志里。UDP 这套架构最大的特点就是"没有反馈",你必须自己造出反馈来,否则排查问题的时候只能靠猜,而猜出来的结论往往离真相很远。