HTTP/2 的头部压缩带来显著收益,但 HPACK 动态表并非无限容量。Nginx 反向代理回源时,如果上游连接长期存活,动态表会被不断更新的头部字段占满,淘汰策略一旦触发,压缩效率会进入周期性的低谷。这个问题在日志中往往表现为单请求字节数忽高忽低、error_log 出现连接重置等信号。本文先梳理 HPACK 动态表在回源路径中的工作方式,再结合日志与抓包手段定位瓶颈,最后给出可落地的优化方案。

一、HPACK 动态表在回源路径中的作用机制
HTTP/2 使用 HPACK 格式压缩请求头和响应头,其核心由静态表、动态表和 Huffman 编码三部分组成。静态表中有 61 个预定义头部字段,例如常见的 :method、:path、content-type 等。动态表初始为空,编码器和解码器通过发送 SETTINGS 帧协商容量大小,默认值通常为 4096 字节。当编码方发送头部时,可以选择把新出现的字段对插入动态表,后续只要发送索引号就能表示完整头部,从而大幅减少传输量。
Nginx 作为反向代理向上游发起 HTTP/2 回源请求时,充当 HPACK 编码方的角色。连接建立后,Nginx 会收到上游服务器发来的 SETTINGS_HEADER_TABLE_SIZE 值,该值决定了动态表的最大容量。如果上游把这个值配置得较小,例如保持 4096 字节默认值,而业务请求头平均体积在 1KB 到 2KB 之间,那么动态表最多只能缓存 2 到 4 组头部。一旦表满,编码器必须按照 LRU 规则驱逐最久未使用的条目。若后续请求带有相似但不完全相同的头部,例如每次变化的 X-Request-Id、时间戳或 TraceId,这些条目无法命中动态表,反而因为不断插入和淘汰产生额外 CPU 开销。
下面的 Nginx 配置启用了 HTTP/2 回源,并添加了一个随请求变化的头部字段,这正好会持续冲刷动态表:
location /api/ {
proxy_pass https://backend.ippipp.com;
proxy_http_version 2;
proxy_set_header Connection "";
proxy_set_header X-Request-Id $request_id;
proxy_set_header X-Trace-Time $msec;
}
动态表的容量并非只由上游服务器单方面决定。客户端可以通过发送 SETTINGS 帧告知自己对动态表大小的限制,但实际生效值是双方协商后的较小值。Nginx 的 HTTP/2 客户端实现并未暴露专门设置动态表容量的指令,因此其回源时的表大小基本取决于上游服务端。如果上游是 Nginx、Apache 或云负载均衡器,各自默认值可能不一致,跨实现连接时更容易出现压缩效率参差不齐的问题。
二、从 Nginx 日志中捕捉 HPACK 动态表瓶颈的信号
动态表瓶颈不会直接报错,但会在日志中留下可追踪的痕迹。最直接的方法是在 log_format 中记录 $request_length、$upstream_response_length、$request_time 和 $upstream_protocol。其中 $request_length 包含请求行、请求头和请求体的总长度,当 HTTP/2 回源时,这个值反映的是经过 HPACK 压缩后的大小。如果动态表频繁刷新,相同或相似请求的 $request_length 会出现周期性波动。
log_format h2_debug '$remote_addr - $request_time - '
'req_len=$request_length up_resp_len=$upstream_response_length '
'proto=$upstream_protocol status=$status';
access_log /var/log/nginx/h2_debug.log h2_debug;
除了直接看字节数,还可以配合抓包进一步确认。使用 tcpdump 捕获回源 443 端口上的 HTTP/2 帧,重点关注 SETTINGS 帧中 HEADER_TABLE_SIZE 参数,以及 HEADERS 帧里使用了多少索引号。下面的命令可以捕获包含 HTTP/2 SETTINGS 帧的流量,并输出到文件供后续分析:
tcpdump -i eth0 -s 0 -A 'tcp port 443' -w h2_backend.pcap
抓包后用 Wireshark 打开,过滤 http2.settings.header_table_size 可以查看协商结果。如果该值只有 4096 字节,而单个请求头经 Huffman 编码后仍然超过 1KB,基本上可以判断动态表处于高淘汰压力下。另一个关键信号是 error_log 中频繁出现 upstream prematurely closed connection 或 http2 frame error,这可能是因为上游在动态表同步过程中遇到无法恢复的状态错误,导致连接重置。
三、缓解动态表瓶颈的工程化手段
要缓解瓶颈,首先从上游服务器入手。如果上游也是 Nginx,可以在其 HTTP/2 配置中适当调大头部相关限制。虽然 Nginx 没有直接命名为 http2_header_table_size 的指令,但增大 http2_max_header_size 和 http2_max_field_size 可以为动态表提供更大的字段接纳空间,间接降低旧条目被过早驱逐的概率。配置示例如下:
server {
listen 443 ssl http2;
http2_max_header_size 64k;
http2_max_field_size 32k;
http2_max_requests 10000;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
}
如果上游服务器不是 Nginx,而是 Go 的 net/http 或云厂商负载均衡器,需要查阅对应文档设置 HTTP/2 头部表大小。例如 Go 服务可以通过 http2.Server 结构体中的 MaxHeaderListSize 或自定义 SETTINGS 帧参数来调整。正确的做法是先在测试环境验证动态表容量与业务头部特征是否匹配,再灰度上线。
其次,从 Nginx 回源侧减少无效头部变化。很多自定义头字段并不需要每个请求都参与压缩,例如全链路追踪的 X-Request-Id、X-B3-TraceId 或 X-Amzn-Trace-Id。如果这些值通过负载均衡器生成,可以在入口层做采样,只对少量请求注入,避免大量唯一值不断冲刷动态表。Nginx 侧可以使用 map 指令控制头部是否发送:
map $request_id $send_trace_id {
default "";
~^[0-9a-f]{8} $request_id;
}
location /api/ {
proxy_set_header X-Request-Id $send_trace_id;
proxy_pass https://backend.ippipp.com;
proxy_http_version 2;
}
此外,保持 HTTP/2 连接复用也是减少动态表重建成本的关键。Nginx 可以通过 keepalive 指令启用到上游的连接池,并设置合理的空闲超时时间。连接复用不仅减少了 TCP 和 TLS 握手开销,还能让动态表在多个请求之间持续积累有效条目。配置参考:
upstream backend {
server backend1.ippipp.com:443;
server backend2.ippipp.com:443;
keepalive 64;
keepalive_timeout 60s;
keepalive_requests 1000;
}
最后,如果业务对回源头部压缩要求极高,可以考虑将一部分头部字段移入请求体或使用 gRPC 等二进制协议传输。gRPC 基于 HTTP/2,但头部非常精简,动态表压力远小于携带大量自定义头的 REST API。不过这种改造涉及协议变更,需要评估客户端、网关和上游服务的兼容性。
总结来看,HPACK 动态表瓶颈是一个由容量限制、头部变化频率和连接复用策略共同作用的结果。通过日志指标监控、抓包确认协商参数、调整上游 HTTP/2 配置以及精简回源头字段,可以有效降低动态表替换频率,恢复头部压缩收益,同时减少 CPU 和带宽浪费。