做实时音视频、游戏帧同步、DNS 解析、监控指标上报这类活儿,绕不开 UDP 协议。它简单到只有八个字节的头部,简单到很多人觉得“没什么可讲的”,可真到线上出问题的时候,丢包、乱序、分片、缓冲区溢出、NAT 超时,一个都跑不掉。我这些年踩过的坑,一大半都跟“以为 UDP 很简单”有关。这篇东西不讲教科书式的分层模型,而是从我实际写代码、抓包、压测、排障的顺序,把 UDP 协议从报文结构、校验和计算、分片机制、内核缓冲区行为,一直讲到应用层怎么自己补可靠性、怎么排查丢包。适合已经写过 socket 但被 UDP 坑过的同学,也适合刚接手实时传输类项目、需要快速建立认知框架的人。看完你至少能做到两件事:抓到一个 UDP 包能逐字段说清楚它是什么;线上出现丢包时,知道该先看哪一层、敲哪条命令。中间会给出可以直接抄的代码和参数,也会写明每个参数为什么是这个值,而不是拍脑袋定的。
1. UDP协议到底是什么:把数据报模型拆开看
UDP 的全称是用户数据报协议,关键词是“数据报”三个字。数据报和字节流的区别,是理解后面所有行为的起点。TCP 给你的是一个可以随便写随便读的水管,你写三次、对方读一次,或者你写一次、对方读三次,都合法;UDP 给你的是一封封信,你发一封信,对方要么完整收到一封,要么什么都没收到,绝不会收到半封。这个“消息边界保留”的特性,是很多实时业务选它的第一原因——游戏里一个位置更新包被拆成两半处理,逻辑就乱了。
但边界保留不是白送的。你要为它付出的代价是:没有连接状态、没有确认、没有重传、没有排序、没有流量控制。内核收到一个 UDP 包,做几件事就交给你了:校验、找到对应 socket、丢进接收队列。完事。它不会替你记“这个包我发出去多久了还没回”,也不会替你记“上次收到的是第几号包”。所有这些东西,要么你不需要,要么你在应用层自己造。
我见过不少项目一开始用 UDP 图省事,写到后面发现自己在重新实现一遍 TCP——序号、ACK、重传、拥塞控制,一个不落地加回来。这不是错,QUIC 就是这么干的,但你要清楚自己在干什么。如果业务本身对延迟不敏感、对丢包零容忍,比如配置文件下发、订单提交,直接用 TCP 或者基于 TCP 的现成方案,别硬上 UDP。选型的第一原则永远是:先问业务能容忍什么样的失败,再问协议能不能给。
1.1 八个字节的首部里到底装了什么
UDP 头部固定 8 字节,四个字段,没有选项字段,没有可变长度部分。这一点和 TCP 差别巨大——TCP 头部最少 20 字节,还带选项,还能变长。UDP 定长带来的好处是解析极快、无歧义,路由器、中间设备处理起来路径短。
| 字段 | 长度 | 含义 | 实战中要注意什么 |
|---|---|---|---|
| 源端口 | 2 字节 | 发送方端口 | 客户端通常由内核随机分配,服务端回包就靠它找回来 |
| 目的端口 | 2 字节 | 接收方端口 | 端口没进程监听时,内核回 ICMP 端口不可达 |
| 长度 | 2 字节 | 首部 + 数据的字节数 | 最小值是 8,包含首部自身,这个字段经常被算错 |
| 校验和 | 2 字节 | 覆盖伪首部 + UDP 首部 + 数据 | IPv4 下可填 0 表示不校验,IPv6 下强制 |
长度字段是最容易被算错的地方。它的值是“首部 8 字节 + 负载长度”,不是负载长度。假如你发 100 字节业务数据,这个字段应该是 108。有些自己发包的场景里,长度字段填成 100,接收端解析时就会把最后 8 字节数据当成别的东西,或者直接因为长度不一致丢弃。
再看理论上的上限:长度字段是 16 位,最大 65535,所以一个 UDP 数据报首部加数据最多 65535 字节,IPv4 下扣掉 IP 首部 20 字节和 UDP 首部 8 字节,单个数据报的负载上限是 65507 字节。注意这只是“理论上”。实际链路上,超过 MTU 的包会被 IP 层分片,任何一个分片丢了,整个数据报就废了,而分片丢失的概率是随包大小显著上升的。所以工程上几乎没有人真的发 65507 字节,这个数字存在的意义更多是让你知道边界在哪。
还有一个细节:UDP 长度字段是冗余的。IP 首部里也有一个总长度字段,减去 IP 首部长度就是 UDP 数据报的长度。那为什么还要有这个字段?因为 UDP 是独立设计的,它不假设自己一定跑在 IP 上。这种“不假设下层”的设计在当年是合理的,现在的实现里这个字段更多起到一致性校验作用——如果 IP 总长度算出来和 UDP 长度字段对不上,包会被丢掉。
1.2 “无连接”和“不可靠”这两个标签该怎么正确理解
这两个词被讲得太多了,反而容易让人误解。先说“无连接”。它的准确含义是:协议本身不维护端到端的状态。没有三次握手,没有四次挥手,没有 SYN、ACK、FIN 这些控制报文,也没有内核里的连接控制块。你调 sendto 的时候,内核只是把数据封装好交给 IP 层,然后就不管了;你调 recvfrom 的时候,内核只是从队列里拿一个数据报给你,拿不到就阻塞或者返回错误。
但“无连接”不等于“不能建立逻辑连接”。你在应用层完全可以让两端先交换一次心跳,记下对方的地址和端口,之后所有包都发往这个地址——这就是一条逻辑连接。QUIC 就是这么做的,它用 Connection ID 来标识连接,即使客户端换了 IP 或者端口,连接也能续上。所以不要把协议层的不维护状态,理解成业务层不能有状态。
再说“不可靠”。这三个字吓退了不少人,但它的准确含义是:协议不承诺送达、不承诺顺序、不承诺不重复,也不承诺不丢。注意是“不承诺”,不是“一定会出问题”。在同机房内网、在链路质量良好的情况下,UDP 的实际丢包率常常是 0,延迟抖动也很小。真正的问题出现在跨公网、跨运营商、无线网络这些场景,丢包率可能到 1% 到 5%,高峰期更高,这时候协议不做的事,就得你来做。
我习惯这样给新人解释:TCP 像挂号信,有回执、有编号、丢了会重发,但邮局会攒够一批再送;UDP 像自己骑摩托送信,快、随时能走,但路上颠掉了没人管你。你要不要用摩托,取决于这封信是不是“晚到就不值钱了”。股票行情、语音帧、游戏位置更新,晚到就没意义,丢了就丢了,下一帧马上来;银行转账,晚到半小时也比丢了强,那就该用挂号信。
这里有个实践上的判断标准,我觉得比任何教科书都实用:问自己“这个数据晚到 200 毫秒,还有价值吗”。有,就走可靠通道;没有,就走 UDP,然后在应用层按业务重要性分级——关键的控制消息(比如开局、结算)自己加确认和重传,非关键的流式数据(比如语音、位置)丢了就丢。
1.3 UDP和TCP怎么选:一张对照表和几个真实判断场景
把两者的差异摊开成表格,比背概念管用。
| 维度 | UDP | TCP |
|---|---|---|
| 连接状态 | 无状态,内核不维护 | 有状态,内核维护控制块 |
| 消息边界 | 保留 | 不保留,是字节流 |
| 可靠性 | 不保证 | 确认 + 重传 |
| 有序性 | 不保证 | 保证有序 |
| 拥塞控制 | 无 | 有(慢启动、拥塞避免等) |
| 头部开销 | 8 字节 | 20 字节起 |
| 一对多 | 天然支持广播、多播 | 只能一对一 |
| 典型延迟 | 单程,无握手成本 | 建连至少一个 RTT |
| 适用业务 | 实时性优先、可容忍丢包 | 正确性优先、可容忍延迟 |
光看表格还不够,落到具体场景上,判断逻辑会清晰很多。
场景一:DNS 查询。一个域名解析请求几十字节,响应通常几百字节,要求是快。用 UDP 的话一次往返就完事,失败了客户端自己重试,成本极低。所以 DNS 默认走 UDP,只有在响应超过 512 字节(现在普遍支持 EDNS0,可以更大)或者需要区域传送的时候才切到 TCP。这是“请求-响应式、短小、可重试”业务的典型代表。
场景二:实时语音和视频。一帧 Opus 编码的音频大概 40 到 120 字节,一帧视频关键帧可能几十 KB。音频帧丢了,最正确的处理是直接丢掉、用前一帧做补偿,而不是等重传——等 200 毫秒重传回来,对方早就说到下一个词了。视频关键帧丢了确实影响大,所以工程上会用 FEC 前向纠错,发冗余数据而不是等重传。这类业务的共同点是:时间敏感度远高于完整性敏感度。
场景三:游戏状态同步。客户端每秒发 20 到 60 次位置,服务端广播给房间内其他人。位置数据是“最新覆盖旧值”的语义,中间丢了几个包完全不影响,只要最新的那个到了就行。这种情况下用 TCP 反而有害,因为 TCP 的队头阻塞会让一个丢失的包拖住后面所有包,等到重传回来,所有位置数据都过期了。这就是所谓的“用可靠传输做不可靠业务,效果更差”。
场景四:内网服务发现和多播。一个节点启动后广播“我上线了”,或者向多播组询问“谁是配置中心”。TCP 根本做不了这个,它只能点对点。UDP 的广播和多播能力在这个场景里不可替代。注意广播只在同一广播域内有效,跨网段要靠多播加路由器配置,这一点后面还会细说。
场景五:日志和指标上报。这类数据量大、允许少量丢失、但绝不能因为上报阻塞主业务。用 UDP 往本地 agent 发,agent 侧做批量聚合再转出去,主业务永远不会被网络卡住。这是 UDP 的另一个价值:失败的代价可控,且不会反向影响调用方。
反过来,凡是“少一条就出错”的场景,都别碰 UDP。配置下发、订单状态变更、数据库写入,这些用 TCP 或者干脆用成熟的消息队列,别自己造轮子。
2. 关键机制拆解:校验和、分片与内核缓冲区
这一章讲三件内核替你做的事,也是出问题时最容易被忽略的地方。很多人调 UDP 只会看业务代码,其实一半的问题出在这三层。
2.1 校验和怎么算:伪首部、反码求和与那段容易写错的代码
UDP 校验和的计算流程是:构造一个“伪首部”,把它和 UDP 首部(校验和字段先置 0)、数据部分拼在一起,按 16 位为单位做反码求和,最后把结果取反写入校验和字段。
伪首部不是真正传输的字节,它只是参与计算,长度 12 字节(IPv4):源 IP 4 字节、目的 IP 4 字节、保留位 1 字节(全 0)、协议号 1 字节(UDP 是 17)、UDP 长度 2 字节。为什么要掺进 IP 地址?因为 UDP 首部本身不含地址信息,如果不把地址纳入校验,一个包被错误路由到别的主机,接收端是察觉不到的。伪首部的存在就是为了防这种张冠李戴。
反码求和有几个坑必须记住:
- 求和过程中产生的进位要回卷加到低位,这叫“端回卷”,不能直接丢掉。
- 如果最终算出的校验和是 0,要写成 0xFFFF。因为 0 在 UDP 里是“未计算校验和”的特殊标记,两者不能混。
- IPv4 下校验和字段填 0 表示发送方不计算,接收方也不校验。这个“优化”在早期很常见,因为当年计算校验和很费 CPU。现在几乎没有理由这么干,除非你在做纯内网的高性能转发。
- IPv6 下不允许关闭校验,填 0 会被直接丢弃。
下面这段 Python 是我平时用来验证校验和计算的,逻辑写得很直白,你可以直接拿去对照抓包结果:
import struct def udp_checksum(src_ip: str, dst_ip: str, payload: bytes, src_port: int, dst_port: int) -> int: # 伪首部:源IP + 目的IP + 保留位 + 协议号 + UDP长度 pseudo = struct.pack('!4s4sBBH', bytes(map(int, src_ip.split('.'))), bytes(map(int, dst_ip.split('.'))), 0, 17, 8 + len(payload)) # UDP首部,校验和字段先填 0 header = struct.pack('!HHHH', src_port, dst_port, 8 + len(payload), 0) data = pseudo + header + 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) # 端回卷 result = (~total) & 0xFFFF return result if result != 0 else 0xFFFF实际抓包验证的时候,你会发现 Wireshark 有时会标红提示 “checksum incorrect”。十有八九不是你算错了,而是网卡做了校验和卸载(checksum offload)。发送端内核把校验和字段填成一个假值,真正的计算交给网卡硬件做,抓包工具在网卡之前就把包截走了,于是看到的是错的。要验证这一点,在抓包机上关掉对应网卡的 offload 再抓一次就对了。这个坑我当年排查了大半天,一直以为自己代码写错了。
2.2 MTU、IP分片与1472这个数字的由来
以太网的标准 MTU 是 1500 字节。一个 UDP 数据报要经过 IP 层封装,IP 首部 20 字节,UDP 首部 8 字节,所以留给业务数据的空间是 1500 - 20 - 8 = 1472 字节。这个数字在实时音视频和游戏开发里几乎是个常识,因为它决定了“单个 UDP 包最大能塞多少业务数据而不用分片”。
为什么不能超?一旦超过 MTU,IP 层就会把这个数据报切成多个分片,每个分片独立传输。问题在于:接收端必须收齐所有分片才能重组出完整数据报,任何一个分片丢失,整个数据报都会被丢弃,而且接收端根本不会告诉你“我丢了第 3 个分片”,它只会默默扔掉。你那边看起来就是“这个包丢了”,而且丢包率被放大了——假设链路单包丢失率 1%,一个切成 3 片的包,整体成功率达到 0.99³ ≈ 97%,看起来只掉 3%,但你要知道丢了的那 3% 里,重传成本是发 3 个包。
更麻烦的是 NAT 和防火墙对分片的处理五花八门。有些设备只检查第一个分片,有些对后续分片直接丢弃,有些重组超时设得很短(Linux 默认 ipfrag_time 是 30 秒)。跨公网的时候,分片的存活率明显低于不分片。
所以工程上的第一原则是:把应用层单包大小控制在 1472 字节以内,最好再保守一点。走公网我一般建议控制在 1200 字节左右,因为路径上可能存在 PPPoE(MTU 1492)、各种隧道封装(各扣 20 到 60 字节不等),你按 1500 算,到了实际链路上就超了。QUIC 的默认初始包大小就是 1200 字节,这个数字不是随便定的。
那怎么知道路径 MTU 到底是多少?标准做法是路径 MTU 发现(PMTUD):发送方把 IP 首部的 DF 位(Don't Fragment)置 1,让中间路由器在包太大时回一个 ICMP “需要分片”的消息,发送方据此调小包大小。听起来很美好,实际经常失效——很多网络设备把 ICMP 直接拦掉,发送方永远收不到反馈,包就一直发不出去,形成黑洞。这就是著名的 PMTU 黑洞问题。
实用做法是:要么在应用层做探测(从小到大逐步发包,看哪个尺寸开始不通),要么干脆保守取 1200 字节。我自己的项目里,凡是跨公网的 UDP,一律按 1200 设计,省心。局域网内可以放宽到 1472,前提是你确认两端网卡 MTU 都是 1500 且中间没有额外封装。
2.3 接收缓冲区:丢包最先发生的地方
这是我认为 UDP 排查中最该先看、但最多人忽略的地方。UDP 的接收流程是这样的:包到达网卡 → 内核协议栈校验 → 找到对应 socket →放进这个 socket 的接收缓冲区队列→ 应用调用 recvfrom 取走。如果应用取得慢,队列满了,后面来的包直接被内核丢掉,而且不会有任何通知,不像 TCP 会通过窗口告诉对方“我这边满了别发了”。
所以“UDP 丢包”有相当一部分根本不是网络丢的,是你自己没来得及读。判断方法很直接,看 Linux 的统计:
# 查看 UDP 层整体统计 cat /proc/net/snmp | grep -A1 "^Udp:" # 或者 netstat -su | grep -i udp关键指标逐个解释:
| 指标 | 含义 | 异常意味着什么 |
|---|---|---|
| InDatagrams | 成功递交给应用的报文数 | 正常增长 |
| NoPorts | 目的端口无监听的报文数 | 有程序在往死端口发包,或服务端没起来 |
| InErrors | 接收错误总数 | 通常是校验和错或缓冲区满 |
| RcvbufErrors | 接收缓冲区满导致的丢弃 | 应用读得太慢,重点看这个 |
| SndbufErrors | 发送缓冲区满导致的丢弃 | 发送太快,或网卡队列堵塞 |
| InCsumErrors | 校验和错误的报文数 | 链路有比特翻转,或 offload 配置问题 |
RcvbufErrors持续增长是明确的信号:你的应用处理速度跟不上到达速度。解决办法不是加大缓冲区——加大只能延缓,治不了根。正确的顺序是:先看单次 recvfrom 处理里有没有耗时操作(写日志、查数据库、加锁),把它挪走;再看是不是每次只读一个包导致系统调用开销过大,改成循环读直到 EAGAIN;最后才考虑调大 SO_RCVBUF。
调大缓冲区的写法:
int size = 4 * 1024 * 1024; // 4MB setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));注意这里有个陷阱:内核会把你的值翻倍再存,因为要留出内部结构开销,所以你设 4MB,实际getsockopt读出来可能是 8MB。另外它受/proc/sys/net/core/rmem_max限制,普通用户最多只能设到这个值,要更大得先改系统参数:
sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.rmem_default=4194304我一直建议实时类业务把 rmem_max 调到 16MB 以上,因为一旦发生突发流量(比如客户端重连风暴),默认的 208KB 根本扛不住,几毫秒就满了。
2.4 端口复用、多播与广播的使用要点
UDP 天然支持一对多,这不是附加功能,而是协议本身没有连接概念的必然结果。三个相关能力值得单独说。
端口复用。SO_REUSEADDR在 UDP 上的作用和 TCP 不太一样。TCP 里它主要解决 TIME_WAIT 状态下的端口占用;UDP 里它允许你绑定一个已经被绑定的地址端口组合,主要用于多播场景——多个进程同时加入同一个多播组,监听同一个端口。SO_REUSEPORT更进一步,允许多个 socket 负载均衡地接收同一个端口的包,内核按哈希分发。做高性能 UDP 服务的时候,常见做法是每个 CPU 核起一个线程、一个 socket,全部绑定同一端口并开启 SO_REUSEPORT,这样避免了多线程抢同一把锁,实测吞吐能上去不少。
多播。发送方向多播地址发包,接收方需要主动加入组:
struct ip_mreq mreq; mreq.imr_multiaddr.s_addr = inet_addr("239.1.1.1"); mreq.imr_interface.s_addr = htonl(INADDR_ANY); setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));多播地址范围是 224.0.0.0 到 239.255.255.255。其中 224.0.0.0/24 是本地链路保留的,路由器不转发。这一点要知道:你选了 224.0.0.1,那这个包永远出不了本网段,别指望跨机房用。另外多播能不能跨网段,完全取决于路由器和交换机有没有配 IGMP 和 PIM,这是网络侧的事,不是代码能解决的。
广播。需要显式开启SO_BROADCAST,否则 sendto 会返回权限错误。广播地址是网段的最后一个地址(比如 192.168.1.255),只能在同一广播域内传播。做设备发现挺好用,做跨网段通信完全没用。
3. 动手实操:从抓包验证到写一个带重传的收发程序
光讲原理容易飘,这一章把工具和代码摆出来,你照着敲一遍,对 UDP 的理解会具体很多。
3.1 环境与工具清单
我常用的几样,都不重:
| 工具 | 用途 | 典型命令 |
|---|---|---|
| tcpdump | 命令行抓包,服务器上必装 | tcpdump -i eth0 udp port 9000 -XX |
| Wireshark | 图形化分析,看字段、看流 | 导入 pcap 后按 udp 过滤 |
| nc | 快速发一个 UDP 包验证通路 | echo hi | nc -u 127.0.0.1 9000 |
| iperf3 | UDP 带宽、丢包、抖动压测 | iperf3 -c host -u -b 10M -t 30 |
| ss | 看 socket 队列和缓冲状态 | ss -uanp |
tcpdump的-XX参数会把包内容以十六进制和 ASCII 双栏打印,验证首部字段的时候特别方便。ss -uanp里有个字段叫Recv-Q,显示当前接收队列里还堆着多少字节没被应用读走,这个值持续不为 0 就说明消费端有瓶颈。
3.2 一个最小可用的收发程序
先把最基本的跑通,服务端:
import socket srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) srv.bind(('0.0.0.0', 9000)) seq = 0 while True: data, addr = srv.recvfrom(65535) # 解析业务自定义的首部:4字节序号 + 4字节时间戳 + 负载 if len(data) < 8: continue peer_seq = int.from_bytes(data[0:4], 'big') payload = data[8:] print(f'from {addr} seq={peer_seq} len={len(payload)}') # 回 ACK,把对方的序号原样带回 srv.sendto(peer_seq.to_bytes(4, 'big'), addr)客户端,带一个简单的超时重传:
import socket, time, struct cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) cli.settimeout(0.2) # 200ms 没回就重传 def send_reliable(payload: bytes, seq: int, retries: int = 3) -> bool: pkt = struct.pack('!II', seq, int(time.time() * 1000)) + payload for attempt in range(retries): cli.sendto(pkt, ('127.0.0.1', 9000)) try: ack, _ = cli.recvfrom(16) if int.from_bytes(ack, 'big') == seq: return True except socket.timeout: # 第 n 次重传,超时时间可以退避放大 cli.settimeout(0.2 * (2 ** attempt)) return False for i in range(10): ok = send_reliable(b'hello' * 10, i) print(i, 'ok' if ok else 'failed')这段代码不到 30 行,但它已经包含了应用层可靠传输的核心三要素:序号、确认、超时重传。真正工程化的实现还要加去重、加滑动窗口、加 RTT 估计,但骨架就是这个。你可以先把它跑起来,用 tcpdump 看每个包,理解序号和 ACK 是怎么对应的,再往上叠加复杂度。
要注意settimeout(0.2)这个值是拍的,实际业务里必须动态算。最朴素的做法是维护一个 RTT 的指数移动平均,超时时间取SRTT + 4 * RTTVAR,这是 TCP 用的经典公式。固定超时在跨公网场景下必然翻车——RTT 从 20 毫秒跳到 300 毫秒的时候,你按 200 毫秒超时就是无脑重传,越重传越堵。
3.3 用tcpdump把首部字段逐个对上
启动服务端后,在另一个终端敲:
tcpdump -i lo udp port 9000 -XX -c 2你会看到类似这样的输出(十六进制部分节选):
0x0000: 4500 0046 0000 4000 4011 0000 7f00 0001 0x0010: 7f00 0001 8f2a 2328 0032 0000 6865 6c6c逐段核对:
45是 IP 版本 4 加首部长度 5(单位是 4 字节,5 × 4 = 20)。0046是 IP 总长度,十进制 70。4011里的11是十进制 17,正是 UDP 的协议号。- 之后是源 IP
7f00 0001(127.0.0.1)和目的 IP,各 4 字节。 8f2a是源端口,2328是目的端口 9000。0032是 UDP 长度,十进制 50,等于 8 字节首部加 42 字节负载。0000是校验和字段,这里是 0,说明在本机回环上校验收到了 offload 影响。- 最后
6865 6c6c就是 “hell” 的 ASCII。
这么对一遍,你对“八个字节”的感受会完全不一样。之后再看任何 UDP 抓包,脑子里都能自动切分出各字段。这一步花十分钟,收益远超读十页 RFC。
3.4 在应用层补可靠性:序号、ACK、重传、去重
如果你的业务真的需要 UDP 的速度又想有可靠性,就得在应用层造一套。我把关键设计点列一下,都是踩过坑总结出来的。
序号要包含在每一个包里,包括 ACK 包。纯 ACK 也需要序号,否则你没法区分“这是对第 5 号包的确认”还是“这是重复的旧 ACK”。ACK 里除了确认号,最好再带上一个接收窗口,告诉对方“我这边还能收多少”,这就是最简版的流量控制。
去重要靠滑动窗口,不能只靠“记住上一个序号”。网络乱序的时候,序号 7 可能比 6 先到。只记上一个序号会导致 6 到了被判为重复而丢弃,或者 7 到了被接受、6 到了又被接受,业务层看到两次 7 之间的 6,逻辑就错了。正确做法是维护一个接收窗口(比如 64 个序号),标记哪些已收到,落在窗口之前的直接丢,窗口内的按位标记去重。
重传超时要有退避,但退避上限要卡住。第一次 200 毫秒,第二次 400,第三次 800,但别无限翻倍,卡在 2 秒左右比较合理。因为实时业务里,超过 2 秒还没到的数据基本没价值了,继续重传只是浪费带宽。
要有快速重传。等超时太慢。如果发送方连续收到三个对同一个包的重复 ACK,说明后面的包已经到了、中间那个丢了,立刻重传中间那个,不用等计时器。这个机制在丢包率 1% 左右的链路上能显著降低平均延迟。
拥塞控制不能完全不做。纯 UDP 没有拥塞控制,在公网上是很危险的——你把链路打满了,别人的包全丢了,最后自己的包也丢了,大家一起崩。至少要做个简单的速率限制,或者参考 BBR 那类基于带宽和 RTT 估计的算法。这一点是自研可靠 UDP 最容易漏掉的,很多团队做出第一版能跑,一上量就把整个机房出口打爆。
这套东西做完整,你就差不多实现了半个 QUIC。所以我一直的建议是:如果不是有非常特殊的需求,直接上 QUIC 或者现成的 KCP 实现,别自己从头造。但你要能看懂它们的实现,知道每个机制为什么存在,出问题时才排查得动。
3.5 压测与观测:iperf3 UDP 模式怎么读结果
验证链路质量的标准动作:
# 服务端 iperf3 -s -p 5201 # 客户端,发 10Mbps,跑 30 秒 iperf3 -c 10.0.0.2 -p 5201 -u -b 10M -t 30 -l 1200-l 1200指定每个包 1200 字节,和前面说的公网保守值一致。结果里重点看三个数:
| 指标 | 含义 | 参考判断 |
|---|---|---|
| Lost/Total Datagrams | 丢包数/总数 | 内网应接近 0,公网 1% 以内可接受 |
| Jitter | 抖动,相邻包到达间隔的偏差 | 语音场景建议低于 30ms |
| Lost/Total 后面括号里的百分比 | 丢包率 | 超过 5% 说明链路明显有问题 |
这里有个容易误读的地方:iperf3 的 UDP 丢包统计是在接收端算的,包含“网络丢了”和“接收端缓冲满了”两种情况。所以看到丢包率高,先别急着找运营商,先在接收端看RcvbufErrors,排除掉自己的问题再往外查。
4. 常见问题与排查技巧实录
这一章是我这些年处理 UDP 问题攒下来的路径和清单,按“先看什么、后看什么”的顺序排,照着走能少绕很多弯路。
4.1 丢包排查:从链路到应用的五层路径
遇到“UDP 丢包”的反馈,我习惯从下往上查,每一层都有明确的判据。
| 层级 | 检查项 | 命令/方法 | 结论 |
|---|---|---|---|
| 链路层 | 网卡是否有错包、丢包 | ip -s link show eth0 | rx/tx errors、dropped 持续增长说明硬件或驱动问题 |
| 网络层 | 是否有分片、ICMP 差错 | tcpdump 看 DF 位和 frag 字段 | 大量分片说明包太大,需要调小 |
| 传输层 | UDP 层统计 | cat /proc/net/snmp | RcvbufErrors 增长是应用太慢,InErrors 增长看校验 |
| socket 层 | 接收队列积压 | ss -uanp看 Recv-Q | 持续非 0 说明消费端瓶颈 |
| 应用层 | 单次处理耗时 | 埋点计时 | 单次超过 1 毫秒就要优化 |
具体的排查顺序,我的习惯是:
第一步,先在接收端跑cat /proc/net/snmp | grep -A1 "^Udp:",隔十秒再看一次,对比RcvbufErrors有没有增长。这一条能直接区分“网络丢的”和“自己丢的”,是最省时间的一步。
第二步,如果 RcvbufErrors 没涨,那就是真丢了,在两端同时抓包。发送端抓到、接收端没抓到,问题在中间链路;发送端都没抓到,问题在你的发送逻辑或者发送缓冲区(看 SndbufErrors)。
第三步,对比两端的抓包文件,看是零星丢还是成片丢。零星丢包通常是链路质量或拥塞;成片丢包(连续几十毫秒全无)多半是网络设备在做限速或者切换,这种要找网络侧的人一起看。
第四步,如果丢的包都是大包、小包不丢,基本可以确定是分片或者 MTU 问题,回去把包大小降到 1200 试试。
提示:抓包文件一定要两端同时抓、同时打时间戳。只抓一端的包,你永远无法判断包是在路上丢的,还是对方根本没处理。
4.2 分片相关的三个坑
坑一:以为没超 MTU 就不会分片。你算的是 1500 - 20 - 8 = 1472,但路径上可能有 PPPoE 扣 8 字节、VXLAN 扣 50 字节、IPsec 扣更多。所以实际可用值比你算的小。规避方法就是前面说的,跨公网按 1200 设计。
坑二:分片重组超时。Linux 默认net.ipv4.ipfrag_time是 30 秒,ipfrag_high_thresh是 4MB。高并发场景下,如果一堆大包同时在路上,重组队列可能被打满,新来的分片直接被丢,表现出来就是随机丢包且丢了不报错。可以适当调大阈值,但根本解决办法还是别发大包。
坑三:NAT 设备对分片的处理不一致。有些 NAT 只对第一个分片做地址转换,后续分片找不到映射表就丢了。这种情况在跨运营商的时候特别常见,而且极难定位——你在两端抓包都能看到包进出,但中间就是不通。唯一的办法还是控制包大小。
4.3 NAT超时与心跳保活
这个坑几乎所有做长连接 UDP 的项目都会踩。NAT 设备为每个内网到外网的映射维护一个表项,空闲一段时间就删掉。不同设备超时时间差别很大,短的 30 秒,长的十几分钟,而且 UDP 的映射超时通常比 TCP 短得多,因为设备不知道怎么判断“这条流结束了”。
一旦映射被删,你发的包就出不去,或者对方的回包找不到你。表现出来就是“连接莫名其妙断了”,而且往往是有规律的——每隔几分钟断一次。
解决办法就是心跳,但心跳的设计有几个讲究:
- 间隔要小于最短的 NAT 超时。保守取 15 到 25 秒,别指望 60 秒能扛住。
- 心跳包要有内容,不能是空包。有些 NAT 对长度为 0 的 UDP 包不刷新映射表,虽然多数设备没这毛病,但加上几个字节的版本号或时间戳更保险。
- 心跳要能检测单向故障。只发不收没有意义。正确做法是收到对方心跳后回一个,发送方记录“上次收到回包的时间”,超过三个心跳周期没收到就判定连接失效,触发重连。这一点很多人漏掉,结果链路已经单向不通了,程序还傻乎乎地一直发。
- 重连要有退避和随机化。断线时如果所有客户端同时重连,服务端瞬间被打满,然后全挂,再全部重连,形成雪崩。退避时间加上随机抖动(比如 1 秒到 3 秒之间随机)能有效打散。
4.4 参数调优速查表
最后把常用的内核参数和 socket 选项整理成表,需要的时候直接查。
| 参数 | 位置 | 建议值 | 说明 |
|---|---|---|---|
| net.core.rmem_max | 系统 | 16MB | 接收缓冲区上限,突发流量的关键 |
| net.core.wmem_max | 系统 | 16MB | 发送缓冲区上限 |
| net.core.netdev_max_backlog | 系统 | 5000 以上 | 网卡收包队列,高pps场景必须调 |
| net.ipv4.ipfrag_time | 系统 | 保持 30 或适当调小 | 分片重组超时 |
| SO_RCVBUF | socket | 4MB 到 16MB | 按业务吞吐和消费速度定 |
| SO_SNDBUF | socket | 1MB 到 4MB | 发送突发时有用,但别指望它解决拥塞 |
| SO_REUSEPORT | socket | 开启 | 多线程/多进程收包,减少锁竞争 |
| SO_BROADCAST | socket | 仅在需要广播时开启 | 不开会直接报权限错误 |
还有一条不在表里但同样重要的经验:收包线程只做收包,别在里面干重活。我见过太多实现,在 recvfrom 之后直接做协议解析、反序列化、业务处理、写数据库,一整套下来几毫秒,包就堆起来了。正确做法是收包线程只把数据丢进无锁队列,由工作线程池消费。这个改动通常能把 RcvbufErrors 直接干到 0。
我个人在实际操作中的体会是,UDP 的问题九成不在协议本身,而在于“你以为它会替你做什么,其实它什么都没做”。它不排队、不重传、不保活、不分片得那么友好。把这些边界搞清楚,该自己补的补上,UDP 用起来其实非常舒服——延迟低、可控性强、出问题时因果关系清楚。最后再分享一个小技巧:接手任何一个 UDP 相关的线上问题,先别急着看代码,先在接收端把/proc/net/snmp的 Udp 段连续采样三次,把差值算出来,这一张表往往就能把排查范围砍掉一半。