1. QUIC 要解决的问题
HTTP/2 已经做了请求多路复用,但底层仍是 TCP。TCP 把整条连接视为一个有序字节流,任何一个报文段丢失,后续所有流的数据都必须在内核缓冲区里排队等待重传——这就是队头阻塞(Head-of-Line Blocking, HOL)。同时 TCP 握手与 TLS 握手串行执行,一次冷启动要花 2 个 RTT 才能发第一个请求。
QUIC 的思路是把传输层整体搬到用户态,跑在 UDP 上:
- 流级独立重传:丢包只影响所属的那条流,其余流照常推进。
- 握手与传输合并:TLS 1.3 内嵌在 QUIC 握手里,1-RTT 建连,会话复用下可 0-RTT。
- 连接迁移:用连接 ID(Connection ID)而非四元组标识连接,手机从 Wi-Fi 切到蜂窝网络不中断。
- 用户态可演进:拥塞控制、丢包检测都在用户态,升级不必等内核。
代价是复杂度全部落到应用层。如果你只是想理解 HTTP/2 与 TLS 在 Zig 里怎么用,可以先看 /zig-tls-http2-networking/;本文从 QUIC 的字节格式讲起。协议原理的完整对照见 HTTP/3 与 QUIC 协议详解 。
2. 包格式与变长整数
QUIC 有两种包:**长包头(Long Header)**用于握手阶段,**短包头(Short Header)**用于 1-RTT 阶段。长包头首字节的最高两位固定为 1,次两位是版本相关的类型:
const PacketType = enum(u2) {
initial = 0,
zero_rtt = 1,
handshake = 2,
retry = 3,
};
const LongHeader = struct {
first: u8,
version: u32,
dcid_len: u8,
dcid: []const u8,
scid_len: u8,
scid: []const u8,
token_len: u64,
token: []const u8,
length: u64,
packet_number: u64,
};
pub fn packetType(first: u8) ?PacketType {
if (first & 0x80 == 0) return null; // 短包头
return @enumFromInt((first & 0x30) >> 4);
}
2.1 变长整数编码
QUIC 用一套自描述的变长整数(Variable-Length Integer):首字节的最高两位给出长度(1/2/4/8 字节),剩余位与后续字节拼成值。
pub fn decodeVarInt(buf: []const u8) !struct { value: u64, len: usize } {
if (buf.len == 0) return error.Truncated;
const len: usize = @as(usize, 1) << @intCast(buf[0] >> 6);
if (buf.len < len) return error.Truncated;
var v: u64 = buf[0] & 0x3f;
for (buf[1..len]) |b| {
v = (v << 8) | b;
}
return .{ .value = v, .len = len };
}
pub fn encodeVarInt(w: anytype, v: u64) !void {
if (v < (1 << 6)) {
try w.writeByte(@intCast(v));
} else if (v < (1 << 14)) {
try w.writeInt(u16, @intCast(v | 0x4000), .big);
} else if (v < (1 << 30)) {
try w.writeInt(u32, @intCast(v | 0x80000000), .big);
} else {
try w.writeInt(u64, v | 0xC000000000000000, .big);
}
}
变长整数遍布 QUIC:流 ID、偏移量、帧长度、连接 ID 长度都用它。Zig 的 std.io.Writer 泛型让编解码可以零成本地适配任意写入端(内存缓冲、socket、ArrayList)。
2.2 帧(Frame)
包体由一串帧组成,每帧以帧类型开头(也是变长整数)。1-RTT 阶段最常用的几类:
| 帧类型 | 编号 | 用途 |
|---|---|---|
PADDING | 0x00 | 填充,使 Initial 包达到 1200 字节 |
PING | 0x01 | 保活、探测路径 |
ACK | 0x02/0x03 | 确认,带延迟编码 |
CRYPTO | 0x06 | 承载 TLS 握手数据 |
STREAM | 0x08~0x0f | 应用数据,低 3 位是 FIN/LEN/OFF 标志 |
MAX_DATA | 0x10 | 连接级流控 |
MAX_STREAM_DATA | 0x11 | 流级流控 |
CONNECTION_CLOSE | 0x1c/0x1d | 关闭连接 |
STREAM 帧的低 3 位是位标志,解码时要先剥离:
pub fn parseStreamFrame(t: u64, r: anytype) !StreamFrame {
var f = StreamFrame{ .id = 0, .offset = 0, .len = 0, .fin = false, .data = &.{} };
f.fin = (t & 0x01) != 0;
const has_len = (t & 0x02) != 0;
const has_off = (t & 0x04) != 0;
f.id = (try decodeVarInt(r)).value;
if (has_off) f.offset = (try decodeVarInt(r)).value;
if (has_len) {
f.len = (try decodeVarInt(r)).value;
} else {
f.len = std.math.maxInt(u64); // 延伸到包尾
}
return f;
}
has_len 为 0 时,帧延伸到包的末尾——这是唯一的优化,省掉一个变长整数。
3. 加密层级
QUIC 的每个包都用一个**包号空间(Packet Number Space)**和对应的密钥集,共三套:
| 空间 | 密钥来源 | 用途 |
|---|---|---|
| Initial | 客户端随机数 + 固定 salt 派生的 DCID | 交换 ClientHello / ServerHello |
| Handshake | TLS 握手密钥 | 交换证书、Finished |
| Application (1-RTT) | TLS 应用密钥 | 全部应用数据 |
Initial 密钥用固定盐值派生,任何人都能解——它的存在只是为了承载 TLS 握手,不能保护机密性。派生过程是 HKDF-Extract + HKDF-Expand-Label:
const initial_salt_v1 = [_]u8{
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17,
0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a,
};
pub fn deriveInitialKeys(dcid: []const u8) !Keys {
// initial_secret = HKDF-Extract(salt, dcid)
var prk: [32]u8 = undefined;
std.crypto.kdf.hkdf.HkdfSha256.extract(&prk, dcid, &initial_salt_v1);
// client_initial_secret = HKDF-Expand-Label(prk, "client in", "", 32)
const client_secret = try hkdfExpandLabel(prk, "client in", "", 32);
return .{
.key = try hkdfExpandLabel(client_secret, "quic key", "", 16),
.iv = try hkdfExpandLabel(client_secret, "quic iv", "", 12),
.hp = try hkdfExpandLabel(client_secret, "quic hp", "", 16),
};
}
HKDF-Expand-Label 的结构是固定的:length 两字节大端 + label 长度字节 + "tls13 " ++ label + context 长度字节 + context。
3.1 头部保护(Header Protection)
QUIC 对包头做一层额外的混淆:用 hp 密钥对采样出的密文做 AES-ECB(或 ChaCha20),得到的掩码异或到首字节的低 4 位与包号字段上。这样即使包号明文可见,中间人也无法从包头推断出包号,避免流量分析。
pub fn removeHeaderProtection(pkt: []u8, pn_offset: usize, hp_key: [16]u8) !void {
// 采样:从 pn_offset+4 起取 16 字节
const sample = pkt[pn_offset + 4 ..][0..16];
var mask: [16]u8 = undefined;
var ctx = std.crypto.core.aes.Aes128.initEnc(hp_key);
ctx.encrypt(&mask, sample);
// 首字节低 4 位(长包头为低 4 位)
const long = (pkt[0] & 0x80) != 0;
const bits: u8 = if (long) 0x0f else 0x1f;
pkt[0] ^= mask[0] & bits;
// 包号长度由首字节低 2 位给出
const pn_len: usize = @as(usize, pkt[0] & 0x03) + 1;
for (0..pn_len) |i| {
pkt[pn_offset + i] ^= mask[1 + i];
}
}
采样偏移必须是 pn_offset + 4——保证无论包号是 1 字节还是 4 字节,采样窗口都不与包号字段重叠。
4. UDP 收发:批处理与 GSO/GRO
QUIC 的服务端每秒要处理海量小包,逐个 recvfrom/sendto 会被系统调用开销压垮。两个内核特性是必需的:
- GSO(Generic Segmentation Offload):一次
sendmsg提交一个「超包」,内核按 MTU 切分。 - GRO(Generic Receive Offload):内核把同流的多个小包合并,一次
recvmmsg收上来。
在 Linux 上用 UDP_SEGMENT 控制消息开启 GSO:
pub fn sendGso(fd: i32, buf: []const u8, addr: *const std.posix.sockaddr, seg_size: u16) !usize {
var cmsg_buf: [64]u8 align(8) = undefined;
const cmsg = std.posix.cmsghdr{
.len = @sizeOf(std.posix.cmsghdr) + @sizeOf(u16),
.level = std.posix.IPPROTO.UDP,
.type = 103, // UDP_SEGMENT
};
@memcpy(cmsg_buf[0..@sizeOf(std.posix.cmsghdr)], std.mem.asBytes(&cmsg));
@memcpy(cmsg_buf[@sizeOf(std.posix.cmsghdr)..][0..2], std.mem.asBytes(&seg_size));
const iov = [1]std.posix.iovec_const{.{ .base = buf.ptr, .len = buf.len }};
var msg = std.posix.msghdr_const{
.name = addr,
.namelen = @sizeOf(std.posix.sockaddr.in6),
.iov = &iov,
.iovlen = 1,
.control = &cmsg_buf,
.controllen = cmsg_buf.len,
.flags = 0,
};
return std.posix.sendmsg(fd, &msg, 0);
}
服务端 UDP socket 还需要几项调优:
# 放大接收缓冲区,避免突发丢包
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.wmem_max=26214400
# 多队列分发,配合 SO_REUSEPORT
sysctl -w net.core.rps_sock_flow_entries=32768
在 Zig 里用 setsockopt 把 SO_RCVBUFFORCE 拉到 16 MiB,再用 SO_REUSEPORT 让多个 worker 线程各自持有独立 socket:
const sz: c_int = 16 * 1024 * 1024;
std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&sz)) catch {};
std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.REUSEPORT, std.mem.asBytes(&@as(c_int, 1))) catch {};
4.1 每个包都要走的处理链
一个 UDP 数据报进入 QUIC 栈要经过:
- 按 DCID 长度做常量时间查找(连接 ID 可能是 0~20 字节,需哈希表 + 长度前缀)。
- 剥离头部保护,还原包号。
- 用对应层级的密钥 AEAD 解密(AES-128-GCM 或 ChaCha20-Poly1305)。
- 重建完整包号(只传了低若干位)。
- 解析帧、投递到对应流。
- 生成 ACK 帧,聚合后随下一个包发出。
第 4 步的包号重建容易出错:包号只传低 1~4 字节,要结合已收到的最大包号推断完整值。
pub fn reconstructPn(truncated: u64, largest: u64, pn_len: usize) u64 {
const expected = largest + 1;
const win: u64 = @as(u64, 1) << @intCast(pn_len * 8);
const half = win / 2;
var candidate = (expected & ~(win - 1)) | truncated;
if (candidate + half <= expected) candidate += win;
if (candidate > expected + half) candidate -= win;
return candidate;
}
5. HTTP/3 映射:QPACK 与请求流
HTTP/3 把 HTTP 语义映射到 QUIC 流上,规则很简洁:
- 每个请求占用一条双向流,由客户端发起。
- 首部用 QPACK 编码(QUIC 版的 HPACK),但因为有 HOL 阻塞顾虑,QPACK 把动态表更新放在单向流上,与请求流解耦。
- 服务端推送用单向流 +
PUSH_PROMISE帧。
QPACK 的三种单向流:
| 流类型 | 值 | 作用 |
|---|---|---|
| 控制流 | 0x00 | 设置、流控上限 |
| 推送流 | 0x01 | 服务端推送 |
| QPACK 编码流 | 0x02 | 动态表插入指令 |
| QPACK 解码流 | 0x03 | 确认已处理的插入 |
流 ID 的低 2 位编码了发起方与方向:0x0 客户端双向、0x1 服务端双向、0x2 客户端单向、0x3 服务端单向。
const StreamDir = enum { client_bidi, server_bidi, client_uni, server_uni };
pub fn streamDir(id: u64) StreamDir {
return @enumFromInt(@as(u2, @intCast(id & 0x03)));
}
QPACK 与 HPACK 的关键差别是不阻塞:首部块可以引用尚未收到的动态表条目,此时该请求被「阻塞」,等编码流上的插入到达后解阻塞。实现时必须维护 max_blocked_streams 上限,否则恶意客户端可以用阻塞请求耗尽内存。
6. 连接迁移与多路径
QUIC 连接由连接 ID 标识,与源/目的 IP 和端口解耦。当客户端 IP 变化(Wi-Fi 切蜂窝)时,只要它继续用同一个 DCID,服务端就能把新地址识别为同一连接。
const Conn = struct {
dcid: [20]u8,
dcid_len: u8,
peer: std.posix.sockaddr.storage,
peer_len: std.posix.socklen_t,
// 地址变化时需要重置拥塞窗口与 RTT 估计
congestion: Congestion,
rtt: RttEstimator,
};
pub fn onPacketFrom(conn: *Conn, from: *const std.posix.sockaddr, len: std.posix.socklen_t) void {
if (!addrEqual(&conn.peer, from, len)) {
// 路径变更:验证新路径后才能迁移
conn.peer = from.*;
conn.peer_len = len;
conn.congestion.reset();
conn.rtt.reset();
}
}
迁移前必须做路径验证(Path Validation):向新地址发送 PATH_CHALLENGE,收到回显的 PATH_RESPONSE 才确认该路径可达,防止放大攻击。
7. 观测与调试
QUIC 全加密,抓包工具看不懂。可行的观测手段:
- qlog:QUIC 定义的结构化事件日志(JSON 流),记录每个包的收发、丢包、拥塞窗口变化。
- 密钥日志:设置
SSLKEYLOGFILE环境变量导出 TLS 密钥,Wireshark 可据此解密。 - 计数器:
packets_dropped、packets_lost、pto_count、cwnd是最该暴露的指标。
pub const Metrics = struct {
packets_sent: u64 = 0,
packets_received: u64 = 0,
packets_lost: u64 = 0,
bytes_sent: u64 = 0,
pto_count: u64 = 0,
cwnd: u64 = 0,
pub fn observe(self: *Metrics, m: []const u8, v: u64) void {
_ = self;
_ = m;
_ = v;
}
};
用 Zig 的 std.atomic.Value(u64) 做无锁累加,避免多线程统计打点成为瓶颈:
var packets_lost = std.atomic.Value(u64).init(0);
pub fn incrLost() void {
_ = packets_lost.fetchAdd(1, .monotonic);
}
8. 实现路线建议
从零写一个生产级 QUIC 栈是数月工作量。务实的路线:
- 先做客户端:只实现 Initial + Handshake + 1-RTT 的发送路径,够用来做压测与协议学习。
- 复用 TLS 1.3 实现:QUIC 需要 TLS 1.3 的
export_keying_material与QUIC传输参数扩展,用 Zig 的std.crypto手写 TLS 1.3 状态机是必要的,但可只覆盖必要子集。 - 不要自己写拥塞控制:先用固定窗口跑通,再逐步引入 NewReno / CUBIC / BBR。
- 用 qlog 做回归:把每次测试的 qlog 存档,协议改动后做 diff。
若你的服务端已经在用 WebSocket 做实时推送,迁移到 HTTP/3 之前值得先读 /zig-websocket-realtime/ 里关于长连接保活与背压的处理。HTTP/3 在性能上的真实收益与代价,参见 HTTP/3 与 QUIC 性能实测 。
小结
QUIC 把可靠传输、加密与多路复用全部收进用户态,用 UDP 承载。在 Zig 里实现的要点:
- 变长整数是所有帧的基础,先把它写对再写别的。
extern struct+ 显式大端读写,别依赖内存布局。- Initial 密钥是公开派生的,不要误以为它有安全意义。
- 头部保护采样偏移固定为
pn_offset + 4。 - UDP 侧务必开 GSO/GRO,否则单核吞吐上不去。
- 包号重建与路径验证是两处最易写错的逻辑。
Zig 没有 GC、没有运行时,非常适合承载这类需要精确控制内存与字节布局的协议实现——每一个字节的归属都是显式的。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。