QUIC 源码级深度拆解:从 varint 编码、包号加密到 0-RTT 与丢包恢复,手写一个能跑通握手的最小协议栈(附完整 Go 实现)
你大概率已经在用 QUIC 了,只是不知道。
打开浏览器 F12,随便刷个大站,Protocol 那一列写着 h3 的请求,走的就是 QUIC。你手机从 WiFi 切到 5G,视频没有卡顿一下就继续播了,也是 QUIC。CDN 厂商拿它当卖点吹了五年,云控制台上那个"开启 HTTP/3"开关,一键就完事。
但真到你要排一个"QUIC 比 TCP 还慢"的线上问题时,会发现网上 90% 的文章翻来覆去只讲三件事:0-RTT、多路复用、连接迁移。再往下问一句——
- 包号(Packet Number)为什么要单独加密?不加会怎样?
- Initial 包的密钥是用什么算出来的?为什么中间盒也能算出来?
- 丢包检测阈值 3 和 9/8 是哪来的?
- 为什么 quic-go 启动时老是警告接收缓冲区太小,不管它会怎样?
- 明明省了 RTT,为什么我压测出来 QUIC 的 CPU 占用是 TCP 的两三倍?
这些才是真正会咬你的地方。这篇文章我们不停在"科普层",直接从字节开始往下挖:varint 怎么编、包头怎么解、Initial 密钥怎么派生、包号保护怎么异或、丢包怎么判、窗口怎么涨、HTTP/3 的 QPACK 为什么要拆成三条流。代码全是 Go,能跑、能改、能拿去调试线上抓包。
篇幅很长,建议配一杯咖啡。
一、先说清楚:QUIC 到底解决了 TCP 的什么问题
技术选型这事儿,先看动机,再看方案。TCP 用了四十年好好的,为什么要重写一个?
1.1 队头阻塞在错误的层级上
HTTP/2 干了一件很聪明的事:把多个请求复用到一条 TCP 连接上,用 Stream ID 区分。逻辑上,10 个请求是并行的。
但 TCP 不知道这回事。TCP 眼里只有一条有序字节流。中间掉了一个包,内核缓冲区里后面所有已经收到的数据都得压着不交给应用——哪怕那些数据属于另外 9 个完全无关的请求。
这就是 HTTP/2 最尴尬的地方:它在应用层做了多路复用,却在传输层被重新串行化了。丢包率越高,HTTP/2 相对 HTTP/1.1 多连接的优势越小,弱网下甚至会更差。
QUIC 的解法很直接:把多路复用下沉到传输层。每条 Stream 有独立的接收缓冲和有序性保证,Stream A 丢包只阻塞 Stream A,Stream B 的数据照常交付。
1.2 握手 RTT 是叠加的
TCP + TLS 1.3 的建连成本:TCP 三次握手 1 个 RTT,TLS 1.3 握手 1 个 RTT,合计 2 个 RTT 才能发第一个字节。TLS 1.2 更惨,3 个 RTT。
QUIC 把 TLS 揉进了传输层握手里,两者共用同一批飞行中的数据包,1 个 RTT 就能开始传应用数据;如果之前连过(有会话票据),0-RTT 直接在第一个包里塞请求。
跨洋链路单向 RTT 150ms,省一个 RTT 就是省 150ms。对首屏、对短连接密集的 API 调用,这是实打实的。
1.3 协议僵化(Ossification)
这是最容易被忽略、但影响最深远的一条。
TCP 头是明文的。二十年来,全世界的 NAT、防火墙、负载均衡、运营商透明代理,都在解析、改写、甚至"优化"TCP 头。结果就是:任何对 TCP 的改动,都可能被某个中间盒当成异常流量丢掉。
TCP Fast Open 推了十年没铺开,新的 TCP 选项一加就有一定比例的连接直接黑洞。协议被中间设备"焊死"了。
QUIC 的应对是物理级的:除了极少数必须明文的字段,包头和载荷全部加密。中间盒看不懂,自然改不了,协议就能继续演进。这也是为什么 QUIC 连包号都要加密——不是为了防偷看内容,是为了防中间盒依赖它。
1.4 连接绑定在四元组上
TCP 连接 = (srcIP, srcPort, dstIP, dstPort)。手机从 WiFi 切蜂窝,源 IP 变了,连接必死,上层得重连 + 重新握手 + 拥塞窗口从头慢启动。
QUIC 用 Connection ID 标识连接,跟 IP/端口解耦。地址变了,只要 CID 对得上、路径验证通过,连接继续,拥塞状态可以部分保留。
1.5 代价:没有免费的午餐
得说实话,QUIC 不是纯赚:
- CPU 开销高:TCP 的收发、重组、ACK 全在内核,还有 GSO/GRO/TSO 一堆硬件卸载;QUIC 在用户态,每个包都要过一次系统调用、一次 AEAD 解密、一次用户态状态机。裸实现下 CPU 能到 TCP 的 2~3 倍。
- UDP 被歧视:部分企业网、校园网、运营商对 UDP 限速甚至封禁,QUIC 必须能优雅回落到 TCP。
- 调试更难:全加密,抓包默认看不懂内容,得配 keylog。
- 生态还在补齐:内核态实现(如
lxin/quic这类 in-kernel QUIC 项目)仍在推进中,是否进主线、什么时候进,以 netdev 邮件列表为准,别当既定事实。
所以下面所有的优化章节,本质上都是在还这几笔债。
二、分层全景:一个 UDP 报文里到底装了什么
先把结构装进脑子,后面拆字节才不迷路。
┌──────────────────────────────────────────────┐
│ UDP Datagram │ ← 一个 UDP 包
│ ┌────────────────────────────────────────┐ │
│ │ QUIC Packet #1 (Initial) │ │ ← 一个 UDP 包可以塞多个 QUIC 包
│ │ ┌──────────┬──────────────────────┐ │ │ (coalesced packets)
│ │ │ Header │ Payload (AEAD 加密) │ │ │
│ │ │ (部分明文)│ ┌────────────────┐ │ │ │
│ │ └──────────┤ │ CRYPTO Frame │ │ │ │ ← 载荷里是帧的序列
│ │ │ ├────────────────┤ │ │ │
│ │ │ │ PADDING Frame │ │ │ │
│ │ │ └────────────────┘ │ │ │
│ │ └──────────────────────┘ │ │
│ ├────────────────────────────────────────┤ │
│ │ QUIC Packet #2 (Handshake) │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
三个关键概念,务必分清:
| 层级 | 单位 | 关键性质 |
|---|---|---|
| Datagram | UDP 报文 | 传输单位,可包含多个 QUIC Packet |
| Packet | QUIC 包 | 加密单位 + 确认单位,有唯一递增包号 |
| Frame | 帧 | 语义单位,STREAM/ACK/CRYPTO…,可跨包重传 |
这里有个 TCP 程序员最容易踩的思维定式:QUIC 重传的是帧,不是包。包号严格单调递增,永不复用;某个包丢了,里面的 STREAM 帧会被重新打包进一个新包号的新包里发出去。这个设计是后面丢包检测能做得干净利落的根本原因(TCP 的重传歧义问题,QUIC 从根上不存在)。
三、地基:变长整数 varint
QUIC 里几乎所有长度、偏移、ID、计数都是变长整数(RFC 9000 §16)。写解析器第一步就是它。
规则极简:看最高两个 bit,决定总长度。
| 前两位 | 总字节数 | 有效位数 | 可表示范围 |
|---|---|---|---|
00 | 1 | 6 | 0 ~ 63 |
01 | 2 | 14 | 0 ~ 16383 |
10 | 4 | 30 | 0 ~ 1073741823 |
11 | 8 | 62 | 0 ~ 4611686018427387903 |
注意上限是 2^62-1,不是 2^64-1,最高两位被征用了。
Go 实现,直接可用:
package quicwire
import (
"errors"
"io"
)
var ErrShortBuffer = errors.New("quicwire: buffer too short")
const MaxVarInt = 1<<62 - 1
// ReadVarInt 解析一个变长整数,返回值和消费的字节数
func ReadVarInt(b []byte) (val uint64, n int, err error) {
if len(b) == 0 {
return 0, 0, ErrShortBuffer
}
// 高 2 bit 决定长度:1/2/4/8
length := 1 << (b[0] >> 6)
if len(b) < length {
return 0, 0, ErrShortBuffer
}
// 首字节低 6 bit 是有效数据
val = uint64(b[0] & 0x3f)
for i := 1; i < length; i++ {
val = val<<8 | uint64(b[i])
}
return val, length, nil
}
// AppendVarInt 用最短编码追加一个变长整数
func AppendVarInt(b []byte, v uint64) []byte {
switch {
case v <= 63:
return append(b, byte(v))
case v <= 16383:
return append(b, byte(v>>8)|0x40, byte(v))
case v <= 1073741823:
return append(b, byte(v>>24)|0x80, byte(v>>16), byte(v>>8), byte(v))
case v <= MaxVarInt:
return append(b,
byte(v>>56)|0xc0, byte(v>>48), byte(v>>40), byte(v>>32),
byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
default:
panic("quicwire: varint overflow")
}
}
// VarIntLen 预计算编码长度,用于组包时的空间预留
func VarIntLen(v uint64) int {
switch {
case v <= 63:
return 1
case v <= 16383:
return 2
case v <= 1073741823:
return 4
default:
return 8
}
}
// 一个便利的读取器,串行解析帧时用
type Reader struct {
buf []byte
pos int
}
func NewReader(b []byte) *Reader { return &Reader{buf: b} }
func (r *Reader) VarInt() (uint64, error) {
v, n, err := ReadVarInt(r.buf[r.pos:])
if err != nil {
return 0, err
}
r.pos += n
return v, nil
}
func (r *Reader) Byte() (byte, error) {
if r.pos >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.pos]
r.pos++
return b, nil
}
func (r *Reader) Bytes(n int) ([]byte, error) {
if r.pos+n > len(r.buf) {
return nil, io.ErrUnexpectedEOF
}
b := r.buf[r.pos : r.pos+n]
r.pos += n
return b, nil
}
func (r *Reader) Remaining() int { return len(r.buf) - r.pos }
一个工程细节:协议允许非最短编码(把 5 编成 8 字节是合法的),但你自己发包时必须用最短编码,否则白白浪费带宽。解析时要容忍,生成时要严格——这是所有二进制协议的通用纪律。
四、包头逐字节拆解
QUIC 有两种包头:长包头(握手阶段用,需要携带完整 CID 和版本)和短包头(握手完成后用,极简省流量)。
4.1 长包头(Long Header)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+
|1|1|T T|R R|P P| ← 首字节
+-+-+-+-+-+-+-+-+
| Version (32) |
+---------------------------------------------------------------+
| DCID Len (8) | Destination Connection ID (0..160) |
+---------------+-----------------------------------------------+
| SCID Len (8) | Source Connection ID (0..160) |
+---------------+-----------------------------------------------+
| [Token Length (i)] [Token (..)] ← 仅 Initial 包 |
+---------------------------------------------------------------+
| Length (i) ← 包号+载荷的总长度 |
+---------------------------------------------------------------+
| Packet Number (8/16/24/32) |
+---------------------------------------------------------------+
| Payload (..) |
+---------------------------------------------------------------+
首字节的位含义:
- bit 7 (
0x80) Header Form:1 = 长包头 - bit 6 (
0x40) Fixed Bit:必须为 1。这个 bit 的唯一作用就是让 QUIC 包能被识别,同时给"greasing"留了口子(QUIC bit greasing 扩展允许置 0,防止中间盒把它当特征) - bit 5-4 Type:
00=Initial,01=0-RTT,10=Handshake,11=Retry - bit 3-2 Reserved:解密后必须是 0,否则协议错误
- bit 1-0 Packet Number Length:值 +1 = 包号字节数(1~4)
⚠️ 关键点:bit 3-0 这四位是被 Header Protection 加密的,你抓到的原始字节里它们是密文。这就是为什么直接按结构体硬解长包头会解出垃圾——必须先去掉包号保护。
4.2 短包头(1-RTT)
+-+-+-+-+-+-+-+-+
|0|1|S|R R|K|P P|
+-+-+-+-+-+-+-+-+
| Destination Connection ID (0..160) | ← 长度靠"约定",不在包里
+---------------------------------------+
| Packet Number (8/16/24/32) |
+---------------------------------------+
| Payload (..) |
+---------------------------------------+
- bit 5 Spin Bit:唯一"故意留给中间盒"的明文信号位,两端按规则翻转,路径上的观测者可以据此估算 RTT,且不泄露其它信息
- bit 2 Key Phase:密钥更新时翻转,告诉对端"我换钥匙了"
- 没有 DCID 长度字段:接收方必须自己知道自己分配的 CID 有多长
最后这点是很多人忽略的架构杠杆:既然 CID 长度和内容由接收方自选,那你完全可以把路由信息编码进 CID——比如前 4 字节写后端实例 ID。四层负载均衡不需要解密,只要按 CID 前缀哈希就能把同一连接永远转到同一台后端,连接迁移换 IP 也不会转错。所有正经的 QUIC LB(包括各家云厂商的方案)都是这么干的。
4.3 解析代码
package quicwire
type PacketType uint8
const (
PacketInitial PacketType = 0x00
PacketZeroRTT PacketType = 0x01
PacketHandshake PacketType = 0x02
PacketRetry PacketType = 0x03
)
type LongHeader struct {
Type PacketType
Version uint32
DCID, SCID []byte
Token []byte
Length uint64 // 包号 + 载荷长度
PNOffset int // 包号字段在原始 buffer 中的偏移,做去保护时要用
FirstByte byte
}
// ParseLongHeader 解析"未去保护"的长包头:
// 此时首字节低 4 位和包号仍是密文,只能拿到 PNOffset。
func ParseLongHeader(b []byte) (*LongHeader, error) {
if len(b) < 7 {
return nil, ErrShortBuffer
}
first := b[0]
if first&0x80 == 0 {
return nil, errors.New("not a long header")
}
if first&0x40 == 0 {
return nil, errors.New("fixed bit is zero")
}
h := &LongHeader{
FirstByte: first,
Type: PacketType((first & 0x30) >> 4),
Version: uint32(b[1])<<24 | uint32(b[2])<<16 | uint32(b[3])<<8 | uint32(b[4]),
}
pos := 5
dcidLen := int(b[pos]); pos++
if dcidLen > 20 || pos+dcidLen > len(b) {
return nil, errors.New("bad DCID length")
}
h.DCID = b[pos : pos+dcidLen]; pos += dcidLen
scidLen := int(b[pos]); pos++
if scidLen > 20 || pos+scidLen > len(b) {
return nil, errors.New("bad SCID length")
}
h.SCID = b[pos : pos+scidLen]; pos += scidLen
// 版本协商包:Version == 0,后面是版本列表,没有包号
if h.Version == 0 {
return h, nil
}
// Retry 包没有 Length / Packet Number
if h.Type == PacketRetry {
return h, nil
}
if h.Type == PacketInitial {
tokenLen, n, err := ReadVarInt(b[pos:])
if err != nil {
return nil, err
}
pos += n
if uint64(pos)+tokenLen > uint64(len(b)) {
return nil, ErrShortBuffer
}
h.Token = b[pos : pos+int(tokenLen)]
pos += int(tokenLen)
}
length, n, err := ReadVarInt(b[pos:])
if err != nil {
return nil, err
}
pos += n
h.Length = length
h.PNOffset = pos // 包号从这里开始,去保护要用
return h, nil
}
五、帧层:20 多种帧,各司其职
包是信封,帧是信纸。载荷解密后就是帧的连续序列,一直解析到载荷末尾。
完整帧类型表(RFC 9000 §19 + RFC 9221):
| 类型值 | 帧 | 作用 | 丢了要重传? |
|---|---|---|---|
| 0x00 | PADDING | 填充,把 Initial 包撑到 1200 字节 | 否 |
| 0x01 | PING | 探活 / 触发对端 ACK | 否 |
| 0x02-0x03 | ACK | 确认收到哪些包号(0x03 带 ECN 计数) | 否 |
| 0x04 | RESET_STREAM | 单向放弃发送某条流 | 是 |
| 0x05 | STOP_SENDING | 让对端别再发这条流 | 是 |
| 0x06 | CRYPTO | 承载 TLS 握手消息 | 是 |
| 0x07 | NEW_TOKEN | 服务端下发地址验证 token,下次省一轮 | 是 |
| 0x08-0x0f | STREAM | 应用数据(低 3 位是 OFF/LEN/FIN 标志) | 是 |
| 0x10 | MAX_DATA | 连接级流控窗口更新 | 是 |
| 0x11 | MAX_STREAM_DATA | 流级流控窗口更新 | 是 |
| 0x12-0x13 | MAX_STREAMS | 允许对端开多少条流 | 是 |
| 0x14-0x17 | *_BLOCKED | "我被窗口卡住了"的信号,纯诊断价值 | 是 |
| 0x18 | NEW_CONNECTION_ID | 下发备用 CID,迁移和防关联要用 | 是 |
| 0x19 | RETIRE_CONNECTION_ID | 弃用某个 CID | 是 |
| 0x1a | PATH_CHALLENGE | 路径验证挑战(8 字节随机数) | 否 |
| 0x1b | PATH_RESPONSE | 原样回挑战值 | 否 |
| 0x1c-0x1d | CONNECTION_CLOSE | 关连接(0x1c 传输层错误,0x1d 应用层错误) | 否 |
| 0x1e | HANDSHAKE_DONE | 服务端宣告握手完成 | 是 |
| 0x30-0x31 | DATAGRAM | 不可靠数据报(RFC 9221),实时音视频用 | 否 |
"丢了要重传"那一列很有信息量:ACK 帧丢了不重传——因为下一个 ACK 会带上更完整的信息,重传旧 ACK 毫无意义。这个思路贯穿整个设计:能靠新状态覆盖的,绝不重传旧状态。
5.1 STREAM 帧
类型值 0b00001XXX,低三位是标志:
0x04OFF:带 Offset 字段0x02LEN:带 Length 字段(不带则"到包尾为止",用于最后一帧省 2 字节)0x01FIN:流结束
type StreamFrame struct {
StreamID uint64
Offset uint64
Data []byte
Fin bool
}
func ParseStreamFrame(r *Reader, typ byte) (*StreamFrame, error) {
f := &StreamFrame{Fin: typ&0x01 != 0}
var err error
if f.StreamID, err = r.VarInt(); err != nil {
return nil, err
}
if typ&0x04 != 0 {
if f.Offset, err = r.VarInt(); err != nil {
return nil, err
}
}
dataLen := r.Remaining()
if typ&0x02 != 0 {
l, err := r.VarInt()
if err != nil {
return nil, err
}
dataLen = int(l)
}
if f.Data, err = r.Bytes(dataLen); err != nil {
return nil, err
}
// 协议约束:offset + len 不得超过 2^62-1
if f.Offset+uint64(len(f.Data)) > MaxVarInt {
return nil, errors.New("stream data beyond max offset")
}
return f, nil
}
Stream ID 的低 2 位编码了流的属性,设计得很省:
| 低 2 位 | 发起方 | 方向 |
|---|---|---|
0x0 | 客户端 | 双向 |
0x1 | 服务端 | 双向 |
0x2 | 客户端 | 单向 |
0x3 | 服务端 | 单向 |
所以客户端第一条双向流永远是 0,第二条是 4,第三条是 8。看到抓包里 Stream ID 是 2、6、10,就知道那是客户端的单向流(HTTP/3 的控制流、QPACK 编解码流就跑在这上面)。
5.2 ACK 帧:反向 gap 编码
ACK 是 QUIC 里编码最绕的帧,但理解了就明白它为什么这么设计。
ACK Frame {
Type (i) = 0x02..0x03,
Largest Acknowledged (i), ← 从最大包号开始
ACK Delay (i), ← 收到该包到发 ACK 的延迟
ACK Range Count (i),
First ACK Range (i), ← 从 Largest 往下连续确认了多少个
ACK Range (..) ..., ← 之后是 (Gap, RangeLength) 对,一路往下
[ECN Counts (..)], ← Type=0x03 时携带
}
它是从大到小倒着编码的。为什么?因为最新的包号最有价值(用来算 RTT),放最前面;而且倒序编码时,后续的 Gap 和 Range 都是相对前一个的差值,数值小,varint 编出来就短。这就是为什么 QUIC 的 ACK 能在确认几百个包的同时只占几十字节。
ACK Delay 字段还有个坑:它的单位不是微秒,而是 2^ack_delay_exponent 微秒,这个指数由传输参数协商,默认是 3(即单位 8 微秒)。算 RTT 时忘了乘这个系数,RTT 会算小 8 倍,然后你的 PTO 会疯狂误触发。
type AckRange struct{ Smallest, Largest uint64 }
type AckFrame struct {
Ranges []AckRange // 从大到小排列
DelayRaw uint64
ECT0, ECT1, ECNCE uint64
HasECN bool
}
func ParseAckFrame(r *Reader, typ byte) (*AckFrame, error) {
f := &AckFrame{}
largest, err := r.VarInt()
if err != nil {
return nil, err
}
if f.DelayRaw, err = r.VarInt(); err != nil {
return nil, err
}
rangeCount, err := r.VarInt()
if err != nil {
return nil, err
}
firstRange, err := r.VarInt()
if err != nil {
return nil, err
}
if firstRange > largest {
return nil, errors.New("invalid first ack range")
}
smallest := largest - firstRange
f.Ranges = append(f.Ranges, AckRange{Smallest: smallest, Largest: largest})
for i := uint64(0); i < rangeCount; i++ {
gap, err := r.VarInt()
if err != nil {
return nil, err
}
length, err := r.VarInt()
if err != nil {
return nil, err
}
// 规范定义:下一个 largest = 上一个 smallest - gap - 2
if smallest < gap+2 {
return nil, errors.New("ack range underflow")
}
largest = smallest - gap - 2
if length > largest {
return nil, errors.New("ack range too long")
}
smallest = largest - length
f.Ranges = append(f.Ranges, AckRange{Smallest: smallest, Largest: largest})
}
if typ == 0x03 {
f.HasECN = true
if f.ECT0, err = r.VarInt(); err != nil { return nil, err }
if f.ECT1, err = r.VarInt(); err != nil { return nil, err }
if f.ECNCE, err = r.VarInt(); err != nil { return nil, err }
}
return f, nil
}
// ActualDelay 把原始值换算成真实时长
func (f *AckFrame) ActualDelay(ackDelayExponent uint8) time.Duration {
return time.Duration(f.DelayRaw<<ackDelayExponent) * time.Microsecond
}
那个 -2 经常有人问:Gap 编码的是"两段确认区间之间未确认包的个数减 1"。之所以减 1,是因为 gap 至少为 1 个包(否则两段就连起来了),减 1 能省编码空间。抠到这个份上,是 CDN 级流量下真的能省钱。
六、握手:TLS 1.3 不再是一层,而是一组回调
这是 QUIC 最难也最漂亮的部分。
在 TCP+TLS 的世界里,TLS 是叠在 TCP 之上的一层,它自己有记录层(TLS Record),自己管分片、自己管重传(靠 TCP)。
QUIC 把这层拆了:TLS 只保留握手状态机和密钥调度,记录层、分片、重传、确认全部交给 QUIC 传输层。TLS 握手消息装在 CRYPTO 帧里传输,丢了由 QUIC 的丢包恢复机制重传。
两者之间只剩四个接口:
- QUIC → TLS:喂握手数据(收到的 CRYPTO 帧内容)
- TLS → QUIC:吐握手数据(要发的 CRYPTO 帧内容)
- TLS → QUIC:某个加密级别的密钥就绪了
- TLS ↔ QUIC:传输参数作为 TLS 扩展携带
6.1 三个加密级别 = 三个独立包号空间
| 加密级别 | 包类型 | 密钥来源 | 包号空间 |
|---|---|---|---|
| Initial | Initial | 由 DCID 和固定 salt 派生(任何人都能算) | 独立 |
| Handshake | Handshake | TLS 握手密钥 | 独立 |
| Application | 0-RTT / 1-RTT | TLS 应用密钥 | 独立(0-RTT 与 1-RTT 共用) |
三个独立的包号空间是很多人第一次读规范时懵掉的地方:三个空间各自从 0 开始编号,ACK 帧只能确认同一空间内的包,Initial 包里的 ACK 不能确认 Handshake 包。
为什么要这么设计?因为不同级别的包用不同密钥保护,接收方可能还没拿到高级别密钥(比如先收到了乱序到达的 Handshake 包但 Initial 还没处理完)。分开编号,各自的丢包检测和 ACK 逻辑就互不干扰,不会出现"确认了一个我还解不开的包"这种逻辑矛盾。
6.2 Initial 密钥派生:为什么"加密了等于没加密"
Initial 包的密钥是用客户端第一个包里的 Destination Connection ID 加一个写死在 RFC 里的 salt 算出来的。也就是说,路径上任何一个能抓到包的人都能解开 Initial 包。
那加密的意义何在?两点:
- 防篡改(AEAD 提供完整性),中间盒改一个字节就会验证失败
- 防僵化——中间盒虽然能解,但需要实现完整的 HKDF+AEAD,成本高、还得跟着版本更新(QUIC v2 换了 salt,所有硬编码的中间盒立刻失效)。这是故意的:每次版本演进都强制中间盒重新适配,让它们不敢依赖内部结构。
派生链条(RFC 9001 §5.2):
initial_secret = HKDF-Extract(initial_salt, client_dst_connection_id)
client_initial_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
server_initial_secret = HKDF-Expand-Label(initial_secret, "server in", "", 32)
key = HKDF-Expand-Label(X_initial_secret, "quic key", "", 16)
iv = HKDF-Expand-Label(X_initial_secret, "quic iv", "", 12)
hp = HKDF-Expand-Label(X_initial_secret, "quic hp", "", 16)
完整 Go 实现(只用标准库 + golang.org/x/crypto/hkdf):
package quiccrypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/binary"
"io"
"golang.org/x/crypto/hkdf"
)
// QUIC v1 的固定 salt(RFC 9001 §5.2)
var initialSaltV1 = []byte{
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17,
0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a,
}
// hkdfExpandLabel 实现 TLS 1.3 的 HKDF-Expand-Label(RFC 8446 §7.1)
// 结构:uint16 length | uint8 labelLen | "tls13 "+label | uint8 ctxLen | context
func hkdfExpandLabel(secret []byte, label string, length int) []byte {
fullLabel := "tls13 " + label
info := make([]byte, 0, 2+1+len(fullLabel)+1)
info = binary.BigEndian.AppendUint16(info, uint16(length))
info = append(info, byte(len(fullLabel)))
info = append(info, fullLabel...)
info = append(info, 0) // 空 context
out := make([]byte, length)
r := hkdf.Expand(sha256.New, secret, info)
if _, err := io.ReadFull(r, out); err != nil {
panic(err)
}
return out
}
// PacketKeys 一个方向、一个加密级别的三件套
type PacketKeys struct {
AEAD cipher.AEAD // 载荷加解密
IV []byte // 与包号异或构造 nonce
HP cipher.Block // 包号保护用的 AES block
}
// DeriveInitialKeys 从客户端首包的 DCID 派生 Initial 密钥
func DeriveInitialKeys(dcid []byte, isClient bool) (*PacketKeys, error) {
initialSecret := hkdf.Extract(sha256.New, dcid, initialSaltV1)
label := "server in"
if isClient {
label = "client in"
}
secret := hkdfExpandLabel(initialSecret, label, 32)
key := hkdfExpandLabel(secret, "quic key", 16)
iv := hkdfExpandLabel(secret, "quic iv", 12)
hp := hkdfExpandLabel(secret, "quic hp", 16)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
hpBlock, err := aes.NewCipher(hp)
if err != nil {
return nil, err
}
return &PacketKeys{AEAD: aead, IV: iv, HP: hpBlock}, nil
}
验证方法:RFC 9001 附录 A 给了完整的测试向量(客户端 DCID 0x8394c8f03e515708)。把它喂进去,看派生出的 key 是否等于规范给的值。协议实现的第一课就是先跑通官方测试向量,别自己造数据自嗨。
6.3 载荷加密:nonce 怎么来的
// Seal 加密一个包的载荷
// header 是完整的包头(作为 AAD),pn 是完整包号
func (k *PacketKeys) Seal(dst, header, payload []byte, pn uint64) []byte {
nonce := make([]byte, len(k.IV))
// 包号右对齐写入,然后逐字节与 IV 异或
binary.BigEndian.PutUint64(nonce[len(nonce)-8:], pn)
for i := range nonce {
nonce[i] ^= k.IV[i]
}
// 包头作为 AAD 参与认证:改包头 = 解密失败
return k.AEAD.Seal(dst, nonce, payload, header)
}
func (k *PacketKeys) Open(dst, header, ciphertext []byte, pn uint64) ([]byte, error) {
nonce := make([]byte, len(k.IV))
binary.BigEndian.PutUint64(nonce[len(nonce)-8:], pn)
for i := range nonce {
nonce[i] ^= k.IV[i]
}
return k.AEAD.Open(dst, nonce, ciphertext, header)
}
注意 nonce 是 IV XOR 包号,不是随机数、不是计数器拼接。因为包号严格不重复,IV 固定,异或结果自然不重复——AEAD 最致命的 nonce 复用问题,被"包号永不复用"这条规则从根上锁死了。
这也解释了为什么 QUIC 规定包号空间用尽(2^62)必须关连接,以及为什么密钥更新有次数限制(AEAD 的使用次数上限)。
6.4 Header Protection:包号为什么要单独加密
载荷已经加密了,包号为什么还要再套一层?
因为包号必须在解密载荷之前就能读到(要用它构造 nonce),所以它不能被 AEAD 保护。如果就这么明文放着,中间盒就能看到包号——能看到就能统计、能推断丢包率、能做"优化",然后协议就又被焊死了。而且明文包号还能用来关联同一个连接迁移前后的流量,有隐私问题。
方案是一层轻量的异或掩码:
// applyHeaderProtection 对包号字段和首字节低位做异或
// pnOffset: 包号字段起始偏移
// pnLen: 包号长度 1~4
func (k *PacketKeys) applyHeaderProtection(packet []byte, pnOffset, pnLen int) error {
// 采样:从"包号字段起始 + 4"开始取 16 字节
// 这么定是因为包号最长 4 字节,+4 保证采样区一定落在密文里
sampleOffset := pnOffset + 4
if sampleOffset+16 > len(packet) {
return errors.New("packet too short for header protection sample")
}
sample := packet[sampleOffset : sampleOffset+16]
// AES-ECB 加密这 16 字节采样,得到 5 字节掩码
mask := make([]byte, 16)
k.HP.Encrypt(mask, sample)
// 首字节:长包头保护低 4 位,短包头保护低 5 位
if packet[0]&0x80 != 0 {
packet[0] ^= mask[0] & 0x0f
} else {
packet[0] ^= mask[0] & 0x1f
}
// 包号字节逐个异或 mask[1..4]
for i := 0; i < pnLen; i++ {
packet[pnOffset+i] ^= mask[i+1]
}
return nil
}
这个函数是自反的——加密和解密是同一个操作(异或两次还原)。解包流程因此有个鸡生蛋的味道:
- 先按固定偏移取采样(此时还不知道包号多长,但采样位置的定义保证了不受影响)
- 算掩码,还原首字节,这才知道包号长度
- 用包号长度还原包号字节
- 还原完整包号(网络上只传低若干位,要用"最近的期望包号"补全高位)
- 用完整包号构造 nonce,AEAD 解密载荷
包号截断还原的逻辑:
// DecodePacketNumber 从截断的包号还原完整包号(RFC 9000 附录 A.3)
// largestAcked: 已确认的最大包号;truncated: 网络上收到的截断值;pnLen: 字节数
func DecodePacketNumber(largestAcked, truncated uint64, pnLen int) uint64 {
expected := largestAcked + 1
win := uint64(1) << (uint(pnLen) * 8)
hwin := win / 2
mask := win - 1
candidate := (expected &^ mask) | truncated
if candidate+hwin <= expected && candidate+win < 1<<62 {
return candidate + win
}
if candidate > expected+hwin && candidate >= win {
return candidate - win
}
return candidate
}
踩坑提醒:只发 1 字节包号时,窗口只有 256。如果网络乱序或突发丢包超过 128 个包,还原就会错,然后 AEAD 解密失败,包被静默丢弃,表现为"莫名其妙的高丢包"。所以实现里必须根据"已确认最大包号与当前包号的差距"动态决定包号长度——差距大就多用几个字节。
七、0-RTT:省下的那个 RTT,代价你得知道
7.1 怎么工作的
第一次连接后,服务端下发 TLS session ticket(携带 PSK)。下次连接时,客户端在 Initial 包之后立即用 0-RTT 密钥发送应用数据,不等服务端回应。
省下 1 个 RTT。跨洋链路能省 150~300ms,对首屏是肉眼可见的。
7.2 三个必须知道的代价
(1)重放攻击是真实存在的
0-RTT 数据没有握手活性证明(no liveness proof)。攻击者可以录下 0-RTT 包,原样重发,服务端会当成新请求执行。
结论很硬:0-RTT 只能承载幂等请求。GET 可以,POST 转账绝对不行。HTTP/3 的实现通常只允许 GET/HEAD 走 0-RTT,其它方法自动降级到 1-RTT 重发。
// 服务端侧的防御思路:只有幂等方法走 early data
func handler(w http.ResponseWriter, r *http.Request) {
// quic-go 会在 0-RTT 请求上打标记(具体 API 见版本文档)
if isEarlyData(r) && r.Method != http.MethodGet && r.Method != http.MethodHead {
// 425 Too Early:让客户端在握手完成后重发
w.WriteHeader(http.StatusTooEarly)
return
}
// 正常处理
}
425 Too Early(RFC 8470)就是为这个场景造的状态码。
(2)反放大限制:3 倍规则
服务端在验证客户端地址之前,发送的数据量不得超过收到数据量的 3 倍。
这是为了防 DDoS 反射放大:攻击者伪造受害者 IP 发一个小包,如果服务端无限制地回一大堆握手数据,那就成了流量放大器。
这条规则的直接后果是:客户端的 Initial 包必须填充到至少 1200 字节(PADDING 帧的用武之地)。不填的话,服务端能回的数据不够放下证书链,握手会多花好几个 RTT 来回挤牙膏。
证书链特别大(比如带完整 CA 链的 RSA 4096)的时候,这条限制会实打实拖慢握手。优化手段:换 ECDSA 证书(小一个数量级)、精简证书链、或者用 NEW_TOKEN 让老客户端下次直接跳过地址验证。
(3)0-RTT 不继承拥塞状态的全部
拥塞窗口可以部分沿用之前的估计,但不能盲信。规范建议保守处理,否则一上来就打爆链路。
八、丢包检测与恢复:RFC 9002 讲了什么
这部分是 QUIC 相对 TCP 真正"设计更干净"的地方。
8.1 前提:没有重传歧义
TCP 里,重传的报文段序列号和原始报文段一样。收到 ACK 时你分不清确认的是原始包还是重传包——这就是 Karn 算法要"重传的包不参与 RTT 采样"的原因,代价是 RTT 估计在丢包时精度下降。
QUIC 里包号严格递增、永不复用,每个 ACK 都能精确对应到唯一一次发送。RTT 采样永远准确,重传的数据也能贡献 RTT 样本。
8.2 双阈值丢包判定
一个包被判定为丢失,需要满足:它比"已确认的最大包号"更旧,且满足下面任意一条:
- 包序阈值:
largest_acked - packet_number >= 3(kPacketThreshold = 3) - 时间阈值:距发送时间超过
max(9/8 × max(latest_rtt, smoothed_rtt), 1ms)(kTimeThreshold = 9/8,kGranularity = 1ms)
那个 9/8 是给网络乱序留的容忍度:允许包晚到 12.5% 个 RTT 而不被误判为丢失。数值来自大规模互联网测量的经验值,不是拍脑袋。
type SentPacket struct {
PN uint64
SentTime time.Time
Size int
AckEliciting bool
InFlight bool
Frames []Frame // 丢了要重传的帧
}
const (
kPacketThreshold = 3
kTimeThreshold = 9.0 / 8.0
kGranularity = time.Millisecond
kInitialRTT = 333 * time.Millisecond
)
// DetectLostPackets 在收到 ACK 后调用
func (s *SentPacketSpace) DetectLostPackets(now time.Time, latestRTT, smoothedRTT time.Duration) (lost []*SentPacket, lossTimer time.Time) {
// 时间阈值 = 9/8 * max(latest, smoothed),且不小于时钟粒度
maxRTT := latestRTT
if smoothedRTT > maxRTT {
maxRTT = smoothedRTT
}
lossDelay := time.Duration(float64(maxRTT) * kTimeThreshold)
if lossDelay < kGranularity {
lossDelay = kGranularity
}
lostSendTime := now.Add(-lossDelay)
for pn, p := range s.sent {
if pn > s.largestAcked {
continue
}
// 条件一:时间超了;条件二:后面已经确认了至少 3 个更新的包
if !p.SentTime.After(lostSendTime) || s.largestAcked >= pn+kPacketThreshold {
delete(s.sent, pn)
lost = append(lost, p)
} else {
// 还没到判丢时间,安排定时器
t := p.SentTime.Add(lossDelay)
if lossTimer.IsZero() || t.Before(lossTimer) {
lossTimer = t
}
}
}
return lost, lossTimer
}
8.3 PTO:兜底的探测超时
如果一个 ACK 都没回来(比如尾包全丢,没有后续包能触发 ACK),阈值检测就失效了。这时靠 PTO(Probe Timeout):
PTO = smoothed_rtt + max(4 × rttvar, kGranularity) + max_ack_delay
PTO 超时不是立刻判丢,而是发送探测包(通常是重发未确认数据,或发 PING)来"敲"出一个 ACK。连续超时则指数退避(PTO × 2^n)。
注意 max_ack_delay 那一项:对端可能故意攒一会儿再发 ACK(默认最大 25ms),不算上就会误判超时。而 Initial/Handshake 空间的 PTO 不加这一项——握手包必须立即确认。
8.4 持续拥塞(Persistent Congestion)
如果在一段时间窗口内,所有发出去的包都丢了,判定为"持续拥塞",拥塞窗口直接砸回最小值(2 个 MSS),相当于 TCP 的 RTO 崩溃恢复。窗口时长约为:
(smoothed_rtt + max(4*rttvar, kGranularity) + max_ack_delay) × kPersistentCongestionThreshold // 阈值为 3
8.5 拥塞控制:QUIC 的真正杀手锏是"可换"
RFC 9002 定义的默认算法是 NewReno,但这只是"起步价"。QUIC 拥塞控制跑在用户态,换算法不需要动内核、不需要客户端升级系统。
| 算法 | 特点 | 适用 |
|---|---|---|
| NewReno | 规范默认,丢包驱动 | 参考实现 |
| CUBIC | Linux 默认,高 BDP 下更激进 | 通用 |
| BBR / BBRv2+ | 基于带宽×时延建模,抗随机丢包 | 弱网、长肥管道、视频 |
对于视频/直播这种"宁可多占带宽也不要卡"的场景,BBR 在有随机丢包的无线链路上通常明显优于 CUBIC——因为 CUBIC 把随机丢包误解成拥塞信号,无谓降速。
这才是 QUIC 最被低估的价值:不是快那 1 个 RTT,而是传输层从此可以按业务迭代。你可以给短连接 API 用一套参数,给大文件下载用另一套,甚至按用户网络类型动态切换。这在 TCP 时代是内核参数,全局生效,改一次要评估整台机器。
九、流量控制:两层窗口与自动调窗
QUIC 有两级流控,缺一不可:
- 流级:
MAX_STREAM_DATA,限制单条流的接收偏移上限 - 连接级:
MAX_DATA,限制所有流累计的接收字节上限
为什么要两级?只有流级的话,对端开 1000 条流,每条都吃满窗口,你的内存就爆了;只有连接级的话,一条流可以霸占整个连接窗口,饿死其它流。
9.1 窗口该开多大
理论下界就是 BDP:
窗口 ≥ 带宽 × RTT
举例:期望单连接跑满 100 Mbps,RTT 50ms:
100 Mbps × 0.05 s = 5 Mbit = 625 KB
默认窗口通常只有几百 KB 甚至更小,跨洋高带宽场景下窗口才是瓶颈,跟拥塞控制一点关系没有。很多人调了半天拥塞算法没效果,其实卡在流控窗口上。
诊断方法很直接:抓 DATA_BLOCKED / STREAM_DATA_BLOCKED 帧。这两个帧存在的唯一意义就是告诉你"我被窗口卡住了"。qlog 里搜到它们,就该调大窗口了。
9.2 更新时机与自动调窗
接收方不能每收一个字节就发一次 MAX_DATA,那 ACK 风暴能把上行打满。常见策略是消费掉窗口的一半就更新一次:
type flowController struct {
bytesRead uint64 // 应用已消费
maxReceiveOffset uint64 // 当前通告的上限
windowSize uint64
maxWindowSize uint64
lastWindowUpdate time.Time
rtt time.Duration
}
// maybeUpdateWindow 返回是否需要发送 MAX_DATA,以及新的上限值
func (f *flowController) maybeUpdateWindow(now time.Time) (bool, uint64) {
remaining := f.maxReceiveOffset - f.bytesRead
if remaining > f.windowSize/2 {
return false, 0 // 还有一半以上,不急
}
// 自动调窗:如果两次更新间隔 < 4 个 RTT,说明应用消费很快,
// 窗口成了瓶颈,翻倍
if !f.lastWindowUpdate.IsZero() &&
now.Sub(f.lastWindowUpdate) < 4*f.rtt &&
f.windowSize < f.maxWindowSize {
f.windowSize = min(f.windowSize*2, f.maxWindowSize)
}
f.lastWindowUpdate = now
f.maxReceiveOffset = f.bytesRead + f.windowSize
return true, f.maxReceiveOffset
}
"间隔小于 4 个 RTT 就翻倍"是 quic-go 一类实现常用的启发式:窗口更新得越频繁,说明窗口越不够用。比让用户手工调参靠谱得多,但上限一定要设死,否则恶意对端能拿内存打你。
十、连接迁移:切网不断流是怎么做到的
10.1 流程
- 客户端 IP 变了(WiFi → 蜂窝),从新地址发包,DCID 保持不变
- 服务端按 CID 找到连接,但发现来源地址变了 → 不能直接信(可能是伪造地址的攻击)
- 服务端向新地址发
PATH_CHALLENGE(8 字节随机数) - 客户端从新地址回
PATH_RESPONSE(原样返回) - 验证通过,切换主路径;在验证完成前,对新路径仍受反放大限制约束
- 拥塞控制和 RTT 估计重置(新路径的特性完全不同)
10.2 CID 轮换与隐私
如果迁移前后用同一个 CID,路径上的观察者就能把你在 WiFi 和 4G 下的流量关联起来——这是隐私泄露。
所以 QUIC 要求:连接建立后双方通过 NEW_CONNECTION_ID 互相下发一批备用 CID,迁移时换一个没用过的 CID。观察者看到的是两条毫不相干的连接。
active_connection_id_limit 传输参数控制对端最多能存几个 CID(至少 2)。CID 池空了就没法迁移——这是移动端"切网偶尔还是断了"的一个常见原因,值得在监控里加个指标。
10.3 NAT Rebinding 不是迁移
NAT 超时后重新映射端口,客户端自己并不知情。服务端看到的现象和迁移一样(地址变了),处理方式也一样(路径验证)。但不需要客户端换 CID。
实践上,把 keepalive 间隔设得比常见 NAT 超时(UDP 通常 30s~120s,比 TCP 短得多)小是必要的。QUIC 的 max_idle_timeout 和 PING 帧就是干这个的。UDP 的 NAT 表项过期比 TCP 快得多,这是从 TCP 迁移过来最容易忽略的运维差异。
十一、HTTP/3 与 QPACK
11.1 HTTP/3 的流布局
QUIC 提供了流,HTTP/3 在上面定规矩:
- 每个请求/响应用一条新的客户端双向流(Stream 0、4、8…)
- 另有三条客户端单向流 + 三条服务端单向流:控制流、QPACK 编码器流、QPACK 解码器流
单向流的第一个 varint 是流类型:
| 值 | 流类型 |
|---|---|
| 0x00 | Control Stream(SETTINGS、GOAWAY 走这里) |
| 0x01 | Push Stream |
| 0x02 | QPACK Encoder Stream |
| 0x03 | QPACK Decoder Stream |
HTTP/3 帧类型(跟 HTTP/2 的编号不一样,别记混):
| 值 | 帧 |
|---|---|
| 0x00 | DATA |
| 0x01 | HEADERS |
| 0x04 | SETTINGS |
| 0x07 | GOAWAY |
| 0x0d | MAX_PUSH_ID |
注意 HTTP/3 没有 HTTP/2 的 WINDOW_UPDATE 和 PRIORITY 帧——流控下沉给 QUIC 了,优先级改用 Priority 头字段(RFC 9218)表达。这就是"下沉"带来的简化。
11.2 QPACK:为什么不能直接用 HPACK
HPACK(HTTP/2 的头部压缩)依赖一个双方同步的动态表,而且要求头部块严格按序处理——因为表的状态是随处理顺序演进的。
放到 QUIC 上就完蛋了:QUIC 的流之间没有全局顺序保证。Stream 8 的头部块先到、Stream 4 的后到,用 HPACK 就无法解压——HTTP/2 的队头阻塞会以另一种形式在头部压缩层复活。
QPACK 的解法是把"表更新"和"表引用"拆开:
- 编码器流(单向、有序):只传"往动态表插入条目"的指令
- 请求流上的头部块:只包含对表项的引用
- 解码器流:回传"我已经处理到第几条插入指令了",让编码器知道哪些表项可以安全引用
关键字段是每个头部块开头的 Required Insert Count:声明"解这个块,你的动态表至少要插到第 N 条"。如果解码器还没收到第 N 条插入指令,这个流就暂时阻塞,但其它流不受影响。
阻塞流的数量上限由 SETTINGS 里的 QPACK_BLOCKED_STREAMS 控制:
- 设为 0 → 完全不阻塞,但编码器只能用静态表和已确认的动态表项,压缩率下降
- 设大一点 → 压缩率高,但可能出现头部阻塞
这是个明确的可调权衡:延迟敏感(API 网关)就调小甚至设 0;带宽敏感、头部重复度高(大量相同 Cookie/UA 的场景)就调大。
QPACK 还有个 99 项的静态表(比 HPACK 的 61 项多,且面向现代 Web 优化),常见头部一个字节搞定。
十二、实战:跑起来一个 HTTP/3 服务
理论说完,动手。以 Go 生态最成熟的 quic-go 为例。
⚠️ quic-go 的 API 在大版本间变动比较频繁(比如
http3.RoundTripper在新版里改名http3.Transport,quic.Connection的类型定义也调整过)。下面代码以 v0.4x/v0.5x 的形态书写,编译不过就去 pkg.go.dev 对一下当前版本签名,不要硬套。
12.1 证书准备
# 本地自签,SAN 必须带 localhost,否则浏览器和 curl 都不认
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout key.pem -out cert.pem -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
用 EC 证书不是随便选的——上文说过反放大 3 倍限制,证书越小握手越快。
12.2 HTTP/3 服务端
package main
import (
"fmt"
"log"
"net/http"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 关键:告诉走 HTTP/1.1/2 过来的客户端"我也支持 h3"
w.Header().Set("Alt-Svc", `h3=":4433"; ma=86400`)
fmt.Fprintf(w, "proto=%s remote=%s\n", r.Proto, r.RemoteAddr)
})
server := &http3.Server{
Addr: ":4433",
Handler: mux,
QUICConfig: &quic.Config{
MaxIdleTimeout: 30 * time.Second,
KeepAlivePeriod: 15 * time.Second, // 顶住 NAT 超时
Allow0RTT: true,
MaxIncomingStreams: 1000,
InitialStreamReceiveWindow: 512 << 10, // 512 KB
MaxStreamReceiveWindow: 6 << 20, // 6 MB
InitialConnectionReceiveWindow: 1 << 20, // 1 MB
MaxConnectionReceiveWindow: 15 << 20, // 15 MB
EnableDatagrams: true, // RFC 9221
},
}
log.Println("HTTP/3 listening on :4433")
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
窗口参数解释一下:默认值对局域网够用,跨地域高带宽场景一定要放大到 BDP 之上(回看第九节的算法)。MaxStreamReceiveWindow 设 6MB 意味着单流最多能吃 6MB 内存,乘以并发流数就是内存上限——这是必须算清楚的账,不然一波并发就 OOM。
12.3 客户端 + qlog 观测
package main
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"github.com/quic-go/quic-go"
"github.com/quic-go/quic-go/http3"
"github.com/quic-go/quic-go/qlog"
)
func main() {
tr := &http3.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // 仅本地自签测试
NextProtos: []string{"h3"},
// 会话缓存是 0-RTT 的前提:没有它就永远拿不到 ticket
ClientSessionCache: tls.NewLRUClientSessionCache(64),
},
QUICConfig: &quic.Config{
// 把连接内部事件写成 qlog,用 qvis 可视化
Tracer: qlog.DefaultConnectionTracer,
},
}
defer tr.Close()
client := &http.Client{Transport: tr}
resp, err := client.Get("https://localhost:4433/")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("proto=%s status=%d body=%s", resp.Proto, resp.StatusCode, body)
_ = os.Stdout
}
运行时带上环境变量:
# qlog 输出目录(能用 qvis 在线可视化:拥塞窗口、RTT、丢包一目了然)
export QLOGDIR=/tmp/qlog
# TLS 密钥日志,Wireshark 用它解密 QUIC
export SSLKEYLOGFILE=/tmp/quic-keys.log
go run client.go
Wireshark 里:Preferences → Protocols → TLS → (Pre)-Master-Secret log filename 填上面那个文件,就能看到解密后的 QUIC 帧,包括 CRYPTO、STREAM、ACK 的完整内容。没有这一步,QUIC 调试基本等于瞎子摸象。
qlog 强烈推荐:它记录的是实现内部视角(拥塞窗口变化、丢包判定、流控更新),这些东西抓包里根本看不到。把 qlog 文件拖进 qvis 这类可视化工具,"为什么这里卡了 200ms"往往一眼就能定位。
12.4 命令行快速验证
# curl 需要用支持 HTTP/3 的构建(--version 里要有 HTTP3 特性)
curl -v --http3 -k https://localhost:4433/
# 只想确认线上站点支不支持 h3
curl -sI https://example.com | grep -i alt-svc
浏览器侧记住一个流程:浏览器不会主动尝试 h3。它先走 HTTP/2,看到响应头里的 Alt-Svc: h3=":443",才把 h3 记进 Alt-Svc 缓存,下一次请求才升级。所以你测的时候第一次看到 h2 是正常的,刷一次再看。
十三、性能优化:QUIC 慢的时候,到底慢在哪
这是最实用的一节。QUIC 部署下去性能不升反降,八成是踩了下面某一条。
13.1 UDP 接收缓冲区(最高频的坑)
quic-go 启动时如果打出这行警告:
failed to sufficiently increase receive buffer size
不要忽略它。默认的 net.core.rmem_max 往往只有几百 KB,高带宽下内核收不过来就直接丢包,表现为"QUIC 丢包率异常高",而链路本身是好的。
# 临时生效
sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000
# 永久
cat <<'EOF' | sudo tee /etc/sysctl.d/99-quic.conf
net.core.rmem_max=7500000
net.core.wmem_max=7500000
EOF
sudo sysctl --system
(具体数值按带宽×RTT 估算,7.5MB 是社区常用的起步值。)
13.2 GSO / GRO:系统调用是最大成本
TCP 时代,一次 send() 可以交给内核几十 KB,内核和网卡负责切成 MTU 大小的包(TSO)。QUIC 在用户态,天然是"一个包一次 sendmsg"——每包一次系统调用,这是 CPU 开销的大头。
解法是 UDP GSO(UDP_SEGMENT socket option):一次系统调用交给内核一大块数据 + 分段大小,内核帮你切成多个 UDP 包。接收侧对应 GRO(UDP_GRO),把多个小包合并上来一次读走。
Linux 上 quic-go 默认开启 GSO。如果遇到某些虚拟网卡/驱动的兼容问题(症状:连接建立后立刻大量丢包),可以临时关掉排查:
QUIC_GO_DISABLE_GSO=true ./yourapp
(环境变量名在不同版本可能有变化,以你用的版本文档为准。)
能开就开:批量发包能把每包 syscall 成本摊薄一个数量级,是 QUIC 从"CPU 是 TCP 的 3 倍"降到"1.2~1.5 倍"的最主要手段。
13.3 MTU 与 DPLPMTUD
QUIC 保守起步:最小 UDP 载荷 1200 字节。但实际链路多数支持 1500 MTU(有效载荷约 1350~1450)。差这 200 字节,吞吐差 15% 以上。
方案是 DPLPMTUD(RFC 8899):连接建立后主动发探测包试更大的 MTU,成功就升级,失败就退回。多数成熟实现都支持,确认它是开着的。
反面案例:隧道场景(IPSec/WireGuard/某些云内网)实际 MTU 低于 1500,而黑洞掉大包又不回 ICMP,DPLPMTUD 探测失败还得退回,白白浪费时间。这种环境下手工把上限钉死更稳。
13.4 ECN
QUIC 原生支持 ECN(ACK 帧的 0x03 变体带 ECN 计数)。ECN 让路由器在丢包之前就用标记通知拥塞,配合 BBR 这类算法能明显降低排队时延。
但真实互联网上仍有中间设备会错误处理 ECN 位。规范要求实现做"ECN 验证":发现异常就自动关闭。这个逻辑要开着,别自作聪明强制开 ECN。
13.5 优化顺序(按投入产出排)
- 调大 UDP 缓冲区 —— 5 分钟,可能是数量级收益
- 确认 GSO/GRO 生效 —— CPU 直接砍半
- 流控窗口按 BDP 调 —— 长肥管道下决定吞吐上限
- 换 ECDSA 小证书 —— 握手 RTT 直接受益
- 开 0-RTT(仅幂等请求) —— 省 1 RTT
- 按业务换拥塞算法 —— 弱网场景收益最大
- 考虑内核态/硬件卸载 —— 大规模才值得投入
13.6 怎么量:别信别人的数字,包括我的
任何"QUIC 比 TCP 快 X%"的结论离开具体环境都没意义。自己量:
# 用 tc 模拟弱网,这才是 QUIC 该赢的战场
sudo tc qdisc add dev eth0 root netem delay 100ms loss 2%
# 对照组:同一份内容,分别走 h2 和 h3
curl -w "@curl-format.txt" -o /dev/null -s --http2 https://your.site/big.bin
curl -w "@curl-format.txt" -o /dev/null -s --http3 https://your.site/big.bin
# 清理
sudo tc qdisc del dev eth0 root
curl-format.txt:
time_namelookup: %{time_namelookup}s\n
time_connect: %{time_connect}s\n
time_appconnect: %{time_appconnect}s\n
time_starttransfer: %{time_starttransfer}s\n
time_total: %{time_total}s\n
speed_download: %{speed_download} B/s\n
经验性的规律(方向性判断,不是承诺):
- 零丢包、低延迟内网:QUIC 大概率不如 TCP,因为省不到 RTT,还多了用户态 CPU 开销
- 高 RTT(跨洋):握手优势明显,短连接场景收益最大
- 有随机丢包的无线链路:QUIC 优势最大,多路复用不被队头阻塞拖累
- 超大文件单流下载:两者接近,瓶颈在拥塞控制和窗口,不在协议头
所以"要不要上 HTTP/3"没有普适答案,取决于你的用户在什么网络里。
十四、生产踩坑清单
- UDP 缓冲区没调 —— 高带宽下内核层丢包,你还在查网络。见 13.1。
- UDP 被限速/封禁 —— 部分企业网、校园网、公共 WiFi 会限制 UDP。必须保留 TCP 回落路径,Alt-Svc 机制天然支持,别把 443/TCP 关了。
- 负载均衡不认 CID —— 四层 LB 按四元组哈希,连接迁移后包被转到另一台后端,连接直接死。必须改成 CID 感知路由,把实例 ID 编进 CID。
- NAT 超时比 TCP 短很多 —— UDP 映射通常 30s 就没了。KeepAlive 间隔要比它小。
- 0-RTT 用在非幂等请求上 —— 重放漏洞,可能造成重复下单/重复扣款。只放行 GET/HEAD,其余回 425。
- 证书链太大 —— 撞上反放大 3 倍限制,握手多几个 RTT。换 ECDSA,砍掉不必要的中间证书。
- 包号长度用太短 —— 乱序超过窗口一半就解不出正确包号,表现为神秘丢包。按已确认包号动态选长度。
- 忘了 ack_delay_exponent —— RTT 算错 8 倍,PTO 疯狂误触发,吞吐雪崩。
- 流控窗口没按 BDP 调 —— 长肥管道下永远跑不满,还以为是拥塞算法的锅。抓
DATA_BLOCKED帧确认。 - 没开 qlog 就上线 —— 出了问题只能靠猜。qlog 落盘量不小,用采样(比如 1%)+ 只在异常连接开启。
- CID 池耗尽 —— 迁移时没有可用的新 CID,切网失败。监控
active_connection_id_limit与实际使用量。 - Alt-Svc 端口写错或没写 —— 浏览器永远不会升级到 h3,你以为上线了其实一个 h3 请求都没有。用
curl -sI | grep -i alt-svc验一下。 - 中间盒把 QUIC 当"未知 UDP"限速 —— 部分运营商如此。灰度时按地域/运营商分组看指标,别看全局平均。
- 在低延迟内网强行上 QUIC —— 大概率负优化。内网 RPC 用 gRPC over HTTP/2 就挺好,别为了新技术而新技术。
十五、选型决策树
你的流量主要在什么网络?
├── 移动端 / 弱网 / 跨地域公网
│ └── 上,收益最明显(多路复用 + 连接迁移 + BBR)
├── 浏览器访问的 Web 站点,静态资源多
│ └── 上,但优先让 CDN / Nginx / Caddy 做终结,业务后端不用改
├── 数据中心内网 RPC(RTT < 1ms,零丢包)
│ └── 别上,HTTP/2 或裸 TCP 更划算,QUIC 的 CPU 开销换不回收益
├── 实时音视频 / 云游戏(可接受丢帧)
│ └── 上,用 QUIC DATAGRAM(RFC 9221)+ WebTransport,比自己撸 UDP 可靠层强
└── 需要穿透企业网 / 强合规环境
└── 谨慎,UDP 可能被封,必须做好 TCP 回落和灰度
最省事的落地路径:让边缘层(Nginx 1.25+ / Caddy / 云厂商 CDN)终结 HTTP/3,回源仍走 HTTP/1.1 或 HTTP/2。用户侧拿到 QUIC 的全部好处,业务代码一行不改。只有当"浏览器直连你的 Go 服务"或"你在做 CDN/网关本身"时,才真的需要在应用里集成 quic-go。
Nginx 侧最小配置:
server {
listen 443 quic reuseport; # QUIC
listen 443 ssl; # TCP 回落,两者同端口对浏览器最友好
http2 on;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.3; # QUIC 强制 TLS 1.3
location / {
# 没这行浏览器永远不会升级到 h3
add_header Alt-Svc 'h3=":443"; ma=86400' always;
proxy_pass http://backend;
}
}
别忘了防火墙放行 UDP/443——这是"配置全对但就是不走 h3"的第一大原因。
十六、总结:QUIC 真正改变的是什么
一路拆下来,回头看几个反直觉的结论:
1. QUIC 最大的价值不是快,是可演进。 省 1 个 RTT 很好,但真正的分水岭是:传输层从内核搬进了用户态。拥塞控制可以按业务换,新特性可以随应用发版铺开,不用等操作系统升级、不用求中间盒放行。TCP Fast Open 推了十年铺不开,QUIC 的新扩展一个季度就能上线——这才是量级差异。
2. 加密不只是为了保密,是为了防僵化。 包号加密、包头加密、连中间盒能解的 Initial 包都要加密,核心目的都是"让中间设备无法依赖协议内部结构"。这是从 TCP 四十年历史里学到的最贵的一课:任何暴露给中间盒的字段,最终都会成为你演进的枷锁。
3. 设计上的干净来自一个小决定:包号永不复用。 就这一条,同时解决了重传歧义(RTT 采样永远准)、AEAD nonce 复用(安全性)、丢包检测(阈值判定简单可靠)三个问题。好的协议设计往往是这样——一个正确的基础约束,省掉后面十个补丁。
4. 没有银弹。 内网低延迟场景 QUIC 大概率是负优化;UDP 被限速的环境必须留 TCP 回落;用户态实现的 CPU 开销是真金白银的服务器成本。工程决策要看你的用户在什么网络里,不看技术潮流。
5. 想真正吃透,就去跑测试向量。 RFC 9001 附录 A 有完整的 Initial 包加解密测试向量,把本文第六节的代码跑通它,你对"密钥怎么来的、包号怎么保护的"就再也不会含糊。这比读十篇科普有用得多。
下一步想深挖的话,我建议按这个顺序:
- 读 RFC 9000(传输)的第 12、13、19 章,跳过其余
- 读 RFC 9002(恢复)的附录 A/B 伪代码,那是最凝练的实现指南
- 拿 quic-go 的
internal/ackhandler和internal/flowcontrol对照上面两份文档看,实现和规范的对应关系非常清晰 - 用 qlog + qvis 把自己服务的一条真实连接可视化一遍,你会发现很多"理论上不该发生"的事
协议这东西,看一百遍不如抓一次包。
代码均为示意实现,聚焦讲清原理,未覆盖全部边界条件与错误处理,生产环境请使用 quic-go / quiche / msquic 等成熟实现。文中 quic-go API 以 v0.4x/v0.5x 形态书写,具体签名以 pkg.go.dev 当前版本为准。