【计算基础|网络13】UDP 与可靠传输
TCP 讲了两篇,好像什么好事都被它占了:可靠、按序、流控、拥塞控制。那 UDP 凭什么还活着?DNS 用它、视频通话用它、游戏用它、QUIC 还在它上面盖了 HTTP/3——一个"不可靠、无连接、不保证顺序"的协议,怎么就成了现代网络的顶流?
这篇把 UDP 讲透:首部就 8 字节长什么样、为什么不可靠还满世界用、怎么在 UDP 上自己实现可靠传输(QUIC 就是这么干的),以及一个面试必问题——TCP 和 UDP 能不能共用一个端口。全部用 Python 实测,包括一个带丢包重传的简化可靠 UDP 协议。
环境:macOS 27、Python 3.14、标准库
socket。不需要装任何第三方包。
一、UDP 是什么
结论先给:UDP 是一个"尽力而为"的无连接传输层协议,首部只有 8 字节,把数据报丢出去就不管了。
| 特性 | 说明 |
|---|---|
| 无连接 | 发数据前不用握手,sendto直接发 |
| 不可靠 | 不保证到达、不保证顺序、不保证不重复 |
| 面向报文 | 你发多长,它就发多长,不会拆也不会粘 |
| 尽最大努力交付 | 能发就发,丢了也不重传 |
| 首部开销小 | 只有 8 字节(TCP 首部至少 20 字节) |
1.1 UDP 首部逐字段拆解
0 7 8 15 16 23 24 31 +--------+--------+--------+--------+ | 源端口 | 目的端口 | ← 各 2 字节 +--------+--------+--------+--------+ | 长度 | 校验和 | ← 各 2 字节 +--------+--------+--------+--------+ | 数据(可选) | +------------------------------------+| 字段 | 长度 | 作用 |
|---|---|---|
| 源端口 | 2 字节 | 发送方端口,不需要时可以填 0 |
| 目的端口 | 2 字节 | 接收方端口 |
| 长度 | 2 字节 | UDP 首部 + 数据的总长度 |
| 校验和 | 2 字节 | 校验首部和数据是否出错;IPv4 中可以为 0(不校验),IPv6 中强制 |
🔴对比 TCP 首部:TCP 至少 20 字节,还要加序号、确认号、窗口、选项……UDP 就这 4 个字段,8 字节。这就是为什么 DNS 这种"一问一答"的场景用 UDP——省掉握手和确认的时间,一个包就搞定。
1.2 UDP vs TCP 对比表
| 维度 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接(三次握手) |
| 可靠性 | 不保证 | 保证可靠交付 |
| 顺序 | 不保证 | 保证按序 |
| 流量控制 | 无 | 有 |
| 拥塞控制 | 无 | 有 |
| 首部开销 | 8 字节 | 20+ 字节 |
| 传输效率 | 高(无握手、无确认) | 相对低 |
| 传输模式 | 面向报文(保留边界) | 面向字节流(会粘包) |
| 多播/广播 | 支持 | 不支持 |
| 适用场景 | 实时、可容忍丢包 | 要求准确传输 |
二、UDP 为什么不可靠还满世界用
"不可靠"听起来像缺陷,但很多场景下不可靠恰恰是优点:
| 场景 | 为什么用 UDP |
|---|---|
| 视频直播/语音通话 | 丢一帧无所谓,卡一下比延迟高更难受 |
| 在线游戏 | 位置信息丢一帧,下一帧就覆盖了,不用重传 |
| DNS 查询 | 一个请求一个响应,三次握手的时间比查询本身还长 |
| DHCP | 刚开机还没 IP,只能广播找 DHCP 服务器 |
| SNMP 网管 | 设备多,偶尔丢一个不影响整体监控 |
| QUIC / HTTP/3 | 在 UDP 上自己实现可靠 + 多路复用,解决 TCP 队头阻塞 |
🔴核心思路:UDP 把"可不可靠"的选择权交给应用层。业务能容忍丢包就直接用;不能容忍就自己在上面实现可靠——QUIC 就是这么干的。
三、UDP 编程实战
3.1 一个最小的 UDP echo
服务端:
importsocket s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)# ← SOCK_DGRAM 就是 UDPs.bind(("127.0.0.1",9200))print("UDP echo 服务端 127.0.0.1:9200")whileTrue:data,addr=s.recvfrom(1024)# 注意:返回 (数据, 对端地址)print(f"收到来自{addr}:{data.decode('utf-8',errors='replace')!r}")s.sendto(b"echo:"+data,addr)# 必须指定对端地址客户端:
importsocket s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)s.settimeout(2)formsgin[b"hello","你好 UDP".encode(),"再发一条".encode()]:s.sendto(msg,("127.0.0.1",9200))# 必须指定地址try:data,addr=s.recvfrom(1024)print(f"发:{msg!r}-> 收:{data!r}")exceptsocket.timeout:print(f"发:{msg!r}-> 超时无响应")s.close()✅ 实测输出:
UDP echo 服务端 127.0.0.1:9200 发: b'hello' -> 收: b'echo:hello' 发: b'\xe4\xbd\xa0\xe5\xa5\xbd UDP' -> 收: b'echo:\xe4\xbd\xa0\xe5\xa5\xbd UDP' 发: b'\xe5\x86\x8d\xe5\x8f\x91\xe4\xb8\x80\xe6\x9d\xa1' -> 收: b'echo:\xe5\x86\x8d\xe5\x8f\x91\xe4\xb8\x80\xe6\x9d\xa1'3.2 UDP 编程三个易错点
⚠️易错点 1:UDP 没有"连接"概念,sendto每次都要指定对端地址,recvfrom每次都要接收返回的地址。如果你觉得每次写地址很烦,可以connect()一下,之后就能用send/recv了——但这不是真的建立连接,只是内核帮你记住默认对端。
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)s.connect(("127.0.0.1",9200))# 不是真连接,只是绑个默认地址s.send(b"hello")# 之后用 send 不用 sendtoprint(s.recv(100))⚠️易错点 2:UDP 报文最大长度受 MTU 限制。理论上限是65535 - 8(UDP首部) - 20(IP首部) = 65507字节,但实际链路 MTU 一般是 1500 字节,超过会被 IP 层分片。分片在网络中任何一片丢了,整个 UDP 包都废了。所以 UDP 应用一般控制在 1472 字节以内。
⚠️易错点 3:IPv4 中 UDP 校验和可以为 0(不校验),IPv6 中强制校验。别依赖 UDP 校验和做应用层校验。
四、在 UDP 上实现可靠传输
UDP 不保证可靠,但业务需要可靠怎么办?答案:自己在 UDP 上加一层可靠协议。QUIC 就是这么干的。我们写一个简化版,看清它需要解决哪几个问题。
4.1 要解决的四个问题
| 问题 | TCP 怎么解决 | 我们简化版怎么做 |
|---|---|---|
| 丢包检测 | 超时 + 重复 ACK | 发送方设超时定时器,没收到 ACK 就重传 |
| 重传 | 超时重传 + 快速重传 | 超时重传 |
| 乱序 | 接收缓冲区按序交付 | 接收方缓存,按序号交付 |
| 累积确认 | ACK 号 = 下一个期望的序号 | 同样:ACK = 期望的下一个序号 |
4.2 报文格式
我们定义一个极简头部:
0 7 8 31 +--------+------------+ | type | seq(4B) | ← type: 0=DATA, 1=ACK, 2=FIN +--------+------------+ | length(2B) | payload | +------------+---------+4.3 完整实现(带 30% 模拟丢包)
""" 简化版可靠 UDP: - 报文: type(1B) | seq(4B, 大端) | length(2B, 大端) | payload - 客户端发数据,服务端回 ACK;客户端超时重传 - 服务端模拟 30% 丢包,验证重传机制 """importsocketimportstructimporttimeimportrandomimportthreading SERVER=("127.0.0.1",9300)LOSS_RATE=0.30TIMEOUT=0.5defpack(type_,seq,payload):returnstruct.pack("!BHI",type_,len(payload),seq)+payloaddefunpack(buf):type_,length,seq=struct.unpack("!BHI",buf[:7])returntype_,seq,buf[7:7+length]defserver():s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)s.bind(SERVER)s.settimeout(2)expected=0received=[]print(f"[server] 启动,模拟丢包率{LOSS_RATE*100:.0f}%")deadline=time.time()+8whiletime.time()<deadline:try:buf,addr=s.recvfrom(2048)exceptsocket.timeout:continuetype_,seq,payload=unpack(buf)iftype_==2:print(f"[server] 收到 FIN, 共交付{len(received)}个包")s.sendto(pack(2,0,b""),addr)breakiftype_!=0:continue# 模拟丢包ifrandom.random()<LOSS_RATE:print(f"[server] 丢弃 DATA seq={seq}(模拟丢包)")continueifseq==expected:received.append(payload)expected+=1print(f"[server] 交付 DATA seq={seq}payload={payload!r}")else:print(f"[server] 乱序/重复 seq={seq}, 期望{expected}, 仍回 ACK={expected}")s.sendto(pack(1,expected,b""),addr)s.close()defclient():s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)s.settimeout(TIMEOUT)data=[f"chunk-{i:02d}".encode()foriinrange(6)]base=0total=len(data)whilebase<total:forseqinrange(base,min(base+2,total)):s.sendto(pack(0,seq,data[seq]),SERVER)print(f"[client] 发送 DATA seq={seq}")try:buf,_=s.recvfrom(2048)type_,ack,_=unpack(buf)iftype_==1:print(f"[client] 收到 ACK={ack}")base=ackexceptsocket.timeout:print(f"[client] 超时,重传 base={base}")s.sendto(pack(2,0,b""),SERVER)s.close()print("[client] 全部发送完成")if__name__=="__main__":random.seed(42)threading.Thread(target=server,daemon=True).start()time.sleep(0.3)client()time.sleep(1)4.4 实测输出(30% 丢包)
✅ 跑一遍,重点看重传怎么救回来的:
[server] 启动,模拟丢包率 30% [client] 发送 DATA seq=0 [server] 交付 DATA seq=0 payload=b'chunk-00' [client] 发送 DATA seq=1 [server] 丢弃 DATA seq=1 (模拟丢包) [client] 收到 ACK=1 [client] 发送 DATA seq=1 [server] 丢弃 DATA seq=1 (模拟丢包) [client] 发送 DATA seq=2 [server] 丢弃 DATA seq=2 (模拟丢包) [client] 超时,重传 base=1 [client] 发送 DATA seq=1 [client] 发送 DATA seq=2 [server] 交付 DATA seq=1 payload=b'chunk-01' [server] 交付 DATA seq=2 payload=b'chunk-02' [client] 收到 ACK=2 ... [server] 收到 FIN, 共交付 6 个包 [client] 全部发送完成🔴 看清楚了:中间丢了 5 个包,全部通过超时重传补回来了,最终 6 个包一个不少按序交付。这就是在 UDP 上实现可靠传输的核心——序号 + 确认 + 超时重传,跟 TCP 的思路一模一样,只是从内核搬到了应用层。
4.5 发送/接收流程图
五、QUIC:UDP 上的现代可靠协议
5.1 QUIC 是什么
QUIC 是 Google 提出的传输层协议,跑在 UDP 上,是 HTTP/3 的底层传输。它解决了 TCP 的几个老问题:
| TCP 的问题 | QUIC 怎么解决 |
|---|---|
| 队头阻塞:一个包丢了,后面的包全卡住 | 传输层多路复用,不同 stream 之间互不影响 |
| 握手慢:TCP 三次握手 + TLS 握手要 2-3 RTT | 0-RTT/1-RTT就能建连 |
| 连接迁移:手机从 WiFi 切到 4G,IP 变了连接就断 | 用 Connection ID 标识连接,IP 变了连接不断 |
| TLS 不在内核 | 内置 TLS 1.3,加密是默认 |
| 拥塞控制改不动 | 应用层实现,升级快 |
5.2 QUIC 怎么在 UDP 上做到可靠
跟我们上面写的 demo 思路一样,只是工程化了:
- 自己的序号空间:每个 stream 独立编号,丢一个 stream 的包不影响其他 stream
- 自己的确认机制:选择性 ACK(SACK),不用像 TCP 那样傻等
- 自己的重传:按 stream 独立重传
- 自己的流控和拥塞控制:应用层实现,能快速迭代
🔴一句话:QUIC = UDP 上的一个用户态 TCP,但比 TCP 更现代(多路复用、0-RTT、连接迁移)。
六、TCP/UDP 能不能共用一个端口
能!这是面试高频题,答案是肯定的。我们直接实测。
6.1 原理
TCP 和 UDP 是两个独立的传输层协议,各自维护自己的端口空间。同一个端口号,TCP 和 UDP 各用各的,内核通过五元组区分连接:
(协议, 源IP, 源端口, 目的IP, 目的端口)协议不同(TCP vs UDP),五元组就不同,就是不同的 socket,不冲突。
6.2 实测代码
importsocketimportthreadingimporttime PORT=9999# ① TCP 服务端tcp_srv=socket.socket(socket.AF_INET,socket.SOCK_STREAM)tcp_srv.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)tcp_srv.bind(("127.0.0.1",PORT))tcp_srv.listen(1)print(f"[TCP] 监听 127.0.0.1:{PORT}")# ② UDP 服务端(同一端口)udp_srv=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)udp_srv.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)udp_srv.bind(("127.0.0.1",PORT))print(f"[UDP] 监听 127.0.0.1:{PORT}")deftcp_demo():c=socket.socket()c.connect(("127.0.0.1",PORT))c.sendall(b"via-tcp")conn,_=tcp_srv.accept()print("[TCP] 服务端收到:",conn.recv(100))conn.close();c.close()defudp_demo():c=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)c.sendto(b"via-udp",("127.0.0.1",PORT))data,_=udp_srv.recvfrom(100)print("[UDP] 服务端收到:",data)c.close()threading.Thread(target=tcp_demo).start()threading.Thread(target=udp_demo).start()time.sleep(1)print("结论:TCP 9999 和 UDP 9999 可以同时绑定,内核按协议区分")✅ 实测输出:
[TCP] 监听 127.0.0.1:9999 [UDP] 监听 127.0.0.1:9999 [UDP] 服务端收到: b'via-udp' [TCP] 服务端收到: b'via-tcp' 结论:TCP 9999 和 UDP 9999 可以同时绑定,内核按协议区分🔴真实例子:DNS 的 53 端口就是同时用 TCP 和 UDP 的——小查询走 UDP,区域传送(数据大)走 TCP。
七、UDP 编程注意事项速查
| 注意点 | 说明 |
|---|---|
recvfrom返回(数据, 对端地址) | 跟 TCP 的recv只返回数据不一样 |
sendto必须指定对端地址 | 或者先connect()绑定默认地址 |
| UDP 报文最大长度 | 理论 65507 字节,实际建议 ≤ 1472(MTU 1500 减 IP/UDP 首部) |
| UDP 校验和 | IPv4 可选(0 表示不校验),IPv6 强制 |
| UDP 不会粘包 | 每次sendto就是一个独立报文,recvfrom一次读一个完整报文 |
| 丢包应用层自己处理 | 内核不会帮你重传 |
总结:你真正需要记住的 7 件事
- UDP 首部只有 8 字节(源端口/目的端口/长度/校验和),比 TCP 轻太多。
- UDP 无连接、不可靠、面向报文,但视频、游戏、DNS 都用它——因为可容忍丢包 + 不需要握手。
- 需要可靠就在应用层自己加:序号 + ACK + 超时重传,QUIC 就是这么干的。
- UDP 编程三剑客:
SOCK_DGRAM、sendto/recvfrom、地址每次都要带。 - QUIC = UDP 上的现代 TCP:多路复用、0-RTT、连接迁移、内置 TLS 1.3。
- TCP 和 UDP 可以共用同一个端口,五元组里"协议"不同就是不同 socket。
- DNS 53 端口同时跑 TCP 和 UDP就是最经典的例子。
验证清单
- 能默写出 UDP 首部的四个字段
- 能说清 UDP 和 TCP 在连接/可靠性/首部/顺序上的区别
- 能写一个 UDP echo 服务端和客户端并跑通
- 能说出为什么 UDP 不需要握手还能用来做 DNS
- 能说清在 UDP 上实现可靠传输需要哪几个机制
- 能解释 QUIC 为什么跑在 UDP 上而不是自己做新的传输协议
- 能回答"TCP 和 UDP 能不能共用端口"并给出原理
- 能说出 UDP 报文实际建议的最大长度是多少、为什么
参考资源
- RFC 768:User Datagram Protocol https://www.rfc-editor.org/rfc/rfc768
- Python socket 官方文档 https://docs.python.org/3/library/socket.html
- QUIC 协议 RFC 9000 https://www.rfc-editor.org/rfc/rfc9000
- MDN:UDP 协议解释 https://developer.mozilla.org/zh-CN/docs/Glossary/UDP
- Cloudflare:什么是 QUIC? https://www.cloudflare.com/learning/performance/what-is-quic/
- QUIC 官方 Wiki(Chromium 项目)https://www.chromium.org/quic/
下一篇《【计算基础|网络14】IP 与 ICMP:路由怎么选、ping 到底在干嘛》讲:IP 首部的关键字段、IP 地址怎么分类(A/B/C/D/E)、公网和私网怎么区分、子网掩码怎么算、ping(ICMP Echo Request/Reply)和 traceroute 到底利用了 IP 的什么机制,以及 NATS 穿透的基本思路。