UDP、广播与组播:发出去就完事,到底能省多少事

UDP、广播与组播:发出去就完事,到底能省多少事
隔壁老康难度 ★★★★★ · 前置:TCP 粘包:为什么你的 onMessage 收到两条消息 | 一句话结论:
sendto发 UDP 不握手、不重传、不排序,成功了只代表「内核收下了这个包」,不代表对方收到。广播是「一个网段全喊一遍」,组播是「订阅了的人才收到」——前者被路由器隔离,后者要看交换机有没有开 IGMP snooping。
发出去就完事,这个说法我信了挺久。第一次写服务发现,sendto 返回 8,控制台干干净净,我就默认对面已经收到包了,剩下的只是对方什么时候处理的问题。后来才明白这个理解从头到尾都是错的,返回值 8 只说明内核拷了 8 个字节进发送缓冲区,它不握手、不重传、不排序,拷完就撒手,连对方在不在线都没问过一句。UDP 的坑几乎全发生在这条缝里,而且是那种你不去数、它就不告诉你的坑。
这话听着像免责声明,用起来才知道是字面意思。
写服务发现脚本的那两天
手上的第一个活不是写服务,是个几十行的运维小工具,同网段里哪个节点在线,靠一句 UDP 广播问出来。
这篇的实验全部用 Python 写,不是说 UDP 跟语言有关,是因为 C 写同样的东西要多三十行样板,sendto 的语义反而被淹了。所有脚本都需要 Python 3.8 以上,标准库就够,不用装任何东西。
1 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
代码短得像玩具,连 try 都不用包,写完就直接在开发机上跑了,返回值 8,也就是字节数,没有异常,控制台干干净净,可另一台机器上的节点一个都没收到。
那天下午我盯着两个终端看了很久,把可能的原因列了四个,按概率排了一遍。
255.255.255.255只在本地链路有效,容器或 VLAN 里可能根本不出网卡。- 对方绑在
127.0.0.1而不是0.0.0.0,收不到广播。 - 中间有防火墙
DROP了udp dport 9999(REJECT会回 ICMP,DROP不会)。 - 对方网卡没开
SO_BROADCAST,或者绑错了网卡。
我那次栽在第二条上。对端进程规规矩矩地绑了 127.0.0.1,一行看上去最没毛病的代码,因为它在开发机上自测的时候一直是对的。UDP 就是这样的东西,发出去没有反馈,收不到也没有提示,所谓发送成功在这里的定义被大幅缩水了,它只说明你这一侧的内核把数据拷进了缓冲区,剩下的全部推给了运气和别人的配置。(这个问题我当时没好意思问别人,因为它看起来像是该自己知道的)
从那天起,我对发送成功这四个字的信任度基本归零。
sendto 返回的那个数字说明什么
一个 UDP 报文,从 sendto 出来到 recvfrom 被读走,中间要过这么几个地方。
graph LR
A["sendto()"] --> B["发送缓冲区"]
B --> C["IP 层<br/>可能分片"]
C --> D["网卡"]
D --> E["网络"]
E --> F["对方网卡"]
F --> G["接收队列"]
G --> H["recvfrom()"]
把 TCP 摆在旁边,区别一目了然。
| 环节 | TCP | UDP |
|---|---|---|
| 建立连接 | 三次握手 | 无 |
| 分包依据 | 字节流,MSS 分段 | 应用层一次 sendto 就是一个数据报 |
| 可靠性 | 重传、确认 | 无 |
| 顺序 | 保证 | 不保证 |
| 拥塞控制 | 有(CUBIC/BBR) | 无 |
| 接收缓冲满 | 反压发送方(窗口) | 直接丢弃,静默 |
表格最后一行值得单独讲,因为它是所有误解的源头。TCP 的接收队列满了会让发送方停下来,窗口收到 0 之后对方自己就不发了,整条链路的速度会自动降下来,这是协议替你做的背压,而且这个背压你在代码里能摸到,send 卡住一下就是信号。UDP 的接收队列满了就直接丢包,收发两头谁都不知道,也没有任何一个字段能把这个信息送回给发送方,所以 sendto 的返回值是内核拷进发送缓冲区多少字节,不是对端收到多少字节,这两件事在压测报告和线上复盘里经常被混成一件,混完之后就会顺理成章地得出 UDP 没丢包、是下游处理慢这种结论。
这个混淆我自己犯过不止一次。
真正能把它分开的办法只有一个,在接收方自己数包。发送方数出来的永远是我发了多少,不是对方收了多少,而 UDP 恰恰不给你任何对账的凭据,你只能在接收端最靠近内核的那一层把计数拿下来,晚一层就已经被应用自己的缓冲搅浑了,所以真要算丢包率,得自己在包里塞个序号,让接收方拿序号跟本地收到的数量对一遍,序号跳了就是丢了,这套东西 UDP 不提供,得自己搭,至于协议层为什么不肯干脆加个确认字段,这个问题我到现在也没完全想明白,只知道真要可靠,那就是 TCP,何必再多一套。
三千字节发出去,IP 层拆成了三份
一个 sendto 就是一个数据报,这句话在应用层成立,到了 IP 层就不成立了。
一个 3000 字节的 UDP 报文,在 MTU 1500 的链路上会被 IP 层切成三个分片,任意一个分片丢了,接收方就凑不齐,整个 UDP 报文会在重组阶段被丢弃,而且没有任何一个分片会被重传,因为 IP 层本来就没有重传这个概念,它只负责尽力送达。这是 UDP 最容易踩的坑,你以为发了 3000 字节,实际上是发出了三个各自可能丢失的分片,丢一个等于全丢,分片不是分成三份发三次,是三个都可能死。
安全的大小是 MTU 减去 IP 头 20 字节、再减 UDP 头 8 字节,落在 1472。保守一点取 1200,给 VPN、隧道和各种封装留出余量。这也解释了 DNS 的老规矩,UDP 响应超过 512 字节就得用 TCP 重来,早期确实是这么规定的,现在有 EDNS0 能到 4096,但实际配置里常常还是压在 1232 左右,因为路径上不知道哪一跳的 MTU 比你以为的小,能过去才是硬道理,带宽和丢包之间总得挑一个。
广播:一个网段全喊一遍
广播的代码只比普通 UDP 多一行,可那一行必须设,不设就是直接失败。
1 | s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) |
SO_BROADCAST 不设的话,sendto 会当场返回 EACCES: Permission denied,不会静默失败,这一点倒是比后面那些坑友善得多,至少它第一时间就告诉你不行。(真正难受的从来不是这种会报错的失败,而是那些一声不响就丢掉的包,真实原因往往特别无聊,无聊到你不愿意相信是这个)
广播的地址有三类,差别全在范围上。
| 地址 | 范围 |
|---|---|
255.255.255.255 |
本地链路(不出网卡,不跨路由) |
192.168.1.255 |
指定网段的定向广播(路由器通常会拒绝转发) |
0.0.0.0 |
不能作为目的地址发广播 |
255.255.255.255 在 Linux 上默认只在默认路由所在的那张网卡上发出去,多网卡机器上如果默认路由指向的不是你想要的那条,包照样发出去了,返回值照样是 8,只是去了别的地方,然后你要在错的那张网卡上抓半天包都不见一个,这时候得指定网卡。
1 | s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b'eth0') |
SO_BINDTODEVICE 需要 CAP_NET_RAW 权限,root 或者容器里加了这个 capability 才行。很多广播发不出去的真实原因就落在这里,报错却往往不指着这一行,它只会说一句权限不足,然后把你的注意力引向别处,一晚上就这么过去了。
广播的根本缺陷在路由器那头,默认不转发,而且这是有意的设计,否则整个互联网的广播流量会互相干扰,早年那些靠广播泛滥就能拖垮一个网段的攻击就是教训。于是广播只能用在同一个二层网段内的服务发现,跨网段要么换组播,要么干脆上中心化的注册中心,我那个脚本后来就是被这条限制逼着改的设计,因为机器一多就不止一个网段了,而广播连一个路由器都过不去,再省事的方案,过不了路由器就等于没有。
组播:订阅了才收得到
组播用 D 类地址,范围从 224.0.0.0 到 239.255.255.255,接收方得先声明自己要收这个组,发送方不用管有没有人在听。
1 | # 接收方:加入组播组 |
我第一次写这块的时候,把 bind() 摆在 IP_ADD_MEMBERSHIP 前面,理由是得先有个端口才能加组,没端口加进去收谁的包。这个理由乍看毫无破绽,所以我从来没去验过,反正代码能跑。后来为了确认别人代码里反过来的写法到底算不算错,我去翻了 igmp.c,__ip_mc_join_group 从头到尾没有检查过 socket 有没有 bind 过,它真正在乎的是 imr_interface 怎么填,以及 SO_BINDTODEVICE 设下的 sk_bound_dev_if,跟 bind() 一点关系都没有。于是这两种顺序我都在机器上跑了一遍,加组都成功,ip maddr 里都能看到那个组。(这个结论我确认了三遍才敢写下来,因为必须先 bind 这个说法在网上到处都是)
顺序这东西,记不住的都是没亲自跑过的。
发送方跟普通 UDP 没有任何区别,只是目的地址填成一个组播地址,但有三条行为值得记住,第三条是排障时最容易漏掉的。
- 只有加入了该组的接口才收得到(
IP_ADD_MEMBERSHIP)。 - 路由器可以转发,但要开 PIM 或 DVMRP,默认不转。
- 交换机默认把组播当广播泛洪,白白浪费带宽,开了 IGMP snooping 才只发给加入了组的端口。
两边的 setsockopt 都写对了,ip maddr 里也看到了组,包就是不到。这时候你在两台机器上 strace 到天亮也不会有结果,因为原因根本不在机器上。(我第一次遇到时把两边的代码逐行对比了三遍,一个字都没差,那种感觉挺折磨人)
三个特别的地址段
| 段 | 含义 |
|---|---|
224.0.0.0/24 |
本地链路保留,不转发出网卡(224.0.0.1 是所有主机) |
239.0.0.0/8 |
管理范围(私有的,类似 10.x),推荐用这个 |
| 其余 | 全球分配 |
做内部服务发现,就用 239.x.x.x,别去碰 224 那一段,它是留给协议自己用的,你占了它,出问题的时候没人能帮你定位。
发送方从哪张网卡出去
接收侧要指定接口,用 mreq 里第二个地址。发送侧是另一件事,对应的调用叫 IP_MULTICAST_IF,参数格式看着像,其实就是个 struct in_addr,填本机某张网卡上的 IP,填 0.0.0.0 表示交还给内核按路由表挑。多网卡机器上不填这一行,内核就按目的地址查路由,而组播地址一般没有明细路由,最后统一落到默认路由那张网卡上,跟你以为的那张可能完全不是同一张。
1 | s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton('192.168.1.10')) |
这一行就是全部。不填,内核替你做决定,而它的决定依据是路由表,不是你的意图。
我第一次踩这个坑是给一台双网卡机器写服务发现,一块走业务网,一块走管理网,接收端我起在管理网那边,发送端怎么发都收不到。ip maddr 里组在,tcpdump 在管理网卡上蹲了十分钟一个包都没有,业务网卡上倒是哗哗地过。当时第一反应是组播被网管拦了,跑去查交换机配置查了小半天,其实发送端压根没往管理网卡上发过,它按默认路由老老实实走了业务网卡,行为完全正确,错的是我没告诉它走哪张。
填的地址得是本机某张网卡上真实存在的,填错了 setsockopt 当场返回 EINVAL,不会静默忽略。(这点比 SO_BINDTODEVICE 友好,那个要 CAP_NET_RAW,这个不要)
一个静默走错,一个当场报错,差别就在这儿。
同一族的还有 IP_MULTICAST_LOOP,默认开着,意思是发出去的时候本机回环一份给自己。本机既发又收的实验里要留着它,关掉就只剩别的机器发来的包能进来了。IPv6 对应的是 IPV6_MULTICAST_IF,参数换成接口的 ifindex 数字,不是地址,写惯了 IPv4 很容易在这里填错一次。
丢包先怀疑自己的接收队列
UDP 的接收缓冲区太小会直接丢包,/proc/net/snmp 里的 Udp: InErrors 会往上涨,所以出问题先看默认值是多少,再看想调的那个值到底有没有生效。
1 | sysctl net.core.rmem_default |
想调大就在 socket 上直接设,能不能真给到位是另一回事。
1 | s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) |
不过写进去的值不是最终值,SO_RCVBUF 会被内核翻倍,内核自己留一半做记账,而且上限是 net.core.rmem_max,想确认只能读回来。
1 | print(s.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)) # 实际值是设置的 2 倍 |
所以设了 4MB,读回来得是 8388608 才算真给到位。要是读回 4194304,那就是只给了一半,另一半被 net.core.rmem_max 卡在那儿了。反过来也一样,默认的 212992 设进去会读回 425984,那个翻倍才是正常的。(第一次读到这个翻倍的数字,我以为是内核把参数记错了)
收不到包,从应用层往链路层翻
这张表从下往上排,越靠下越容易被忽略,而问题往往就藏在越靠下的地方。
| 层 | 检查 |
|---|---|
| 应用 | 绑定的是 0.0.0.0 还是 127.0.0.1? |
| socket | 设了 SO_REUSEADDR 没?组播要 IP_ADD_MEMBERSHIP |
| 内核 | iptables -L -n、nft list ruleset |
| 网卡 | 广播要不要 SO_BINDTODEVICE? |
| 链路 | 交换机开 IGMP snooping 了没?VLAN 隔离了没? |
| 路由 | ip route、ip maddr |
ip maddr show 是查组播加入情况最快的命令,比在应用里打日志快得多,看到 239.1.1.1 就说明加入成功了。
1 | ip maddr show dev eth0 |
1 | 2: eth0 |
如果没有,说明 setsockopt(IP_ADD_MEMBERSHIP) 根本没成功,通常是 mreq 里的接口地址写错了,写成了对端的地址,或者本机根本没有这个地址,也可能组播地址根本不在 D 类段里。至于 join 和 bind() 的先后,前面已经说过没有要求,别在这上面找原因,那属于拿一个不成立的假设去解释一个真实的现象。
mreq 的格式
mreq 这个结构体只有两个字段,第一个是组地址,第二个是最容易错的,填 INADDR_ANY,也就是 0.0.0.0,由内核根据路由表挑一个接口。
1 | struct ip_mreq { |
填具体地址,比如 192.168.1.10,就只在那个接口上加入。多网卡机器上填具体地址更可控,但那个地址必须是本机某个接口上的地址,否则返回 EADDRNOTAVAIL,而这个错误码看起来跟地址写错一点关系都没有,我第一次见的时候去查了半小时路由表。
上云之后这两条路都断
组播依赖二层,而容器和云把二层遮住了,常见的有三种死法。
- Docker 默认的
bridge网络不转发组播(只转发单播和特定广播),要么--net=host,要么改用 macvlan。 - Kubernetes 的 CNI 插件多数不支持组播,Calico、Flannel 都不行,除非上 macvlan / Multus。
- 云厂商的 VPC 网络通常禁止组播,AWS、阿里云都不支持,AWS 倒是有专门的 Transit Gateway Multicast,但配起来很麻烦。
所以云上做服务发现,组播基本不可用。Consul、etcd、Eureka 这类中心化方案流行,原因不太在协议设计得多漂亮,更多是组播在云上根本跑不起来,一个在物理网上很优雅的方案,进了 VPC 就变成一个配置项都点不了的功能,方案选型里最硬的那条约束经常不是技术优劣,而是这地方根本不允许。
把广播和组播摆在一起看,差别其实只有两行。
| 广播 | 组播 | |
|---|---|---|
| 地址 | 255.255.255.255 / 网段广播 |
224.0.0.0/4 |
| 谁能收 | 同网段所有主机 | 只有加入了组的 |
| 二层行为 | 泛洪 | 泛洪(未开 snooping 时) |
| 跨路由 | 默认不转发 | 默认不转发,但要配置 PIM |
| 云上可用 | 通常不可用 | 通常不可用 |
| 适用 | 极简单的同网段发现 | 媒体流、行情分发 |
两者在云上的处境一模一样,都用不了,这就是现代服务发现默认长成注册中心加长连接的原因,不是大家不想用广播,是真跑不通。
这几段脚本可以自己跑一遍
下面这些都能在本地跑,除了抓包和 ip maddr 那两条,其余不需要 root。
最小的 UDP echo
先写个最小的 echo 服务端,收到什么就把大写版发回去。
1 | # udp_server.py |
客户端设个 2 秒超时,收不到就当丢了。
1 | # udp_client.py |
两个脚本一起跑起来。
1 | python3 udp_server.py & |
发到一个没人监听的端口
下面这个实验最反直觉,输出是 sent 10,一点错都没有。
1 | import socket |
这个端口上没有进程在监听,sendto 依然成功,原因在 ICMP。内核确实收到了 port unreachable,但它不急着把这个错误交给应用,而是先记在 socket 上,等到下一次对这个地址 sendto 或者 recvfrom 时才翻译成 ECONNREFUSED。
1 | import socket, time |
UDP 的错误报告是延迟的,而且和引发它的那次操作对不上号。这是它跟 TCP 手感上最大的差别,也是很多人说 UDP 明明没报错的原因,报错其实来过,只是来晚了一步,落在了另一行代码上。
发一个超过 MTU 的包
1 | import socket |
回环上会成功,回环的 MTU 是 65536,什么都能装下。跨机器测试就是另一回事了,对方在内网 MTU 1500 上,3000 字节会被切成三个分片,任何一个丢了整包都失败。用 tcpdump 能看到,eth0 是示例,先 ip -br link 看清你自己的网卡名,笔记本上多半是 wlan0 或 ens33,前面那些实验都在 127.0.0.1 上跑,抓包得抓 lo,加个 -v 还能看到 IP fragments 的标记。
1 | ip -br link # 找到网卡名 |
1 | IP 10.0.3.14.51234 > 10.0.5.9.9900: UDP, length 3000 |
测广播
1 | # bcast_send.py |
1 | # bcast_recv.py |
1 | python3 bcast_recv.py & |
回环上会收到。但注意 bcast_recv 得绑 0.0.0.0 才收得到,绑 127.0.0.1 收不到,这就是发出去没报错但没人收的第一个原因,跟我当初那个坑一模一样,这也是为什么我后来写了这个最小复现。
测组播
接收端先加入组,再收。
1 | # mcast_recv.py |
发送端就多设一个 TTL。
1 | # mcast_send.py |
1 | python3 mcast_recv.py & |
验证有没有加入成功,直接看 maddr 就行。
1 | ip maddr show | grep 239.1.1.1 |
看 UDP 的丢包统计
netstat -su 和内核那份都行,后者更干净。
1 | netstat -su | head -20 |
1 | cat /proc/net/snmp | grep -A1 '^Udp:' |
InErrors 在涨,说明接收缓冲满了或者报文格式有问题,RcvbufErrors 在涨,那基本可以确定是缓冲不够,不用再往网络上找原因。
把接收缓冲调大再读回来
1 | import socket |
after 会是 8388608,也可能是被 rmem_max 截断之后的值乘以 2。如果它是 212992,你设的 8MB 会被砍到 212992,读回来是 425984。
1 | sysctl net.core.rmem_max |
想真要 8MB,得先 sysctl -w net.core.rmem_max=16777216。
这几条卡过我一整晚
下面这几条都是一句话能说清、但不知道的时候能卡一整晚的。
- 我以为
sendto成功就是发出去了。它只代表内核把数据拷进了发送缓冲区,网卡有没有真发、对方有没有收,一概不知道。 - 我以为网络质量好 UDP 就不会丢包。网络再完美,接收方的 socket 缓冲区满了照样丢,这是应用层的事,不是网络的事。
- 我以为一个
sendto对应一个recvfrom。在同一个 socket 上、没有分片、缓冲够的前提下成立,分片一丢整条就没了,那条也就没有对应的recvfrom。 - 我以为设了
SO_BROADCAST就能发广播。还得目的地址是广播地址、网卡支持、防火墙没拦,多网卡时还要SO_BINDTODEVICE,那需要CAP_NET_RAW。 - 我以为加入组播组就一定能收到。还得交换机和路由器肯转发,云环境和多数 CNI 都不支持组播。
- 我以为广播比组播简单,所以可以先上广播。广播会打扰同网段所有主机,包括完全不关心的那些,组播只发给订阅者,规模一大,广播就是噪音污染。
- 我以为 UDP 比 TCP 快。单包延迟上少一次握手,确实快,但吞吐上 UDP 没有拥塞控制,容易把网络打爆,这也是 QUIC 要在用户态自己实现一套拥塞控制的原因。
- 我以为
SO_REUSEADDR能让多个进程同时收同一个组播。能,这就是组播的正确用法,多个进程收同一份流,但普通的单播 UDP 上,Linux 里SO_REUSEADDR不会让多个进程都收到包,只有一个能收。
这几条里除了一两条,剩下都是我先写错了才想明白的,事前没人提醒,事后想起来又觉得挺显然,技术里这种事后显然的东西最花时间。
压成几行
sendto成功只代表内核接收了这个操作,对方收没收到、有没有丢包,UDP 一概不知。- 一个
sendto的数据报在 IP 层可能被分片,任意分片丢失整条丢弃。安全大小 1200 到 1472 字节。 - 广播用
255.255.255.255(本地链路,不出网卡)或网段广播,必须设SO_BROADCAST,路由器默认不转发。 - 组播用
224.0.0.0/4,接收方必须IP_ADD_MEMBERSHIP,内部用239.0.0.0/8。 ip maddr show是查组播加入最快的命令。- 云上广播和组播基本不可用(Docker bridge、多数 CNI、云 VPC 都不转发),这就是中心化服务发现流行的真实原因。
- UDP 接收缓冲满了静默丢弃,
/proc/net/snmp的RcvbufErrors会涨,SO_RCVBUF受net.core.rmem_max限制。 - UDP 的错误报告是延迟且错位的,
ICMP port unreachable会在下一次sendto/recvfrom时才变成ECONNREFUSED。
套接字这一层能直接用眼睛看见的东西,到这里差不多就见底了。再往前走是把它包进一个类里,状态机、缓冲、超时怎么摆,是两百行还是两千行,在那一步就定下来了,而这一层恰好是最没法从返回值里看出来的东西,返回值只告诉你内核接下了这一单,后面的事它一概不知。










