土法炼钢兴趣小组的算法知识备份

【网络工程】UDP 工程:何时用 UDP、怎么用好 UDP

文章导航

分类入口
network
标签入口
#udp#multicast#nat-traversal#network-programming

目录

很多教科书把 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-alive

UDP 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: on

5.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 的某些特性(有序交付、重传延迟、连接绑定)在你的场景中是有害的。

几个工程原则:

  1. 默认用 TCP/QUIC。 除非你有明确的理由(实时性、多播、NAT 穿越、自定义可靠性),否则 TCP 或 QUIC 是更安全的选择。“UDP 性能更好”在大多数场景下是错误的——TCP 的内核态实现高度优化,用户态 UDP 协议很难超越它。

  2. 控制包大小在 MTU 以内。 IP 分片是 UDP 编程中最常见也最难排查的问题源。设置 IP_PMTUDISC_DO,让过大的包在应用层失败而非静默分片。

  3. 接收缓冲区要足够大。 UDP 没有流控——如果你收不过来,内核就丢。生产环境至少 rmem_max=26214400(25 MB),高流量场景 128 MB+。

  4. 用 recvmmsg/sendmmsg 和 GSO/GRO。 高 pps 场景下系统调用是瓶颈。批量操作 + 卸载是关键优化手段。

  5. 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 的设计对比

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-08-06 · network

【网络工程】可编程数据平面与 P4:软件定义转发

传统网络设备的转发逻辑固化在硬件中。P4 语言让交换机的转发管线可编程——你可以定义自己的包头解析、匹配规则和转发动作。本文从 P4 语言核心概念出发,讲解 Parser/Match-Action/Deparser 的编程模型、可编程交换机芯片(Tofino)的架构、P4 在数据中心和运营商网络中的应用案例,以及 P4 与 eBPF 的定位差异。

2025-07-26 · network

【网络工程】网络 I/O 模式:Reactor、Proactor 与协程

Reactor 和 Proactor 是网络服务器的两种核心 I/O 处理模式。本文从单线程 Reactor、多线程主从 Reactor、Proactor 与 io_uring 的天然契合,到 Go goroutine、Rust async 和 Java Virtual Thread 的协程网络 I/O 对比,系统分析各模式的适用场景与工程权衡。

2025-07-26 · network

【网络工程】可靠 UDP 框架:KCP、ENet 与 QUIC 的设计对比

TCP 的可靠传输是一种固定策略——全量有序、丢包即重传、拥塞窗口统一管理。但很多场景需要'可定制的可靠性':游戏要低延迟重传、视频要部分可靠、RPC 要多路复用无队头阻塞。本文深入对比 KCP、ENet、QUIC 三个在 UDP 上构建可靠传输的框架,剖析它们的 ARQ 策略、流控设计和工程取舍。


By .