做网站性能优化这几年,我经常收到一类反馈:页面明明“没有报错”,但打开就是慢,图片一张张蹦出来,脚本像是在排队过闸机,白屏好几秒,刷新几次时快时慢。去服务器上看CPU、内存都正常,后端接口响应很快,Nginx日志也干干净净。第一次遇上这种问题时,我确实绕了不少弯路,后来翻着浏览器开发者工具里的Network面板看了很久,才真正看清一个被大多数人忽略的瓶颈:浏览器对同一域名下的并发请求数量有严格限制,而HTTP/1时代的连接模型,会让一大堆请求排着长队,等待那有限的几个连接。
这篇文章基于我实际排查过的几类页面慢问题,把浏览器请求并发限制和HTTP/1问题从现象到原理、从定位到解决,完整讲一遍。内容主要面向前端、全栈开发,也适合做运维和性能测试的同学参考,看完之后你可以照着同样的思路去排查自己的项目。
1. 打开一个普通页面,为什么请求全在“排队”?
1.1 先从Network面板里的异常现象说起
大多数时候,用户说“页面卡”,第一反应是看后端接口。但有一类页面,接口都是几十毫秒返回,Nginx的upstream耗时也看不出问题,页面却依然要等好几秒。这时候打开开发者工具,切到Network面板刷新页面,能看到一个非常典型的画面:网络请求列表里,一长串请求状态显示为pending,点开任何一个请求,Timing那边写着Queueing时间高达几百毫秒甚至几秒。
这就是最典型的并发请求受限特征。尤其常见于传统多页应用、门户首页、H5营销页这些“资源品类多、数量大、没有统一构建优化”的场景,几十个脚本、几十张图片、几个字体文件全部指向同一个域名,又没有走HTTP/2连接,于是大家全堵在一个狭小的管道口。
有个容易踩的误区是:很多人看到pending就以为是服务器慢,或者怀疑某个请求卡住拖垮了整个页面。实际上,pending只代表这个请求还没有收到响应,它可能还在排队等浏览器分配连接,压根没有发到服务器上去。如果你在服务器访问日志里找不到那个请求,或者请求时间比浏览器里显示的时间晚很多,大概率就是卡在了浏览器这一侧的并发队列里。
1.2 浏览器为什么要对并发请求数量做限制
浏览器对同一域名下的HTTP/1.x请求并发数设上限,不是bug,而是一种有意为之的保护机制。试想一下,如果没有这个限制,一个打开了几十个标签页的浏览器,每个页面里有几十个资源,同一个域名可能会瞬间建立成百上千条TCP连接,这会让操作系统端口、文件描述符、服务器连接池同时被压垮,整个网络栈都会出问题。
各个主流浏览器对同一域名HTTP/1.x连接数的限制,大致如下表,不同版本略有差异:
| 浏览器 | HTTP/1.1下每个域名的最大并发连接数 |
|---|---|
| Chrome | 6 |
| Firefox | 6 |
| Edge(基于Chromium) | 6 |
| Safari | 6(老版本可能只有4) |
| IE 10 / 11 | 6 到 8 左右 |
需要注意,这个数字面向的是“域名级”限制,不是页面级。也就是说,一个页面同时加载 cdn.a.com、cdn.b.com、api.c.com 三个域名,每个域名在HTTP/1.1下各有6个连接额度,总共最多可以同时发起18个请求。但如果页面里所有资源都堆在同一个域名下,那就只有6条“车道”。
1.3 所谓的“排队时间”到底对应哪几个阶段
在DevTools的Network面板里,请求Timing会拆分成几个阶段,命名和含义在不同版本的Chrome里略有不同,但核心就是下面这些:
- Queueing:请求进入待发送队列后,排队等待分配连接的时间。如果看到一段很长的Queueing,而且集中在请求的最开头,通常就是撞上了并发连接数上限。
- Stalled:连接建立之前的一段停滞时间,在旧版本DevTools里可能包含排队时间。现阶段看到Stalled,也经常和连接池状态有关。
- DNS Lookup:域名解析时间,正常情况下几毫秒到几十毫秒,如果超过100毫秒就要考虑DNS配置是否有问题。
- Initial connection:TCP握手时间。普通请求正常几毫秒到几十毫秒,跨地区跨运营商时可能上百毫秒。
- SSL:TLS握手时间,HTTPS请求才会有这个阶段。
- Request sent:发送请求头的时间,一般很短。
- Waiting (TTFB):浏览器发出请求后,等待服务器返回首字节的时间。这个阶段才是真正体现服务端处理能力和网络链路质量的地方。
- Content Download:响应体下载时间,受资源大小、带宽、是否使用压缩影响。
很多人在排查时容易忽略一个关键点:如果Queueing或者Stalled占了总耗时的绝大部分,说明问题出在浏览器连接管理这一侧,不在服务器。真正的服务端问题,应该体现在Waiting(TTFB)居高不下。这两个方向一旦搞反,排查就会白费很多功夫。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. HTTP/1.1连接模型的致命短板:6连接上限与队头阻塞
2.1 为什么偏偏是6条连接
关于这个6,很多资料会引用RFC 2616里的一个建议:单个客户端到单个服务器尽量保持不超过2条连接。但实际上,这个建议早就被现实打脸了,现代网页的资源数量越来越多,2条连接根本不够用,所以浏览器厂商默认放宽到了6条,并持续了很多年。
6条连接是一个“够用但紧张”的经验值。对单个域名来说,如果页面资源少,6条并发足够;如果资源一多,立刻就会排队。而HTTP/1.1本身没有多路复用能力,一个连接同时只能处理一个请求的来回,所以并行度完全取决于连接数。换句话说,HTTP/1.1时代想提高页面加载速度,最直接的手段就是尽量少发请求,或者想方设法多开几个域名来扩容连接数。
2.2 队头阻塞:比连接数上限更隐蔽的慢
HTTP/1.1其实在协议层面支持Pipelining,允许一个连接上连续发送多个请求,服务器按顺序返回。理论上这样能缓解连接数不够的问题。但Pipelining有个致命缺陷:如果第一个请求处理很慢,后面所有请求的响应都要卡着等,哪怕它们本身很快。这就是常说的队头阻塞(Head-of-Line Blocking)。
因为Pipelining对中间代理和服务器兼容性要求太苛刻,实际环境中很容易出现异常,所以现代浏览器根本没有默认启用它。实际效果就是:HTTP/1.1下的一个连接,同一时刻最多只有一个请求处于传输或等待响应状态。并行能力完全靠那6个连接撑着。
队头阻塞还意味着,同一域名下如果有一个请求特别慢,哪怕其他请求已经排到连接上,也可能被它拖住。这也解释了为什么某些页面在“某个脚本偶发变慢”时,其他静态资源也跟着一顿一顿。
2.3 Keep-Alive:解决了握手问题,但也埋了连接池的坑
HTTP/1.1默认开启Keep-Alive,也就是连接复用。之前HTTP/1.0时代每次请求都要重新建TCP连接,三次握手加TLS握手,开销非常大。Keep-Alive让同一个TCP连接可以被后续请求复用,省掉了重复握手,这确实是个巨大的进步。
但Keep-Alive也带来了新的麻烦:一个页面加载完之后,浏览器和服务器之间还会保留着一批空闲连接,等着缓存的资源过期后再来复用。如果站点访问量大,服务器端就会积累大量这种半空闲连接。服务端如果没设置好keep-alive超时时间和连接回收机制,很容易出现连接数被打满、新用户连不上、请求排队加剧的连锁反应。
这也就是为什么排查HTTP/1性能问题时,不能只看浏览器侧,服务端的长连接配置也是重要的一环。后面我会专门讲这块的调试参数。
3. 用Chrome开发者工具精确定位并发瓶颈:完整排查链路
3.1 第一步:看瀑布图里的Queueing高不高
排查过程中,我建议先把Network面板的列配置调整好。在Network面板的表头上右键,勾选Protocol和Connection ID这两列,后面会非常有帮助。
然后刷新页面,重点观察请求瀑布图。不要只看加载完成时间,而是逐个看请求Timing的前半段:
- 如果多个请求在同一时间点开始排队,且Queueing时间很长、随后才开始DNS或连接,那基本就是并发连接数触顶。
- 如果请求很快进入Initial connection或SSL阶段,但Waiting(TTFB)很长,那重点排查服务端和网络链路。
有一个小经验:把瀑布图的视图宽度拉大,可以看到每条请求的详细Timing分解。如果看到Queueing所占的色块几乎都是同一个时间区间,比如页面一开始集中请求大量资源,那说明“同时发起请求太多”导致的连接竞争,而不是单个请求的问题。
3.2 第二步:用Performance面板确认请求并发窗口和优先级
Network面板适合看“每个请求”的详细情况,但要看整体并发窗口,Performance面板更适合。按F12切到Performance面板,点击录制后刷新页面,停止录制后,在结果时间线里能看到网络请求的并发分布情况。
资源加载的优先级也会在瀑布图里以不同颜色体现。很多开发者容易把“低优先级请求在后面”误判成“被并发限制堵住了”,这时需要结合资源本身判断,比如图片、统计脚本通常是低优先级,它们排队等待并不一定代表连接数满了,可能只是浏览器故意把这个请求放到更晚发起。这个细节不搞清楚,很容易得出错误结论。
3.3 第三步:通过Protocol和Connection ID验证连接模型
只看Timing还是间接推断,最直接的办法是验证连接模型。在Network列里开启Protocol和Connection ID后,可以看到每个请求用的是h2、http/1.1还是其他协议,以及多个请求是否复用了同一个TCP连接。
如果一个站点是HTTP/1.1,你会看到请求被分散到最多6个不同的Connection ID上;如果是HTTP/2,所有请求几乎都挂在同一个Connection ID上。这个观察结果可以直接验证“是不是连接数不足”的假设。
此外,Chrome还自带一个更底层的网络诊断工具:chrome://net-export。进入这个页面后点击开始记录,再刷新业务页面,然后停止记录并导出JSON文件,可以在 netlog-viewer 里看到每个连接事件的完整过程,包括连接池分配、排队原因、TLS握手细节。这个工具平时用不上,但在排查复杂连接问题时非常有用。
3.4 一个真实案例的完整复盘
为方便理解,我举个实际的案例。有一个资讯站点的首页,改造前页面里有大约90个静态资源,全部指向同一个主域名,协议是HTTP/1.1,没有启用CDN分域。用户在普通宽带下打开,页面总加载时长大约15.8秒。
当时我观察到的大致情况是这样的:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 首屏HTML下载 | 约200ms | 服务端响应本身很快 |
| 首批CSS/JS并发加载 | 约0.5秒 | 启动阶段只允许6个连接 |
| 剩余60多个资源排队加载 | 约12秒 | 大量资源在队列里等待连接释放 |
| 图片懒加载触发 | 约1.5秒 | 后加载的图片继续占连接 |
| 总加载完成 | 15.8秒 | 用户直观感受是页面卡死 |
排查过程中,我在服务器访问日志里发现一个有趣现象:很多静态资源请求的时间点,比浏览器里显示“请求发起”的时间点晚了好几秒。这说明这些请求确实没有及时到达服务器,被卡在了浏览器本地队列里。
顺藤摸瓜,我在Network面板看到大量请求的Queueing时间集中在1到3秒,原因就清楚了。后面做了静态资源域名拆分和HTTP/2升级,首屏时间掉到了3秒以内,这个案例留到下一节展开讲。
4. 绕过或解决限制:域名分片、资源合并与HTTP/2升级
4.1 域名分片:经典但需要慎重使用
域名分片的思路特别简单:既然一个域名最多6条连接,那我把资源分散到多个域名,总连接数不就翻倍了?比如原来所有静态资源都在 static.example.com,现在可以拆成 static1.example.com、static2.example.com、static3.example.com,三个域名加起来就有18个连接额度。
这是HTTP/1.1时代最常用的扩容手段,也确实有效。但代价不小:
- 每个新域名都增加一次DNS解析,DNS解析慢的地区和网络下,这个开销可能抵消并发收益。
- 跨域请求可能带来额外的CORS处理,Cookie的传递范围也要重新设计。
- 连接增多意味着服务器维护的连接数增多,连接池压力增大。
- 如果分片太多,比如浏览器对每个域名建6个连接,但对全局连接池也有上限,到时候仍然会等。
域名分片更适合用在“资源数量大、HTTP/1短期无法升级”的存量项目里,而且拆分的域名数量不要贪多,2到4个就够用了。我在那个资讯站项目里,最初就是把图片拆到 img1.example.com 和 img2.example.com,资源下载并发立刻翻倍,首屏进度明显改善。
4.2 资源合并与内联:治标也能治本
如果说拆分域名是把道路变多,那合并资源就是把车变少。HTTP/1.1时代最有效的优化,其实就是减少请求数:把所有 JavaScript 合并成一个JS文件、所有CSS合并成一个CSS文件、小图标做成雪碧图或字体图标、首屏CSS直接内联到HTML里。
很多开发者会抱怨合并文件导致缓存粒度变差:哪怕只改一行代码,整个大文件都要重新下载。这个矛盾确实存在,但HTTP/1.1下性能和缓存的取舍就是这样,只能结合业务场景权衡。一个可行的策略是把长期不动的公共库合并成一个文件,业务代码单独合并成一个文件,再按版本号刷新缓存,尽量兼顾缓存命中率。
内联的收益在于,一些体积很小、又不适合走异步加载的关键CSS和基础配置脚本,可以直接打进HTML。这样浏览器连请求都不用发了,省去DNS、连接、队头阻塞一整套功夫。但内联只适合真正关键且体积小的内容,全部内联会把HTML撑得很大,反而影响首字节时间。
4.3 升级到HTTP/2:从根本上解决并发限制
升级HTTP/2是我现在处理这类问题时的首选方案。HTTP/2引入多路复用,允许在一个TCP连接上同时传递多个请求和响应流。浏览器不再需要为每个请求建立独立连接,所有请求共用一个连接,天然绕开了“每域名6条连接”的限制。
升级HTTP/2需要两个前提:一是站点必须启用HTTPS(主流浏览器只在TLS上启用HTTP/2),二是服务器软件需要支持ALPN协商。目前Nginx、Apache、Tomcat、Caddy这些主流软件都支持,CDN厂商也普遍默认开启,技术门槛其实比想象中低。
很多资料会强调“HTTP/2一定要配合HTTPS”,但实际配置中,如果只是把证书挂上去但TLS握手优化没做好,首屏性能可能反而更差。所以升级HTTP/2时,同时要检查TLS会话复用、OCSP装订、证书链完整性这几个细节。这些配置项平时不起眼,但直接影响一次连接建立的开销。
4.4 服务端与CDN侧的关键配置项
以Nginx为例,升级HTTP/2时主要确认这几个配置:
code复制server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
http2_max_concurrent_streams 128;
keepalive_timeout 65s;
keepalive_requests 1000;
}
有几个参数值得展开说:
http2_max_concurrent_streams用于限制单个连接上的并发流数量,默认128。如果页面资源实在太多,或者存在大量的长轮询请求,这个值可以调大一些,但要留意服务端内存和文件描述符。keepalive_timeout决定空闲连接在服务端保留多久。太短会让连接频繁断开重建,太长又会堆积空闲连接。一般建议在60秒到75秒之间。keepalive_requests决定一个keep-alive连接上最多能处理多少个请求。HTTP/1时代默认100左右,HTTP/2场景下建议调大到1000甚至更高,否则连接达到请求数上限会被关闭,多路复用的优势会被削弱。
如果你用的是CDN,大多数情况下CDN会自动协商HTTP/2和TLS参数,弱项反而在回源链路。如果源站是HTTP/1.1,用户访问CDN节点时是HTTP/2,但CDN回源时又退回HTTP/1.1。这种情况前端看似已经升级,回源环节仍然存在队头阻塞。排查时可以在服务端日志里看回源请求的协议版本,不要只看浏览器侧的Network面板。
5. 排查路上容易翻车的隐藏细节与经验笔记
5.1 HTTP/2和域名分片不要混用
我在处理那个资讯站时,一开始保留了原本的图片子域分片,然后直接上了HTTP/2。结果发现效果没有想象中好,因为分片后每个域名都可能单独建立一条TCP连接,HTTP/2的多路复用无法跨连接协作。
HTTP/2场景下,多个分片域名等于制造了多份连接握手成本和TLS协商成本。更合理的做法是:通过HTTP/2把所有静态资源集中在一个域名下,或者最多再单独分一个cookie-free的资源域名,但不要为“增加并发连接数”去做大量分片。如果你在已经升级HTTP/2的站上还看到大量分片子域,建议逐步收敛回来。
5.2 服务端keep-alive超时和全局连接耗尽
还有一种隐蔽问题:服务端的连接回收机制和浏览器队列互相放大。当服务端keep-alive超时时间设置得过短,比如几十秒甚至是默认的5秒,空闲连接很快会被服务端关闭,但浏览器并不知道,下次请求可能复用一个已失效的连接,于是重新建连。
当站点同时存在大量短连接请求时,服务端并发连接数会迅速冲高,新连接迟迟进不来。这时候从客户端看,请求会反复出现Initial connection时间变长、偶发超时;从服务端看,连接数还没到峰值,但连接状态大量处于TIME_WAIT或CLOSE_WAIT。排查这种问题要同时看两边的指标,不能只盯一处。调优方向通常是:把keep-alive超时调到一个合理范围,适当提高连接数上限,确保worker进程或线程数足够。
5.3 被忽略的第三方脚本、字体和favicon
很多开发者在排查并发问题时,注意力都放在自己业务域的静态资源上,却漏掉了第三方请求。统计脚本、在线客服、广告SDK、外部字体这些资源往往指向第三方域名,浏览器同样要按域名限制为它们分配连接。
更麻烦的是,第三方脚本一旦变慢,浏览器在HTTP/1.1下无法绕开队头阻塞,会拖累同一个域名下的其他请求。比如某个字体文件来自 fonts.example.com,而这个域名同时还要加载几个别的页面共用资源,只要字体文件响应慢,同域名的其他资源也会跟着排在后面。这也是为什么现在的性能优化方案都强调“尽量自托管第三方资源”或至少“给第三方请求设置超时降级”。
favicon也是个小坑。favicon请求优先级很低,但它在页面加载早期就会被自动发起。如果浏览器发现favicon不在本地缓存里,它会占用一个连接请求额度,去请求那个你可能根本没在意的16x16图标。某些内网旧系统里,favicon请求一直404,浏览器仍然会等响应,影响后面资源的排队顺序。排查这类边角问题时,把favicon的响应头加上长效缓存,是成本最低的优化之一。
5.4 浏览器差异和跨浏览器支持问题
前面提到过,不同浏览器的HTTP/1.1并发限制略有差异,但更麻烦的是不同浏览器对HTTP/2、QUIC协议的支持度不同。比如在老版本Windows 7自带的旧浏览器上,站点可能只支持HTTP/1.1,无法协商HTTP/2,最终仍会走老的连接限制,同时TLS版本也必须兼容到1.2。
跨浏览器支持问题不只是在手机页面上出现。有些企业用户还在用旧版浏览器,或者通过代理访问外网,代理本身可能不支持HTTP/2,导致即便源站支持,客户端和代理协商后依然退回HTTP/1.1。这种环境下,域名分片仍然是一个现实可用的兜底方案,但前提是你知道自己面对的是一批旧浏览器用户,而不是所有用户。
5.5 几个顺手就能用的命令和脚本
排查时我经常用几条命令行,帮助确认服务端协议和连接协商状态。比如用curl直接看HTTP版本:
bash复制curl -sI -o /dev/null -w '%{http_version}\n' https://example.com/
输出如果是 2,说明服务端支持HTTP/2;如果是 1.1,说明当前协商用的是HTTP/1.1。如果浏览器显示h2但curl显示1.1,可能是curl版本或编译选项不支持HTTP/2,需要换一条能展示协议协商过程的命令:
bash复制curl -sv --http2 https://example.com/ -o /dev/null 2>&1 | less
用openssl检查服务端ALPN支持也很直接:
bash复制openssl s_client -connect example.com:443 -alpn h2,http/1.1 -servername example.com </dev/null 2>/dev/null | grep -i 'ALPN protocol'
看到 ALPN protocol: h2 基本就能确认服务端正确宣告了HTTP/2能力。
另外,服务端日志里检查协议版本也很容易。Nginx的$http2变量可以确认当前请求是否走HTTP/2,把日志格式里加上这个变量,统计一下实际用户访问的协议占比,对比浏览器面板里看到的情况,往往能发现一些配置层面两边不一致的问题。
我在实际项目里,建了一个简单的排查清单:先看Network的Queueing占比,再确认主要资源所在域名的连接能开几个,接着看Protocol和Connection ID,最后对比服务端日志。整个过程大概十分钟,基本能把“并发限制”和“服务端慢”这两种症状区分干净。以后你在排查页面加载慢时,也可以按这条链路走一遍,大概率能少走很多弯路。
