Rust 网络编程:从 TCP 到 QUIC,别被「下一代协议」吓到
TCP 是互联网的普通话,QUIC 是它的下一代方言——但大多数人还没学会说。
做移动端同步功能的时候,遇到一个经典问题:用户从 WiFi 切到 5G,TCP 连接直接断了,重连要三次握手 + TLS 握手,加起来 300ms+,用户感知到明显卡顿。
我开始研究 QUIC,发现它天生支持「连接迁移」——IP 变了连接不断。但网上关于 Rust QUIC 的教程要么太理论,要么跑分满天飞,实际写代码的时候还是一脸懵。
这篇文章就一个目标:从零搞懂 Rust 网络编程,从 TCP 到 QUIC,聚焦「什么时候用、怎么用、踩什么坑」。
TCP 基础——先搞懂「地基」
Rust 的异步网络编程几乎绑定 tokio,这是事实。不想用也得用,生态就是这样。
tokio 的 TCP API 设计得很直觉:TcpListener::bind → accept → 拿到 TcpStream 读写。但直觉不等于没问题,我踩了两个大坑。
坑一:Accept 循环里做重活
我第一次写的时候,在 accept 之后直接做了数据库查询和认证:
// 千万不要这样写
loop {
let (mut socket, addr) = listener.accept().await?;
let user = db.authenticate(&addr).await?; // 阻塞!
let config = load_config().await?; // 阻塞!
tokio::spawn(async move { /* ... */ });
}tokio 的 accept 循环是单线程的(默认 runtime),你在循环里做任何 .await 都会阻塞下一个连接进来。高峰期用户反馈「连不上」,查了半天发现是认证慢拖累了整个 accept。
教训:accept 循环里只做「接收」这件事,其他全部丢进 tokio::spawn。
loop {
let (socket, addr) = listener.accept().await?;
tokio::spawn(async move {
// 所有重活在这里面做
handle_connection(socket, addr).await;
});
}坑二:半包粘包——TCP 没有消息边界
TCP 是字节流,不是消息流。你发两个 write_all("hello", "world"),对面可能收到 "helloworld",也可能收到 "hel" "loworld"。
解决方案:长度前缀协议。 发送时先写 4 字节长度,再写消息体;接收时先读 4 字节长度,再读对应长度的消息体。简单粗暴,但管用。
经验法则:所有基于 TCP 的自定义协议,一定要自己做消息分帧。 别信「TCP 是可靠传输所以不用管」这种鬼话。
TLS——别裸奔
TCP 本身不加密,数据明文传输。生产环境必须加 TLS。
rustls vs native-tls
| 选项 | 底层 | 优点 | 缺点 |
|---|---|---|---|
rustls |
纯 Rust | 无系统依赖、交叉编译友好 | 不支持所有 TLS 特性 |
native-tls |
系统 OpenSSL/Schannel | 完整 TLS 支持 | 依赖系统库、交叉编译麻烦 |
我的建议:选 rustls。 除非你有特别的理由需要系统级 TLS 库。rustls 是纯 Rust 实现,编译到任何平台都不用操心 OpenSSL 版本问题。
加 TLS 的核心就两步:用 rcgen 生成自签证书(开发环境),用 tokio-rustls 包一层 TlsAcceptor。TCP 连接上来先做 TLS 握手,握手成功后拿到的 TlsStream 实现了 AsyncRead + AsyncWrite,后续正常读写就行。
最大的教训:开发环境用自签证书没问题,但一定要把证书生成逻辑封装好,别每次手动生成。 rcgen 这个 crate 一行代码生成证书,比命令行方便一百倍。
HTTP 协议演进——为什么要 QUIC
这一节不写代码,帮你理清概念。
TCP 的三个痛点
HTTP/1.1 (TCP)
┌─────────────────────────────────────────┐
│ 客户端 ←── 建连 (1 RTT) ──→ 服务器 │
│ ←── TLS (1-2 RTT) ──→ │
│ ←── 请求/响应 ──→ │
│ 问题1: 每个请求要排队(队头阻塞) │
│ 问题2: 新请求要等上一个响应回来 │
└─────────────────────────────────────────┘
HTTP/2 (TCP + TLS)
┌─────────────────────────────────────────┐
│ 客户端 ←── 建连 (1 RTT) ──→ 服务器 │
│ ←── TLS (1-2 RTT) ──→ │
│ ←── 多个 stream 并发 ──→ │
│ 解决了: 队头阻塞(应用层) │
│ 没解决: TCP 层面丢一个包,所有 stream 都等│
└─────────────────────────────────────────┘
HTTP/3 (QUIC)
┌─────────────────────────────────────────┐
│ 客户端 ←── 建连 + TLS (1 RTT) ──→ 服务 │
│ ←── 0-RTT (第二次连接) ──→ │
│ ←── 独立 stream,互不阻塞 ──→ │
│ 解决了: TCP 队头阻塞(内核层面) │
│ 额外: 连接迁移、天然加密 │
└─────────────────────────────────────────┘
| 特性 | TCP + TLS | QUIC |
|---|---|---|
| 握手延迟 | 2-3 RTT(TCP 1 + TLS 1-2) | 1 RTT(首次),0-RTT(重连) |
| 队头阻塞 | TCP 层存在(丢包阻塞所有数据) | 无(每个 stream 独立) |
| 连接迁移 | 不支持(IP 变了就断) | 支持(基于 Connection ID) |
| 加密 | 可选(TLS 是单独层) | 内置(QUIC 头部也加密) |
一句话:QUIC = TCP 的可靠传输 + TLS 的加密 + HTTP/2 的多路复用,全部在用户态实现。
QUIC 协议原理——说人话版本
QUIC 的设计哲学很简单:TCP 在内核里改不动了,那就搬到用户态重新做一个。
┌────────────────────────────────────────────┐
│ 应用程序 │
├────────────────────────────────────────────┤
│ QUIC 协议(用户态) │
│ ├── 可靠传输(像 TCP) │
│ ├── 多路复用(像 HTTP/2) │
│ ├── TLS 1.3 加密(内置,不用另外叠) │
│ └── 连接迁移(基于 Connection ID) │
├────────────────────────────────────────────┤
│ UDP(内核只管收发 UDP 包,其他不管) │
├────────────────────────────────────────────┤
│ IP 层 │
└────────────────────────────────────────────┘
几个核心设计决策:
为什么跑在 UDP 上? 不是因为 UDP 比 TCP 好,而是因为 TCP 在内核里改不动了。要加连接迁移、要改拥塞控制,得改内核代码,不现实。UDP 是最接近的「白板」——内核只管收发包,其他全交给用户态的 QUIC 自己搞。
Connection ID 取代四元组。 TCP 用(源IP:端口, 目标IP:端口)标识连接,IP 一变就断。QUIC 用一个随机生成的 Connection ID 标识连接,IP 变了只要 ID 还能路由到服务器,连接就不断。
Stream 是 QUIC 的灵魂。 TCP 一个连接只有一个字节流,一个包丢了,后面所有数据都得等。QUIC 在一个连接里开了多个独立的 Stream,每个 Stream 有自己的序号,丢包只阻塞那个 Stream,不影响其他的。这就是「多路复用不队头阻塞」的原理。
加密是内置的,不是可选的。 TCP + TLS 是两层,QUIC 把 TLS 1.3 直接集成进协议里。连包头都加密了,中间人看不到你在传什么。这是好事,但也意味着你没法像 TCP 那样用 Wireshark 直接看明文——得导入密钥才行。
最大的教训:QUIC 不是「更好的 TCP」,是「重新设计的传输层」。 它解决的不是 TCP 的 bug,而是 TCP 的架构限制。内核不想改的东西,QUIC 在用户态全做了。
QUIC 入门——为什么选 Quinn
Rust 有三个 QUIC 库:
| 库 | 背后是谁 | 特点 | 适合谁 |
|---|---|---|---|
| Quinn | 社区 | 纯 Rust、tokio 原生、30+ 版本 | 大多数 Rust 开发者 |
| Quiche | Cloudflare | Rust + C(BoringSSL)、内置 HTTP/3 | Cloudflare 生态、需要 C FFI |
| s2n-quic | AWS | Rust + C(s2n-tls)、Provider 架构 | AWS 深度集成 |
我选 Quinn,理由很简单:
- 纯 Rust,不用装 cmake、不用编译 OpenSSL
- tokio 原生,不需要自己管理事件循环
- 社区最大,Tailscale、iroh 都在用
- 配合
h3crate 就能跑 HTTP/3
QUIC 的核心概念
QUIC 的 API 设计和 TCP 很像,但有几个关键区别:
- Endpoint:对应一个 UDP socket,可以同时跑 client 和 server。TCP 里一个 socket 只能是一个角色。
- Connection:一个 QUIC 连接,对应多个 Stream。TCP 里一个连接就是一个字节流。
- Stream:独立的字节流,多个 stream 互不阻塞。这是 QUIC 解决队头阻塞的关键。
代码结构大致是:Endpoint::server() → accept() 拿到 Connecting → .await 拿到 Connection → accept_bi() 拿到 (SendStream, RecvStream) → 读写。和 TCP 的 TcpListener → accept → TcpStream 几乎一一对应,但底层是 UDP。
经验法则:QUIC 的证书验证是在连接建立时完成的,不是在 stream 层面。 一个连接建立后,所有 stream 都共享这个加密上下文。开发环境用自签证书,
rcgen一行搞定。
QUIC 的杀手锏——连接迁移
这是 QUIC 最实用的特性,也是我最初选择 QUIC 的原因。
TCP 的问题:IP 变了就断
用户在 WiFi 下:
客户端 (192.168.1.100) ←──TCP连接──→ 服务器
用户切到 5G:
客户端 (10.0.0.50) ←──???──→ 服务器
TCP 四元组变了 (源 IP 变了),连接直接断开
需要重新: TCP 握手 (1 RTT) + TLS 握手 (1-2 RTT) = 300ms+
QUIC 的方案:Connection ID
用户在 WiFi 下:
客户端 (192.168.1.100) ←──QUIC连接 (ID=42)──→ 服务器
用户切到 5G:
客户端 (10.0.0.50) ←──同一个连接 (ID=42)──→ 服务器
IP 变了?没关系,Connection ID 没变,连接继续
QUIC 用 Connection ID 标识连接,而不是四元组(源 IP、源端口、目标 IP、目标端口)。IP 变了,只要 Connection ID 还能到达服务器,连接就不断。
Quinn 里这个特性是自动的,你不需要额外配置。
最大的教训:连接迁移不是魔法。 如果 NAT 映射丢失、或者防火墙不认识这个 UDP 流,连接还是会断。但比 TCP 好太多了——TCP 是「IP 变了一定断」,QUIC 是「IP 变了不一定断」。
QUIC 的另一个杀手锏——0-RTT
第一次连接 QUIC 需要 1 RTT(和 TCP + TLS 1.2 差不多),但第二次连接可以 0-RTT——客户端直接带着数据发过去,不用等握手。
0-RTT 的坑:重放攻击。 客户端发的 0-RTT 数据可能被中间人截获并重放。所以 0-RTT 里不能放「扣款」这种操作,只能放「查询」这种重放无害的操作。
经验法则:0-RTT 适合发「请求」,不适合发「指令」。 如果你不确定操作是否幂等,老老实实用 1-RTT。
什么时候用 TCP,什么时候用 QUIC
用 TCP 的场景
- 大多数 Web 服务:HTTP/1.1 + HTTP/2 已经够用,axum/hyper 生态成熟
- 需要最大兼容性:代理、负载均衡、防火墙都认识 TCP
- 团队熟悉:tcpdump、Wireshark、tcp 调优工具链完善
- 简单场景:不需要多路复用、不需要连接迁移
用 QUIC 的场景
- 移动端应用:连接迁移是刚需,WiFi ↔ 5G 切换不断连
- 游戏/视频直播:多路复用 + 低延迟,丢一个包不卡其他流
- P2P / NAT 穿透:UDP 天然比 TCP 更容易穿洞
- 自定义协议:想要加密 + 多路复用,又不想自己叠 TLS + TCP
我的选型路径
你的场景是什么?
│
├─ Web 服务 / API → TCP + HTTP/2(用 axum/hyper)
│
├─ 移动端同步 / 实时通信 → QUIC(用 Quinn)
│
├─ 游戏服务器 → QUIC(多路复用)或 UDP(极低延迟)
│
├─ P2P → QUIC(NAT 穿透友好)
│
└─ 不确定 → 先用 TCP,遇到瓶颈再换 QUIC
总结
TCP 不是过时了,它是互联网的基石。QUIC 不是来替代 TCP 的,它是来解决 TCP 解决不了的问题的。
回到开头那个移动端同步的问题:用 QUIC 的连接迁移,WiFi 切 5G 的时候连接不断,用户感知从 300ms+ 降到了 0ms。这不是 QUIC 比 TCP 快,而是 QUIC 根本不需要重新建连。
一句话:TCP 是你的默认选择,QUIC 是你解决特定问题的武器。 别因为「QUIC 是下一代」就盲目切换,也别因为「TCP 够用」就忽视 QUIC 的价值。
如果想深入 QUIC,推荐看 Quinn 官方 book:https://quinn-rs.github.io/quinn/