开启 HTTP/2 Server Push 后,静态资源响应路径发生明显变化。以往浏览器解析 HTML 后逐个发起请求,现在服务器可以在返回主文档时把关联的 CSS、JavaScript 主动推送到客户端。这个机制能显著降低首屏延迟,但也带来两个容易忽视的问题:一是推送的资源没有命中浏览器缓存时会造成带宽浪费,二是 access_log 中会突然多出百分号、反斜杠加字母的转义序列,给日志检索和审计造成干扰。要同时解决这两类问题,需要从 Nginx 的 http2_push 配置、log_format 的 escape 机制以及响应体编码三个层面入手。

一、HTTP/2 Server Push 的配置与自动预加载
http2_push 指令只能在已经启用 HTTP/2 的监听端口上使用,典型配置是 listen 443 ssl http2;。该指令的语法为 http2_push uri;,其中 URI 必须是以斜杠开头的同源路径。配置多个推送资源时,可以在同一个 location 块中写多行,每行对应一个独立的静态文件。需要注意的是,这里的 URI 并不是浏览器最终发起的完整 URL,而是一个站内路径,Nginx 会根据当前主请求的 Host 和 Scheme 自动补全。
下面是一段可工作的基础配置:
server {
listen 443 ssl http2;
server_name ipipp.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
location / {
root /var/www/html;
index index.html;
http2_push /css/main.css;
http2_push /js/app.js;
}
}
当主请求访问 / 时,Nginx 会在发送 index.html 之前向客户端推送 main.css 和 app.js。这个推送行为通过 PUSH_PROMISE 帧实现,浏览器收到后可以选择接收,也可以发送 RST_STREAM 拒绝。如果浏览器本地缓存中已经有了这些资源,推送就会浪费一部分上行带宽,因此生产环境中通常会结合 http2_push_preload 指令,让 Nginx 解析上游应用返回的 Link 响应头,只推送真正被标记为 preload 的资源。
自动预加载的配置并不复杂,只需要在 server 或 location 层添加 http2_push_preload on;。此时 Nginx 会读取响应头中的 Link 头,例如 Link: </css/main.css>; rel=preload; as=style,并将其转换成对应的 HTTP/2 推送。这样做的好处是推送策略由应用层动态控制,可以避免在 Nginx 配置里写死资源路径,也能结合用户会话或 A/B 测试灵活调整。
二、日志编码的来源与 escape 参数控制
很多人在开启 HTTP/2 后看到 access.log 里出现 %7B、%22 这样的内容,第一反应是 Nginx 把日志写坏了。实际上,这是两类完全不同的编码行为。百分号编码通常来自客户端原始请求 URI,例如请求路径包含中文、空格或 JSON 花括号时,浏览器或前端框架会先做 URL 编码,再发送给服务器。Nginx 的 $request 和 $request_uri 变量记录的是原始未解码形式,因此日志中自然会出现 GET /assets/%E6%90%9C%E7%B4%A2?q=%7B%22id%22%3A1%7D HTTP/2.0 这样的行。
另一类转义则是 Nginx 自身产生的。默认的 log_format combined 使用了 escape=default,它会把双引号、反斜杠、换行符等特殊字符转换成 \x22、\x5C、\n 这种形式。比如用户代理字符串中如果带了一个双引号,日志里就会显示 \x22,这不是客户端发来的内容,而是 Nginx 为了保持日志的单行结构主动做的转义。要控制这个行为,可以在自定义 log_format 时指定 escape=json 或 escape=none。
以下是一个适合 ELK 或 jq 解析的 JSON 日志格式:
log_format json_escape escape=json '{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log json_escape;
escape=json 会按照 JSON 字符串的规则转义,双引号会变成 \",反斜杠变成 \\,这样日志整体是一个合法的 JSON 对象,适合机器读取。如果只是想提高人工可读性,也可以用 escape=none,让 Nginx 不再转义,但这样做会带来日志注入风险:攻击者可以在请求头中插入换行符伪造一条假日志。折中方案是根据日志用途选择,审计场景用 json,开发调试场景用 none。
排查时还要注意 $uri 与 $request_uri 的区别。$request_uri 是原始请求行中的 URI,包含查询参数且未解码;$uri 是经过路径规范化之后的当前请求路径,可能会被重写模块修改。如果日志中需要保留用户真正请求的原始编码,应优先使用 $request_uri;如果只需看最终解析后的路由,$uri 更方便。把这两个变量同时记录到日志里,可以快速判断 URL 编码到底发生在客户端还是 Nginx 内部。
三、响应体压缩与推送资源验证的注意事项
日志编码和响应体编码不能混为一谈。gzip、br 是 HTTP 内容编码,服务器把资源压缩后传输,浏览器解压后再使用。开启 HTTP/2 Server Push 后,被推送的静态资源同样可以经过 gzip 压缩,前提是 gzip_types 覆盖了对应 MIME 类型。Nginx 记录 $body_bytes_sent 时写的是压缩后的字节数,因此日志中资源大小可能比磁盘上的原始文件小很多,这是正常现象,并不是日志编码错误。如果浏览器端解压失败,首先要检查 gzip_types 是否包含了 text/css 或 application/javascript。
下面是一段结合 gzip 和 HTTP/2 推送的验证配置:
server {
listen 443 ssl http2;
server_name ipipp.com;
gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1024;
location /assets/ {
root /var/www/html;
http2_push /assets/main.css;
http2_push /assets/app.js;
}
}
配置完成后,可以用 curl 直接观察推送与压缩效果:
curl -I --http2 -H 'Accept-Encoding: gzip, br' https://ipipp.com/assets/main.css
返回头中如果出现 HTTP/2 200 和 content-encoding: gzip,说明资源被成功压缩。若需要确认 Server Push 是否真正发生,可以在 Chrome DevTools 的 Network 面板开启 Protocol 列,查看资源是否由 Push 提供。更简单的验证方法是使用 nghttp -ans https://ipipp.com/,它会列出所有收到的 PUSH_PROMISE 帧。
盲目推送所有静态资源并不一定带来性能提升。当浏览器缓存中已经存在 main.css 时,服务器仍然推送副本,就会消耗额外的上行带宽,甚至可能挤压主文档的传输优先级。比较稳妥的做法是只推送首屏关键资源,或者通过 Cookie 判断用户是否首次访问。比如在应用层设置一个 first_visit 标记,只有首次访问才返回 preload Link 头,从而让 http2_push_preload 精准触发。经过多轮压测对比,这种按需推送策略通常能在缓存命中率和首屏延迟之间取得更好的平衡。
NginxHTTP/2 Server Push日志编码修改时间:2026-09-23 13:00:00