很多教科书把 UDP 定义为”不可靠的传输协议”,然后花两页讲完它的 8 字节头部,就转向 TCP 了。这给人一种印象:UDP 是 TCP 的残缺版,只在”不需要可靠性”的场景下凑合用。
这是对 UDP 最大的误解。
UDP 不是 TCP 的简化版——它是一个完全不同的设计选择。TCP 帮你做了连接管理、流量控制、拥塞控制、有序交付、重传。UDP 什么都不做。但”什么都不做”恰恰是某些场景下最好的选择——它把所有控制权交给应用层,让你根据自己的需求定制传输策略。
QUIC 跑在 UDP 上。DNS 的默认传输是 UDP。实时音视频用 UDP。游戏网络用 UDP。这些场景选择 UDP 不是因为”不需要可靠性”,而是因为 TCP 的可靠性机制在这些场景中反而是有害的。
本文从 UDP 的协议本质出发,系统分析它的工程优势、编程陷阱和调优方法。
一、UDP 协议解剖
1.1 极简头部
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Length | Checksum |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data ... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
总计 8 字节。对比 TCP 的 20-60 字节头部:
- 没有序列号(不保证有序)
- 没有确认号(不做确认)
- 没有窗口大小(不做流控)
- 没有标志位(没有连接状态机)
- 没有选项字段(没有 SACK/Timestamps/Window Scaling)
8 字节头部不只是”节省空间”——它意味着 UDP 的处理路径极短。内核不需要维护连接状态、不需要管理重传定时器、不需要计算拥塞窗口。这直接转化为更低的 CPU 开销和更低的延迟。
1.2 Length 字段的含义
# Length = UDP 头部 (8 bytes) + 数据部分
# 最小值 = 8(空数据报)
# 最大值 = 65535(但受 IP 层 MTU 限制,实际很少超过 1472 字节)
# 为什么实际最大值是 1472?
# Ethernet MTU = 1500 bytes
# IP Header = 20 bytes (无 Option)
# UDP Header = 8 bytes
# UDP Payload = 1500 - 20 - 8 = 1472 bytes
# 如果 UDP 数据 > 1472 bytes → IP 层分片(Fragment)
# IP 分片在工程中几乎总是坏事(后面详细分析)1.3 Checksum 的工程细节
# UDP Checksum 覆盖范围:
# - 伪头部(Pseudo Header): 源 IP + 目标 IP + 协议号 + UDP 长度
# - UDP 头部
# - UDP 数据
# IPv4 中 UDP Checksum 是可选的(值为 0 表示未计算)
# IPv6 中 UDP Checksum 是强制的(RFC 8200)
# Linux 默认行为:
# 发送时:计算 Checksum(除非使用 SO_NO_CHECK)
# 接收时:验证 Checksum(如果不为 0)
# 禁用 Checksum(极端性能场景)
int disable = 1;
setsockopt(fd, SOL_SOCKET, SO_NO_CHECK, &disable, sizeof(disable));
# 注意:仅在可信网络(如数据中心内部)中考虑
# 公网禁用 Checksum 会导致静默数据损坏
# 查看 Checksum 错误统计
cat /proc/net/snmp | grep Udp
# InCsumErrors: Checksum 错误的包数
# 如果持续增长 → 网络有数据损坏(网线/交换机问题)1.4 UDP 在内核中的处理路径
收包路径(简化):
NIC → DMA → Ring Buffer → NAPI/softirq → ip_rcv()
→ udp_rcv() → __udp4_lib_lookup() → socket receive buffer
→ application recv()/recvmsg()
发包路径(简化):
application send()/sendmsg() → udp_sendmsg()
→ ip_make_skb() → ip_output() → dev_queue_xmit()
→ NIC → DMA → wire
对比 TCP:
- UDP 没有 tcp_ack_snd_check、tcp_transmit_skb 等重传/拥塞逻辑
- UDP 没有 tcp_data_queue 的排序/去重逻辑
- UDP 的 softirq 处理时间约为 TCP 的 1/3
二、UDP 的真正优势
2.1 无队头阻塞
TCP 保证字节流的有序交付。如果包 #3 丢了,包 #4、#5 必须在接收缓冲区里等 #3 重传成功后才能交给应用——这就是队头阻塞(Head-of-Line Blocking,HoL Blocking)。
TCP (有序交付):
收到: [1] [2] [_] [4] [5]
↑
#3 丢了
应用只能读到 [1] [2]
[4] [5] 被阻塞在内核缓冲区
等 #3 重传成功后才能交给应用
UDP (无序交付):
收到: [1] [2] [_] [4] [5]
↑
#3 丢了
应用立即收到 [1] [2] [4] [5]
#3 的丢失由应用自己决定是否处理
对于实时音视频,队头阻塞比丢包更有害:
视频通话场景:
TCP: 丢了第 100 帧的一个包 → 第 101-105 帧全部被阻塞
→ 画面卡顿 200ms(等重传 + 播放积压)
→ 用户感知:明显卡顿
UDP: 丢了第 100 帧的一个包 → 第 100 帧花屏/跳过
→ 第 101 帧立即播放
→ 用户感知:轻微闪烁(几乎不可察觉)
2.2 无连接开销
# TCP 连接成本:
# - 三次握手: 1 RTT (或 TFO 0 RTT)
# - 内核状态: 每连接约 3-4 KB(struct tcp_sock + 缓冲区)
# - conntrack: 如果经过 NAT/LB,每连接一条记录
# - TIME_WAIT: 关闭后占用端口 60 秒
# UDP "连接"成本:
# - 握手: 0 RTT
# - 内核状态: 无(除非 connect() 了 UDP socket)
# - conntrack: 仍然有(如果经过 NAT)
# - 关闭: 即时
# DNS 查询为什么用 UDP:
# 一次 DNS 查询 = 1 个请求包 + 1 个响应包
# 用 TCP: SYN → SYN-ACK → ACK → Query → Response → FIN → ... = 最少 4 RTT
# 用 UDP: Query → Response = 1 RTT
# DNS 响应通常 < 512 bytes,完全不需要 TCP 的可靠传输2.3 多播与广播
TCP 是点对点协议——一个连接只有两个端点。UDP 支持一对多通信:
# === 广播(Broadcast)===
# 发送到同一子网的所有主机
# 目标地址: 255.255.255.255(受限广播)或子网广播地址
# 广播发送
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(b"DISCOVER", ("255.255.255.255", 9999))
# 用途: DHCP 发现、局域网服务发现
# === 多播(Multicast)===
# 发送到一个多播组,只有加入该组的主机才收到
# 多播地址范围: 224.0.0.0 - 239.255.255.255
# 多播发送
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.sendto(b"data", ("239.1.1.1", 5000))
# 多播接收
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("", 5000))
# 加入多播组
mreq = struct.pack("4s4s", socket.inet_aton("239.1.1.1"),
socket.inet_aton("0.0.0.0"))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# 工程场景:
# - IPTV: 一个视频流发送给几万个用户
# - 金融行情: 交易所行情推送
# - 集群发现: 节点加入集群时多播通告
# - VXLAN: 用多播学习 MAC 地址2.4 延迟确定性
TCP 延迟分布(受重传影响):
P50: 1ms
P99: 5ms
P999: 200ms ← 一次 RTO 超时重传
P9999: 3200ms ← 连续重传的指数退避
UDP 延迟分布(无重传机制):
P50: 0.8ms
P99: 1.2ms
P999: 2ms ← 网络抖动
P9999: 丢包(但不会等待)
对于高频交易、游戏等延迟敏感场景:
TCP 的问题不在 P50 — P50 可能比 UDP 更好(TCP 的 Nagle 合并除外)
问题在尾延迟 — 一次重传就是灾难性延迟
三、UDP 编程的工程陷阱
3.1 IP 分片:UDP 最大的坑
# 当 UDP 数据报大于 MTU 时,IP 层会进行分片
# 示例:发送 3000 字节 UDP 数据(MTU=1500)
# IP 会将其分为 3 个分片:
# 分片1: IP(MF=1, Offset=0) + UDP Header + Data[0:1472]
# 分片2: IP(MF=1, Offset=185) + Data[1472:2952]
# 分片3: IP(MF=0, Offset=369) + Data[2952:3000]
#
# 注意:只有第一个分片有 UDP 头部
# 后续分片只有 IP 头部 + 数据
# IP 分片的工程危害:
# 1. 丢失放大
# 任何一个分片丢失 → 整个数据报丢失
# 3 个分片,每片 0.1% 丢包率 → 整个数据报丢失率 ≈ 0.3%
# N 个分片,丢包率 p → 数据报丢失率 ≈ 1 - (1-p)^N
# 2. 防火墙/NAT 问题
# 很多防火墙默认丢弃 IP 分片(安全策略)
# NAT 设备需要维护分片重组状态(消耗资源、可能超时)
# 某些云环境(如 AWS Security Group)不支持 IP 分片
# 3. 重组开销
# 接收端需要缓存分片、等待其他分片到达、重组
# 内核分片重组有超时(默认 30-60 秒)
# 大量分片可能导致 IP 分片重组缓冲区溢出
# 查看分片统计
cat /proc/net/snmp | grep -A 1 Ip:
# ReasmReqds: 需要重组的分片数
# ReasmFails: 重组失败的次数
# FragCreates: 创建的分片数
# FragFails: 分片创建失败的次数
# 查看分片重组超时
sysctl net.ipv4.ipfrag_time
# 30(秒,默认)工程原则:永远不要让 UDP 数据报超过 MTU。
# 安全的 UDP 最大载荷:
# 以太网 MTU 1500: 1500 - 20(IP) - 8(UDP) = 1472 bytes
# 考虑 VLAN/VXLAN: 1450 - 1400 bytes (保守值)
# 公网安全值: 1280 - 20 - 8 = 1252 bytes (IPv6 最小 MTU)
# 在代码中设置 DF 位(Don't Fragment),避免静默分片
int val = IP_PMTUDISC_DO;
setsockopt(fd, IPPROTO_IP, IP_MTU_DISCOVER, &val, sizeof(val));
# 如果数据报太大,sendto() 返回 EMSGSIZE 而非静默分片
# 这样你可以在应用层处理:分割数据或报错3.2 接收缓冲区溢出
# UDP 没有流量控制——发送端不会因为接收端"忙不过来"而减速
# 如果应用处理速度 < 网络到达速度 → 内核接收缓冲区溢出 → 丢包
# 查看 UDP 缓冲区溢出
cat /proc/net/snmp | grep Udp:
# 关注 RcvbufErrors(接收缓冲区满导致的丢包)
# 关注 SndbufErrors(发送缓冲区满导致的发送失败)
# 或者用 ss 查看
ss -u -a
# Recv-Q: 接收缓冲区中等待应用读取的字节数
# 如果 Recv-Q 持续增长 → 应用读取太慢
# 查看默认和最大缓冲区大小
sysctl net.core.rmem_default # 接收缓冲区默认值(通常 212992 = 208 KB)
sysctl net.core.rmem_max # 接收缓冲区最大值
sysctl net.core.wmem_default # 发送缓冲区默认值
sysctl net.core.wmem_max # 发送缓冲区最大值
# 增大 UDP 接收缓冲区
sysctl -w net.core.rmem_max=26214400 # 25 MB
sysctl -w net.core.rmem_default=26214400
# 在应用中设置
int bufsize = 26214400;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize));
# 注意:实际分配值是设置值的 2 倍(内核额外开销)
# 或使用 SO_RCVBUFFORCE(需要 CAP_NET_ADMIN,不受 rmem_max 限制)3.3 NAT 穿越
# UDP 没有连接状态,但 NAT 设备需要维护映射表
# NAT 映射: (内部IP:内部Port) ↔ (外部IP:外部Port)
# NAT 映射的超时问题:
# TCP: NAT 设备跟踪 TCP 状态机,知道连接何时结束
# UDP: NAT 设备无法判断"通信结束",只能靠超时
# 查看 NAT/conntrack 的 UDP 超时
sysctl net.netfilter.nf_conntrack_udp_timeout # 无回包时超时(默认 30s)
sysctl net.netfilter.nf_conntrack_udp_timeout_stream # 双向通信超时(默认 120s)
# 问题场景:
# VoIP 通话中 30 秒无语音 → NAT 映射超时 → 对方的语音包被丢弃
# 游戏中 30 秒挂机 → NAT 映射消失 → 回到游戏时断连
# 解决方案:Keep-Alive 包
# 每 15-25 秒发送一个心跳包(远小于 NAT 超时时间)
# STUN 绑定请求也可以作为 keep-aliveUDP NAT 穿越技术:
场景:两个都在 NAT 后面的客户端想要 P2P 通信
Client A (192.168.1.10:5000) Client B (10.0.0.20:6000)
↓ ↓
NAT A (1.1.1.1:40000) NAT B (2.2.2.2:50000)
↓ ↓
Internet
步骤(UDP Hole Punching):
1. A 和 B 都连接到一个公网 STUN/中继服务器
2. 服务器告诉 A: "B 的公网地址是 2.2.2.2:50000"
服务器告诉 B: "A 的公网地址是 1.1.1.1:40000"
3. A 向 2.2.2.2:50000 发送 UDP 包
→ 在 NAT A 上打开了一个"洞": 允许 2.2.2.2:50000 → 1.1.1.1:40000
4. B 向 1.1.1.1:40000 发送 UDP 包
→ 在 NAT B 上打开了一个"洞": 允许 1.1.1.1:40000 → 2.2.2.2:50000
5. 双方的 NAT 都已经为对方的地址开了洞
→ P2P 通信建立
NAT 类型对穿越成功率的影响:
┌────────────────────┬──────────────────┬─────────┐
│ NAT A 类型 │ NAT B 类型 │ 成功率 │
├────────────────────┼──────────────────┼─────────┤
│ Full Cone │ 任意 │ ~100% │
│ Restricted Cone │ Restricted Cone │ ~90% │
│ Port Restricted │ Port Restricted │ ~80% │
│ Symmetric │ Symmetric │ ~10% │
│ Symmetric │ Port Restricted │ ~30% │
└────────────────────┴──────────────────┴─────────┘
3.4 数据报边界
// TCP 是字节流——send() 的边界和 recv() 的边界无关
// UDP 是数据报——每次 send() 对应一个独立的数据报
// TCP 的边界问题:
send(fd, "Hello", 5, 0);
send(fd, "World", 5, 0);
// recv() 可能返回 "HelloWorld"(粘包)或 "Hel" + "loWorld"(拆包)
// 需要应用层自己定义消息边界(长度前缀/分隔符)
// UDP 的边界保证:
sendto(fd, "Hello", 5, 0, &addr, sizeof(addr));
sendto(fd, "World", 5, 0, &addr, sizeof(addr));
// 两次 recvfrom() 分别返回 "Hello" 和 "World"
// 每个数据报是独立的,不会粘包
// 但可能乱序或丢失
// 注意:如果 recv 缓冲区小于数据报大小
char buf[3];
recvfrom(fd, buf, 3, 0, ...);
// 返回 3 字节 "Hel",剩余 "lo" 被丢弃!(MSG_TRUNC 标志会被设置)
// 因此 recv 缓冲区必须足够大3.5 UDP connect() 的工程价值
// UDP socket 默认是"未连接的"——每次 sendto() 指定目标地址
// 但可以对 UDP socket 调用 connect()——这不是建立连接,而是设置默认目标
// 未连接 UDP:
sendto(fd, data, len, 0, &addr, sizeof(addr)); // 每次指定地址
// 内核每次: 路由查找 → 源地址选择 → 发送
// 已连接 UDP:
connect(fd, &addr, sizeof(addr));
send(fd, data, len, 0); // 不需要地址
// 内核: 路由查找只做一次(缓存在 socket 中)
// 已连接 UDP 的工程优势:
// 1. 性能: 避免每次 sendto 的路由查找(高频发送时差距明显)
// 2. 错误反馈: 能收到 ICMP 错误(如 Port Unreachable)
// 未连接 UDP 的 sendto() 不会返回 ECONNREFUSED
// 已连接 UDP 的 send() 会返回 ECONNREFUSED(如果收到 ICMP Port Unreachable)
// 3. 安全: 只接受来自 connect 目标的包(内核过滤)
// Google 的建议(gRPC over QUIC 实践):
// 对固定目标的 UDP 通信,始终使用 connect()四、UDP 的典型应用场景
4.1 DNS
# DNS 默认使用 UDP/53
# 为什么:一次查询通常 < 512 字节,一个 RTT 完成
# DNS 什么时候切换到 TCP?
# 1. 响应超过 512 字节(传统限制)或 EDNS0 协商的 buffer size
# 2. 响应中设置了 TC(Truncated)标志 → 客户端用 TCP 重试
# 3. 区域传输(Zone Transfer, AXFR/IXFR)始终用 TCP
# 4. DNS over TLS (DoT) / DNS over HTTPS (DoH) 用 TCP/TLS
# DNS over QUIC (DoQ, RFC 9250) — 结合了 UDP 的低延迟和 TLS 的安全
# 底层仍然是 UDP,但有 QUIC 提供的可靠性和加密
# DNS 的 UDP 编程注意事项:
# - 使用 EDNS0 时 buffer size 不要超过 MTU(推荐 1232 bytes)
# - 超时重传由客户端自己实现(通常 2-5 秒)
# - 需要处理 ICMP Port Unreachable(服务不可用)4.2 实时音视频(RTP/RTCP)
# RTP (Real-time Transport Protocol) 跑在 UDP 上
# 为什么不用 TCP:
# 1. 视频帧有严格的播放时限,过时的重传数据无用
# 2. TCP 的队头阻塞导致所有帧延迟
# 3. TCP 的拥塞控制对实时媒体过于保守
# RTP 在 UDP 上做了什么(应用层实现):
# - 序列号: 检测丢包和乱序
# - 时间戳: 同步音视频
# - SSRC: 标识不同的媒体流
# - 不做重传: 丢了就丢了(或用 FEC 纠错)
# RTP 报文结构(跑在 UDP 数据报中):
# UDP(8B) | RTP Header(12B) | RTP Payload(编码后的音视频数据)
# RTCP (RTP Control Protocol):
# - 与 RTP 配合,提供接收质量反馈
# - Receiver Report: 丢包率、抖动、延迟
# - 发送端根据 RTCP 反馈动态调整编码质量
# WebRTC 的传输层选择:
# 媒体: SRTP over UDP (加密的 RTP)
# 数据通道: SCTP over DTLS over UDP (可靠有序)
# 信令: WebSocket over TCP (需要可靠传输)4.3 游戏网络
# 游戏网络的典型需求:
# - 玩家位置更新: 每秒 20-60 次,容忍丢包,不容忍延迟
# - 游戏事件(攻击/道具): 必须可靠,可以容忍少量延迟
# - 语音: 同 RTP
# 常见的游戏网络架构:
#
# 方案 1: 纯 UDP + 应用层可靠性
# 所有数据走 UDP
# 重要消息在应用层实现 ACK + 重传
# 位置更新不重传(反正下一帧会覆盖)
#
# 方案 2: TCP + UDP 双通道
# TCP: 登录、匹配、聊天、道具交易(可靠但延迟不敏感)
# UDP: 位置同步、战斗事件(低延迟)
#
# 方案 3: 可靠 UDP 框架(KCP/ENet)
# 在 UDP 上实现选择性可靠:
# - 频道 0: 可靠有序(游戏事件)
# - 频道 1: 不可靠有序(位置同步)
# - 频道 2: 不可靠无序(特效/粒子)
# 游戏网络的 UDP 优化:
# 1. 每个包携带最新状态(快照),而非增量
# → 丢包后下一个包自动恢复,不需要重传
# 2. 客户端预测 + 服务端权威
# → 减少感知延迟
# 3. 包大小控制在 MTU 以内(避免分片)
# → 典型游戏包 50-200 字节4.4 QUIC
# QUIC 选择 UDP 的原因:
# 1. 绕过 TCP 中间设备(防火墙/NAT/IDS 对 TCP 的 DPI)
# 2. 用户态实现协议栈(不受内核 TCP 版本限制)
# 3. 连接迁移(UDP 没有连接绑定到 IP:Port)
# QUIC 在 UDP 上构建了完整的可靠传输:
# - 连接管理: Connection ID(不绑定 IP)
# - 多路复用: 多个 Stream 独立流控
# - 可靠传输: 按 Stream 独立重传(无跨 Stream HoL)
# - 拥塞控制: 默认 Cubic/BBR
# - 加密: 集成 TLS 1.3
# QUIC 的 UDP 层注意事项:
# - QUIC 数据报不会超过 MTU(QUIC 自己做分片)
# - QUIC 使用 PMTUD 或 DPLPMTUD 探测 MTU
# - QUIC Initial 包最小 1200 字节(用 PADDING 填充,过滤放大攻击)
# - 某些网络阻止 UDP/443 → QUIC 需要回退到 TCP五、高性能 UDP 编程
5.1 recvmmsg / sendmmsg:批量收发
// 传统方式:每次系统调用收/发一个包
for (int i = 0; i < 1000; i++) {
recvfrom(fd, buf, sizeof(buf), 0, ...); // 1000 次系统调用
}
// 批量方式:一次系统调用收/发多个包
// Linux 3.0+ 支持 recvmmsg, Linux 3.0+ 支持 sendmmsg
#define BATCH_SIZE 64
struct mmsghdr msgs[BATCH_SIZE];
struct iovec iovecs[BATCH_SIZE];
char bufs[BATCH_SIZE][1500];
for (int i = 0; i < BATCH_SIZE; i++) {
iovecs[i].iov_base = bufs[i];
iovecs[i].iov_len = 1500;
msgs[i].msg_hdr.msg_iov = &iovecs[i];
msgs[i].msg_hdr.msg_iovlen = 1;
}
// 一次系统调用接收最多 BATCH_SIZE 个数据报
int n = recvmmsg(fd, msgs, BATCH_SIZE, MSG_WAITFORONE, NULL);
// MSG_WAITFORONE: 等第一个包到达后,立即返回所有已就绪的包
// 返回值 n = 实际接收的数据报数量
// 发送类似
int sent = sendmmsg(fd, msgs, n, 0);
// 性能对比(单线程,小包 64 字节):
// recvfrom: ~1.2M pps
// recvmmsg: ~3.5M pps (3x 提升)
// 原因: 减少系统调用次数(上下文切换 + TLB 刷新)5.2 GRO/GSO:通用接收/发送卸载
# UDP GSO (Generic Segmentation Offload) — Linux 4.18+
# 应用发送一个大缓冲区 + 指定 segment size
# 内核或网卡将其分割成多个 UDP 数据报
# 好处:减少系统调用次数 + 利用网卡硬件卸载
# QUIC 实现(如 quiche、quinn)广泛使用 UDP GSO
# 启用 UDP GSO
int gso_size = 1472; // 每个 UDP 数据报的大小
setsockopt(fd, SOL_UDP, UDP_SEGMENT, &gso_size, sizeof(gso_size));
# 发送时:一次 sendmsg 可以发送 N * gso_size 的数据
# 内核在 IP 层将其分割为多个 1472 字节的 UDP 数据报
# UDP GRO (Generic Receive Offload) — Linux 5.0+
# 反过来:网卡/内核将多个 UDP 数据报合并成一个大缓冲区交给应用
# 需要设置 socket option
int enable = 1;
setsockopt(fd, SOL_UDP, UDP_GRO, &enable, sizeof(enable));
# 检查网卡是否支持 UDP GSO/GRO
ethtool -k eth0 | grep -i gro
# generic-receive-offload: on
ethtool -k eth0 | grep -i gso
# tx-udp-segmentation: on5.3 SO_REUSEPORT:多线程接收
// 问题:单个 UDP socket 在单核上接收,成为瓶颈
// 解决:多个 socket 绑定相同端口,内核按流(五元组哈希)分发
// 每个工作线程创建自己的 socket
int fd = socket(AF_INET, SOCK_DGRAM, 0);
int enable = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(enable));
bind(fd, &addr, sizeof(addr));
// 多个线程/进程可以绑定相同的 addr:port
// Linux 3.9+ 支持 SO_REUSEPORT
// Linux 4.5+ 支持 SO_REUSEPORT + eBPF 自定义分发策略
// 使用 SO_ATTACH_REUSEPORT_EBPF 自定义流量分发
// (可以按 QUIC Connection ID 分发,而非五元组)
// 性能提升:
// 单 socket: ~2M pps(单核瓶颈)
// 4 socket (REUSEPORT): ~7.5M pps(接近线性扩展)5.4 内核参数调优
# === 接收缓冲区 ===
# 高流量 UDP 服务必须增大接收缓冲区
# 查看当前丢包
nstat -z | grep UdpRcvbufErrors
# 如果持续增长 → 缓冲区太小
# 增大缓冲区
sysctl -w net.core.rmem_max=67108864 # 64 MB
sysctl -w net.core.rmem_default=26214400 # 25 MB
# === netdev_budget ===
# 每次 softirq 处理的最大包数
sysctl net.core.netdev_budget
# 默认 300,高 pps 场景可增大
sysctl -w net.core.netdev_budget=600
# === backlog ===
# 每 CPU 的 backlog 队列长度
sysctl net.core.netdev_max_backlog
# 默认 1000,高 pps 场景
sysctl -w net.core.netdev_max_backlog=10000
# === RPS (Receive Packet Steering) ===
# 如果网卡只有单队列,用 RPS 将包分散到多核处理
echo "ff" > /sys/class/net/eth0/queues/rx-0/rps_cpus
# ff = 使用 8 个 CPU 核心处理接收
# === RFS (Receive Flow Steering) ===
# 将包送到正在处理该 flow 的 CPU(改善缓存命中)
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
# === 中断亲和 ===
# 将网卡中断绑定到特定 CPU,避免中断抖动
# 使用 irqbalance 或手动设置
cat /proc/interrupts | grep eth0
echo 2 > /proc/irq/IRQ_NUM/smp_affinity # 绑定到 CPU 1六、UDP 与 TCP 的工程对比
┌──────────────────┬──────────────────────┬─────────────────────────┐
│ 维度 │ TCP │ UDP │
├──────────────────┼──────────────────────┼─────────────────────────┤
│ 连接模型 │ 面向连接(状态机) │ 无连接(数据报) │
│ 可靠性 │ 内核保证 │ 应用自行实现 │
│ 有序性 │ 内核保证 │ 不保证 │
│ 流控 │ 滑动窗口 │ 无(应用自行控制) │
│ 拥塞控制 │ 内核实现 │ 无(但 QUIC 在应用层做)│
│ 头部开销 │ 20-60 字节 │ 8 字节 │
│ 延迟(P50) │ 低 │ 更低 │
│ 延迟(P99) │ 受重传影响,可能很高 │ 确定性好 │
│ NAT 穿越 │ 有状态,映射可靠 │ 无状态,需 keep-alive │
│ 多播 │ 不支持 │ 支持 │
│ 内核 CPU 开销 │ 高(状态维护) │ 低 │
│ 编程复杂度 │ 低(内核做了大部分) │ 高(很多事要自己做) │
│ 中间设备兼容性 │ 最好(DPI 深入) │ 好(某些限速 UDP) │
│ 适用消息大小 │ 任意(字节流) │ 建议 < MTU(数据报) │
└──────────────────┴──────────────────────┴─────────────────────────┘
选型决策树:
你的数据需要...
├── 可靠有序传输?
│ ├── 是,且延迟要求宽松 → TCP
│ ├── 是,且需要低延迟 → QUIC (UDP 上的可靠传输)
│ └── 是,但只对部分数据 → 可靠 UDP 框架 (KCP/ENet)
│
├── 实时性优先(容忍丢包)?
│ ├── 音视频 → UDP + RTP
│ ├── 游戏状态同步 → UDP + 自定义协议
│ └── 传感器数据 → UDP + 应用层重传
│
├── 一对多通信?
│ ├── 同一子网 → UDP 广播
│ └── 跨网段 → UDP 多播
│
└── P2P 通信(双方在 NAT 后)?
└── UDP Hole Punching + STUN/TURN
七、UDP 安全注意事项
7.1 UDP 放大攻击
# 攻击原理:
# 1. 攻击者伪造源 IP 为受害者的 IP
# 2. 向开放的 UDP 服务发送小请求
# 3. 服务返回大响应 → 响应发向受害者
# 4. 放大系数 = 响应大小 / 请求大小
# 常见放大源和放大系数:
# DNS: ~28x-54x
# NTP: ~557x (monlist 命令)
# Memcached: ~51000x (stats 命令)
# SSDP: ~30x
# CharGen: ~358x
# 防御措施(作为 UDP 服务开发者):
# 1. 限制响应大小 ≤ 请求大小的 N 倍
# 2. 需要认证后才返回大响应
# 3. 限速(Rate Limiting)—— 同一源 IP 的请求频率
# 4. BCP 38: ISP 层面的源地址验证(Ingress Filtering)
# 检查你的服务是否被用于反射攻击
tcpdump -i eth0 udp and dst port YOUR_PORT -c 100
# 如果看到大量不同源 IP 的相似请求 → 可能被当作反射器7.2 UDP Flood 防御
# UDP Flood: 大量 UDP 包打到目标端口
# 与 TCP SYN Flood 不同,UDP 没有 Cookie 等内置防御
# Linux 内核层面:
# 1. 限制 ICMP Port Unreachable 的发送速率
sysctl net.ipv4.icmp_ratelimit
# 默认 1000(ms),每秒最多发 1 个 ICMP 错误
# 防止 UDP 到未监听端口时 ICMP 响应成为新的放大源
# 2. iptables/nftables 限速
iptables -A INPUT -p udp --dport 53 -m hashlimit \
--hashlimit-name dns --hashlimit-above 100/sec \
--hashlimit-burst 150 --hashlimit-mode srcip \
-j DROP
# 每个源 IP 每秒最多 100 个 DNS 查询
# 3. XDP 早期丢弃(最高性能)
# 在驱动层直接丢弃恶意 UDP 包
# 参考 Cloudflare 的 XDP-based DDoS 防御八、案例:高频行情接收系统的 UDP 优化
# === 背景 ===
# 金融交易所通过 UDP 多播推送行情数据
# 每秒 50 万+ 条行情更新,每条 100-200 字节
# 要求:零丢包(影响交易决策),延迟 < 10µs(竞争优势)
# === 问题 ===
# 初始版本丢包率 0.3%(每秒丢 1500 条行情)
# 在开盘和收盘时段(流量峰值)丢包率飙升到 2%
# === 第 1 步:定位丢包点 ===
# 检查网卡层面
ethtool -S eth0 | grep -i drop
# rx_dropped: 0 → 网卡没丢
# rx_missed_errors: 0 → Ring Buffer 没满
# 检查内核层面
nstat -z | grep UdpRcvbufErrors
# UdpRcvbufErrors 45231 → 接收缓冲区溢出!
# 确认:丢包发生在 socket 接收缓冲区
# === 第 2 步:增大缓冲区 ===
sysctl -w net.core.rmem_max=134217728 # 128 MB
# 应用中设置
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &(int){67108864}, sizeof(int));
# 实际分配 128 MB(内核翻倍)
# 丢包率下降到 0.05% — 但仍然不够
# === 第 3 步:优化接收路径 ===
# 3a. 使用 recvmmsg 批量接收
# 从 recvfrom 切换到 recvmmsg,batch=256
# 系统调用次数减少 256 倍
# 3b. CPU 亲和
# 将接收线程绑定到固定 CPU
# 将网卡中断绑定到相邻 CPU(同一 NUMA 节点)
taskset -c 2 ./market_receiver
echo 4 > /proc/irq/IRQ_NUM/smp_affinity # CPU 2
# 3c. 禁用 CPU 节能(避免 C-state 唤醒延迟)
echo performance > /sys/devices/system/cpu/cpu2/cpufreq/scaling_governor
# 3d. 使用 busy polling(避免中断延迟)
sysctl -w net.core.busy_poll=50 # µs
sysctl -w net.core.busy_read=50
# 或者在 socket 上设置
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &(int){50}, sizeof(int));
# === 第 4 步:验证 ===
# 丢包率: 0.3% → 0.05% → 0.001%(每天约 500 条,可接受)
# 延迟: P50 从 25µs 降到 3µs
# 剩余丢包原因: 交换机上游偶发微突发
# === 进一步优化(如果需要零丢包)===
# 方案 A: DPDK 用户态收包(绕过内核,但开发复杂度高)
# 方案 B: 多播组冗余订阅(A/B 双路行情,应用层去重)
# 方案 C: 内核旁路 AF_XDP(比 DPDK 更轻量)九、结论
UDP 的工程价值不在于”简单”——恰恰相反,用好 UDP 比用好 TCP 难得多。TCP 帮你处理了可靠传输的全部复杂性,UDP 把这些复杂性留给了你。选择 UDP 的唯一理由是:TCP 的某些特性(有序交付、重传延迟、连接绑定)在你的场景中是有害的。
几个工程原则:
默认用 TCP/QUIC。 除非你有明确的理由(实时性、多播、NAT 穿越、自定义可靠性),否则 TCP 或 QUIC 是更安全的选择。“UDP 性能更好”在大多数场景下是错误的——TCP 的内核态实现高度优化,用户态 UDP 协议很难超越它。
控制包大小在 MTU 以内。 IP 分片是 UDP 编程中最常见也最难排查的问题源。设置 IP_PMTUDISC_DO,让过大的包在应用层失败而非静默分片。
接收缓冲区要足够大。 UDP 没有流控——如果你收不过来,内核就丢。生产环境至少
rmem_max=26214400(25 MB),高流量场景 128 MB+。用 recvmmsg/sendmmsg 和 GSO/GRO。 高 pps 场景下系统调用是瓶颈。批量操作 + 卸载是关键优化手段。
NAT 环境下保持心跳。 UDP 的 NAT 映射超时远短于 TCP。30 秒的通信间隙就可能导致 NAT 映射消失。每 15-25 秒发送 keep-alive 包。
TCP 工程系列讲了”如何把 TCP 用好”。UDP 的挑战不同——它是”如何在一张白纸上画出你需要的传输协议”。下一篇我们来看几个在 UDP 上构建可靠传输的框架——KCP、ENet 和 QUIC——它们各自做出了什么设计取舍。
上一篇:TCP 现代扩展:FastOpen、MPTCP 与 ECN
下一篇:可靠 UDP 框架:KCP、ENet 与 QUIC 的设计对比
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】可编程数据平面与 P4:软件定义转发
传统网络设备的转发逻辑固化在硬件中。P4 语言让交换机的转发管线可编程——你可以定义自己的包头解析、匹配规则和转发动作。本文从 P4 语言核心概念出发,讲解 Parser/Match-Action/Deparser 的编程模型、可编程交换机芯片(Tofino)的架构、P4 在数据中心和运营商网络中的应用案例,以及 P4 与 eBPF 的定位差异。
【网络工程】Socket 编程模型演进:从阻塞到多路复用
网络编程模型的选择决定了服务的并发能力上限。本文从阻塞 I/O 到非阻塞、select、poll、epoll,逐步解剖每种模型的系统调用开销、性能边界与适用场景,用 C 代码实测从 C10K 到 C1M 的演进。
【网络工程】网络 I/O 模式:Reactor、Proactor 与协程
Reactor 和 Proactor 是网络服务器的两种核心 I/O 处理模式。本文从单线程 Reactor、多线程主从 Reactor、Proactor 与 io_uring 的天然契合,到 Go goroutine、Rust async 和 Java Virtual Thread 的协程网络 I/O 对比,系统分析各模式的适用场景与工程权衡。
【网络工程】可靠 UDP 框架:KCP、ENet 与 QUIC 的设计对比
TCP 的可靠传输是一种固定策略——全量有序、丢包即重传、拥塞窗口统一管理。但很多场景需要'可定制的可靠性':游戏要低延迟重传、视频要部分可靠、RPC 要多路复用无队头阻塞。本文深入对比 KCP、ENet、QUIC 三个在 UDP 上构建可靠传输的框架,剖析它们的 ARQ 策略、流控设计和工程取舍。