Nginx作为反向代理回源时,如果源站支持HTTP/2,理论上可以利用HPACK头部压缩大幅减少请求开销。但不少运维同学发现,明明配了proxy_http_version 2相关的参数,抓包看到的回源请求头部还是原样膨胀,甚至比HTTP/1.1更慢。这个现象的根源,几乎都指向HPACK动态表没有被正确利用。这篇文章从HPACK的压缩原理讲起,结合Nginx的实际配置和日志分析,把动态表优化的完整思路梳理清楚。

一、HPACK头部压缩的基本原理
HTTP/2之所以能减少头部开销,靠的是HPACK这一专门的压缩格式。它由静态表和动态表两部分组成。静态表内置了61个常见的头部字段与取值组合,比如:method: GET、:path: /这类高频条目,直接用一个字节甚至半个字节的索引就能表示。动态表则是基于连接级别的FIFO队列,每个新的头部键值对如果被判定为可索引,就会进入队尾,同时在会话内分配一个递增的索引号。
关键在于:动态表是连接维度的。也就是说,两个Nginx worker与源站建立的两条TCP连接,各自维护独立的动态表。如果Nginx回源时连接频繁销毁重建,动态表里积累的条目就会全部作废,每次都要重新学习。这就是很多场景下"开了HTTP/2但没效果"的第一大原因。
HPACK的编码选择有四种:带增量索引的字面量、不带索引的字面量、永不索引的字面量、以及直接引用索引。Nginx作为客户端发送请求时,会根据头部字段是否安全来决定编码方式。像authorization这类敏感头部默认走永不索引,不会进入动态表;而user-agent、x-forwarded-for等则可以增量索引。理解这一点,后面分析日志时才知道哪些字段天生无法压缩。
二、Nginx回源侧的配置要点与常见坑
先明确一个前提:Nginx开源版对回源HTTP/2的支持历史上一直是空白,直到较新版本才加入proxy_http_version 2的实验能力,商业版Nginx Plus则更早支持。如果你的版本不支持,常见做法是在Nginx前挂一层支持HTTP/2客户端能力的代理,或者直接升级版本。配置上核心参数如下:
# 回源走 HTTP/2(需要较新的 Nginx 版本支持)
upstream backend {
server 10.0.0.8:443;
# 保持长连接,这是动态表生效的前提
keepalive 64;
keepalive_requests 10000;
keepalive_timeout 120s;
}
server {
listen 443 ssl http2;
location /api/ {
proxy_pass https://backend;
proxy_http_version 2;
proxy_set_header Connection "";
# 动态表容量由 SETTINGS_HEADER_TABLE_SIZE 协商,默认 4096 字节
proxy_set_header Accept-Encoding "";
}
}这里最容易被忽视的是keepalive。没有连接复用,每条新连接的动态表都是空的,头部压缩毫无意义。建议把keepalive_requests调大,避免连接在生命周期内被过早关闭。同时注意proxy_set_header里如果有大量变化值(比如带随机数的trace id),这些头部每次都是新条目,会不断挤占动态表空间,把有价值的长条目顶出去。
另一个坑是动态表大小协商。HPACK通过SETTINGS_HEADER_TABLE_SIZE帧声明本端动态表上限,默认4096字节。如果源站声明了一个很小的值,Nginx作为发送方也必须遵守,压缩效率就会打折。可以通过nginx -V确认编译参数里是否包含http_v2_module,并在error日志中开启debug级别观察帧交互。
三、通过日志定位头部膨胀问题
优化前要先量化。Nginx的log_format里可以借助$upstream_http_*和$bytes_sent做粗粒度统计,但HPACK层面的细节需要抓包。tcpdump配合Wireshark可以解出HTTP/2帧,重点看HEADERS帧里每个字段用的是"Indexed Header Field"还是"Literal Header Field"。如果满屏都是Literal且带incremental indexing标记,说明动态表在正常学习;如果全是never indexed的Literal,说明头部被判定为敏感或频繁变化,压缩无门。
# 在源站侧抓包并交给 Wireshark 分析
tcpdump -i eth0 -w /tmp/h2.pcap 'tcp port 443 and host 10.0.0.8'
# Wireshark 过滤:只看 HPACK 的字面量编码
# http2 && http2.header.type == 1
log_format upstream_h2 '$remote_addr [$time_local] '
'upstream=$upstream_addr '
'status=$upstream_status '
'rt=$upstream_response_time '
'bytes_sent=$bytes_sent '
'conn_reuse=$upstream_connect_time';有一个实用的判断技巧:观察$upstream_connect_time。如果大量请求该值为0,说明命中了keepalive连接复用;如果普遍在几毫秒以上,说明每次都在新建连接,动态表自然失效。把连接复用率和回源头部的平均字节数做成趋势图,优化前后的对比会非常直观。我们内部一次压测中,开启长连接并清理了易变头部后,单请求回源头部从980字节降到310字节左右,源站CPU的压缩处理开销也同步下降。
四、动态表层面的针对性优化策略
第一,收敛头部数量。逐个审视proxy_set_header配置,删掉源站不需要的字段。每个不必要且不断变化的头部,都是对动态表空间的持续污染。第二,对必须传递的可变字段,考虑放到请求体或单独的路径参数中,避免占用HPACK空间。第三,评估调大动态表的收益。服务端可以通过配置把表上限提到16K甚至32K,但要注意动态表过大也有代价:内存占用按连接数线性增长,一千条keepalive连接乘以32K就是32MB,需要结合机器内存权衡。
第四点关于条目淘汰机制。动态表本质是一个按插入顺序排队的环形结构,新条目进入时如果空间不足,会从队头逐条驱逐旧条目。这意味着如果单个超大头部(比如超长的cookie)偶尔出现一次,就可能把整个表的有效内容冲掉。建议对超大cookie做拆分或精简,或者在业务层把静态部分和动态部分分离。这往往是被忽略但收益最明显的一步。
最后做个总结:HPACK动态表优化的核心不在参数本身,而在于三个前提的建立——连接必须长存、头部必须稳定、空间必须够用。三者缺一,压缩收益都会大打折扣。先用日志和抓包确认现状,再逐项落实长连接配置、头部治理和表容量协商,回源链路的性能提升是水到渠成的事。