编程 QUIC 源码级深度拆解:从 varint、包号加密到 0-RTT 与丢包恢复,手写一个能跑通握手的最小协议栈(附完整 Go 实现)

2026-08-02 06:18:46 +0800 CST views 8

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)             │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

三个关键概念,务必分清:

层级单位关键性质
DatagramUDP 报文传输单位,可包含多个 QUIC Packet
PacketQUIC 包加密单位 + 确认单位,有唯一递增包号
Frame语义单位,STREAM/ACK/CRYPTO…,可跨包重传

这里有个 TCP 程序员最容易踩的思维定式:QUIC 重传的是帧,不是包。包号严格单调递增,永不复用;某个包丢了,里面的 STREAM 帧会被重新打包进一个新包号的新包里发出去。这个设计是后面丢包检测能做得干净利落的根本原因(TCP 的重传歧义问题,QUIC 从根上不存在)。


三、地基:变长整数 varint

QUIC 里几乎所有长度、偏移、ID、计数都是变长整数(RFC 9000 §16)。写解析器第一步就是它。

规则极简:看最高两个 bit,决定总长度

前两位总字节数有效位数可表示范围
00160 ~ 63
012140 ~ 16383
104300 ~ 1073741823
118620 ~ 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 Type00=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):

类型值作用丢了要重传?
0x00PADDING填充,把 Initial 包撑到 1200 字节
0x01PING探活 / 触发对端 ACK
0x02-0x03ACK确认收到哪些包号(0x03 带 ECN 计数)
0x04RESET_STREAM单向放弃发送某条流
0x05STOP_SENDING让对端别再发这条流
0x06CRYPTO承载 TLS 握手消息
0x07NEW_TOKEN服务端下发地址验证 token,下次省一轮
0x08-0x0fSTREAM应用数据(低 3 位是 OFF/LEN/FIN 标志)
0x10MAX_DATA连接级流控窗口更新
0x11MAX_STREAM_DATA流级流控窗口更新
0x12-0x13MAX_STREAMS允许对端开多少条流
0x14-0x17*_BLOCKED"我被窗口卡住了"的信号,纯诊断价值
0x18NEW_CONNECTION_ID下发备用 CID,迁移和防关联要用
0x19RETIRE_CONNECTION_ID弃用某个 CID
0x1aPATH_CHALLENGE路径验证挑战(8 字节随机数)
0x1bPATH_RESPONSE原样回挑战值
0x1c-0x1dCONNECTION_CLOSE关连接(0x1c 传输层错误,0x1d 应用层错误)
0x1eHANDSHAKE_DONE服务端宣告握手完成
0x30-0x31DATAGRAM不可靠数据报(RFC 9221),实时音视频用

"丢了要重传"那一列很有信息量:ACK 帧丢了不重传——因为下一个 ACK 会带上更完整的信息,重传旧 ACK 毫无意义。这个思路贯穿整个设计:能靠新状态覆盖的,绝不重传旧状态

5.1 STREAM 帧

类型值 0b00001XXX,低三位是标志:

  • 0x04 OFF:带 Offset 字段
  • 0x02 LEN:带 Length 字段(不带则"到包尾为止",用于最后一帧省 2 字节)
  • 0x01 FIN:流结束
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 的丢包恢复机制重传。

两者之间只剩四个接口:

  1. QUIC → TLS:喂握手数据(收到的 CRYPTO 帧内容)
  2. TLS → QUIC:吐握手数据(要发的 CRYPTO 帧内容)
  3. TLS → QUIC:某个加密级别的密钥就绪了
  4. TLS ↔ QUIC:传输参数作为 TLS 扩展携带

6.1 三个加密级别 = 三个独立包号空间

加密级别包类型密钥来源包号空间
InitialInitial由 DCID 和固定 salt 派生(任何人都能算独立
HandshakeHandshakeTLS 握手密钥独立
Application0-RTT / 1-RTTTLS 应用密钥独立(0-RTT 与 1-RTT 共用)

三个独立的包号空间是很多人第一次读规范时懵掉的地方:三个空间各自从 0 开始编号,ACK 帧只能确认同一空间内的包,Initial 包里的 ACK 不能确认 Handshake 包。

为什么要这么设计?因为不同级别的包用不同密钥保护,接收方可能还没拿到高级别密钥(比如先收到了乱序到达的 Handshake 包但 Initial 还没处理完)。分开编号,各自的丢包检测和 ACK 逻辑就互不干扰,不会出现"确认了一个我还解不开的包"这种逻辑矛盾。

6.2 Initial 密钥派生:为什么"加密了等于没加密"

Initial 包的密钥是用客户端第一个包里的 Destination Connection ID 加一个写死在 RFC 里的 salt 算出来的。也就是说,路径上任何一个能抓到包的人都能解开 Initial 包。

那加密的意义何在?两点:

  1. 防篡改(AEAD 提供完整性),中间盒改一个字节就会验证失败
  2. 防僵化——中间盒虽然能解,但需要实现完整的 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
}

这个函数是自反的——加密和解密是同一个操作(异或两次还原)。解包流程因此有个鸡生蛋的味道:

  1. 先按固定偏移取采样(此时还不知道包号多长,但采样位置的定义保证了不受影响)
  2. 算掩码,还原首字节,这才知道包号长度
  3. 用包号长度还原包号字节
  4. 还原完整包号(网络上只传低若干位,要用"最近的期望包号"补全高位)
  5. 用完整包号构造 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规范默认,丢包驱动参考实现
CUBICLinux 默认,高 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 流程

  1. 客户端 IP 变了(WiFi → 蜂窝),从新地址发包,DCID 保持不变
  2. 服务端按 CID 找到连接,但发现来源地址变了 → 不能直接信(可能是伪造地址的攻击)
  3. 服务端向新地址PATH_CHALLENGE(8 字节随机数)
  4. 客户端从新地址回 PATH_RESPONSE(原样返回)
  5. 验证通过,切换主路径;在验证完成前,对新路径仍受反放大限制约束
  6. 拥塞控制和 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 是流类型:

流类型
0x00Control Stream(SETTINGS、GOAWAY 走这里)
0x01Push Stream
0x02QPACK Encoder Stream
0x03QPACK Decoder Stream

HTTP/3 帧类型(跟 HTTP/2 的编号不一样,别记混):

0x00DATA
0x01HEADERS
0x04SETTINGS
0x07GOAWAY
0x0dMAX_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.Transportquic.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 优化顺序(按投入产出排)

  1. 调大 UDP 缓冲区 —— 5 分钟,可能是数量级收益
  2. 确认 GSO/GRO 生效 —— CPU 直接砍半
  3. 流控窗口按 BDP 调 —— 长肥管道下决定吞吐上限
  4. 换 ECDSA 小证书 —— 握手 RTT 直接受益
  5. 开 0-RTT(仅幂等请求) —— 省 1 RTT
  6. 按业务换拥塞算法 —— 弱网场景收益最大
  7. 考虑内核态/硬件卸载 —— 大规模才值得投入

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"没有普适答案,取决于你的用户在什么网络里。


十四、生产踩坑清单

  1. UDP 缓冲区没调 —— 高带宽下内核层丢包,你还在查网络。见 13.1。
  2. UDP 被限速/封禁 —— 部分企业网、校园网、公共 WiFi 会限制 UDP。必须保留 TCP 回落路径,Alt-Svc 机制天然支持,别把 443/TCP 关了。
  3. 负载均衡不认 CID —— 四层 LB 按四元组哈希,连接迁移后包被转到另一台后端,连接直接死。必须改成 CID 感知路由,把实例 ID 编进 CID。
  4. NAT 超时比 TCP 短很多 —— UDP 映射通常 30s 就没了。KeepAlive 间隔要比它小。
  5. 0-RTT 用在非幂等请求上 —— 重放漏洞,可能造成重复下单/重复扣款。只放行 GET/HEAD,其余回 425。
  6. 证书链太大 —— 撞上反放大 3 倍限制,握手多几个 RTT。换 ECDSA,砍掉不必要的中间证书。
  7. 包号长度用太短 —— 乱序超过窗口一半就解不出正确包号,表现为神秘丢包。按已确认包号动态选长度。
  8. 忘了 ack_delay_exponent —— RTT 算错 8 倍,PTO 疯狂误触发,吞吐雪崩。
  9. 流控窗口没按 BDP 调 —— 长肥管道下永远跑不满,还以为是拥塞算法的锅。抓 DATA_BLOCKED 帧确认。
  10. 没开 qlog 就上线 —— 出了问题只能靠猜。qlog 落盘量不小,用采样(比如 1%)+ 只在异常连接开启。
  11. CID 池耗尽 —— 迁移时没有可用的新 CID,切网失败。监控 active_connection_id_limit 与实际使用量。
  12. Alt-Svc 端口写错或没写 —— 浏览器永远不会升级到 h3,你以为上线了其实一个 h3 请求都没有。用 curl -sI | grep -i alt-svc 验一下。
  13. 中间盒把 QUIC 当"未知 UDP"限速 —— 部分运营商如此。灰度时按地域/运营商分组看指标,别看全局平均。
  14. 在低延迟内网强行上 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-gointernal/ackhandlerinternal/flowcontrol 对照上面两份文档看,实现和规范的对应关系非常清晰
  • qlog + qvis 把自己服务的一条真实连接可视化一遍,你会发现很多"理论上不该发生"的事

协议这东西,看一百遍不如抓一次包。


代码均为示意实现,聚焦讲清原理,未覆盖全部边界条件与错误处理,生产环境请使用 quic-go / quiche / msquic 等成熟实现。文中 quic-go API 以 v0.4x/v0.5x 形态书写,具体签名以 pkg.go.dev 当前版本为准。

推荐文章

php获取当前域名
2024-11-18 00:12:48 +0800 CST
Vue中的表单处理有哪几种方式?
2024-11-18 01:32:42 +0800 CST
用 Rust 玩转 Google Sheets API
2024-11-19 02:36:20 +0800 CST
程序员茄子在线接单