
很多团队第一次把服务从 TCP 切到 UDP都是奔着快和省事去的觉得无连接、不用握手、不用维护状态怎么算都划算。结果第一版上线监控面板上接收侧丢包率稳定在 3%~8%第一反应是机房网络有问题拉着运维查路由、查交换机、查对端限速折腾两天最后发现发送端的sendto全部返回成功一个错误都没有包是在本机内核的接收缓冲区里被默默丢掉的。这个场景我见过不止一次它几乎概括了 UDP 协议最容易被误解的地方——它把控制权交给你同时也把责任甩给了你。这篇内容就把 UDP 从首部字段一路拆到工程落地讲清楚它的边界在哪里、哪些事情必须自己做、哪些坑几乎人人都会踩一遍。适合已经写过 socket、但被 UDP 的各种玄学丢包折磨过的开发者也适合还在犹豫该不该上 UDP 的技术选型人看完你应该能自己判断手里这个业务到底该不该用它。1. 实时链路为什么敢用UDP一次看起来像网络故障的排查开场1.1 从5%丢包说起问题并不在网络上那次排查的起点非常典型一个实时数据上报的场景客户端每秒发 200 个数据报单个报文 800 字节左右服务端用单线程recvfrom循环收。压测环境一切正常上了真实流量之后服务端统计到的收包数比客户端统计的发送数少了 5% 上下而且这个比例和流量大小强相关——流量越大丢得越多。一开始所有人的注意力都在网络上因为丢包这个词天然指向链路。但我当时坚持先在本机看了一眼中断和 socket 队列用ss -u -a -n -i观察接收队列发现Recv-Q在流量高峰时几乎一直是满的。这一步基本就定性了包已经到达内核只是应用没来得及取走缓冲区满了之后新来的包被内核直接丢弃发送端完全感知不到。改用多进程加SO_REUSEPORT、把接收缓冲区调大之后丢包率降到 0.02% 以下剩下的部分才是真正的链路丢包。这里的关键认知是UDP 的丢包至少有四个完全不同的来源从下往上分别是物理链路拥塞或错包、中间设备限速或策略丢弃、本机内核接收缓冲区溢出、应用层自己处理不及。它们的现象长得一模一样但处理手段天差地别。不从下往上分层定位就只能靠猜而猜的代价通常是几天时间。1.2 UDP到底放弃了什么又换回了什么把 UDP 和 TCP 摆在一起对比的文章很多但大多数只停留在可靠 vs 不可靠这种口号层面实际指导意义有限。我更愿意从内核帮你做了什么这个角度看TCP 在内核里维护了一整套状态机包括连接建立与拆除、序号与确认、超时重传、滑动窗口、拥塞控制、乱序重组、粘包拆包的处理。这些东西你一行代码都不用写代价是内核要替你决策而且很多决策的参数你改不动。UDP 把这些几乎全部去掉了只留下端口寻址和一层可选的校验。换来的是三样东西第一是延迟的可预测性没有重传等待没有拥塞窗口爬坡一个包发出去要么到要么不到不会出现因为前面一个包丢了所以后面全卡住的队头阻塞第二是控制权的完整性什么时候重传、重传几次、要不要让路给别的流全部由应用层说了算第三是拓扑上的灵活性广播、组播这些一对多的寻址方式TCP 从设计上就不支持。代价也同样明确所有可靠性工作下移到了应用层而且内核不再提供背压。TCP 发送缓冲区满了会把send阻塞住逼着生产者降速UDP 的sendto只要把数据塞进发送缓冲区就返回成功缓冲区满了才返回EAGAIN接收端更是彻底没有反馈通道。这意味着 UDP 链路上任何一个环节的处理能力不足都会以丢包的形式体现而不是以延迟上升的形式体现。理解这一点后面所有的工程细节才有落脚点。1.3 什么样的业务适合把可靠性交给上层不是所有业务都值得为 UDP 付出这些额外成本。我的判断标准通常看三条能不能容忍偶发丢包、延迟敏感度有多高、有没有必要做一对多。能容忍丢包的典型是实时音视频和游戏状态同步。一帧视频丢了解码器做个错误隐藏人眼基本看不出来一个位置同步包丢了下一个包 20ms 后就到插值一下就补上了。这类场景里重传一个已经过时的包不仅没用反而会占用带宽、加剧拥塞所以 TCP 的重传机制在这里是负资产。延迟敏感度高的典型是实时交互类业务比如远程操作的指令下发。这类场景对丢包的容忍度其实不高但对确定性延迟的要求极高宁可快速失败让上层做降级也不能出现几百毫秒的不可预测停顿。至于一对多组播和广播是 UDP 独占的能力做局域网设备发现、行情推送、流媒体分发时几乎绕不开。反过来说凡是每条消息都必须到达、顺序不能乱、数据量还不小的场景老老实实用 TCP 或者基于 UDP 的成熟可靠传输方案自己从零写一套可靠层大概率会在某个边界条件上翻车。2. 拆开那8个字节UDP首部字段与伪首部校验的完整推演2.1 四个字段各自的职责与边界UDP 首部固定 8 字节四个 16 位字段简单到可以背下来但每个字段的边界条件都值得说清楚。源端口和目的端口各 2 字节取值范围 0~65535。源端口在多数客户端场景下由内核自动分配走的是临时端口范围Linux 上默认是net.ipv4.ip_local_port_range定义的那一段。这里有个容易被忽略的限制单个客户端 IP 对同一个服务端IP:Port组合理论上最多只能建立 65535 个不同的源端口会话做大规模长连接或高并发 UDP 会话时这个上限会先于带宽成为瓶颈。解决办法是多绑几个本地 IP或者用多台机器分摊。长度字段 2 字节指的是首部 数据的总长度最小值是 8纯首部、无数据最大值是 65535。注意它统计的是 UDP 层的长度不含 IP 首部。IPv4 下 UDP 载荷最大是 65535 - 8 - 20 65507 字节IPv6 下因为 UDP 长度字段可以为 0走 Jumbogram 扩展理论上能更大但实际工程中几乎没人这么用。校验和字段 2 字节覆盖范围包括伪首部、UDP 首部和数据。在 IPv4 里它是可选的填 0 表示发送端不计算校验和接收端也不校验在 IPv6 里它是强制的填 0 是非法值。实践中我不建议关掉它因为关掉之后错包只能靠上层协议自己发现而很多上层协议并没有做这件事。提示判断一个 UDP 包是否真的到了应用层看的是应用有没有recvfrom到它而不是发送端有没有返回成功。sendto返回的字节数只代表数据进入了本机 socket 发送缓冲区和对面是否收到没有任何关系。2.2 校验和为什么要把伪首部算进去伪首部这个概念初次接触时很反直觉——它并不在网络上真实传输只是参与校验和计算时临时拼出来的 12 字节IPv4或 40 字节IPv6结构。IPv4 的伪首部包含源 IP、目的 IP、一个保留字节、协议号UDP 是 17和 UDP 长度IPv6 的伪首部包含 16 字节源地址、16 字节目的地址、4 字节上层报文长度、3 字节零和 1 字节下一首部值。为什么要多此一举因为如果只校验 UDP 首部和数据那么一个被错误路由到本机的包只要它内部数据自洽校验就能通过应用会收到一个看起来合法但不属于自己的报文。把 IP 地址纳入校验范围之后地址在传输过程中被篡改或出错的情况就能被检出。这是一个很朴素但有效的设计用上层校验覆盖下层关键字段。计算过程本身是标准的 16 位反码求和把伪首部、UDP 首部校验和字段先置 0和数据按 16 位一组切开逐组相加进位回卷加到低位最后取反码。有一个细节值得记住如果计算结果恰好是 0x0000发送时要写成 0xFFFF因为全 0 在校验和字段里有未计算的特殊含义两者在反码运算里是等价的但语义不能混。# 手工计算 UDP 校验和的最小示例用于理解过程 def udp_checksum(src_ip, dst_ip, payload: bytes, src_port, dst_port): udp_len 8 len(payload) pseudo src_ip dst_ip bytes([0, 17]) udp_len.to_bytes(2, big) udp_hdr src_port.to_bytes(2, big) dst_port.to_bytes(2, big) \ udp_len.to_bytes(2, big) b\x00\x00 data pseudo udp_hdr 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) total ~total 0xFFFF return total if total ! 0 else 0xFFFF实际抓包时你会看到校验和错误的包在大部分网卡上会被硬件直接丢掉只有开了混杂模式或者网卡关闭了校验和卸载checksum offload才能抓到。这也是为什么用tcpdump在本机抓包看到包明明在但应用收不到——很可能就是校验和有问题被丢了。排查这类问题时先看一眼/proc/net/snmp里的InCsumErrors计数比盲猜快得多。2.3 抓包对照一个1472字节的包长什么样理解协议最快的方式还是看真实报文。以太网 MTU 是 1500 字节扣掉 20 字节 IPv4 首部和 8 字节 UDP 首部UDP 载荷上限就是 1472 字节。这个数字在工程中出现频率极高很多采集类系统就是按 1472 来切包目的就是让每个 UDP 报文塞进一个以太网帧避免 IP 分片。下面是一个典型的 1472 字节载荷的 UDP 报文在tcpdump里的样子十六进制部分略去数据区IP 10.0.0.12.54321 10.0.0.34.9000: UDP, length 1472 0x0000: 4500 05dc 1a2b 4000 4011 8f3c 0a00 000c 0x0010: 0a00 0022 d431 2328 05dc 1a2e逐段读4500是 IPv4 版本 4 加首部长度 55×420 字节05dc是总长度 15001a2b是标识4000里高 3 位是标志DF 位置 1表示不分片4011里40是 TTL 6411是协议号 17 也就是 UDP后面是首部校验和与源目 IP。再往后d431是源端口 543212328是目的端口 900005dc是 UDP 长度 15001a2e是校验和。从这个报文能读出两件事第一UDP 长度字段填的是 1500包含了 8 字节首部所以载荷是 1492不对这里我做个小修正——如果 UDP 长度是 1500那 IP 总长度应该是 1520但以太网 MTU 只有 1500所以这个包必然分片。真正不分片的情况下UDP 长度字段应该是 14801472 载荷 8 首部IP 总长度 1500。第二DF标志位一旦置 1任何加上了首部之后超过 MTU 的包都会被中间设备丢弃并回一个 ICMP 需要分片的消息这反而是件好事——它让问题暴露出来而不是被悄悄分片。3. 无连接不等于无责任把可靠性补在上层还是在中间件3.1 可靠UDP的四种典型补全路线既然 UDP 不保证送达那保证送达这件事就得有人做。业界大致有四条路线选哪条取决于你的业务边界和对可控性的要求。第一条是自己写最小可靠层。核心就三件事给每个报文加序号、接收端回确认、发送端维护重传队列。听起来简单但一旦要考虑乱序、去重、窗口、超时估算代码量就会迅速膨胀到几千行而且几乎必然会漏掉某个边界条件。我只建议在报文语义非常简单、丢包容忍度较高的场景下自己写比如局域网内的控制指令通道。第二条是在 UDP 之上套一层成熟的可靠传输协议。QUIC 是最典型的代表它在 UDP 之上重建了可靠流、拥塞控制、多路复用还顺带把 TLS 1.3 集成进来解决了队头阻塞问题。如果你的场景是需要一个比 TCP 更快的可靠传输QUIC 基本是首选不用自己造。KCP 是另一个选择它用牺牲部分带宽换取更低延迟的策略在同等的丢包率下延迟表现通常优于 TCP代价是流量放大约 10%~20%。第三条是用 RTP/RTCP 这类面向媒体的协议栈。它们不强求每个包都到而是通过序列号、时间戳和接收端报告来做丢包隐藏、抖动缓冲和码率自适应。做音视频的话这条路是标准答案。第四条是在应用层做幂等加补偿。传输层索性不保证业务层通过消息 ID 去重、定时对账、定期全量同步来兜底。这条路线对基础设施改动最小适合内部系统之间的数据上报。路线典型方案适用场景主要代价自研最小可靠层序号 ACK 重传队列语义简单的控制通道边界条件多维护成本高成熟可靠传输协议QUIC、KCP需要可靠且低延迟的数据流需要引入额外依赖或改造接入层媒体协议栈RTP/RTCP、SRTP音视频、实时流需要配套的抖动缓冲与拥塞控制应用层幂等补偿消息 ID 去重 对账内部数据上报、日志采集有延迟需要业务侧配合3.2 重传、序号与拥塞控制的最小实现骨架如果你确实要自己写一层那么有几个设计决策必须先定下来它们是后续所有问题的根源。序号怎么编。单调递增的 32 位或 64 位整数是最常见的做法回绕问题一定要提前想清楚比较大小的时候用差值而不是直接比大小。另一种是包序号加时间戳的组合好处是能同时支撑乱序重组和 RTT 估算。ACK 怎么回。逐包 ACK 在包量大时会把上行带宽吃光通常用累积 ACK 加选择性 ACK 的组合告诉对端我连续收到了到 N 为止的所有包另外还额外收到了这几个。ACK 本身也要做聚合比如每收到 2 个包或者每 10ms 回一次。重传超时怎么定。固定超时是最简单也最容易出问题的做法。稍微认真一点的做法是维护 RTT 的平滑值和方差超时时间取平滑 RTT 加上若干倍方差同时设一个下限比如 50ms避免在低延迟链路上因为抖动触发大量无谓重传。拥塞控制要不要做。这一条最容易被省略也是自研可靠层在生产环境翻车的主要原因。没有拥塞控制一旦链路出现丢包发送端还在全速发只会让丢包率进一步上升最终整条链路退化到几乎不可用。哪怕只做一个最简单的版本——丢包时把发送速率减半一段时间无丢包再线性增长——也比完全没有强得多。# 极简的 RTT 估算与重传超时计算只演示思路 class RttEstimator: def __init__(self): self.srtt None # 平滑 RTT self.rttvar None # RTT 方差 self.min_rto 0.05 # 50ms 下限 def update(self, sample): if self.srtt is None: self.srtt sample self.rttvar sample / 2 else: self.rttvar 0.75 * self.rttvar 0.25 * abs(self.srtt - sample) self.srtt 0.875 * self.srtt 0.125 * sample def rto(self): if self.srtt is None: return 0.2 return max(self.min_rto, self.srtt 4 * self.rttvar)3.3 别自己造轮子什么情况下该直接上成熟方案有个很实用的判断原则如果你的可靠层需要处理连接迁移多路复用加密握手路径 MTU 发现中的任意两项以上就别自己写了。这四件事每一项单独做都不算难但组合起来的复杂度是指数级上升的而且出问题的时候极难复现。QUIC 已经把这些都做完了而且有成熟的开源实现可以直接嵌入语言覆盖也够全。真正适合自研的场景通常是协议语义极简比如只有固定 32 字节的状态包、运行环境受限嵌入式设备内存只有几十 KB、或者需要和已有协议做深度耦合比如自定义的私有协议栈已经有大量存量设备。还有一种情况值得单独提只是想在局域网内做高可靠传输。局域网丢包率极低链路 RTT 通常小于 1ms这种情况下可靠的实现难度会大幅下降简单的序号加超时重传就够用不需要复杂的拥塞控制。但要注意一旦这套东西要跨公网前面的假设就全部失效了。4. 缓冲区、MTU与IO模型UDP工程化的三个硬约束4.1 接收缓冲区溢出UDP丢包最常见的一号元凶回到开头那个 5% 丢包的故事根因就是接收缓冲区。这里把机制讲透。每个 UDP socket 在内核里有一块接收缓冲区大小由net.core.rmem_default决定可以用SO_RCVBUF调整。数据到达网卡之后经过驱动、协议栈最终挂到这个 socket 的接收队列上。应用调用recvfrom时从队列里取走一个数据报。如果队列满了新到的包直接被丢弃不通知发送端也不通知接收端最多在/proc/net/snmp的RcvbufErrors计数上加一。这意味着两件事。第一UDP 的丢包在缓冲区这一层是静默的只能靠计数器事后发现。第二能不能扛住突发流量取决于缓冲区能装下多少个包。举个具体的数假设单个报文 1200 字节缓冲区设为 2MB那么大约能缓存的报文数是 2097152 / 1200 ≈ 1747 个扣除内核的结构体开销实际更少。如果应用处理能力是每秒 5 万包那么 2MB 缓冲区只能吸收约 35ms 的处理停顿。一旦发生一次 100ms 的 GC 或者磁盘抖动溢出就是必然的。所以调缓冲区之前先算清楚两个数峰值收包速率和最长处理停顿时间两者相乘就是最低需要的缓冲字节数再乘个 1.5 的安全系数。# 查看当前系统的 UDP 相关统计重点看 RcvbufErrors netstat -su | grep -A 10 ^Udp: # 或者直接读 proc cat /proc/net/snmp | grep ^Udp: # 查看内核缓冲区上限 sysctl net.core.rmem_default net.core.rmem_max sysctl net.core.netdev_max_backlog # tcpdump 之外用 ss 看接收队列积压情况 ss -u -a -n -i调优的时候有几个容易踩的坑。第一个是SO_RCVBUF设置的值会被内核翻倍用于内部簿记开销所以设置 4MB 实际得到 8MB反过来说如果你读回来是 8MB 不要惊讶。第二个是设置值受net.core.rmem_max限制超过部分会被静默截断所以必须先把 sysctl 调大。第三个是netdev_max_backlog这个参数管的是网卡到协议栈之间的队列很多人在收包瓶颈时只调了 socket 缓冲区忘了这一层也会丢包现象是RcvbufErrors不涨但包就是少了。注意一味把缓冲区调大并不是好事。缓冲区越大应用处理不过来时积压的包越多而这些包往往已经过时处理它们只是浪费 CPU还会掩盖真实的问题。合理的做法是调到能吸收正常的抖动就够了然后通过监控RcvbufErrors来持续观察。4.2 MTU与IP分片1472这个数字的来历和它的陷阱前面提到 1500 减去 20 减 8 得到 1472这是 IPv4 下的经典值。IPv6 的首部是 40 字节所以对应的值是 1500 - 40 - 8 1452。除此之外还有几层封装会继续吃掉空间PPPoE 要扣 8 字节VXLAN 要扣 50 字节GRE 要扣 24 字节IPsec 隧道也要扣掉几十字节。这就是为什么在云上或者跨专线的环境里按 1472 发包很可能直接被丢。分片的机制是这样的如果 IP 层的包超过了出接口 MTU且没有设置 DF 标志IP 层会把它切成多个分片分别发送接收端根据 IP 首部的标识、偏移和 MF 标志重组。问题在于只要任何一个分片丢失整个原始数据报就作废接收端无法交付半个 UDP 报文。而且重组需要占用接收端的内存缓冲区还有超时限制。在丢包率 1% 的链路上发一个 8000 字节的包需要切成 6 个分片只要其中一个丢了整包重传实际有效丢包率会放大到接近 6%。所以工程上的原则很明确尽量不让 UDP 报文触发 IP 分片。具体做法有几个层次一是把应用层的报文大小控制在 1200 字节左右。这个数字不是随便定的QUIC 选它是因为 IPv6 的最小 MTU 要求是 1280减去 40 字节 IPv6 首部和 8 字节 UDP 首部正好剩 1232再留一点余量给隧道封装。按 1200 来切包走绝大多数网络都不会分片。二是开启路径 MTU 发现PMTUD也就是设置IP_MTU_DISCOVER为IP_PMTUDISC_DO让内核在包超过 MTU 时返回EMSGSIZE而不是默默分片。这样问题会在发送端立刻暴露而不是变成对端的神秘丢包。但 PMTUD 依赖 ICMP 反馈而很多网络会屏蔽 ICMP所以实践中通常还会配一个保守的兜底值。三是如果真的需要传大块数据在应用层做分片和重组自己维护分片序号和超时丢弃而不是依赖 IP 层。import socket sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # 禁止 IP 层分片超过 MTU 直接报 EMSGSIZE sock.setsockopt(socket.IPPROTO_IP, socket.IP_MTU_DISCOVER, socket.IP_PMTUDISC_DO) # 设置发送与接收缓冲区 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024) sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1 * 1024 * 1024) try: sock.sendto(bx * 2000, (10.0.0.34, 9000)) except OSError as e: # 在开启了 PMTUD 的情况下超长报文会抛 EMSGSIZE print(send failed:, e)4.3 单socket还是多socket并发收包的几种组织方式UDP 是无连接的一个 socket 天然就能处理来自任意客户端的报文所以最直观的写法是单线程recvfrom加业务分发。这种写法在低流量下完全够用但流量一大就会遇到两个问题单核打满以及前面提到的缓冲区溢出。往下走有几条路各有利弊。多线程共享同一个 socket。多个线程同时recvfrom内核会加锁串行化实际吞吐提升有限而且在高并发下锁竞争反而会拖慢。这条路我不太推荐除非线程数很少。多进程绑同一个端口加SO_REUSEPORT。内核会按四元组哈希把报文分发到不同的 socket每个进程各自处理互不干扰。这是目前 Linux 上最实用的方案扩展性好进程挂掉一个也只影响一部分流量。缺点是哈希固定某个热点客户端可能一直落在同一个进程上需要靠增加分片数来摊平。另外要注意SO_REUSEPORT的负载均衡是按流做的对于 UDP 来说流就是四元组所以同一对 IP 端口之间的所有报文会固定落到同一个 socket。单线程加epoll加批量收取。recvmmsg一次系统调用可以收多个数据报能显著降低系统调用开销。在 10 万 pps 这个量级批量收取带来的提升是肉眼可见的。配合sendmmsg批量发送单核处理能力可以再上一个台阶。内核旁路方案。当单机需要处理百万级 pps 时常规协议栈的开销就成了瓶颈这时候会考虑 DPDK、AF_XDP 这类方案把网卡数据直接搬到用户态。代价是复杂度陡增需要独占网卡、自己做协议栈运维和调试成本都很高。除非确实撞到了性能墙否则我不建议走这条路。方案适用规模优点主要限制单线程 recvfrom万 pps 以下实现最简单单核上限无冗余多线程共享 socket万级 pps改动小锁竞争扩展性差多进程 SO_REUSEPORT十万级 pps水平扩展容错好哈希倾斜需调分片数recvmmsg 批量收发十万级 pps系统调用开销低需要处理批量语义AF_XDP / DPDK百万级 pps极致性能复杂度高运维成本大5. 广播、组播与穿透边界UDP的拓扑能力与限制5.1 单播之外的三种寻址方式TCP 只能一对一这是它的设计前提。UDP 支持三种额外的寻址方式这是它在某些场景下不可替代的根本原因。广播发往本地网段内的所有主机。IPv4 里有受限广播地址 255.255.255.255 和子网广播地址比如 192.168.1.255。使用前必须在 socket 上设置SO_BROADCAST选项否则会返回权限错误。广播的局限很明显不跨路由器只在本地网段有效而且所有主机都要消耗资源处理这个包网段里设备一多就会造成明显的广播风暴。组播是更精细的方案发送方把包发到一个组播地址只有加入了该组的主机才会收到。IPv4 的组播地址范围是 224.0.0.0/4其中 224.0.0.0/24 是本地链路保留段像 mDNS 用的 224.0.0.251、SSDP 用的 239.255.255.250 都属于约定俗成的固定地址。使用组播需要加入组IP_ADD_MEMBERSHIP并关注 TTL 设置——组播包的 TTL 决定了它能跨多少个路由器默认值 1 意味着只在本地网段生效。任播在 UDP 层面没有专门的支持它是靠路由层面把同一个地址宣告到多个位置来实现的客户端看起来是在单播实际被路由到了最近的一个节点。很多公共 DNS 服务用的就是这套机制。import socket, struct # 加入组播组并接收 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((, 5353)) mreq struct.pack(4s4s, socket.inet_aton(224.0.0.251), socket.inet_aton(0.0.0.0)) sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) while True: data, addr sock.recvfrom(2048) print(addr, len(data))实践中组播最大的坑在于中间网络设备对组播的支持程度参差不齐。交换机需要开启 IGMP snooping 才能避免把组播泛洪成广播路由器需要配置 PIM 才能跨网段转发云环境里组播往往直接被禁用。所以组播方案在落地前一定要先在目标网络环境里做连通性验证不能想当然。5.2 NAT与连接跟踪UDP会话为什么放着放着就断了UDP 本身没有连接概念但中间设备为了做地址转换和状态过滤会给它维护一份会话表。这份表的条目是有超时时间的一旦超时表项被删除后续的包就会被丢弃或者拒绝。不同设备的默认超时差异很大。常见的家用路由器和运营商级 NAT 上UDP 会话的空闲超时可能在 30 秒到 5 分钟之间。这意味着如果一个 UDP 会话超过这个时间没有任何报文往来通道就会被回收。表现出来就是客户端和服务端都觉得自己还活着但包发不过去了。解决办法很直接——保活。定期发一个小的探测包让中间设备的状态表保持活跃。间隔要比最短的超时时间更短实践中常用 15 到 25 秒。但保活本身也有代价移动端上频繁唤醒无线网卡会显著增加耗电所以在移动场景里需要配合应用层的前后台状态来动态调整。还有一种更隐蔽的情况NAT 设备重启或者表项被提前回收但客户端仍然按原来的映射发数据这时候包会被丢弃而服务端完全不知道。所以客户端必须有一个长时间收不到响应就重建会话的机制不能假设通道一直有效。提示Linux 上的nf_conntrack_udp_timeout和nf_conntrack_udp_timeout_stream两个参数控制 UDP 连接跟踪的超时前者用于没有回包的会话默认 30 秒后者用于双向都有流量的会话默认 120 秒。做网关或代理类服务时这两个值往往要调大。5.3 反射放大风险与必须做的收口UDP 有一个天然的安全弱点源地址可以伪造。因为不需要握手攻击者可以伪造受害者的 IP 向服务发送请求服务把响应发给了受害者。如果请求很小而响应很大就形成了放大效应。DNS、NTP、SSDP 这些基于 UDP 的服务历史上都因为这个问题被滥用过。作为服务提供方该做的事情很明确而且都是常规工程手段对无认证的请求做响应大小限制。如果协议允许把响应控制在请求大小的一定倍数以内最理想的是不超过请求本身的大小。对来源做速率限制和异常检测。同一来源在短时间内发送大量请求时降低响应优先级或者直接丢弃。注意这里要小心误伤NAT 后面的合法用户共享一个出口 IP限速阈值要留足余量。能加认证就加认证。哪怕是最简单的 cookie 机制也能让伪造源地址的攻击者收不到有效响应因为第二个包需要带上第一个包里的随机值。别暴露不必要的服务。开放的调试接口、状态查询接口如果响应很大又不需要认证就是最理想的放大工具。这类接口应该只监听内网地址或者干脆默认关闭。做入口流量清洗。对于规模较大的服务上游的流量清洗是兜底手段但这是最后一道防线不能替代前面的收口。6. 选型决策与压测把UDP用在正确的地方6.1 一张对照表什么时候选UDP什么时候老实回TCP做了这么多年前后端我发现选型失误的根源往往不是技术能力不够而是问题定义不清楚。下面这张表是我自己常用的判断依据按场景归类。场景特征推荐选择理由音视频通话、直播推流UDPRTP/WebRTC丢包可隐藏重传无意义实时游戏状态同步UDP状态有时效性旧包无用高频行情推送UDP 组播优先一对多延迟敏感局域网设备发现UDP 广播/组播TCP 不支持一对多大文件传输TCP 或 QUIC需要可靠且有序支付、订单类业务TCP不允许丢消息语义强依赖顺序内部服务间 RPCTCP 或 QUIC生态成熟调试工具全指标采集、日志上报UDP 加应用层补偿量大允许少量丢失跨公网的稳定长连接QUIC基于 UDP兼顾低延迟与可靠性有个判断技巧值得分享问自己如果这条消息丢了业务能不能自己发现并补救。如果能UDP 是可以考虑的如果不能而且补救成本很高那还是老实上可靠传输。很多团队选 UDP 是觉得性能好但实际瓶颈根本不在传输层换过去之后性能没提升多少可靠性问题倒是接二连三。6.2 压测怎么做才有意义指标、工具与观测点UDP 的压测和 TCP 差别很大因为发送端收不到任何反馈很容易测出发送 100 万包全部成功这种毫无意义的结论。有效的压测必须同时在发送端和接收端埋点然后对账。发送端要记录的是应用层调用sendto的次数、成功次数、失败次数区分EAGAIN、EMSGSIZE、ENOBUFS等错误码、实际发出的字节数。接收端要记录的是recvfrom收到的包数、字节数、丢包数发送数减接收数、乱序数、重复数、端到端延迟分布。工具方面iperf3的 UDP 模式可以做基础带宽测试但它只能告诉你吞吐和丢包率测不出应用层的处理能力。真正压业务逻辑还得自己写客户端或者用现成的压测框架。我通常会用一个小脚本做发包接收端跑真实服务并暴露 Prometheus 指标这样能得到最贴近生产的结论。观测点上除了应用层指标一定要看这几个内核计数器# UDP 层统计重点是 InErrors / RcvbufErrors / SndbufErrors / InCsumErrors cat /proc/net/snmp | grep ^Udp: # 网卡层丢弃看 drop 和 fifo 两列 ip -s link show eth0 # socket 队列积压 ss -u -a -n -i | head -20 # 中断分布如果集中在单个核上说明需要开 RSS 或者调整亲和性 cat /proc/interrupts | grep eth0压测的节奏也要注意。不要一上来就打满应该从小到大分几档每一档稳定跑够时间至少 60 秒让缓冲区进入稳态观察各项指标随流量增长的变化曲线。如果丢包率在某个流量点突然从 0 跳到明显的百分比那个点就是系统的实际容量上限比理论计算值靠谱得多。6.3 上线前的检查清单按我自己的习惯UDP 服务上线前会过一遍下面这些项每一条都对应过真实的故障。报文大小是否控制在 1200 字节以内有没有针对隧道封装的余量。曾经有个服务在测试环境跑得好好的上了生产走了 VXLAN 隧道1500 的包全部被丢因为隧道封装后又多了 50 字节。缓冲区是否按峰值速率和最长停顿算过rmem_max和netdev_max_backlog是否同步调过。这两个不一起调光调前者没用。保活机制是否存在间隔是否短于目标网络的最短 NAT 超时。跨运营商、跨云厂商的链路超时值差异会很大。错误处理是否覆盖了EAGAIN、EMSGSIZE、ECONNREFUSED。特别是ECONNREFUSED在对端没监听的时候如果 socket 调过connectrecvfrom会收到这个错误很多人没处理导致进程直接崩掉。监控是否覆盖了接收侧丢包、缓冲区溢出、校验和错误、乱序和重复。只在发送端埋点是不够的发送端的成功什么都不能说明。限速与收口是否做了特别是响应大小和来源速率。这一条经常被当成以后再说然后就没有以后了。容量验证是否在真实网络拓扑上做过而不是只在同机房的低延迟环境里跑。同机房 RTT 小于 0.5ms跨公网可能 30ms 以上缓冲区需求差了几十倍。回退方案是否准备好。UDP 服务出问题时能不能快速切回 TCP 或者降级到低频上报这个开关要提前做好。最后分享一个我自己踩过的坑。有次排查一个偶发的丢包问题各种计数器都正常缓冲区没溢出网卡没丢包链路也没问题最后发现是应用层的线程池队列满了任务被静默丢弃但日志里只记了处理成功——因为丢弃发生在一个try/except的宽泛捕获里。这件事之后我改了一个习惯凡是可能静默丢弃数据的地方无论在哪一层都必须有一个计数器哪怕只是一个内存里的累加值定期打到日志里。UDP 这套架构最大的特点就是没有反馈你必须自己造出反馈来否则排查问题的时候只能靠猜而猜出来的结论往往离真相很远。