Linux 基础体系 · 第 63/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux UDP 与 Datagram:缓冲、丢包、分片、批处理和适用边界

UDP(User Datagram Protocol,用户数据报协议)是一种无连接传输协议。它向 IP 层提交一个个独立的数据报(datagram),每个数据报都保留自己的边界;接收端也以数据报为单位读取,而不是像 TCP 那样读取一个连续字节流。

“无连接”并不意味着 Linux 完全不维护状态。UDP Socket 仍然可能处于已绑定本地地址、已选择对端、拥有接收队列和错误队列等状态,但 UDP 不提供连接建立、可靠重传、按序交付或拥塞控制这些 TCP 语义。

本文讨论 Linux 中 UDP Datagram 的完整数据路径:

flowchart LR
    A[应用 sendto/sendmsg] --> B[发送 Socket 缓冲]
    B --> C[UDP/IP 协议栈]
    C --> D[路由与邻居解析]
    D --> E[网卡发送队列]
    E --> F[网络链路]
    F --> G[接收网卡]
    G --> H[NAPI/协议栈]
    H --> I[接收 Socket 队列]
    I --> J[应用 recvfrom/recvmsg]

每一层都可能成为瓶颈,也都可能丢弃数据。应用成功调用 sendto(),只能证明数据被本机内核接受到某个阶段,不能证明对端收到,更不能证明对端应用已经读取。


一、Datagram 的核心语义:消息有边界,但没有可靠性

1. 一个 UDP Datagram 是什么

一个 UDP 数据报通常由以下部分组成:

IP 头部
UDP 头部
应用负载

UDP 头部包含:

  • 源端口;
  • 目的端口;
  • UDP 长度;
  • 校验和。

UDP 长度包含 UDP 头部和负载,不包含 IP 头部。IPv4 下 UDP 负载理论上最多为:

65535208=65507 字节65535 - 20 - 8 = 65507\text{ 字节}

这里使用了 IPv4 最小 IP 头部 20 字节和 UDP 头部 8 字节。这个值只是协议字段给出的理论上限,不代表在真实网络中适合发送这么大的 Datagram。实际可用大小通常受路径 MTU 限制。

IPv6 的基本头部为 40 字节,因此从 IPv6 的 16 位 Payload Length 字段看,UDP 负载理论上不超过:

655358=65527 字节65535 - 8 = 65527\text{ 字节}

但 IPv6 要求中间路由器不对数据包进行分片,过大的数据报通常会因为路径 MTU 而无法发送,应用必须依靠路径 MTU 发现或自行分片。

2. UDP 保留消息边界

假设发送端执行:

sendto(fd, "abc", 3)
sendto(fd, "12345", 5)

接收端执行:

recvfrom(fd, buffer, 8)

第一次读取得到 "abc",第二次读取得到 "12345"。两次发送不会自动合并成 "abc12345"

这就是 Datagram 与 TCP 字节流的根本区别:

特性 UDP Datagram TCP 字节流
消息边界 保留 不保留
接收单位 一个数据报 任意可用字节
丢失处理 内核通常直接丢弃整个数据报 TCP 负责重传
顺序 不保证 保证有序
重复 可能重复 TCP 对应用呈现可靠字节流
建立连接 不需要握手 需要握手
拥塞控制 协议本身不提供 TCP 提供

UDP 的“消息边界”有一个容易忽略的限制:接收缓冲区不足时,不能把一个 Datagram 分多次读取出来。

例如,发送端发送 1000 字节,接收端使用长度为 100 的缓冲区读取:

char buf[100];
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);

常见 Linux 行为是:

  1. 返回前 100 字节;
  2. 余下 900 字节被丢弃;
  3. 下一次 recvfrom() 读取的是下一个 Datagram,而不是这个 Datagram 的剩余部分。

使用 recvmsg() 时,可以通过 MSG_TRUNC 检查数据报是否被截断:

struct iovec iov = {
    .iov_base = buf,
    .iov_len = sizeof(buf),
};

struct msghdr msg = {
    .msg_iov = &iov,
    .msg_iovlen = 1,
};

ssize_t n = recvmsg(fd, &msg, 0);

if (n >= 0 && (msg.msg_flags & MSG_TRUNC)) {
    /* 收到的数据报大于提供的缓冲区 */
}

在 Linux 上,使用 recvmsg()MSG_TRUNC 时,返回值在相关场景下可以反映完整数据报长度,而不仅是实际复制进缓冲区的长度。跨平台代码不能只依赖这一行为,应同时检查 msg_flags,并为应用协议设置明确的最大消息大小。

3. UDP 没有可靠性,但可能收到错误

UDP 不提供:

  • 重传;
  • 确认;
  • 去重;
  • 顺序恢复;
  • 流量控制;
  • 拥塞控制。

但是 Linux 可能把 ICMP 错误反馈给 UDP Socket。例如目标主机返回 ICMP Port Unreachable,连接状态的 UDP Socket 后续调用可能得到 ECONNREFUSED。未连接 UDP Socket 的错误处理细节受内核、Socket 选项和错误来源影响,不能把所有网络失败都期待为同步错误。

sendto() 返回成功通常只表示:

  1. 参数合法;
  2. 本地路由和协议栈接受了数据;
  3. 数据被放入发送路径或完成了本地处理。

它不表示:

  • 对端端口存在;
  • 对端应用已调用 recvfrom()
  • 中间网络没有丢弃;
  • 对端校验和检查通过;
  • 对端业务已经处理。

二、UDP Socket 的生命周期和连接状态

1. 未连接 UDP

典型发送流程如下:

int fd = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in peer = {
    .sin_family = AF_INET,
    .sin_port = htons(9000),
    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
};

sendto(fd, data, data_len, 0,
       (struct sockaddr *)&peer, sizeof(peer));

每次发送都携带目的地址。接收端通常执行:

int fd = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in local = {
    .sin_family = AF_INET,
    .sin_port = htons(9000),
    .sin_addr.s_addr = htonl(INADDR_ANY),
};

bind(fd, (struct sockaddr *)&local, sizeof(local));

recvfrom(fd, buf, sizeof(buf), 0,
         (struct sockaddr *)&peer, &peer_len);

一个 UDP 接收 Socket 通常按照本地地址和端口接收数据。多个进程或 Socket 是否能够共享同一个端口,还会受到 SO_REUSEADDRSO_REUSEPORT、绑定地址、网络命名空间和内核版本行为的影响,不能简单理解成“设置了复用选项就一定广播给所有进程”。

2. 已连接 UDP

UDP 也可以调用:

connect(fd, (struct sockaddr *)&peer, sizeof(peer));

这不会执行 TCP 三次握手。它主要改变本地 Socket 行为:

  • 后续可以使用 send()recv()
  • 内核只接收匹配该对端的 Datagram;
  • 可以更明确地关联异步错误;
  • 内核可以减少每次发送时查找目的地址的工作;
  • 不需要每次调用都传递目标地址。

UDP connect() 仍然不提供可靠传输,也不保证对端存在。它更准确的含义是“为这个 Socket 选择默认对端并限制接收来源”。


三、缓冲:发送成功、接收排队和真正丢包不是一回事

1. Socket 接收队列

当数据包到达 UDP 协议层时,内核需要把它排入目标 Socket 的接收队列:

网卡收到数据
    ↓
驱动/NAPI
    ↓
IP 层
    ↓
UDP 按端口查找 Socket
    ↓
复制或挂接到 Socket 接收队列
    ↓
应用 recvfrom() 取走

如果应用读取速度小于到达速度,接收队列会逐渐增长。当队列受限时,新的 Datagram 可能被丢弃。

用一个简化模型表示:

  • λ \lambda :数据报到达速率;
  • μ \mu :应用处理速率;
  • B B :接收队列能够容纳的有效数据量;
  • Q(t) Q(t) :时刻 t t 的队列占用量。

在没有其他限制时:

dQdt=λμ\frac{dQ}{dt} = \lambda - \mu

当:

λ>μ\lambda > \mu

队列会增长;当:

Q(t)BQ(t) \ge B

新的数据报就可能被丢弃。即使短时间平均速率满足 λ<μ \lambda < \mu ,突发流量也可能让队列溢出,因为峰值期间的积压仍然可能超过 B B

2. SO_RCVBUF 不是“精确可用字节数”

可以使用:

int size = 4 * 1024 * 1024;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));

查询:

int actual;
socklen_t len = sizeof(actual);
getsockopt(fd, SOL_SOCKET, SO_RCVBUF, &actual, &len);

Linux 通常会对用户设置的 SO_RCVBUF 值进行内部处理,常见现象是查询到的值大于设置值。这与内核为管理开销、协议控制结构预留空间的实现有关。不能把 getsockopt() 返回值简单当作“应用负载可以使用的精确字节数”。

此外,实际结果还可能受到以下限制:

net.core.rmem_max
net.core.rmem_default
net.ipv4.udp_mem
net.ipv4.udp_rmem_min

其中:

  • net.core.rmem_max 限制普通 Socket 接收缓冲区上限;
  • net.core.rmem_default 是默认值;
  • net.ipv4.udp_mem 描述 UDP 全局内存压力阈值;
  • UDP Socket 即使申请了较大缓冲区,也可能受到全局内存压力和其他内核资源限制。

查看当前值:

sysctl net.core.rmem_default
sysctl net.core.rmem_max
sysctl net.ipv4.udp_mem
sysctl net.ipv4.udp_rmem_min

不同发行版、内核版本和容器网络命名空间的可见值可能不同。修改这些参数通常需要 root 权限,并且可能影响整台机器上的其他服务。

3. 发送缓冲区满时会发生什么

发送端调用 sendto() 时,数据可能经历:

  1. 应用进入系统调用;
  2. 内核构造 UDP/IP 数据;
  3. 查询路由和邻居;
  4. 排入设备发送队列;
  5. 交给网卡驱动。

如果发送路径无法立即接受数据:

  • 阻塞 Socket 可能等待;
  • 非阻塞 Socket 可能返回 EAGAINEWOULDBLOCK
  • 某些资源错误可能返回 ENOBUFS
  • 路径 MTU 发现可能导致 EMSGSIZE
  • 参数、地址或权限错误会产生其他错误。

但是即使 sendto() 成功,数据之后仍可能在网卡队列、链路、路由器或接收端被丢弃。

因此,“发送系统调用没有报错”与“业务消息可靠到达”之间没有等价关系。


四、丢包路径:从应用到网卡的多个独立位置

UDP 丢包不能只归因于“Socket 缓冲区太小”。常见路径如下:

应用没有及时读取
  → Socket 接收队列溢出

协议栈处理不过来
  → softnet backlog 或 CPU 处理能力不足

网卡接收环形队列溢出
  → 驱动来不及回收 RX descriptor

校验和、协议、端口不匹配
  → 数据包被协议栈丢弃

IP 分片重组失败
  → 原始 Datagram 整体丢弃

发送端设备队列拥塞
  → 本机发送路径丢弃或返回错误

中间路由器队列拥塞
  → 发送端通常不知道

接收端无匹配 UDP Socket
  → 可能产生 ICMP Port Unreachable,数据本身不交付

1. 查看 UDP 统计

Linux 常用:

cat /proc/net/snmp

重点查看 Udp: 行,例如:

  • InDatagrams:交付给 UDP Socket 的数据报数量;
  • NoPorts:没有匹配监听端口的数据报;
  • InErrors:接收错误;
  • OutDatagrams:UDP 层发送的数据报;
  • RcvbufErrors:因接收缓冲相关问题丢弃;
  • SndbufErrors:发送缓冲相关错误;
  • 某些内核还会提供与 checksum、ignored multi、memory error 相关的字段。

也可以使用:

nstat -az UdpInDatagrams UdpInErrors UdpRcvbufErrors UdpSndbufErrors

字段名称和可见性受 iproute2 与内核版本影响。先用 nstat -az 查看本机实际存在的计数器,不要假设所有发行版都暴露完全相同的名称。

查看 Socket 队列:

ss -u -a -n -m

常见输出中的 Recv-Q 表示等待应用读取的接收队列规模,Send-Q 表示发送方向的排队信息。-m 可显示 Socket 内存信息,但不同版本的 ss 输出格式可能不同。

2. 查看网卡和软中断层统计

ip -s link show dev eth0
ethtool -S eth0

ip -s link 可以看到接口层面的 RX/TX errors、dropped 等统计。ethtool -S 提供驱动和硬件相关计数器,但字段完全依赖网卡驱动。

查看协议栈 backlog 相关信息:

cat /proc/net/softnet_stat

这个文件是按 CPU 行列出的内部统计,字段并不适合直接凭肉眼解释。诊断时应结合内核版本、文档或脚本解析,不能看到某个非零字段就直接断言“UDP Socket 丢包”。

3. 正确的诊断方法是差分计数器

一次完整测试应记录基线:

nstat -az > /tmp/udp.before
ip -s link show dev eth0 > /tmp/link.before

运行固定时长的发送和接收测试后再记录:

nstat -az > /tmp/udp.after
ip -s link show dev eth0 > /tmp/link.after

比较测试期间的增量。只看累计值容易误判,因为计数器可能在系统运行数天后已经包含历史故障。

同时使用抓包确认数据包在哪一层消失:

sudo tcpdump -ni eth0 udp port 9000

可以分别在发送端、接收端、容器 veth、宿主机物理接口上抓包。若发送端能看到而接收端看不到,问题在链路或中间设备;若接收接口能看到而 Socket 统计增加但应用没有读到,问题更可能在应用处理或 Socket 队列;若接口统计已经显示丢包,则应检查网卡、驱动和软中断压力。


五、UDP 分片:一个大 Datagram 可能变成多个 IP 分片

1. UDP 不负责分片

UDP 只生成一个 UDP 数据报。若这个数据报对应的 IP 包超过某条链路的 MTU,分片由 IP 层处理,而不是 UDP 层处理。

以常见以太网 MTU 1500 为例:

IPv4

IPv4 无分片时,UDP 负载上限为:

1500208=1472 字节1500 - 20 - 8 = 1472\text{ 字节}

IPv6

IPv6 基本头部下,UDP 负载上限为:

1500408=1452 字节1500 - 40 - 8 = 1452\text{ 字节}

实际路径可能包含 VLAN、隧道、PPPoE、VXLAN 或 VPN 封装,因此有效 MTU 可能更小。

可以查看接口 MTU:

ip link show dev eth0

查看到目标的路由信息:

ip route get 192.0.2.10

ip route get 主要展示路由选择、出口接口和源地址;它不一定直接给出端到端真实路径 MTU。路径 MTU 需要结合 Socket 错误、路由缓存、探测报文和抓包判断。

2. IPv4 分片的后果

假设一个 UDP Datagram 被 IPv4 分为三个分片:

分片 1:IP 头 + 数据片段 A
分片 2:IP 头 + 数据片段 B
分片 3:IP 头 + 数据片段 C

接收端必须先完成 IP 层重组,才能把完整 UDP Datagram 交给 UDP Socket。任意一个分片丢失,都可能导致整个 Datagram 无法交付。

因此,分片把一次丢包机会扩大成多个分片的联合成功条件。若每个分片独立成功概率为 pp,共有 kk 个分片,则整个 Datagram 成功重组的近似概率为:

Pdatagram=pkP_{\text{datagram}} = p^k

例如每个分片成功率为 0.990.99

  • 1 个分片:0.990.99
  • 3 个分片:0.9930.97030.99^3 \approx 0.9703
  • 10 个分片:0.99100.90440.99^{10} \approx 0.9044

这还没有考虑重组超时、设备对分片的特殊处理和分片攻击面。

IPv4 分片通常只在最后一片包含 UDP 头部之后的数据关系中体现,网络设备和抓包工具还需要根据 IP Identification、Fragment Offset、MF 等字段重组观察。

3. IPv6 的分片边界不同

IPv6 中间路由器不负责分片。发送端如果发现数据报超过路径 MTU,通常会收到 ICMPv6 Packet Too Big。发送主机可以使用 Fragment Extension Header 在源端分片,但中间路由器仍不负责替它分片。

这意味着:

  • IPv6 大 UDP 数据报更依赖路径 MTU 发现;
  • 防火墙阻断 ICMPv6 Packet Too Big 可能造成“黑洞”;
  • 应用层不应把“能在本地局域网发送”当成“能在所有路径发送”。

4. PMTUD 与 EMSGSIZE

路径 MTU 发现(Path MTU Discovery,PMTUD)试图让发送端获知从本机到目标的最小链路 MTU。

当 Socket 启用不允许分片或内核已经知道路径 MTU 较小,发送过大的 Datagram 可能失败并返回:

EMSGSIZE

这比静默分片更适合检测问题,但要求网络正确传递 ICMP/ICMPv6 错误。

Linux 可通过 IP_MTU_DISCOVER 控制 IPv4 行为,例如:

int mode = IP_PMTUDISC_DO;
setsockopt(fd, IPPROTO_IP, IP_MTU_DISCOVER,
           &mode, sizeof(mode));

具体模式包括是否允许分片、是否强制 DF 等。IPv6 使用对应的 IPv6 行为和 Socket 选项。应用应根据目标协议、内核版本和部署路径进行验证,不应只在一个局域网环境中测试后就固定参数。

5. 业务层分片通常更可控

如果一个业务消息大于安全的 UDP 负载上限,常见做法是由应用层拆分:

message_id
fragment_index
fragment_count
payload
checksum

接收端根据 message_id 重组。应用层分片的优点是:

  • 可以设置重组超时;
  • 可以限制同时重组的消息数量;
  • 可以检测缺片;
  • 可以选择只重传缺失片;
  • 可以避免依赖 IP 分片。

但它并没有自动获得可靠性。若业务要求完整消息最终到达,仍需实现确认、重传、重复检测、顺序处理和拥塞控制。


六、批处理:减少系统调用,不改变 UDP 语义

单个 Datagram 对应一次 recvfrom()sendto(),在高 PPS(packets per second,包每秒)场景下,系统调用、Socket 查找、时间戳处理和调度开销可能成为瓶颈。

Linux 提供几种不同层面的批处理能力,它们不能混为一谈。

1. recvmmsg():一次读取多个 Datagram

recvmmsg() 允许一次系统调用读取多个独立的 UDP 数据报。每个 mmsghdr 对应一个 Datagram,因此仍然保留消息边界。

最小示例:

#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define BATCH 8
#define PAYLOAD 2048

int main(void) {
    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd < 0) {
        perror("socket");
        return 1;
    }

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(9000),
        .sin_addr.s_addr = htonl(INADDR_ANY),
    };

    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        close(fd);
        return 1;
    }

    char buffers[BATCH][PAYLOAD];
    struct iovec iov[BATCH];
    struct mmsghdr msgs[BATCH];
    struct sockaddr_in peers[BATCH];

    memset(msgs, 0, sizeof(msgs));

    for (int i = 0; i < BATCH; i++) {
        iov[i].iov_base = buffers[i];
        iov[i].iov_len = sizeof(buffers[i]);

        msgs[i].msg_hdr.msg_iov = &iov[i];
        msgs[i].msg_hdr.msg_iovlen = 1;
        msgs[i].msg_hdr.msg_name = &peers[i];
        msgs[i].msg_hdr.msg_namelen = sizeof(peers[i]);
    }

    int n = recvmmsg(fd, msgs, BATCH, 0, NULL);
    if (n < 0) {
        perror("recvmmsg");
        close(fd);
        return 1;
    }

    for (int i = 0; i < n; i++) {
        printf("datagram[%d] length=%u\n", i, msgs[i].msg_len);
    }

    close(fd);
    return 0;
}

编译:

cc -O2 -Wall -Wextra receiver.c -o receiver

运行:

./receiver

另一个终端发送数据:

printf 'one'   | socat - UDP:127.0.0.1:9000
printf 'two'   | socat - UDP:127.0.0.1:9000

前置条件是系统安装了 socat,并且接收端口没有被占用。接收程序中数组的每一项对应一个 Datagram;msgs[i].msg_len 是第 i 个数据报实际接收的长度。recvmmsg() 返回本次取出的数据报数量,而不是总字节数。

生产代码还应处理:

  • 非阻塞模式下的 EAGAIN
  • MSG_WAITFORONE 等等待策略;
  • 单个缓冲区太小导致的 MSG_TRUNC
  • 关闭 Socket 和线程并发;
  • 接收时间戳、目的地址、接口索引等控制消息;
  • 批量大小与尾延迟之间的取舍。

批量过大可能增加单次处理延迟和缓存占用;批量过小则减少系统调用收益。

2. sendmmsg():一次发送多个 Datagram

sendmmsg()recvmmsg() 对称,一次提交多个 mmsghdr。它仍然发送多个独立 UDP 数据报,不会把它们合并成一个业务消息。

struct mmsghdr msgs[BATCH];
struct iovec iov[BATCH];

/* 为每个 msgs[i] 设置独立的 iov、目标地址和长度 */

int n = sendmmsg(fd, msgs, BATCH, 0);
if (n < 0) {
    perror("sendmmsg");
} else if (n < BATCH) {
    /*
     * 只有前 n 个消息被成功提交。
     * 从 n 开始的消息需要按照应用策略重试或丢弃。
     */
}

批量发送的部分成功尤其重要。不能因为返回值小于 0 或小于批量数量,就假设整个批次都没有发送。

3. UDP GRO、GSO 和 UDP_SEGMENT

Linux 还可能在网卡和协议栈层面使用 GRO(Generic Receive Offload)或 GSO(Generic Segmentation Offload)。

UDP GRO

UDP GRO 允许内核在接收路径上合并某些满足条件的数据包,以减少处理开销。对应用而言,是否仍以单个 Datagram 形式呈现,取决于协议、内核和 Socket 行为;应用不能仅凭“网卡抓包中看到的包数量”推断 recvmsg() 的调用次数。

UDP GSO / UDP_SEGMENT

UDP GSO 允许应用或内核先提交一个较大的缓冲区,再按指定的 segment size 在发送路径上切成多个 UDP Datagram。它减少了高 PPS 发送场景中的处理开销,但在线路上通常仍然是多个独立 UDP 包。

可通过 UDP_SEGMENT 控制消息的分段大小。该能力是 Linux 特有扩展,版本、网卡和虚拟设备支持情况需要核实;较新的 Linux 内核通常支持,但不能把它当作通用 POSIX API。一个典型控制消息形式是:

uint16_t segment_size = 1200;

char control[CMSG_SPACE(sizeof(segment_size))];
memset(control, 0, sizeof(control));

struct msghdr msg = {0};
msg.msg_control = control;
msg.msg_controllen = sizeof(control);

struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_UDP;
cmsg->cmsg_type = UDP_SEGMENT;
cmsg->cmsg_len = CMSG_LEN(sizeof(segment_size));

memcpy(CMSG_DATA(cmsg), &segment_size, sizeof(segment_size));

这段代码只是控制消息的核心部分,实际还必须设置 msg_iov、目标地址、负载长度,并检查 sendmsg() 错误。segment_size 必须使每个最终 UDP/IP 包适合路径,且具体能力受内核和设备限制。

UDP GSO 不等于:

  • 一个超大 UDP Datagram;
  • 网络层不会丢包;
  • 对端一定支持某种硬件卸载;
  • 应用获得可靠传输。

它只是把多个逻辑 Datagram 的发送处理集中化。若需要将一个大业务消息作为整体可靠传输,仍应使用应用层协议或 TCP/QUIC 等更高层机制。


七、缓冲区、Datagram 大小和吞吐量的算例

假设:

  • 每个 UDP Datagram 的应用负载为 L=1200L = 1200 字节;
  • 接收 Socket 有效队列容量约为 B=4B = 4 MiB;
  • 应用处理速率为 μ=200,000 \mu = 200{,}000 个 Datagram/s;
  • 突发输入速率为 λ=300,000 \lambda = 300{,}000 个 Datagram/s;
  • 每个 Datagram 的内核实际占用高于 1200 字节,这里先用负载近似。

突发期间净增长速率为:

(λμ)×L=(300000200000)×1200=120000000 字节/s(\lambda-\mu)\times L = (300000-200000)\times1200 = 120000000\text{ 字节/s}

约为 114.4 MiB/s。因此 4 MiB 队列理论上只能吸收:

4 MiB114.4 MiB/s0.035 秒\frac{4\text{ MiB}}{114.4\text{ MiB/s}} \approx 0.035\text{ 秒}

也就是约 35 毫秒的持续突发。

这个算例说明两件事:

  1. 增大 Socket 缓冲区只能延后溢出;
  2. 如果长期 λ>μ \lambda > \mu ,任何有限缓冲区最终都会耗尽。

如果把 Datagram 数量固定为 1200 字节而改成 12000 字节,单位时间字节吞吐会增加十倍,但每个数据报更容易超过路径 MTU,可能触发分片或 EMSGSIZE。因此“少发大包”与“避免分片”之间存在边界,不应只从系统调用数量判断方案优劣。


八、一个可运行的 UDP 截断实验

下面用 Python 展示 Datagram 边界和接收缓冲区不足时的行为。

接收端:

#!/usr/bin/env python3
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 9000))

while True:
    data, peer = sock.recvfrom(8)
    print(f"from={peer} length={len(data)} data={data!r}")

发送端:

#!/usr/bin/env python3
import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

sock.sendto(b"abc", ("127.0.0.1", 9000))
sock.sendto(b"0123456789", ("127.0.0.1", 9000))

预期接收结果类似:

from=('127.0.0.1', 某个端口) length=3 data=b'abc'
from=('127.0.0.1', 某个端口) length=8 data=b'01234567'

第二个 Datagram 的剩余 89 不会在下一次读取中出现。若协议要求完整接收,接收缓冲区必须至少覆盖协议允许的最大 Datagram,或者使用 recvmsg() 检测截断并丢弃该消息。

Python 也可以使用 recvmsg()

data, ancdata, flags, peer = sock.recvmsg(8, 0)

if flags & socket.MSG_TRUNC:
    print("datagram was truncated")

这个实验只在回环接口上验证 Socket 语义,不代表真实网卡路径不会分片或丢包。回环接口通常具有较大的有效 MTU,并绕过了许多物理网卡和中间网络问题。


九、UDP 校验和、错误检测与“收到数据”的含义

UDP 校验和用于检测传输中的部分比特错误。IPv4 中 UDP 校验和字段允许为零,表示发送端未启用校验和;IPv6 中 UDP 校验和通常是必需的,存在特定例外规则。

校验和正确只说明:

  • 当前收到的数据满足校验算法;
  • 在校验覆盖范围内没有检测到错误。

它不说明:

  • 数据来自业务期望的发送者;
  • 数据没有被恶意构造;
  • 数据没有重复;
  • 数据没有延迟;
  • 数据对应的是最新状态。

如果业务需要身份认证或防篡改,UDP 校验和不够,应使用应用层认证标签、DTLS、IPsec 或 QUIC 等机制。

校验和卸载还会影响抓包判断:在发送路径上,抓包工具可能看到尚未由网卡填充完成的校验和,因此出现“checksum incorrect”不一定代表线上数据错误。应结合抓包位置、网卡卸载配置和接收端统计判断。


十、UDP 与拥塞控制:应用必须面对网络现实

UDP 本身没有 TCP 那样的拥塞控制。如果应用持续以固定高速率发送,它可能:

  • 填满本机发送队列;
  • 填满接收端 Socket 队列;
  • 填满路由器队列;
  • 与 TCP 流量竞争时造成更严重的丢包;
  • 在丢包发生后继续加速,形成恶性循环。

因此,“UDP 更快”不是无条件结论。UDP 省去连接管理、可靠重传和有序字节流等开销,但可靠性、速率控制、重传、排序和重复抑制需要由应用或更高层协议承担。

常见业务策略不同:

实时状态

例如传感器最新值、在线状态、游戏位置:

  • 旧数据过时后价值下降;
  • 可以允许丢包;
  • 通常更重视低延迟;
  • 可使用序列号检测丢包和乱序;
  • 可以只保留最新状态。

可靠消息

例如任务提交、支付指令、配置变更:

  • 不能只依赖 UDP;
  • 需要确认、重试、幂等键和重复检测;
  • 要定义超时和最大重试次数;
  • 需要避免重试风暴。

大文件或连续字节流

例如文件传输、日志流:

  • IP 分片和应用层重组会增加复杂度;
  • TCP、QUIC 或专用可靠传输协议通常更合适;
  • 若使用 UDP,自行实现可靠传输的成本不能被“少一次握手”掩盖。

十一、UDP 的安全边界和生产风险

1. 源地址不能直接当作身份

UDP 支持源地址伪造。服务端不能仅凭源 IP 或源端口确认发送者身份,特别是面对公网流量时。

2. 放大攻击

如果服务端对一个小请求返回更大的响应,攻击者可能伪造受害者源地址,诱导服务端向受害者发送响应。DNS、NTP、反射服务都曾出现过类似风险。

防护包括:

  • 限制未认证请求的响应大小;
  • 对请求和响应建立可验证关联;
  • 使用随机事务标识;
  • 对来源和速率进行限制;
  • 对公网服务配置防火墙、ACL 和监控;
  • 不在没有必要时暴露开放 UDP 端口。

3. 分片攻击和重组资源耗尽

IP 分片重组需要内核保留状态和内存。攻击者可以发送不完整、重叠或大量分片,增加重组压力。现代内核和网络设备会进行多种限制,但应用仍应避免依赖分片,并监控异常流量。

4. 端口扫描与无监听端口

向未监听的 UDP 端口发送数据通常不会得到像 TCP SYN 那样确定的握手结果。目标可能:

  • 返回 ICMP Port Unreachable;
  • 被防火墙静默丢弃;
  • 服务端实际监听但不响应;
  • 网络路径过滤 ICMP。

因此 UDP 端口探测天然比 TCP 更难解释,业务健康检查应设计显式请求和响应,而不是只检查 sendto() 是否成功。


十二、如何选择 Datagram 大小

没有一个对所有网络都安全的固定最大值,但可以用路径 MTU推导上限。

对于 IPv4、无 IP 选项、路径 MTU 为 MM 的情况:

LUDP payloadM208L_{\text{UDP payload}} \le M - 20 - 8

对于 IPv6 基本头部:

LUDP payloadM408L_{\text{UDP payload}} \le M - 40 - 8

例如:

场景 假设 MTU IPv4 UDP 负载上限 IPv6 UDP 负载上限
普通以太网 1500 1472 1452
有额外隧道开销 小于 1500 更小 更小
回环接口 取决于接口 MTU 可能很大 可能很大

工程上常会选择明显小于理论上限的值,为隧道、扩展头、封装和部署差异留出余量。但这属于经验建议,不是 UDP 规范保证。若协议必须跨越未知网络,应通过 PMTUD、探测或应用层协商确定可接受大小。

还要区分:

  • 逻辑消息大小:业务想表达的完整消息;
  • UDP Datagram 大小:一次 sendto() 发送的数据报;
  • IP 包大小:加入 IP 头部后的大小;
  • 物理帧大小:加入链路层头部后的大小;
  • GSO 提交缓冲区大小:应用一次提交给内核的数据量。

这些大小不一定相等。


十三、常见误解与对应失败表现

误解一:UDP 没有连接,所以没有状态

实际情况是 UDP Socket 仍有绑定、路由、接收队列、错误队列和命名空间状态。connect() 也会改变 Socket 的接收过滤和错误处理。

失败表现:程序认为“UDP 不需要 bind”,多个实例启动后却因为本地端口冲突失败。

误解二:sendto() 成功表示对端收到

实际情况是成功通常只表示本地发送路径接受了数据。

失败表现:发送端日志显示全部成功,但服务端没有相同数量的业务记录。

诊断方式:同时比较发送端抓包、接收端接口抓包、UDP 统计和应用接收计数。

误解三:增大 SO_RCVBUF 就不会丢包

实际情况是缓冲只能吸收有限突发,不能弥补长期处理速率不足;并且受到全局内存上限约束。

失败表现:短时间丢包减少,但持续压测仍然出现 RcvbufErrors

误解四:一个大 UDP 数据报只是一次“大包”

实际情况是它可能被 IP 分片,任意分片丢失都会导致整个 Datagram 无法交付。

失败表现:小包正常,大包在跨网段、VPN 或云网络中间歇性失败。

误解五:批处理会把多个 UDP 消息合并

实际情况是 recvmmsg()sendmmsg() 只是减少系统调用次数,每个元素仍然对应独立 Datagram。

失败表现:应用错误地把一个批次当成一个业务消息,导致协议解析错误。

误解六:抓包看到校验和错误就一定是网络损坏

实际情况是发送端硬件校验和卸载可能让抓包发生在校验和填写之前。

失败表现:发送端 tcpdump 报错,但接收端可以正确接收;需要结合卸载配置和接收端统计确认。


十四、生产前的验证流程

一个有意义的 UDP 验证至少应覆盖以下路径:

1. 验证接口和路由

ip -br addr
ip link show dev eth0
ip route get 192.0.2.10

确认:

  • 地址是否存在;
  • 接口是否 UP;
  • 目标是否走预期出口;
  • 源地址是否符合预期;
  • MTU 是否包含隧道或虚拟网络限制。

2. 验证监听和队列

ss -u -l -n -m

确认监听地址、端口和接收队列。若服务运行在容器中,还要在正确的网络命名空间内检查。

3. 验证 Datagram 大小

从小负载开始逐步增加,观察:

  • 是否返回 EMSGSIZE
  • 接收端是否出现截断;
  • 中间路径是否出现 IP 分片;
  • 大包丢失是否明显增加。

抓包:

sudo tcpdump -ni any udp port 9000

-i any 便于初步观察,但它的抓包位置和链路层呈现不如明确指定接口,精确诊断时应分别抓物理接口、bridge、veth 或隧道设备。

4. 验证队列压力

压测期间周期性记录:

ss -u -n -m
nstat -az
ip -s link show dev eth0

把以下计数分开:

发送调用数
发送成功数
接收接口包数
UDP 交付数
应用 recv 成功数
业务校验通过数
业务确认数

只有最后几项都匹配,才能说明业务层真正处理了数据。UDP 层计数与业务层计数不一致时,必须沿数据路径逐层定位。

5. 验证错误处理

测试代码应主动覆盖:

  • 接收缓冲区不足;
  • 非阻塞下的 EAGAIN
  • 发送过大导致的 EMSGSIZE
  • 对端不存在;
  • 接收端进程暂停;
  • Socket 接收队列达到上限;
  • 网络接口短暂 down;
  • 进程重启后的重复消息;
  • 多线程同时读取同一个 Socket。

不要只测试“本机回环、低速、小包、单线程”这一条成功路径。


十五、UDP 的适用边界

UDP 适合以下场景:

  • 单个消息有明确边界;
  • 允许丢失或由业务自行处理丢失;
  • 低延迟比可靠重传更重要;
  • 广播、组播或多接收者分发;
  • 服务发现、简单查询和响应;
  • 实时状态更新;
  • 对传输控制有专门协议设计;
  • 需要使用 QUIC、DNS、RTP 等建立在 UDP 之上的协议。

UDP 不适合直接承担以下职责:

  • 不允许丢失的事务;
  • 未设计幂等性的重试操作;
  • 大规模连续字节流;
  • 依赖严格有序的业务数据;
  • 需要公平拥塞控制但应用完全不实现控制;
  • 依赖网络自动重传而没有更高层协议。

最终选择应基于业务语义,而不是“UDP 更快”或“TCP 更可靠”这样的单句判断:

消息是否必须完整到达?
消息是否有自然边界?
能否接受重复和乱序?
是否需要广播或组播?
是否能实现确认、重传、幂等和限速?
路径 MTU 是否可控?
是否需要面对公网攻击和放大风险?

UDP 提供的是轻量的数据报传输接口,不是完整的可靠消息系统。Linux 为它提供了 Socket 缓冲、异步错误、路径 MTU 发现、批量收发和硬件卸载等能力,但这些能力分别作用于不同层次,不能互相替代:增大缓冲不能替代流量控制,批处理不能替代可靠性,GSO 不能替代应用层分片,PMTUD 也不能替代业务重传。理解这些边界,才能在 UDP 的低延迟和 Datagram 语义之外,正确承担应用真正需要承担的责任。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。