在一个 Vue/Vite 前端项目的测试环境中,HTTP 访问完全正常,但切换到 HTTPS 后,浏览器会批量出现下面的错误:
GET https://app.example.com:8443/assets/chunk-a1b2c3.js
net::ERR_HTTP2_SERVER_REFUSED_STREAM
GET https://app.example.com:8443/assets/styles-d4e5f6.css
net::ERR_HTTP2_SERVER_REFUSED_STREAM
Uncaught (in promise) Error: Unable to preload CSS
出现问题的环境使用 OpenResty 1.27.1.2-5-1-focal,HTTPS 由 OpenResty 直接提供,并启用了 HTTP/2:
server {
listen 8443 ssl;
http2 on;
ssl_certificate /etc/openresty/tls/example.crt;
ssl_certificate_key /etc/openresty/tls/example.key;
# 其他配置省略
}
最终定位到的关键配置是:
keepalive_timeout 0;
keepalive_requests 5000;
将 keepalive_timeout 改为非零值后,HTTPS 下的 HTTP/2 资源恢复正常:
keepalive_timeout 75s;
keepalive_requests 1000;
本文记录完整的判断过程,以及这个配置为什么会让问题只在 HTTPS + HTTP/2 下暴露出来。
一、问题现象
站点同时监听两个端口:
listen 8080;
listen 8443 ssl;
http2 on;
实际表现如下:
失败的不是单个文件,而是同一页面加载的多个静态资源,包括动态导入的 JavaScript、组件 CSS 和预加载资源。浏览器控制台还出现了:
Unable to preload CSS
这条错误不是根因。它只是说明 CSS 的网络请求已经失败,前端运行时无法完成 preload。
二、HTTP/2 支持 HTTPS,也与 Vue 无直接冲突
HTTP/2 和 HTTPS 属于不同层次:
HTTPS 表示 HTTP 通过 TLS 加密传输;
HTTP/1.1、HTTP/2 表示 HTTP 的协议版本;
浏览器访问 HTTPS 时,会通过 TLS ALPN 协商使用
h2或http/1.1。
OpenResty/Nginx 支持在 HTTPS 上使用 HTTP/2。下面的配置是正常组合:
listen 8443 ssl;
http2 on;
Vue、React、Vite 等前端框架不会让 HTTP/2 失效。不过,现代前端项目通常会把页面拆分成大量 JS/CSS chunk,并通过动态导入和 preload 集中加载资源。这种请求模式会更容易暴露 HTTP/2 连接管理问题。
因此,前端资源数量是触发条件之一,但不是协议错误的根因。
三、ERR_HTTP2_SERVER_REFUSED_STREAM 表示什么
HTTP/2 会在一条 TCP/TLS 连接中承载多个独立的 stream。服务端可以通过 RST_STREAM 拒绝某个尚未处理的流,也可以通过 GOAWAY 表示连接不再接受后续流。
Chrome 收到相应信号后,可能显示:
ERR_HTTP2_SERVER_REFUSED_STREAM
它通常说明请求尚未进入正常的应用处理流程。它不等同于:
静态文件不存在;文件不存在通常是 HTTP
404;TLS 证书错误;证书错误通常会发生在连接建立阶段;
Vue 组件代码错误;这里失败的是网络协议层请求;
gzip 不支持 HTTP/2。
四、关键根因:keepalive_timeout 0
问题环境的全局配置包含:
keepalive_timeout 0;
keepalive_requests 5000;
Nginx 官方文档对 keepalive_timeout 的定义很明确:第一个参数决定客户端持久连接在服务端保持的时间,值为 0 时禁用持久连接。默认值是 75s。
这两项配置的意图实际上相互抵消:
keepalive_timeout 0禁止连接复用;keepalive_requests 5000允许一条持久连接最多处理 5000 个请求;由于持久连接已被禁用,后者基本没有机会发挥作用。
HTTP/1.1 下,这通常表现为频繁重新建立连接,浏览器仍可能完成资源加载。HTTP/2 则依赖一条连接承载多个并发流:
一条 HTTPS/TCP 连接
├── stream 1:index.html
├── stream 3:index.js
├── stream 5:chunk-a.js
├── stream 7:styles-a.css
└── stream 9:vendor.js
页面先获取 HTML 和入口脚本,入口脚本执行后又会动态加载更多资源。如果服务端不允许连接保持和复用,连接可能在前一批请求结束后关闭或进入 GOAWAY 状态;浏览器随后仍尝试在原 HTTP/2 会话中创建资源流,部分流便可能被拒绝。
这正好解释了几个现象:
HTTP 端口正常,HTTPS + HTTP/2 异常;
首页或少量资源能够加载,后续 JS/CSS 批量失败;
将
keepalive_timeout改为75s后恢复;Nginx 错误日志里不一定能找到失败资源的 URL。
Nginx 历史上也有一条专门讨论 keepalive_timeout 0 与 HTTP/2 的问题记录。早期“首个请求前连接就被关闭”的实现问题后来已修复,但 0 仍然明确表示禁用客户端持久连接,不适合常规浏览器 HTTP/2 页面。
五、修复配置
对于普通网站、管理后台和前端 SPA,可以先使用 Nginx 默认值:
http {
keepalive_timeout 75s;
keepalive_requests 1000;
# 其他配置
}
如果希望只对 HTTPS 站点调整,也可以写在对应的 server 中:
server {
listen 8443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/openresty/tls/example.crt;
ssl_certificate_key /etc/openresty/tls/example.key;
ssl_protocols TLSv1.2 TLSv1.3;
keepalive_timeout 75s;
keepalive_requests 1000;
root /srv/www/example-app;
index index.html;
try_files $uri $uri/ /index.html;
}
对于该站点,60s 到 75s 都是合理起点。连接数非常多的公网服务可以根据内存、连接数和访问模型调整到 30s 到 60s,但启用 HTTP/2 时不建议直接设置为 0。
应用配置前先检查语法:
/usr/local/openresty/nginx/sbin/nginx -t
检查通过后再重载:
/usr/local/openresty/nginx/sbin/nginx -s reload
六、为什么增加 sendfile 和 gzip 后看起来也修好了
排查过程中曾同时加入以下配置:
sendfile on;
keepalive_timeout 75s;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_http_version 1.1;
gzip_comp_level 2;
gzip_types text/plain application/javascript text/javascript text/css application/xml;
修改后页面恢复,很容易误以为是 sendfile 或 gzip 修复了 HTTP/2。实际上:
sendfile on可以提高静态文件发送效率;gzip 可以减少 JS/CSS 的传输量,缩短流的存活时间;
gzip_http_version 1.1表示允许压缩的最低 HTTP 版本,不会把 HTTP/2 降级为 HTTP/1.1;修改配置并 reload 也会创建新 worker,并促使客户端建立新连接。
这些因素可能降低问题出现的概率,但对比最终完整配置后,真正与故障直接对应的变化是:
- keepalive_timeout 0;
+ keepalive_timeout 75s;
排查此类问题时应尽量一次只修改一个变量,否则容易把性能优化配置误认为协议问题的根因。
七、为什么 error.log 中没有失败链接
这是 HTTP/2 问题中很容易造成误判的一点。
普通访问日志记录的是已经进入 HTTP 请求处理流程的请求。如果一个 HTTP/2 stream 在完整请求头被解析、URI 被交给 location 处理之前就被拒绝,那么:
access.log里可能没有该请求;error.log里可能只有连接级信息;日志中不一定存在浏览器显示的完整资源 URL。
可以先搜索访问日志:
grep 'styles-d4e5f6.css' /var/log/openresty/example-access.log
判断方式:
没有记录:更支持请求在 HTTP/2 流或连接层被拒绝;
有
200:Nginx 已处理请求,需要继续检查中间网关、网络设备或浏览器连接;有
404、403、5xx:按对应 HTTP 状态码检查资源路径、权限或服务错误。
为了让访问日志包含协议和连接信息,可以增加:
log_format h2_detail
'$remote_addr [$time_local] "$request" '
'status=$status protocol=$server_protocol h2=$http2 '
'connection=$connection connection_requests=$connection_requests '
'request_time=$request_time';
access_log /var/log/openresty/example-access.log h2_detail;
八、检查最终生效配置,而不是只看一个文件
OpenResty 配置通常会包含多个目录:
include /usr/local/openresty/nginx/conf/conf.d/*.conf;
include /usr/local/openresty/nginx/conf/default/*.conf;
include /etc/openresty/waf.conf;
因此,需要查看合并后的有效配置:
/usr/local/openresty/nginx/sbin/nginx -T 2>&1 |
grep -nE 'keepalive_timeout|keepalive_requests|http2|max_concurrent|limit_conn|limit_req'
重点检查:
是否有某个
server或location再次覆盖为keepalive_timeout 0;WAF 或其他 include 是否设置了较小的
limit_conn、limit_req;是否异常调低了
http2_max_concurrent_streams;服务是否在故障期间频繁 reload、重启或退出 worker。
下面两行只负责创建限流共享内存区,本身不会执行限流:
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
只有其他配置实际使用 limit_conn 时才会限制连接数量,因此仍需检查所有 include 文件。
九、验证修复结果
首先确认 HTTPS 仍然协商为 HTTP/2,而不是因为关闭 HTTP/2 才恢复。
在 Chrome 开发者工具 Network 面板中显示 Protocol 列:
h2:正在使用 HTTPS + HTTP/2;http/1.1:HTTPS 正常,但当前没有使用 HTTP/2。
也可以使用 curl 验证协议:
curl -skI --http2 \
https://app.example.com:8443/assets/styles-d4e5f6.css
单个 curl 请求只能验证 HTTP/2 协商和基本响应,不能完全模拟浏览器同时加载几十个资源。因此最终仍应使用浏览器连续多次硬刷新,并观察:
Network 中协议是否为
h2;所有 JS/CSS 是否返回
200或304;是否仍有
ERR_HTTP2_SERVER_REFUSED_STREAM;OpenResty 是否发生 reload、worker 退出或连接异常。
十、结论
这次问题与 Vue/Vite、HTTPS 证书以及 gzip 兼容性无关。OpenResty 1.27.1.2 支持 HTTPS + HTTP/2,实际故障来自不合适的连接配置:
keepalive_timeout 0;
HTTP/2 依赖一条持久连接复用多个 stream。禁用 keep-alive 后,页面后续动态加载的资源流可能遇到连接关闭或 GOAWAY,最终被 Chrome 报告为 ERR_HTTP2_SERVER_REFUSED_STREAM。
将其恢复为合理的非零值即可:
keepalive_timeout 75s;
keepalive_requests 1000;
这次排查最值得记住的经验有三点:
HTTPS 正常建立不代表 HTTP/2 连接生命周期配置合理;
前端大量 chunk 往往只是放大了连接层问题;
HTTP/2 流在进入请求处理前被拒绝时,日志里未必有对应 URL。
参考资料
Nginx
keepalive_timeout官方文档Nginx Ticket #2142:
keepalive_timeout 0与 HTTP/2
评论区