【计算基础|网络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 首部逐字段拆解
text
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
服务端:
python
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # ← SOCK_DGRAM 就是 UDP
s.bind(("127.0.0.1", 9200))
print("UDP echo 服务端 127.0.0.1:9200")
while True:
data, addr = s.recvfrom(1024) # 注意:返回 (数据, 对端地址)
print(f"收到来自 {addr}: {data.decode('utf-8', errors='replace')!r}")
s.sendto(b"echo:" + data, addr) # 必须指定对端地址
客户端:
python
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
for msg in [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}")
except socket.timeout:
print(f"发: {msg!r} -> 超时无响应")
s.close()
✅ 实测输出:
text
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 了------但这不是真的建立连接,只是内核帮你记住默认对端。
python
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("127.0.0.1", 9200)) # 不是真连接,只是绑个默认地址
s.send(b"hello") # 之后用 send 不用 sendto
print(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 报文格式
我们定义一个极简头部:
text
0 7 8 31
+--------+------------+
| type | seq(4B) | ← type: 0=DATA, 1=ACK, 2=FIN
+--------+------------+
| length(2B) | payload |
+------------+---------+
4.3 完整实现(带 30% 模拟丢包)
python
"""
简化版可靠 UDP:
- 报文: type(1B) | seq(4B, 大端) | length(2B, 大端) | payload
- 客户端发数据,服务端回 ACK;客户端超时重传
- 服务端模拟 30% 丢包,验证重传机制
"""
import socket
import struct
import time
import random
import threading
SERVER = ("127.0.0.1", 9300)
LOSS_RATE = 0.30
TIMEOUT = 0.5
def pack(type_, seq, payload):
return struct.pack("!BHI", type_, len(payload), seq) + payload
def unpack(buf):
type_, length, seq = struct.unpack("!BHI", buf[:7])
return type_, seq, buf[7:7 + length]
def server():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(SERVER)
s.settimeout(2)
expected = 0
received = []
print(f"[server] 启动,模拟丢包率 {LOSS_RATE*100:.0f}%")
deadline = time.time() + 8
while time.time() < deadline:
try:
buf, addr = s.recvfrom(2048)
except socket.timeout:
continue
type_, seq, payload = unpack(buf)
if type_ == 2:
print(f"[server] 收到 FIN, 共交付 {len(received)} 个包")
s.sendto(pack(2, 0, b""), addr)
break
if type_ != 0:
continue
# 模拟丢包
if random.random() < LOSS_RATE:
print(f"[server] 丢弃 DATA seq={seq} (模拟丢包)")
continue
if seq == expected:
received.append(payload)
expected += 1
print(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()
def client():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(TIMEOUT)
data = [f"chunk-{i:02d}".encode() for i in range(6)]
base = 0
total = len(data)
while base < total:
for seq in range(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)
if type_ == 1:
print(f"[client] 收到 ACK={ack}")
base = ack
except socket.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% 丢包)
✅ 跑一遍,重点看重传怎么救回来的:
text
[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 各用各的,内核通过五元组区分连接:
text
(协议, 源IP, 源端口, 目的IP, 目的端口)
协议不同(TCP vs UDP),五元组就不同,就是不同的 socket,不冲突。
6.2 实测代码
python
import socket
import threading
import time
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}")
def tcp_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()
def udp_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 可以同时绑定,内核按协议区分")
✅ 实测输出:
text
[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 www.rfc-editor.org/rfc/rfc768
- Python socket 官方文档 docs.python.org/3/library/s...
- QUIC 协议 RFC 9000 www.rfc-editor.org/rfc/rfc9000
- MDN:UDP 协议解释 developer.mozilla.org/zh-CN/docs/...
- Cloudflare:什么是 QUIC? www.cloudflare.com/learning/pe...
- QUIC 官方 Wiki(Chromium 项目)www.chromium.org/quic/
下一篇《【计算基础|网络14】IP 与 ICMP:路由怎么选、ping 到底在干嘛》讲:IP 首部的关键字段、IP 地址怎么分类(A/B/C/D/E)、公网和私网怎么区分、子网掩码怎么算、ping(ICMP Echo Request/Reply)和 traceroute 到底利用了 IP 的什么机制,以及 NATS 穿透的基本思路。