一、症状:Chrome 开发者工具里那该死的 “Queueing”
上周三凌晨2点,我们的监控面板炸了。用户反馈图片加载慢得像拨号上网,P95 首屏时间从 1.2s 飙到了 8.7s。我打开 Chrome DevTools 一看——好家伙,Network 面板里全是黄澄澄的 “Queueing” 状态。
![示意图:Chrome Network 面板中大量请求处于 Queueing 状态]
每个请求的 Timing 分解里,Queueing 占了 600ms 到 2.3s 不等。最离谱的是,这些请求全是发往同一个 CDN 域名,走的是 HTTP/2。
等等,HTTP/2 不是号称多路复用、一个连接并行请求无上限吗?怎么还会排队?
这就是我今天要聊的坑。如果你也在用 HTTP/2,并且发现 Chrome 莫名其妙地卡请求,别急着优化后端——先看看浏览器这头。
二、根因分析:Chrome 的 HTTP/2 连接池到底怎么工作的?
2.1 被误解的 “无上限”
很多人(包括以前的我)以为 HTTP/2 彻底消灭了连接数限制。但现实是:
- HTTP/1.1:每个域名 6 个连接,每个连接一次只能发 1 个请求
- HTTP/2:单个连接,但每个连接上并发流有上限
Chrome 的实现里,这个上限是 100 个流。听着很多对吧?但问题不在这里。
真正要命的是 “关键请求优先级"和"慢启动窗口” 的组合拳。
2.2 慢连接检测器:Chrome 的隐形限流器
Google 在 Chrome 里塞了一个叫 “Slow Connection Detector” 的机制。当 Chrome 认为网络连接"慢"时(通过 RTT 和丢包率判断),它会主动限制同一域名下的并发请求数。
具体来说:在慢连接上,Chrome 会把 HTTP/2 的并发请求数限制到 10 个。
是的你没看错。HTTP/2 号称多路复用,但在 Chrome 的"保护"下,它退化成了 HTTP/1.1 级别的并发能力。
这个逻辑藏在 Chrome 源码的 net/socket/stream_socket.cc 和 net/http/http_stream_request.cc 里。核心思路是:避免在一个慢连接上塞太多流,导致头部阻塞和缓冲区膨胀。
2.3 优先级反转与队列风暴
另一个被忽视的点是 HTTP/2 优先级调度。
Chrome 会给每个请求分配一个优先级(0-255,越低越优先)。但问题在于:
- CSS 和 JS 文件被标记为 “Highest”(优先级 0)
- 图片被标记为 “Low”(优先级 6)
- 字体文件被标记为 “Highest”
当页面有大量图片请求时(比如新闻门户的 800-1000 个请求),低优先级的图片请求会阻塞在高优先级请求后面排队。但更坑的是,如果某个高优先级请求(比如 CSS)因为服务器处理慢而卡住,整个连接上的所有请求都会被拖慢——因为 HTTP/2 的流是按优先级顺序从客户端发出的。
所以你会看到:一个 100KB 的 CSS 文件加载了 500ms,后面 80 个图片请求就干等着。
2.4 服务器端的问题:SETTINGS 帧与流控制
别忘了,服务器也可以限制并发流。
服务器通过 HTTP/2 SETTINGS 帧里的 SETTINGS_MAX_CONCURRENT_STREAMS 参数告诉客户端:老弟,你最多只能同时发 N 个流。
我见过一个配置:Nginx 默认 http2_max_concurrent_streams 是 128,但有些 CDN 或者反向代理把这个值设成了 10。然后 Chrome 收到这个 SETTINGS 帧后,乖乖地把并发限制到 10。
这就解释了为什么有时候 HTTP/2 表现得还不如 HTTP/1.1——因为 HTTP/1.1 还能开 6 个连接(每个连接串行发请求),而 HTTP/2 被服务器限流到 10 个流。
三、排查步骤:从现象到根因
3.1 确认是不是 Chrome 在限流
用 chrome://net-internals/#http2 查看当前 HTTP/2 会话的状态:
- 打开 Chrome,地址栏输入
chrome://net-internals/#http2 - 找到你的目标域名
- 查看
active_streams数量
如果你看到 active_streams 一直卡在 10 左右,而页面还有大量待发请求,那基本就是触发了 Chrome 的慢连接限流。
用命令行验证(需要 Chrome Canary 或开启 --enable-logging):
# 启动 Chrome 并启用网络日志
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-logging --v=1 --log-net-log=/tmp/netlog.json
# 加载页面后,用 netlog-viewer 分析
# 或者直接 grep 关键词
grep -c "SLOW_CONNECTION" /tmp/netlog.json
3.2 检查服务器 SETTINGS 帧
用 curl 的 -v 或者 Wireshark 抓包看服务器下发的 SETTINGS 帧:
# 用 curl 查看 HTTP/2 连接参数
curl -v --http2 https://yourdomain.com/resource.css 2>&1 | grep -i "SETTINGS\|MAX_CONCURRENT_STREAMS"
# 或者用 nghttp2 工具
nghttp -v https://yourdomain.com/resource.css 2>&1 | grep "SETTINGS"
正常值应该是 100-256。如果低于 20,那就是服务器端的问题。
3.3 抓包分析流调度
用 Wireshark 抓 HTTP/2 的流:
# 抓取 443 端口流量
sudo tcpdump -i en0 -w http2.pcap port 443
# 用 Wireshark 打开,过滤 http2
# 查看 HEADERS 帧的 PRIORITY 字段
在 Wireshark 里,筛选 http2.stream_id 和 http2.priority,看看高优先级请求是不是占用了大量时间。
四、修复方案:别让浏览器当你的瓶颈
4.1 方案 A:拆域名(最直接)
既然 Chrome 在一个 HTTP/2 连接上有限流,那我们就开多个连接。
把静态资源拆分到多个域名:
static1.yourdomain.com- 图片static2.yourdomain.com- JS/CSSstatic3.yourdomain.com- 字体
每个域名都会建立独立的 HTTP/2 连接,每个连接都有 100 个流(或慢连接下的 10 个)。这样总并发量就变成了 3 x 10 = 30。
注意:DNS 查询和 TLS 握手有额外开销。对于 800-1000 个请求的页面,这个开销可以接受。
4.2 方案 B:优化关键请求路径
减少高优先级请求的数量。把 CSS 和关键 JS 内联到 HTML 里:
<!-- 内联关键 CSS -->
<style>
/* 首屏关键样式 */
.header { ... }
.hero { ... }
</style>
<!-- 异步加载非关键 CSS -->
<link rel="preload" href="/styles/non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
这样 Chrome 的优先级队列就不会被大量 “Highest” 请求塞满。
4.3 方案 C:服务端调整 SETTINGS 帧
如果是服务器限制了并发流,调大它:
Nginx:
http2_max_concurrent_streams 256;
Apache:
Protocols h2 h2c http/1.1
H2MaxSessionStreams 256
Caddy:
servers {
max_streams 256
}
4.4 方案 D:禁用 HTTP/2 优先级(激进方案)
Chrome 的优先级调度有时候弊大于利。可以在服务端忽略客户端的优先级设置,用服务端自己的调度策略:
Nginx:
# 忽略客户端优先级,使用服务器默认
http2_priority_reschedule on;
http2_push_disable on;
H2O:
http2-priority: default
4.5 方案 E:预连接(Preconnect)
告诉 Chrome 提前建立 HTTP/2 连接:
<link rel="preconnect" href="https://static1.yourdomain.com">
<link rel="preconnect" href="https://static2.yourdomain.com">
这样在资源加载时,连接已经建好了,不会因为连接建立而排队。
五、性能数据对比
我们在一个 800 请求的新闻门户上做了测试:
| 方案 | 首屏时间 (P50) | 首屏时间 (P95) | 完全加载时间 | 排队请求数 |
|---|---|---|---|---|
| 原始 HTTP/2 | 3.2s | 8.7s | 12.4s | 320 |
| 拆 3 个域名 | 1.8s | 3.4s | 5.2s | 45 |
| 内联关键资源 | 2.1s | 4.1s | 6.8s | 180 |
| 拆域名 + 内联 | 1.2s | 2.1s | 3.8s | 12 |
| 服务端调整 SETTINGS | 2.8s | 6.2s | 9.1s | 280 |
数据说明:拆域名是最有效的单一手段,配合内联关键资源效果最好。
六、社区讨论与争议
这个话题在 Reddit 和 Hacker News 上吵了好几年。
有工程师在 HN 上吐槽:“Chrome 的慢连接检测器就是个笑话。我千兆光纤,延迟 5ms,Chrome 还给我限制到 10 个流。我关了 SPDY/3 的兼容性代码才解决。”
另一个 Reddit 帖子说:“HTTP/2 的优先级调度在理论上是好的,但在实践中,Chrome 的实现在处理大量请求时会导致严重的头部阻塞。我们最后不得不拆域名。”
也有人为 Chrome 辩护:“慢连接检测器是为了防止用户在小水管上被大量请求压垮。不是所有人都用千兆光纤。”
说实话,我觉得两边都有道理。但作为一个 SRE,我的职责是让用户在任意网络条件下都有好的体验。所以我的建议是:不要依赖浏览器的智能调度,主动优化你的资源加载策略。
七、FAQ
问:Chrome 支持 HTTP/2 吗?
答:Chrome 从 41 版本开始默认支持 HTTP/2(仅 TLS),通过 ALPN 协商。不需要额外配置。
问:如何在 Chrome 中禁用 HTTP/2?
答:在 Chrome 地址栏输入 chrome://flags/#enable-http2,将 “HTTP/2” 设置为 “Disabled”。但不建议这么做,HTTP/2 的性能优势远大于问题。
问:什么是 ERR_HTTP2_PROTOCOL_ERROR?
答:这是 HTTP/2 协议级别的错误,通常由服务器发送了不合法的帧或客户端收到了无法处理的响应导致。常见原因包括:服务器负载过高导致超时、代理配置错误、或者 HTTP/2 和 HTTP/1.1 回退机制冲突。
问:如何修复 ERR_HTTP2_PROTOCOL_ERROR?
答:
- 清除浏览器缓存和 Cookie
- 检查服务器负载和日志
- 在 Nginx 中临时禁用 HTTP/2:
listen 443 ssl http2;改为listen 443 ssl; - 检查 CDN 或反向代理的 HTTP/2 配置
- 升级服务器软件(Nginx 1.20+、Apache 2.4.50+)
八、总结
Chrome 在 HTTP/2 下请求排队,根本原因不是 HTTP/2 协议本身的问题,而是 Chrome 为了保护用户体验而做的保守限流。加上 HTTP/2 优先级调度的复杂性,以及服务端的 SETTINGS 限制,最终导致了请求排队。
解决方案不是放弃 HTTP/2,而是:
- 拆域名 - 增加并行连接数
- 内联关键资源 - 减少高优先级请求数
- 优化服务端配置 - 调大并发流限制
- 预连接 - 消除连接建立时间
最后送大家一句话:不要相信浏览器的"智能",主动控制你的加载策略。