一、UDP丢包:你以为的“丢包”和实际的“丢包”可能不是一回事
很多做实时类业务的开发者,比如做直播、语音通话、在线游戏的,都被UDP丢包折磨过。明明自己写的代码逻辑没问题,测试环境也跑得好好的,一上线就出现画面卡顿、声音断断续续,抓包一看确实有包没收到,却不知道问题出在哪。其实UDP丢包的坑,大多藏在它“无连接”的特性里,今天咱们就把这个坑挖透,再教你怎么精准找到丢包的位置。
先给大家掰明白一个最容易混淆的点:UDP的“无连接”到底是什么意思?简单说,就是发数据的人不管对方收没收到,直接把包扔出去;收数据的人也不会提前告诉发的人“我准备好了”,收到包就处理,没收到也不会主动问。这个特性带来的最大问题,就是“丢包”可能不是真的丢了,而是根本没被发出来、或者在某个环节被拦下来了,很多人误以为抓包没看到就是“丢了”,其实是没找对地方。
二、UDP无连接下丢包的深层成因:从发端到收端的全链路排查
咱们把一个UDP包从发端到收端的整个过程拆成几个环节,每个环节都可能出问题,而且很多问题和UDP的无连接特性直接相关。
2.1 发端的“假丢包”:包根本没发出去
很多人以为发端的问题就是代码写错了,其实不然,UDP的无连接特性让发端的很多问题更隐蔽。举个例子,发端的操作系统内核有一个UDP发送缓冲区,这个缓冲区的大小是有限的。如果发端的业务线程不停地往内核扔包,内核来不及把这些包发到网卡,缓冲区满了之后,新的包就会被内核直接扔掉——但这个过程,发端的业务代码是完全感知不到的!因为UDP的sendto函数只要把包放到内核缓冲区,就会返回成功,不管后面能不能发出去。
我之前遇到过一个直播推流的问题:发端的推流线程每秒要发100个包,每个包1KB,内核的UDP发送缓冲区默认只有16KB,也就是最多存16个包,那每秒就会有84个包被内核直接扔掉。但推流代码里sendto每次都返回0(成功),业务代码以为包都发出去了,收端自然就会出现卡顿。
再给大家举一个更细节的例子:发端的网络配置有问题,比如网卡的MTU(最大传输单元)是1500字节,业务代码发的UDP包加IP头、UDP头之后总大小是1600字节,超过了MTU。这时候内核会自动把包拆成两个分片,但如果其中一个分片丢了,整个包就废了;更坑的是,如果发端设置了“禁止分片”的标志,内核会直接把这个大包扔掉,发端的业务代码还是感知不到。
2.2 中间链路的“真丢包”:网络设备的无差别拦截
中间链路包括路由器、交换机、防火墙这些设备,这些设备对UDP包的处理逻辑和TCP完全不同。TCP有重传机制,中间设备知道如果包丢了,发端会重传,所以不会轻易扔包;但UDP没有重传,中间设备的缓冲区满了之后,会优先扔UDP包,因为扔了也不会影响链路的稳定性。
还有一个常见的问题是NAT设备的处理。很多家庭网络、公司内网都用NAT,NAT设备会维护一个映射表,记录内网的IP和端口和外网的对应关系。对于TCP来说,这个映射表是有连接状态的,只要连接没断,映射就会一直存在;但对于UDP来说,NAT设备会认为“这个包是无连接的,没用了”,过一段时间就会把映射删掉。如果发端这时候再发一个包,NAT设备不知道该把包转到哪个内网地址,就会直接扔掉。我之前做过一个在线游戏的项目,玩家在家玩的时候,经常出现突然连不上的情况,查了半天就是NAT的映射过期导致的。
2.3 收端的“隐形丢包”:包到了却没被处理
收端的丢包是最容易被误解的,很多人以为收端抓包没看到包就是中间链路丢了,其实包可能已经到了收端的网卡,只是没被业务代码收到。
第一个常见的问题是收端的UDP接收缓冲区满了。和发端类似,收端的内核也有一个UDP接收缓冲区,如果业务代码处理包的速度赶不上收包的速度,缓冲区满了之后,新到的包就会被内核扔掉。而且UDP的无连接特性让收端不会主动通知发端“我缓冲区满了,别发了”,发端还是会继续发,导致丢包越来越严重。
第二个问题是收端的网卡中断问题。现在的网卡都有“RSS(接收端缩放)”功能,会把收到的包分配到不同的CPU核心上处理。如果某个核心的负载特别高,处理不过来,分到这个核心的包就会被积压,甚至被扔掉。我之前遇到过一个语音通话的问题,收端的一个CPU核心负载达到了100%,导致分到这个核心的语音包全部被积压,声音断断续续,其他核心都很空闲,就是因为RSS的分配不均。
第三个问题是业务代码的处理逻辑问题。比如业务代码收到一个包之后,需要先解析包头,再处理数据,如果包头解析错了,就会把包当成无效包扔掉。还有一种情况是业务代码的逻辑有问题,比如收到包之后,需要先做一个耗时的操作(比如查询数据库),导致收包的线程被阻塞,新到的包就会被内核扔掉。
三、精准排查丢包的实践方法:抓包+内核统计
知道了丢包的成因,接下来就是怎么排查。我总结了一套“分层排查法”,先从发端查,再查中间链路,最后查收端,每一步都用具体的工具和方法,保证能找到问题。
3.1 排查发端:先确认包有没有真的发出去
发端排查的核心是“内核有没有把包扔了”,这里需要用到两个工具:ss和netstat,还有内核的UDP统计信息。
首先,我们可以用ss命令查看发端的UDP发送缓冲区的使用情况。ss命令可以显示套接字的详细信息,包括发送缓冲区的大小和已使用的大小。比如我们要查看进程号为1234的进程的UDP发送缓冲区:
ss -s -p | grep 1234
如果输出里的Send-Q(发送队列)的值接近Send-Buf(发送缓冲区大小),说明发送缓冲区满了,新的包会被内核扔掉。
然后,我们可以查看内核的UDP统计信息,用netstat命令:
netstat -s | grep -E 'UDP|dropped'
这个命令会输出内核的UDP统计信息,包括收到的包数、发送的包数、因为缓冲区满被扔掉的包数等。重点看“packets dropped due to full buffer”这一行,如果这个数值一直在增加,说明发端的发送缓冲区满了,一直在扔包。
举个具体的例子,我之前排查直播推流的问题时,用netstat命令查看发端的UDP统计信息,发现“packets dropped due to full buffer”每秒钟增加80多,和之前算的丢包数一致,确认是发送缓冲区满了导致的。后来我把发端的UDP发送缓冲区大小从16KB改成了128KB,问题就解决了。
3.2 排查中间链路:确认包有没有到收端
中间链路的排查最麻烦,因为我们不能直接查中间的路由器、交换机,所以需要用“两端抓包对比”的方法。
首先,我们在发端抓包,用tcpdump命令,指定要抓的UDP端口和IP:
tcpdump -i eth0 udp port 1234 and dst 192.168.1.100 -w send.pcap
这个命令会把发端发往192.168.1.100端口1234的UDP包存到send.pcap文件里。
然后,我们在收端抓包,同样用tcpdump命令:
tcpdump -i eth0 udp port 1234 and src 192.168.1.200 -w recv.pcap
这个命令会把收端收到的来自192.168.1.200端口1234的UDP包存到recv.pcap文件里。
抓包一段时间后,我们可以用tshark命令对比两个抓包文件的包数:
tshark -r send.pcap | wc -l
tshark -r recv.pcap | wc -l
如果send.pcap的包数远大于recv.pcap的包数,说明中间链路丢包了。
如果怀疑是NAT的问题,我们可以在发端和收端同时抓包,然后对比包的源IP和源端口。如果发端发的包的源IP是192.168.1.200,收端收到的包的源IP变成了另一个IP,说明NAT设备修改了源IP,这时候我们需要确认NAT的映射表有没有过期。
3.3 排查收端:确认包有没有被业务代码收到
收端排查的核心是“包到了网卡,有没有到业务代码”,这里需要用到tcpdump和内核的UDP统计信息。
首先,我们在收端抓包,用tcpdump命令:
tcpdump -i eth0 udp port 1234 -w recv.pcap
然后,我们用netstat命令查看收端的UDP统计信息:
netstat -s | grep -E 'UDP|dropped'
重点看“packets dropped due to full buffer”这一行,如果这个数值一直在增加,说明收端的接收缓冲区满了,一直在扔包。
如果收端的接收缓冲区没满,我们再查看业务代码的收包情况。比如我们的业务代码是用Python写的,监听端口1234,我们可以在代码里加日志,记录收到的包数:
import socket
# 创建UDP套接字
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 绑定端口
sock.bind(('0.0.0.0', 1234))
# 记录收到的包数
recv_count = 0
while True:
# 接收包,最大长度1024字节
data, addr = sock.recvfrom(1024)
recv_count += 1
# 每收到100个包,打印一次收包数
if recv_count % 100 == 0:
print(f"收到包数:{recv_count}")
运行这个代码一段时间后,我们对比代码打印的收包数和tcpdump抓的包数,如果两者的数值差一直在增加,说明包到了网卡,但没被业务代码收到,这时候需要排查业务代码的逻辑,比如有没有阻塞、有没有错误处理等。
四、UDP丢包的应用场景、优缺点和注意事项
4.1 应用场景
UDP因为无连接、延迟低的特性,主要用在对延迟要求高、对丢包不敏感的场景,比如:
- 实时音视频通话:比如微信语音、Zoom视频,偶尔丢一个包只会出现短暂的卡顿,不会影响整体体验。
- 在线游戏:比如王者荣耀、绝地求生,玩家的位置、动作等信息需要实时传输,丢包只会影响局部的游戏体验,不会导致整个游戏崩溃。
- 实时监控:比如工厂的设备监控,需要实时传输设备的运行数据,丢包只会导致短暂的数据缺失,不会影响整体的监控。
4.2 优缺点
UDP的优点很明显:延迟低,因为不需要建立连接,不需要重传,不需要确认;实现简单,不需要维护连接状态,不需要处理重传、超时等逻辑;开销小,UDP头只有8字节,比TCP头小很多。
但UDP的缺点也很突出:不可靠,没有重传机制,丢包了也不会主动重传;没有流量控制,发端可以不停地发包,不管收端能不能处理;没有拥塞控制,发端不会根据网络的拥塞情况调整发送速度,可能会导致网络拥塞越来越严重。
4.3 注意事项
在使用UDP的时候,需要注意以下几点:
- 不要发太大的包:尽量把UDP包的大小控制在MTU以内,避免被内核分片或者扔掉。
- 合理设置缓冲区大小:根据业务的实际情况,合理设置发端和收端的UDP缓冲区大小,避免缓冲区满了扔包。
- 自己实现可靠机制:如果业务需要可靠传输,比如文件传输,需要自己在应用层实现重传、确认、流量控制等机制。
- 做好丢包处理:如果业务允许丢包,比如实时音视频,需要做好丢包后的处理,比如用前一个包的数据填充,避免出现卡顿。
五、总结
UDP的无连接特性给我们带来了低延迟的好处,但也带来了丢包的问题。很多人遇到UDP丢包的时候,只会以为是中间网络的问题,其实丢包可能出现在发端、中间链路、收端的任何一个环节。通过分层排查的方法,先确认包有没有从发端发出去,再确认包有没有到收端,最后确认包有没有被业务代码收到,就能精准找到丢包的位置,解决问题。
最后再给大家提一个小建议:在做UDP相关的业务时,一定要做好监控,比如监控发端和收端的缓冲区使用情况、监控丢包率、监控网络延迟等,这样才能及时发现问题,避免影响业务。
评论
围绕“UDP协议无连接特性下应用层丢包现象的深层成因剖析以及借助系统抓包与内核统计进行准确排查定位的实践思路”参与讨论