1. 先搞清楚 UDP 到底是个什么东西
1.1 一个真实踩过的坑:为什么我的数据"丢"了
先说个我自己的经历。早些年接手一个实时数据采集的下行通道,服务端按固定周期往外推数据,客户端那边一直报"数据不全"。第一反应是网络问题,结果抓包一看,服务端发出去的包一个不少,客户端网卡也全都收到了,问题出在客户端进程里——它在做业务处理时阻塞了两百多毫秒,UDP 接收缓冲区被塞满,后面来的包内核直接扔了,连个错误码都不会返回给发送方。
这就是 UDP 最本质的性格:它只负责把数据从 A 搬到 B 的门口,敲一下门就走,门开不开、东西有没有人接,它一概不管。这件事在 TCP 上几乎不可能发生,因为 TCP 有滑动窗口和确认重传,接收方处理不过来时窗口会收缩,发送方自然就慢下来。UDP 没有这个机制,发送方的节奏完全由应用自己控制。
所以讨论 UDP,永远绕不开一句话:协议本身不保证什么,所有的保证都得你在应用层自己实现。这句话听起来像废话,但它是理解后面所有细节的总纲。你在 UDP 上遇到的每一个"诡异"问题——丢包、乱序、重复、截断——追到根上都是这一句。
1.2 控制权在谁手里:UDP 与 TCP 的分工逻辑
把两者的差异摊开看,其实不是"谁好谁坏",而是把复杂度放在哪一层的问题。
TCP 把可靠性、顺序性、流量控制、拥塞控制全打包进了内核,应用层拿到的是一条"看起来永远不会出错"的字节流。代价是你没法干预它的决策:它认为该重传就重传,该等就等,你觉得延迟高也没辙,除非换协议。
UDP 则把这些全部剥离,内核只做三件事:加个 8 字节的头部、算一下校验和、按目的端口投递。剩下的可靠性、顺序、拥塞控制全部交给你。这意味着你可以做出 TCP 做不到的事,比如:
- 不要重传。直播场景丢一帧画面无所谓,但如果为了补那一帧卡住 200 毫秒,体验反而更差。TCP 的队头阻塞在这是致命的,UDP 天然没有这个问题。
- 自己决定拥塞策略。有些业务知道自己的链路质量,能比内核的通用算法调得更激进或者更保守。
- 多路复用一条连接。QUIC 就是个典型,多个逻辑流共用一个 UDP 端口,某个流丢包不阻塞其他流。
反过来说,如果你的业务对丢数据零容忍,又没什么特殊需求,那老老实实用 TCP,别为了"快"去用 UDP。我见过太多团队为了省那点握手延迟上了 UDP,结果花三个月补可靠性层,最后做出来的东西性能还不如 TCP,因为他们的重传逻辑写得比内核差得远。
1.3 哪些场景天生适合 UDP,哪些千万别碰
判断标准其实很朴素:看数据的时间价值。
时效性远大于完整性的,UDP 是正解。音视频通话、实时对战、行情推送、监控画面,这些场景里一个 200 毫秒前的位置更新毫无意义,不如直接丢掉等下一个。DNS 查询也是经典案例,一个请求一个响应,用 TCP 得先握手三次,UDP 一个来回就完事,丢了就重发查询,代价很低。
状态同步类业务同样合适,比如游戏里客户端每隔几十毫秒上报一次位置,中间丢一两个包完全不影响,因为下一个包会覆盖掉旧状态。这类"最新值覆盖"的模型,UDP 用起来非常顺手。
反过来,文件传输、配置下发、账务流水、指令确认这些绝对不能丢的,就别折腾了。还有一类容易忽略的:响应体很大的请求。UDP 单个数据报最大 65507 字节(IPv4 下减去 IP 头和 UDP 头),一旦超过 MTU 就会被 IP 层分片,而分片只要丢一片,整个数据报就废了,等于放大了丢包率。大包场景下 UDP 的表现会比 TCP 差得多。
我通常给人一条粗糙但好用的判断线:单条消息在 1200 字节以内、能容忍偶发丢失、或者丢了自己能重发且重发代价低,满足这三条就可以考虑 UDP;否则先老老实实 TCP。
2. 报文结构:8 个字节里的全部信息
2.1 逐字段拆解,每个字段都有坑
UDP 头部短得让人安心,就四个 16 位字段,一共 8 字节。
| 字段 | 长度 | 作用 | 容易踩的坑 |
|---|---|---|---|
| 源端口 | 2 字节 | 标识发送方端口,可选 | 置 0 表示不需要回包,某些场景会导致中间设备不建映射 |
| 目的端口 | 2 字节 | 用于投递 | 无监听时回 ICMP 端口不可达,但很多防火墙会吞掉 |
| 长度 | 2 字节 | 头部+数据的字节数,最小为 8 | 值不可信,接收方应使用 IP 层给的实际长度 |
| 校验和 | 2 字节 | 覆盖伪首部+UDP 头+数据 | IPv4 下可为 0 表示不校验,IPv6 下强制必填 |
长度字段这里有个细节值得说。理论上发送方填什么,接收方就信什么,但真实网络中经常出现长度字段和实际 IP 载荷长度对不上的情况(中间设备改写、驱动 bug、虚拟化层的问题)。Linux 内核的处理是以 IP 层提供的长度为准备,长度字段只用来做校验。你自己写解析器时也别直接信这个字段,容易越界读。
校验和字段在 IPv4 下填 0 是合法的,表示"我不校验",接收方直接放行。这个设计在当年是为了省算力,现在基本没人这么干了,因为一旦链路上有静默错误,你收到的就是脏数据。IPv6 直接取消了这个选项,必须计算。
另一个必须知道的事实:校验和是有可能算错的,而且算错的方式很隐蔽。因为它用的是 16 位反码求和,一个比特翻转能被检出的概率很高,但不是 100%。对于要求高的场景,光靠 UDP 校验和不够,还得在应用层加一层 CRC 或者哈希。
2.2 伪首部:那个"不存在"的 12 字节
算校验和的时候,参与计算的不只是 UDP 报文本身,前面还要虚拟地拼上一段"伪首部",内容包括源 IP、目的 IP、一个填 0 的字节、协议号 17、以及 UDP 长度。这段东西不会真的发到线路上,只是为了让校验和能覆盖到 IP 地址,防止包被错误投递到别的主机还能通过校验。
IPv4 下伪首部是 12 字节,IPv6 下是 40 字节(两个 16 字节地址加 4 字节长度加 3 字节零和 1 字节下一头部)。如果你的程序要手算校验和,这里最容易出错——很多人算出来的校验和对不上,99% 是伪首部拼错了,或者是长度字段填了数据的长度而没算上 8 字节头部。UDP 长度在伪首部和真头部里出现的值是一样的,都是"8 + 数据长度"。
计算过程本身不复杂:把所有 16 位字按大端拼起来相加,溢出的高位回卷加到低位,最后取反码。发出去的时候校验和字段本身先填 0。
def udp_checksum(src_ip, dst_ip, payload): pseudo = bytes(map(int, src_ip.split('.'))) + \ bytes(map(int, dst_ip.split('.'))) + \ b'\x00\x11' + len(payload).to_bytes(2, 'big') data = pseudo + 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) return (~total) & 0xffff这段代码可以直接拿去做单元测试,验证方式很简单:把你算出来的结果填进头部,然后对整个报文(含伪首部)再算一遍,结果应该是 0。
2.3 MTU 与分片:为什么你的大包总是收不全
这是 UDP 实操里最常见的问题,没有之一。
以太网的标准 MTU 是 1500 字节,减去 20 字节 IP 头、8 字节 UDP 头,单个 UDP 数据报的载荷最多 1472 字节。超过这个数,IP 层就会分片,把一个 UDP 数据报拆成多个 IP 分片发送。
分片的问题在于:任何一个分片丢失,整个 UDP 数据报都无法重组,接收方直接丢弃全部。假设链路丢包率 1%,一个拆成 4 片的数据报全部到达的概率是 0.99^4 ≈ 96%,等效丢包率一下涨到 4%。如果拆成 10 片,等效丢包率接近 10%。这就是为什么说"大包会放大丢包"。
更麻烦的是路径 MTU。从你到对方中间可能经过 PPPoE、隧道、虚拟网络,实际可用的 MTU 可能只有 1400 甚至更低。如果设置了 IP 层的"不分片"标志(DF),超大的包会被中间设备丢弃,并可能返回一个 ICMP 需要分片的错误——但这个 ICMP 在很多网络里是被过滤掉的,于是你看到的现象就是"小包能过,大包全丢,毫无线索"。
我的经验做法是:业务层把单个 UDP 载荷控制在 1200 字节以内。这个数字留了足够余量,能覆盖绝大多数现实网络路径。如果你的数据本来就大,别指望 IP 分片,自己在应用层切块、编号、重组,这样至少能知道哪一块丢了、要不要补。
3. 动手搭一个能用的 UDP 通道
3.1 先跑通最小可用的收发
先把裸的收发跑通,别急着上业务逻辑。服务端大概长这样:
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", 9999)) print("listening", srv.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)) while True: data, addr = srv.recvfrom(2048) seq = int.from_bytes(data[:4], "big") srv.sendto(seq.to_bytes(4, "big"), addr)两个细节要留意。一个是我用recvfrom(2048)而不是 65535,因为缓冲区开太大会让每次系统调用的开销变大,而实际业务包根本到不了那么大;如果你的业务确实需要收大包,那再调。另一个是打印出来的SO_RCVBUF值会是你设置值的两倍——这不是 bug,内核在记账时按两倍计算,用来覆盖协议头的额外开销,所以你设 4MB,实际生效的可能是 8MB,但这个值仍然受net.core.rmem_max上限约束。
客户端:
import socket cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) cli.settimeout(1.0) for i in range(5): cli.sendto(i.to_bytes(4, "big"), ("127.0.0.1", 9999)) try: resp, _ = cli.recvfrom(2048) print("ack", int.from_bytes(resp, "big")) except socket.timeout: print("timeout", i)注意这里的settimeout只是让recvfrom不永久阻塞,它并不会让内核帮你重传。超时之后要不要重发、重发几次、间隔多久,全是你的决定。
3.2 在应用层补一个"够用"的可靠层
绝大多数业务不需要完整的 TCP 语义,只需要"重要消息必达 + 重复消息去重"。照着这个目标设计,头部 12 字节就够:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 4 | 会话 ID,区分不同客户端 |
| 4 | 4 | 序列号,单调递增 |
| 8 | 1 | 标志位,bit0 表示需要确认 |
| 9 | 1 | 消息类型 |
| 10 | 2 | 载荷长度 |
打包用struct.pack("!IIBBH", sid, seq, flags, mtype, plen)就行,注意用大端。接收侧维护一个滑动窗口和已收到的序列号集合:
- 序列号小于窗口左边界的,直接判为重复丢弃;
- 落在窗口内的,收下并记录,同时回一个只带序列号的 ACK;
- 序列号跳跃说明中间丢了,先缓存后面的包,等缺口补齐再按顺序交给业务层(如果业务不要求顺序,这一步可以省掉)。
重传策略上,我不建议用固定间隔。首次重传等 200 毫秒,之后每次翻倍,上限 2 秒,重传超过 5 次就判定链路失效。这个参数组合在局域网和普通公网环境下都比较稳,弱网环境下可以适当放宽首包时间。
有个容易忽略的点:ACK 本身也可能丢。如果接收方收到重复包就重发 ACK,发送方可能会收到多个 ACK,这无所谓,去重逻辑在发送侧处理掉就行。反过来,如果接收方对重复包不理不睬,发送方就会一直重传直到次数耗尽。所以规则要定清楚:收到重复包,照样回 ACK,但不要重复提交给业务层。
3.3 抓包验证:眼见为实
写完代码一定要抓包看,光靠日志看不出真相。
tcpdump -i any -nn udp port 9999 -vv -X -c 20-X会把十六进制和 ASCII 一起打出来,你能直接对着自己设计的头部逐字节核对。有一次我死活对不上序列号,最后发现是struct.pack漏了!,用了本机字节序,在小端机器上打包顺序全反了。
还有一个抓包时的经典困惑:你会看到自己发出的包校验和是错的。别慌,这是网卡和内核的校验和卸载机制在起作用,抓包点位于计算之前,所以抓到的是"未计算"状态,真实包发到线上是正确的。验证办法是在接收端抓,如果接收端算出来是对的,就说明没问题。
4. 内核参数与高并发收包
4.1 缓冲区:最容易成为瓶颈的地方
UDP 没有流控,发送方想发多快就发多快,接收方处理不过来时,超出缓冲区容量的包直接丢弃。这个"丢弃"是静默的,应用层收不到任何错误,只能通过内核计数器发现。
先看要监控什么:
nstat -az | grep -i udp ss -uanm | head -20重点看几个计数器:UdpRcvbufErrors表示因接收缓冲区满而丢弃的包数,UdpInErrors是各类入向错误的总和,UdpInCsumErrors是校验和失败,UdpNoPorts是目的端口没人监听。只要UdpRcvbufErrors在涨,就说明你的应用处理速度跟不上,或者缓冲区太小。
调参顺序我一般是这样的:
| 参数 | 作用 | 建议值(按业务量级) |
|---|---|---|
| net.core.rmem_max | 单个 socket 接收缓冲区上限 | 8MB ~ 32MB |
| net.core.netdev_max_backlog | 网卡到协议栈的队列长度 | 5000 ~ 20000 |
| net.core.rmem_default | 默认接收缓冲区 | 1MB ~ 4MB |
| net.ipv4.udp_mem | UDP 全局内存页数(低/压力/高) | 按物理内存 5%~10% 估 |
| net.ipv4.ipfrag_high_thresh | IP 分片重组队列上限 | 按需调大,但别指望它 |
改完rmem_max之后,应用里setsockopt设置的值才能生效,否则内核会把它悄悄截断到上限,你设了 32MB 实际只有 208KB,然后纳闷为什么还是丢包。
另一个提升收包能力的办法是批量收包。Linux 提供recvmmsg,一次系统调用能取多个报文,在高包量场景下能把 CPU 占用砍掉一大截。Python 没有直接暴露这个接口,得用ctypes或者换成 C/Go。如果你的 QPS 到了每秒几万包的量级,一次收一个包的开销就开始显著了。
4.2 中间设备不待见"沉默"的 UDP
这个坑我踩过不止一次。UDP 是无连接的,中间设备为了省资源,会给每个映射表项设置老化时间,通常是几十秒。如果你的应用长时间不发包,表项就被回收了,之后对方再发过来的包找不到对应关系,直接被丢。
现象很有迷惑性:连接"看起来"还在,但一段时间没数据之后,突然就收不到了,重连一下又好了。
解决办法是心跳,但心跳间隔要掐准。默认老化时间如果是 30 秒,那你得在 15 到 20 秒内至少发一次包。不要去猜,直接问网络运维拿实际配置,或者自己做实验:每隔一秒发个探针,看第几次开始收不到回包。
心跳包还有个小技巧:带上递增的序号,并且要求对端回 ACK。单向发包只能保活自己这侧的表项,如果对侧也要保活,必须有来回。
4.3 多线程与端口复用
单个 UDP socket 在多核机器上会成为瓶颈,因为所有包都进同一个接收队列。SO_REUSEPORT能让多个进程/线程绑定到同一端口,内核按四元组做哈希分发,每个 socket 有独立的队列,天然并行。
int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));要注意的是,哈希分发是按四元组来的,同一个客户端的所有包会固定落到同一个 socket 上,所以这不会打乱单个会话的包顺序,但也意味着热点客户端无法被分摊。如果你的流量集中在少数几个大客户端上,SO_REUSEPORT帮助有限,得改架构。
还有一点:SO_REUSEPORT时不要指望SO_REUSEADDR帮你解决一切。两者语义不同,前者是负载均衡,后者是允许绑定处于 TIME_WAIT 状态的端口,混用会出问题,尤其多进程启动时可能出现某个进程一个包都收不到。
5. 排查实录:六类高频问题
5.1 问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 完全收不到包 | 绑定了 127.0.0.1 / 防火墙拦截 / 端口填错 | ss -uan看是否监听在 0.0.0.0,对端nc -u试探 |
| 小包正常,大包 100% 丢 | 路径 MTU 小于包长,且 DF 置位 | ping -M do -s 1472逐级试探,业务层限长 1200 |
偶发丢包且UdpRcvbufErrors涨 | 接收缓冲区满 | 调大rmem_max和SO_RCVBUF,优化处理逻辑 |
UdpInCsumErrors持续增长 | 链路错误或中间设备篡改 | 抓包对比,检查是否有设备做内容改写 |
| 空闲一段时间后断流 | 中间设备映射表项老化 | 加双向心跳,间隔小于老化时间 |
sendto返回ENOBUFS | 发送缓冲区满或本地路由不可达 | 检查SO_SNDBUF、网卡队列、路由表 |
5.2 几个不那么明显的细节
第一个:bind的地址。绑定127.0.0.1只能收本机回环的包,绑定0.0.0.0才是所有网卡。这个错误太低级,但我见过在容器环境里因为绑定到了容器内部 IP,导致宿主机转发过来的包被丢的情况。
第二个:端口不可达的反馈是 ICMP。你往一个没监听的端口发 UDP,对端会回一个 ICMP 端口不可达。但很多网络设备会过滤这类消息,所以你看到的是"发了没反应"而不是"连接被拒绝"。这也意味着 UDP 的connect()并不会真正建立连接,它只是在内核里记下对端地址,方便用send()而不是sendto()。
第三个:recvfrom的缓冲区大小决定截断。如果传入的 bufsize 小于实际数据报长度,多出来的部分会被静默丢弃,返回的长度是 bufsize 而不是真实长度。你在应用层看到的就是"数据被截断了",但没有任何错误提示。这就是开头那个故事的另一个版本——所以缓冲区大小一定要比预期最大包长还大一点。
第四个:零长度 UDP 包是合法的。头部 8 字节,载荷 0 字节,能正常收发。有些扫描工具会用它探测端口,你的代码如果假设"收到包至少有几个字节",容易在解析头部时崩掉。
第五个:并发下的乱序比想象中更常见。不只是网络路径会导致乱序,多线程收包、多队列网卡、SO_REUSEPORT分发加上不同核之间的调度延迟,都可能让包的处理顺序和到达顺序不一致。所以但凡业务依赖顺序,序列号必须在应用层判,别假设内核会按序交给你。
第六个:容器和虚拟化环境的 MTU 往往不是 1500。常见的是 1450 或 1400,叠了隧道之后可能更低。跨环境部署时不要硬编码 1472,启动时做一次探测,或者直接把上限压到 1200,省心。
6. 我在实际项目里的一些处理习惯
写得差不多了,说几个不成体系但确实管用的习惯。
永远在应用层给 UDP 加一个序列号和时间戳。就算业务不要求可靠,加上这两个字段也几乎没有成本,但出问题时能立刻判断是丢了、重了还是晚了。没有序列号的 UDP 日志基本没法分析,你连"到底丢了多少"都说不清。
别迷信"UDP 一定比 TCP 快"。在丢包率低的链路上,两者的差距远没有想象中大,尤其当你自己实现的重传逻辑不如内核精细时。我测过一些场景,加了简单重传层的 UDP 在弱网下反而不如 TCP 稳定。选型时先量一量实际链路质量,别凭感觉。
压测要模拟真实网络条件。本机回环测出来的数据毫无参考价值,因为回环几乎不丢包也不乱序。用tc netem注入延迟和丢包再去测:
tc qdisc add dev eth0 root netem delay 50ms loss 1% reorder 5%这行命令能让你在实验室里重现大部分线上问题,调完参数记得tc qdisc del dev eth0 root清掉,不然忘了这回事会把后面的测试结果全带偏。
监控一定要盯内核计数器,而不是只看应用日志。应用层能看到的只是"我收到了什么",看不到"内核替我扔了什么"。把UdpRcvbufErrors、UdpInErrors这两个指标接进监控,设个告警阈值,比出事后翻日志高效得多。
最后一条,如果你的可靠性需求超过了"重发几次 + 去重"这个量级,认真考虑一下现成的方案。自己从零写拥塞控制、流控、多路复用,投入产出比通常很差,而且很容易在某些边界条件下出问题。这部分工作量远比看起来大,我见过不止一个团队低估了它。