强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

HTTP/2 与 RPC 精讲教程 / 04 - 头部压缩 HPACK

第 04 章:头部压缩 HPACK

用算法对抗冗余——HTTP/2 的带宽优化利器


4.1 为什么需要头部压缩

在 HTTP/1.1 中,每个请求和响应都携带完整的文本头部。对于典型的 Web 应用,大量请求携带几乎相同的头部信息,造成严重的带宽浪费。

4.1.1 头部冗余的量化分析

典型 HTTP/1.1 请求头部(约 500-800 字节):

GET /api/users HTTP/1.1\r\n
Host: api.example.com\r\n                           # 24 字节
User-Agent: Mozilla/5.0 (Windows NT 10.0; ...)      # ~120 字节
Accept: application/json, text/plain, */*            # ~40 字节
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8            # ~35 字节
Accept-Encoding: gzip, deflate, br                   # ~30 字节
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...       # ~200 字节(JWT)
Cookie: session_id=abc123; theme=dark; lang=zh-CN    # ~60 字节
Connection: keep-alive                                # ~24 字节

总计:约 533 字节 × 每个请求

页面加载 100 个资源 = 约 53KB 仅用于头部
其中 80% 的头部内容在请求之间完全相同!

4.1.2 gzip 为什么不适合头部压缩

问题说明
安全漏洞CRIME 攻击:通过观察压缩后大小变化推断明文内容
上下文隔离gzip 需要连续数据流,而 HTTP 请求是独立的
CPU 开销全量压缩/解压的 CPU 消耗较高
内存占用需要维护较大的压缩上下文

⚠️ CRIME 攻击原理简述

攻击者可以猜测 Authorization 头部中的 Token:
1. 受害者发送携带 Token 的请求
2. 攻击者控制请求中的部分数据(如 URL 参数)
3. 通过暴力枚举猜测 Token 前缀
4. 观察压缩后请求大小变化
5. 如果猜测正确,重复的模式会使请求变小
6. 逐字符恢复完整 Token

4.2 HPACK 设计原理

HPACK(HTTP/2 Header Compression)是专为 HTTP/2 设计的头部压缩方案。它通过三种机制实现高效压缩:

机制说明效果
静态表(Static Table)预定义的 61 个常用头部键值对消除最常见头部的传输
动态表(Dynamic Table)连接级别新增的头部条目消除重复头部的传输
Huffman 编码对字符串进行变长编码压缩字符串长度

4.3 静态表(Static Table)

静态表是一张全局共享的、只读的查找表,包含 61 个最常用的头部键值对。

4.3.1 完整静态表

索引头部名称头部值
1:authority(空)
2:methodGET
3:methodPOST
4:path/
5:path/index.html
6:schemehttp
7:schemehttps
8:status200
9:status204
10:status206
11:status304
12:status400
13:status404
14:status500
15accept-charset(空)
16accept-encodinggzip, deflate
17accept-language(空)
18-21accept-ranges, access-control-*, age, allow(空)
22-28authorization, cache-control, content-disposition, …(空)
29content-type(空)
30-61cookie, date, etag, expect, expires, from, …(空)

💡 设计特点

  • 索引 1-15 包含键和值(最常见的键值对)
  • 索引 16-61 仅包含键(值变化太大,不值得预定义)
  • 所有头部名称都是小写的

4.3.2 静态表查询示例

# 静态表实现
STATIC_TABLE = {
    1: (":authority", ""),
    2: (":method", "GET"),
    3: (":method", "POST"),
    4: (":path", "/"),
    5: (":path", "/index.html"),
    6: (":scheme", "http"),
    7: (":scheme", "https"),
    8: (":status", "200"),
    9: (":status", "204"),
    10: (":status", "206"),
    11: (":status", "304"),
    12: (":status", "400"),
    13: (":status", "404"),
    14: (":status", "500"),
    15: ("accept-charset", ""),
    16: ("accept-encoding", "gzip, deflate"),
    # ... 完整 61 个条目
}

def find_in_static_table(name: str, value: str = "") -> int:
    """在静态表中查找匹配的条目"""
    for idx, (n, v) in STATIC_TABLE.items():
        if n == name and (v == value or value == ""):
            return idx
    return -1

# 示例查询
idx = find_in_static_table(":method", "GET")
print(f":method:GET 在静态表索引 {idx}")  # 输出: 2

idx = find_in_static_table(":status", "200")
print(f":status:200 在静态表索引 {idx}")  # 输出: 8

4.4 动态表(Dynamic Table)

动态表是一个连接级别的先进先出(FIFO)队列,存储在通信过程中出现的新头部条目。

4.4.1 动态表工作原理

初始状态:动态表为空
最大大小:由 SETTINGS_HEADER_TABLE_SIZE 决定(默认 4096 字节)

请求 1 发送:
  HEADERS: :method=GET, :path=/api/users, authorization=Bearer abc123
  
  编码结果:
  - :method=GET → 静态表索引 2(直接引用)
  - :path=/api/users → 动态表新增索引 62
  - authorization=Bearer abc123 → 动态表新增索引 63
  
  动态表状态:
  索引 62: :path = /api/users
  索引 63: authorization = Bearer abc123

请求 2 发送:
  HEADERS: :method=GET, :path=/api/orders, authorization=Bearer abc123
  
  编码结果:
  - :method=GET → 静态表索引 2
  - :path=/api/orders → 动态表新增索引 64
  - authorization=Bearer abc123 → 动态表索引 63(已有!不传输)
  
  节省了 authorization 头部的传输!

4.4.2 动态表实现

from collections import OrderedDict
from typing import Optional, Tuple

class DynamicTable:
    def __init__(self, max_size: int = 4096):
        self.max_size = max_size
        self.current_size = 0
        self.entries: OrderedDict[int, Tuple[str, str]] = OrderedDict()
        self.next_index = 62  # 静态表最大索引 + 1
    
    def entry_size(self, name: str, value: str) -> int:
        """计算条目占用的字节数"""
        return len(name.encode()) + len(value.encode()) + 32  # 32 字节开销
    
    def add(self, name: str, value: str):
        """添加新条目"""
        entry_size = self.entry_size(name, value)
        
        # 如果单个条目超过最大大小,清空表
        if entry_size > self.max_size:
            self.entries.clear()
            self.current_size = 0
            return
        
        # 驱逐旧条目以腾出空间
        while self.current_size + entry_size > self.max_size:
            self._evict()
        
        # 插入新条目(头部插入,最近使用的在前)
        self.entries[self.next_index] = (name, value)
        self.next_index += 1
        self.current_size += entry_size
    
    def _evict(self):
        """驱逐最旧的条目"""
        if self.entries:
            _, (name, value) = self.entries.popitem(last=False)
            self.current_size -= self.entry_size(name, value)
    
    def find(self, name: str, value: str = "") -> Optional[int]:
        """查找匹配的条目"""
        for idx, (n, v) in self.entries.items():
            if n == name and (v == value or value == ""):
                return idx
        return None
    
    def resize(self, new_max_size: int):
        """调整表大小"""
        while self.current_size > new_max_size:
            self._evict()
        self.max_size = new_max_size

# 使用示例
table = DynamicTable(max_size=256)
table.add(":path", "/api/users")
table.add("authorization", "Bearer abc123")

idx = table.find("authorization", "Bearer abc123")
print(f"找到 authorization 在索引 {idx}")  # 62 或 63

table.resize(128)  # 缩小表大小,驱逐旧条目

4.5 索引地址空间

HPACK 的索引地址空间由静态表和动态表组成:

索引地址空间:

  1          62         62+动态表大小
  │          │          │
  ▼          ▼          ▼
┌───┬────────┬──────────┐
│静态表(61) │  动态表    │
└───┴────────┴──────────┘
  1..61      62..N

查找顺序:先查静态表,再查动态表

4.6 Huffman 编码

HPACK 使用 Huffman 编码对头部值中的字符串进行压缩。

4.6.1 Huffman 编码原理

原理:高频字符使用短编码,低频字符使用长编码

HTTP 头部中字符频率统计:
字符    频率      Huffman 编码(示例)
'a'    高频      000
'e'    高频      001
'o'    中频      010
空格   高频      011
'a'-'z'         10xxxx
'A'-'Z'         11xxxxx

4.6.2 Huffman 编码实现

# HPACK Huffman 编码简化示例
# 注意:实际 HPACK 使用 RFC 7541 定义的 Huffman 表

class HuffmanEncoder:
    """简化的 Huffman 编码器(演示用)"""
    
    # 简化的 Huffman 表(仅演示部分字符)
    HUFFMAN_TABLE = {
        'a': '000',    # 3 bits
        'e': '001',    # 3 bits
        'i': '010',    # 3 bits
        'o': '011',    # 3 bits
        't': '100',    # 3 bits
        'n': '101',    # 3 bits
        's': '1100',   # 4 bits
        'r': '1101',   # 4 bits
        ' ': '1110',   # 4 bits (空格)
        'h': '11110',  # 5 bits
        'l': '111110', # 6 bits
        'd': '111111', # 6 bits
    }
    
    # 反向表
    REVERSE_TABLE = {v: k for k, v in HUFFMAN_TABLE.items()}
    
    @classmethod
    def encode(cls, text: str) -> str:
        result = []
        for ch in text:
            if ch in cls.HUFFMAN_TABLE:
                result.append(cls.HUFFMAN_TABLE[ch])
            else:
                # 不在表中的字符使用 8 位原始编码
                result.append(format(ord(ch), '08b'))
        return ''.join(result)
    
    @classmethod
    def decode(cls, bits: str) -> str:
        result = []
        i = 0
        while i < len(bits):
            for length in range(3, 9):  # 尝试不同长度
                chunk = bits[i:i+length]
                if chunk in cls.REVERSE_TABLE:
                    result.append(cls.REVERSE_TABLE[chunk])
                    i += length
                    break
            else:
                # 8 位原始字符
                result.append(chr(int(bits[i:i+8], 2)))
                i += 8
        return ''.join(result)

# 示例
text = "the cat"
encoded = HuffmanEncoder.encode(text)
decoded = HuffmanEncoder.decode(encoded)

print(f"原文: {text}")
print(f"编码: {encoded}")
print(f"原文比特数: {len(text) * 8}")
print(f"压缩后比特数: {len(encoded)}")
print(f"压缩率: {len(encoded) / (len(text) * 8) * 100:.1f}%")
print(f"解码: {decoded}")

4.7 编码类型

HPACK 定义了三种头部字段表示方式:

4.7.1 索引头部字段(Indexed Header Field)

完整匹配静态表或动态表中的条目,仅需传输索引号。

格式:
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+

最高位为 1 表示这是索引头部字段
7 位可表示索引 1-127,超出则用扩展编码

示例::method=GET → 索引 2 → 编码为 0x82(10000010)

4.7.2 字面量头部字段(Literal Header Field)

不匹配表中的条目,需传输名称和值。

格式(带索引名):
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 |      Index (6+)       |
+---+---+-----------------------+
| H |     Value Length (7+)      |
+---+---------------------------+
| Value String (Length octets)   |
+-------------------------------+

H=0: 不使用 Huffman 编码
H=1: 使用 Huffman 编码

4.7.3 带索引更新的字面量头部字段

新增到动态表的头部字段。

格式(不带索引名,新增到表):
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |       0       |
+---+---+---+---+---+-----------+
| H |     Name Length (7+)       |
+---+---------------------------+
| Name String (Length octets)    |
+---+---------------------------+
| H |     Value Length (7+)      |
+---+---------------------------+
| Value String (Length octets)   |
+-------------------------------+

4.8 完整编码示例

from typing import List

class HPACKEncoder:
    def __init__(self, dynamic_table_size: int = 4096):
        self.dynamic_table = DynamicTable(dynamic_table_size)
    
    def encode_header(self, name: str, value: str) -> bytes:
        """编码单个头部字段"""
        # 1. 检查静态表
        static_idx = find_in_static_table(name, value)
        if static_idx > 0:
            # 完整匹配静态表 → 索引头部字段
            return self._encode_indexed(static_idx)
        
        # 2. 检查动态表
        dynamic_idx = self.dynamic_table.find(name, value)
        if dynamic_idx:
            return self._encode_indexed(dynamic_idx)
        
        # 3. 检查名称是否在表中(仅名称匹配)
        name_idx = self._find_name(name)
        
        # 4. 编码为字面量
        encoded = self._encode_literal(name, value, name_idx)
        
        # 5. 添加到动态表
        self.dynamic_table.add(name, value)
        
        return encoded
    
    def _encode_indexed(self, index: int) -> bytes:
        """编码索引头部字段"""
        if index < 128:
            return bytes([0x80 | index])
        else:
            # 多字节编码(简化处理)
            return bytes([0x80 | (index & 0x7F)])
    
    def _encode_literal(self, name: str, value: str, name_idx: int = -1) -> bytes:
        """编码字面量头部字段"""
        result = bytearray()
        
        if name_idx > 0:
            # 使用索引的名称
            result.append(0x40 | (name_idx & 0x3F))
        else:
            # 名称也需要编码
            result.append(0x00)
            result.extend(self._encode_string(name))
        
        result.extend(self._encode_string(value))
        return bytes(result)
    
    def _encode_string(self, s: str) -> bytes:
        """编码字符串(带长度前缀)"""
        encoded = s.encode('utf-8')
        length = len(encoded)
        
        if length < 128:
            return bytes([length]) + encoded
        else:
            # 多字节长度编码
            return bytes([0x80 | (length & 0x7F)]) + encoded
    
    def _find_name(self, name: str) -> int:
        """仅在表中查找名称(不匹配值)"""
        for idx, (n, _) in STATIC_TABLE.items():
            if n == name:
                return idx
        return self.dynamic_table.find(name) or -1

# 使用示例
encoder = HPACKEncoder()

# 编码一组请求头部
headers = [
    (":method", "GET"),
    (":path", "/api/users"),
    (":scheme", "https"),
    (":authority", "api.example.com"),
    ("accept", "application/json"),
    ("authorization", "Bearer mytoken123"),
]

total_bytes = 0
for name, value in headers:
    encoded = encoder.encode_header(name, value)
    total_bytes += len(encoded)
    print(f"{name}: {value}")
    print(f"  编码: {encoded.hex()}")
    print(f"  大小: {len(encoded)} 字节")

print(f"\n总计: {total_bytes} 字节")
print(f"文本格式估算: {sum(len(n)+len(v) for n,v in headers)} 字节")

4.9 安全问题与对策

4.9.1 时序攻击(Timing Attack)

⚠️ 动态表的信息泄露风险

攻击场景:
1. 攻击者观察到请求 A 的 HEADERS 帧大小为 100 字节
2. 攻击者猜测 authorization 头部为 "Bearer abc123"
3. 攻击者诱导受害者发送相同头部
4. 如果请求 B 的 HEADERS 帧更小(动态表命中),
   则说明攻击者的猜测正确

对策:
- 定期清空动态表
- 使用 SETTINGS_HEADER_TABLE_SIZE = 0 禁用动态表
- 敏感头部设置为不入表(never indexed)

4.9.2 敏感头部处理

# 敏感头部标记
SENSITIVE_HEADERS = {
    "authorization",
    "cookie",
    "set-cookie",
    "proxy-authorization",
}

class SecureHPACKEncoder(HPACKEncoder):
    def encode_header(self, name: str, value: str) -> bytes:
        if name.lower() in SENSITIVE_HEADERS:
            # never-indexed:不添加到动态表
            return self._encode_never_indexed(name, value)
        return super().encode_header(name, value)
    
    def _encode_never_indexed(self, name: str, value: str) -> bytes:
        """never-indexed 编码"""
        result = bytearray()
        result.append(0x10)  # never-indexed 标志
        result.extend(self._encode_string(name))
        result.extend(self._encode_string(value))
        return bytes(result)

4.9.3 禁用动态表

// 禁用 HPACK 动态表(最高安全级别)
package main

import (
	"net/http"
)

func main() {
	server := &http.Server{
		Addr: ":8443",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Hello"))
		}),
		// 通过自定义 HTTP/2 Transport 设置动态表大小为 0
	}
	
	// 方法 1:客户端禁用动态表
	// transport := &http2.Transport{
	//     MaxDecoderHeaderTableSize: 0,
	//     MaxEncoderHeaderTableSize: 0,
	// }
	
	_ = server
}

4.10 业务场景:大规模 API 网关

场景:API 网关处理每秒 10 万个请求

无头部压缩(HTTP/1.1):
- 每请求头部大小:~600 字节
- 每秒带宽:600 × 100,000 = 60 MB/s = 480 Mbps(仅头部)

HPACK 压缩后(HTTP/2):
- 首次请求:~200 字节
- 后续请求(动态表命中):~30 字节
- 平均每秒带宽:30 × 100,000 = 3 MB/s = 24 Mbps

节省带宽:95%!
// API 网关 HPACK 调优
package main

import (
	"golang.org/x/net/http2"
	"net/http"
)

func main() {
	server := &http.Server{
		Addr: ":8443",
	}
	
	// 自定义 HTTP/2 配置
	h2Server := &http2.Server{
		// 动态表大小(根据内存预算调整)
		MaxDecoderHeaderTableSize: 8192,
		MaxEncoderHeaderTableSize: 8192,
		
		// 最大帧大小
		MaxReadFrameSize: 1 << 20, // 1MB
		
		// 并发流数
		MaxConcurrentStreams: 1000,
	}
	
	_ = h2Server
	_ = server
}

4.11 注意事项

⚠️ 动态表大小设置

  • 过小:压缩效果差,重复头部仍需传输
  • 过大:消耗服务器内存(每个连接独立一份)
  • 建议:根据可用内存和并发连接数计算

⚠️ Huffman 编码的开销

  • 短字符串的 Huffman 编码可能比原文更长
  • 编码器应比较编码前后大小,选择更小的方案
  • 大多数实现已自动处理此问题

💡 最佳实践

  • 使用小写的头部名称(HPACK 规范要求)
  • 避免过长的头部值(如超长 Cookie)
  • 敏感头部使用 never-indexed 标记
  • 定期审计动态表的内存使用

4.12 扩展阅读


第 03 章 - 多路复用 | 第 05 章 - 服务器推送