【计算基础|网络13】UDP 与可靠传输

【计算基础|网络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 思路一样,只是工程化了:

  1. 自己的序号空间:每个 stream 独立编号,丢一个 stream 的包不影响其他 stream
  2. 自己的确认机制:选择性 ACK(SACK),不用像 TCP 那样傻等
  3. 自己的重传:按 stream 独立重传
  4. 自己的流控和拥塞控制:应用层实现,能快速迭代

🔴 一句话: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 件事

  1. UDP 首部只有 8 字节(源端口/目的端口/长度/校验和),比 TCP 轻太多。
  2. UDP 无连接、不可靠、面向报文,但视频、游戏、DNS 都用它------因为可容忍丢包 + 不需要握手。
  3. 需要可靠就在应用层自己加:序号 + ACK + 超时重传,QUIC 就是这么干的。
  4. UDP 编程三剑客 :SOCK_DGRAM、sendto/recvfrom、地址每次都要带。
  5. QUIC = UDP 上的现代 TCP:多路复用、0-RTT、连接迁移、内置 TLS 1.3。
  6. TCP 和 UDP 可以共用同一个端口,五元组里"协议"不同就是不同 socket。
  7. DNS 53 端口同时跑 TCP 和 UDP 就是最经典的例子。

验证清单

  • 能默写出 UDP 首部的四个字段
  • 能说清 UDP 和 TCP 在连接/可靠性/首部/顺序上的区别
  • 能写一个 UDP echo 服务端和客户端并跑通
  • 能说出为什么 UDP 不需要握手还能用来做 DNS
  • 能说清在 UDP 上实现可靠传输需要哪几个机制
  • 能解释 QUIC 为什么跑在 UDP 上而不是自己做新的传输协议
  • 能回答"TCP 和 UDP 能不能共用端口"并给出原理
  • 能说出 UDP 报文实际建议的最大长度是多少、为什么

参考资源


下一篇《【计算基础|网络14】IP 与 ICMP:路由怎么选、ping 到底在干嘛》讲:IP 首部的关键字段、IP 地址怎么分类(A/B/C/D/E)、公网和私网怎么区分、子网掩码怎么算、ping(ICMP Echo Request/Reply)和 traceroute 到底利用了 IP 的什么机制,以及 NATS 穿透的基本思路。

相关推荐
不是万能的豆学长1 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
ShineWinsu1 小时前
对于HTML:HTML常见标签的解析—上
前端·面试·html·浏览器·标签·图片·超链接
阿狗童鞋1 小时前
SpringSecurity权限认证实战指南
java·后端·spring
用户125758524361 小时前
Golang后台管理框架推荐:XYGo Admin与devinggo怎么选?
后端·go·ai编程
tachibana21 小时前
Go 语言 Map 的底层实现原理
开发语言·后端·golang·make
九零HTTP1 小时前
从 Cookie 到 Token,登录态到底是怎么维持的
后端
考研保研资料分享1 小时前
北大软微软件工程保研经验:哈工大面试追问与补录风险取舍
面试·职场和发展·软件工程
Gopher_HBo2 小时前
go-grpc服务端调用链
后端
程序边界2 小时前
用了三年数据同步软件,我想好好聊聊KFS这套东西到底强在哪(上)
后端