HTTP/2 回源把头部压缩从若干请求一轮升级成连接级状态压缩,这带来的好处是请求体积更小、连接复用更充分,但隐患也很明显:HPACK 动态表只要出现一次不同步,Nginx 的 error_log 里就会冒出 invalid header block fragment 或 compression error,源站侧也同样可能拒绝处理。实际项目中,很多团队在把 upstream 切到 HTTP/2 之后发现偶发 502、504,查 access log 看不到具体原因,只能靠抓包定位,效率很低。本文结合 Nginx 日志回源排查经验,梳理 HPACK 动态表的更新与错误触发条件,并参考 RFC9450 对动态表状态隔离和诊断信息保留的规范要求,给出可落地的配置与排查路径。

回源 HTTP/2 下 HPACK 动态表的基本行为
HPACK 把头部字段压缩成一个索引表:静态表固定 61 项,动态表容量由 SETTINGS_HEADER_TABLE_SIZE 决定,默认 4096 字节。每个连接维护独立的动态表,编码器决定新增条目还是引用已有索引,解码器必须保持完全相同的表状态。Nginx 作为反向代理回源时,对上游连接来说它同时扮演两个角色:发送请求时是 HPACK 编码器,接收响应时是 HPACK 解码器。上游连接被 keepalive 复用后,这两种角色在同一个 TCP 或 TLS 连接上交替出现,动态表也随之跨请求累积。
RFC9450 专门强调,代理场景下动态表的状态不允许跨连接共享,也不能把与上游的一部分头部块迁移到另一条连接上;任何连接级 SETTINGS 帧对动态表容量的调整,都必须只影响当前连接。Nginx 的 http2 模块遵循这一隔离原则,因此如果源站在 IO 线程里错误地复用连接上下文,或者 Nginx 侧因连接复用条件判断不严,把请求发到已经进入关闭流程的连接,就可能出现表状态与头部块不匹配。
可以这样理解:客户端与 Nginx 之间维护一套 HPACK 表,Nginx 与源站之间维护另一套,两套表完全独立。调试时不要想当然认为客户端发来的头部块可以直接透传给源站。Nginx 会先完整解码,再按上游连接重新编码。这样一来,客户端侧 HPACK 的异常不一定会传回源站,但回源侧一旦动态表错误,日志会直接指向 upstream 连接,这是排查时需要抓住的重点。
开启 Nginx 调试日志并识别 HPACK 报错
Nginx 的 HTTP/2 模块在 error_log 级别为 debug 时会输出连接级状态变化、头部块长度、动态表更新等事件。要在回源链路出现异常时获得足够信息,首先需要把 error_log 调整到 debug 级别。对于生产环境,可以只针对特定 server 或 location 打开,减少日志量。下面是一个最小配置:
server {
listen 443 ssl http2;
server_name gateway.ipipp.com;
error_log /var/log/nginx/error.log debug;
location / {
proxy_pass https://backend;
proxy_http_version 2.0;
proxy_set_header Connection "";
}
}
日志中常见的 HPACK 相关错误包括 unknown table index、bad table size update、header block fragment too large 等。unknown table index 表示源站返回的头部块引用了动态表索引,但 Nginx 当前连接里该索引不存在或已经因表容量收缩被淘汰;bad table size update 则是动态表容量更新指令给出的值超过了 SETTINGS 帧协商的上限。看一个实际日志片段:
[debug] 38817#0: *12 http2 upstream recv HEADERS frame sid=1 length=86 [debug] 38817#0: *12 http2 hpack dynamic table size 4096 [error] 38817#0: *12 upstream sent invalid header: unknown table index 62
仅凭这一条 error 还不够,需要结合前后 debug 行判断第 62 号索引是被动态表收缩淘汰,还是源站编码时引用了错误连接上的状态。RFC9450 要求实现保存连接 ID 和流 ID 作为诊断上下文,Nginx 日志中的 *12 就是连接序号,sid=1 是流编号,这两个值可以帮助关联 tcpdump 抓包或源站日志。
除了 error_log,access_log 也建议记录请求长度和响应长度变化。Nginx 目前没有直接暴露 HPACK 表状态的变量,但可以结合 $request_length 和 $upstream_response_length 观察头部大小是否异常。更可靠的方法是在调试窗口内对上游地址用 nghttp2 客户端模拟相同请求,观察解码输出,从而判断问题是否只在 Nginx 上游连接上复现。
动态表不同步的三种典型根因
第一种根因是 SETTINGS 帧协商不一致。源站也许发送 SETTINGS_HEADER_TABLE_SIZE 为 8192,Nginx 回源模块接受后开始允许更大的动态表,但源站实现却因重启或热更新丢失了该值。接下来编码器写入索引 63 到 100 的条目,解码器仍然只用 4096 字节容量,索引自然失效。这种问题会在连接建立初期随机出现,重启 Nginx 或源站可能暂时缓解,因为连接重新协商。
第二种根因是 keepalive 连接复用与连接关闭竞态。Nginx 启用 proxy_http_version 2.0 后,如果 upstream 块配置了 keepalive,连接会被多个请求共用。源站若在返回响应后立刻关闭连接,Nginx 可能已经把下一个请求写入该连接;此时动态表已经被源站释放,请求头部块却引用了旧索引。日志会出现 writev failed 或 broken pipe,而 HPACK 错误被掩盖在连接断开之下。把 upstream 的 keepalive 调大不一定解决,关键是让 Nginx 感知连接关闭时及时剔除。
第三种根因涉及头部块与动态表更新的顺序。RFC9450 强调,如果编码器先发送动态表扩容或收缩指令,再发送引用该表的 HEADERS 帧,解码器必须严格按序处理;反过来,编码器不能假设接收端已经完成更新。某些源站实现会批量发送 SETTINGS 后立刻推送响应,导致 Nginx 侧对头部块解码时表尚未应用新容量。遇到这种源站,可以尝试在 Nginx 上游配置中显式降低动态表容量或禁用连接复用,观察错误是否消失。
回源配置优化与 RFC9450 适配建议
首先,如果源站 HPACK 实现存在明显兼容问题,最直接的兜底是把回源协议回退到 HTTP/1.1。虽然放弃了头部压缩,但连接稳定性和日志可读性更好,适合作为临时止血方案。Nginx 配置如下:
upstream backend {
server 127.0.0.1:8443;
keepalive 16;
}
server {
listen 443 ssl http2;
server_name gateway.ipipp.com;
location / {
proxy_pass https://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
其次,如果仍然需要 HTTP/2 回源,可以限制上游连接复用的请求数,避免动态表无限累积。Nginx 的 keepalive_requests 指令可以设置每条上游连接最多处理多少个请求,超过后主动关闭并重建连接。例如:
upstream backend {
server 127.0.0.1:8443;
keepalive 32;
keepalive_requests 100;
keepalive_time 60s;
}
同时关注上游返回的 SETTINGS 帧。RFC9450 提示动态表错误往往与容量调整有关,可以在源站侧固定 SETTINGS_HEADER_TABLE_SIZE 为默认 4096,避免频繁变化。Nginx 自身也可以通过 http2_max_field_size 或 http2_max_header_size 限制单字段和头部块大小,但这两个指令主要作用于客户端侧连接;回源侧的头部大小仍受源站和 proxy_buffer 相关配置约束,因此需要同时调整 proxy_buffer_size 和 proxy_buffers,避免头部块超限被截断。
最后,建立日志告警规则。把 error_log 中 upstream sent invalid header 和 http2 compression error 作为关键字接入监控,配置次数阈值告警。出现告警时保留抓包文件,使用 nghttp2 或 wireshark 过滤 http2.header 和 http2.hpack 字段,查看具体是哪一端先发出异常的 HEADERS 帧。通过日志与抓包相互印证,可以快速确认是否需要升级源站 HTTP/2 库或调整 Nginx 回源策略。