2021 年我接手优化公司官网首页:打开一次要五六秒,慢得让人想关掉。怎么让它快起来?这件事我压根没多想。第一版我做得很顺手:网上讲前端优化的文章都说"慢就是因为文件又多又大",那我照着做——把几十个小 JS、小 CSS 合并成几个大 bundle,把零碎图标拼成雪碧图,再把所有资源压缩一遍、挂上 CDN。就完事了。改完一测——真不错:首屏好像是快了那么一点点。我心里挺踏实:"前端优化嘛,不就是把文件弄小、弄少?"可等我盯着浏览器的 Network 瀑布图反复看、等真实用户的访问数据回来,一串问题冒了出来。第一种最先把我打懵:我把几十个文件合并成大 bundle,首屏是快了点,可只要改一行 JS,整个几百 KB 的 bundle 缓存全部失效,用户每次都得重新下载一大坨。第二种最难缠:Network 瀑布图里,一堆请求明明体积很小,却一格一格地排着队——前面那个不下载完,后面的就纹丝不动,根本不是"同时在下"。第三种最头疼:我学网上的"域名分片",把资源拆到 img1、img2、img3 几个子域名上,想骗浏览器多开几条连接,结果首次访问时每个子域名都要重新 DNS 解析、重新 TCP 握手、重新 TLS 握手,反而更慢了。第四种最莫名其妙:同样一个页面,在我本地 localhost 上飞快,一上线就慢,我一度怀疑是服务器配置出了问题。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"页面慢,就是因为文件又多又大,把文件弄小、弄少就快了"。这句话把所有注意力都放在了"传输的内容"上,却完全没看"内容是怎么被传输的"——也就是 HTTP 协议本身。可慢的根子,恰恰就在这里。我脑子里,HTTP 就是一根管子,我把文件塞进去、它流到浏览器,管子是被动的、透明的,我能优化的只有"塞进去的东西"。可这个想法,从根上忽略了一件事:我用的那个 HTTP/1.1,它的连接模型本身,就是这次加载慢的真正瓶颈。HTTP/1.1 有一条铁律——一个 TCP 连接,在同一时刻只能处理一个请求-响应:你发出一个请求,必须等它的响应完整回来,才能在这条连接上发下一个请求。这就是"队头阻塞"(head-of-line blocking)。浏览器当然知道这样太慢,它的补救办法是:对同一个域名,最多同时开 6 条 TCP 连接,让 6 个请求并行。可我的首页有几十个资源,6 条连接,就意味着几十个请求要在 6 条队伍里慢慢排,前面排着一个大文件,它后面那几个小文件再小,也只能干等。我做的那些优化——合并文件、雪碧图、域名分片——全部都是在"绕开"这个 6 连接上限和队头阻塞的土办法:合并是把"多个请求"硬凑成"一个请求"来少排队,雪碧图是把多张图凑成一张图来少排队,域名分片是骗浏览器多开几条队。它们治的全是"请求数太多排不过来"这个症状,却没人去治那个病根——HTTP/1.1 的连接模型本身就不该让请求这样排队。而且每个土办法都带着副作用:合并毁掉了缓存的颗粒度,域名分片又把 TCP 和 TLS 握手的成本翻了好几倍。真正把页面加载做快,核心不是"把文件弄得更小更少",而是认清前端加载慢的根子,常常在 HTTP/1.1 的连接模型——单连接串行、队头阻塞、每域名 6 连接上限;真正的解法是升级到 HTTP/2,用一条连接上的"多路复用"让几十个请求真正并行,而过去那些合并、雪碧图、域名分片的招数,到了 HTTP/2 下不但没必要,有些还有害。这篇文章就把 HTTP/2 这个坑梳理一遍:为什么 HTTP/1.1 下"文件再小也快不起来"、HTTP/2 的多路复用到底是什么、怎么启用 HTTP/2、为什么 HTTP/2 下要"反向优化"、HTTP/2 还没解决的 TCP 队头阻塞与 HTTP/3,以及一些把 HTTP/2 落地要避开的工程坑。
问题背景
这个坑普遍,是因为绝大多数前端性能教程,都默认你跑在一个"理想的管子"上,只教你优化内容——压缩、合并、懒加载——几乎没人先带你看清你脚下这根管子(HTTP/1.1)本身长什么样、有什么硬限制。它错得隐蔽,是因为本地开发永远测不出来:localhost 上没有真实网络延迟,一个请求几毫秒就回来了,队头阻塞和 6 连接上限带来的排队几乎看不见——这正是"本地飞快、上线就慢"的真相。它只在真实网络、真实延迟、几十个资源的线上环境里才暴露,而那时你已经在合并、分片这些土办法里越陷越深。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:小文件也在瀑布图里一格格排队;合并 bundle 毁掉缓存颗粒度;域名分片反而更慢;本地飞快、上线就慢。
- 错误认知一:以为慢只跟"传输的内容"有关。真相是"内容怎么被传输"——HTTP 协议的连接模型——常常才是瓶颈。
- 错误认知二:以为请求是并行下载的。真相是 HTTP/1.1 一条连接同一时刻只能跑一个请求,浏览器每域名最多开 6 条连接。
- 错误认知三:以为合并、雪碧图、域名分片是"正经优化"。真相是它们只是绕开 HTTP/1.1 限制的土办法,各有副作用。
- 真相:根治办法是换连接模型——HTTP/2 用一条连接的多路复用让请求真正并行,土办法到 HTTP/2 下要被反向撤销。
一、为什么 HTTP/1.1 下"文件再小也快不起来"
先把第一版那个服务摆出来。它跑在一个最普通的 HTTP/1.1 的 nginx 上:
# 第一版:一个最普通的 HTTP/1.1 站点配置(瓶颈就藏在协议里)
server {
listen 443 ssl; # 注意:这里只是 ssl,没有 http2
server_name www.example.com;
ssl_certificate /etc/nginx/cert.pem;
ssl_certificate_key /etc/nginx/key.pem;
root /var/www/site;
# 首页 index.html 里 link 了几十个 JS / CSS / 图片
}
这套配置本身没写错,它的慢,藏在 listen 443 ssl 这一行没写的东西里——它没开 HTTP/2,于是整个站点跑在 HTTP/1.1 上。HTTP/1.1 有一条铁律:一条 TCP 连接,同一时刻只能进行一个请求-响应。你在这条连接上发了请求 A,就必须等 A 的响应完整回来,才能发请求 B。这就是队头阻塞。浏览器的补救办法是对每个域名最多开 6 条连接,可几十个资源分到 6 条队里,瀑布图就长这样:
HTTP/1.1:每域名最多 6 条连接,30 个资源在 6 条队里排
连接1: [====app.js 很大====][icon1.png][====]
连接2: [==style.css==][icon2.png][logo.png][==]
连接3: [==vendor.js==][icon3.png][banner.jpg ......]
连接4: [font.woff2][icon4.png][==]
连接5: [==chunk1.js==][icon5.png][==]
连接6: [==chunk2.js==][icon6.png][==]
↑ 队头那个大文件没下完,它后面排的所有小文件全都干等
你用 curl 也能直接量出这件事。对同一个域名连续请求多个资源,HTTP/1.1 下它们是一个接一个串行完成的:
# 看清 HTTP/1.1 的串行:--http1.1 强制用 1.1,-w 打印每段耗时
curl -s -o /dev/null --http1.1 \
-w "协议=%{http_version} 建连=%{time_connect}s 总耗时=%{time_total}s\n" \
https://www.example.com/app.js
# 在 1.1 下,浏览器同域名只能挤 6 条连接
# 一个慢响应会阻塞同一条连接上排在它后面的所有请求
这里要建立的第一个、也是最重要的认知是:当你优化一个系统时,你必须分清楚两类东西——"被传输/被处理的内容",和"传输/处理它的那套底层机制"。第一版的全部努力,压缩、合并、雪碧图,都死死盯着第一类:内容。这背后是一个很隐蔽的假设——底层机制(HTTP 协议)是一根透明的、理想的、不会成为瓶颈的管子,我唯一能动的就是往管子里塞的东西。可这个假设在 HTTP/1.1 这里根本不成立:HTTP/1.1 的连接模型不是一根透明的管子,它是一根本身就很窄、而且会自己堵自己的管子——单连接串行、6 连接上限、队头阻塞,这些限制是协议层面的硬约束,你在内容上做的任何优化都绕不过它们。这就解释了那几个怪现象:小文件也排队,是因为限制的是"并发的请求数",跟单个文件多小没关系;本地飞快上线慢,是因为本地没有网络延迟,队头阻塞的代价被掩盖了。要建立的通用认知是:遇到性能问题,不要一上来就埋头优化"内容",先抬头问一句——我脚下这套底层机制本身,是不是已经到顶了?很多时候,真正的数量级提升,不在于你把内容优化得多精细,而在于你换掉了那套已经过时的底层机制。对这次的问题来说,那套该被换掉的机制,就是 HTTP/1.1。
二、HTTP/2 多路复用:一条连接上跑几十个请求
HTTP/2 解决问题的方式,不是把管子修宽一点,而是彻底换掉 HTTP/1.1 的连接模型。它最核心的能力,叫多路复用(multiplexing)。
要理解它,先理解 HTTP/2 引入的两个概念。一个是帧(frame):HTTP/2 不再把一个请求或响应当成一个不可分割的整体来传,而是把它切成很多个小小的帧。另一个是流(stream):同一条 TCP 连接上,可以同时存在很多个"流",每个流对应一个请求-响应,每个帧都标记着自己属于哪个流。于是,多个流的帧,可以在同一条连接上交错着发:
HTTP/2:只开 1 条 TCP 连接,所有请求的帧在上面交错并行
一条连接: [流1帧][流3帧][流2帧][流1帧][流5帧][流2帧][流4帧]...
└ app.js └ icon └css └app.js└font └css └vendor
关键:流2 的一个帧慢了,流1、流3、流5 的帧照样在发、照样在到
→ 没有"一个大文件堵死后面所有请求"这回事了
→ 30 个资源,真正意义上在一条连接里并行地跑
这就是质变所在:HTTP/1.1 里,一个请求必须等前一个整个完成;HTTP/2 里,几十个请求的帧交错在一条连接上跑,谁的数据先准备好谁的帧就先发,互不阻塞。浏览器加载同一个页面,走哪个协议,路径完全不同:
[mermaid]
flowchart TD
A[浏览器要加载 30 个资源] --> B{用的是哪个协议}
B -->|HTTP/1.1| C[每个域名最多开 6 条 TCP 连接]
C --> D[30 个资源在 6 条队里串行排队]
D --> E[队头的大文件阻塞它后面的所有请求]
B -->|HTTP/2| F[只开 1 条 TCP 连接]
F --> G[30 个请求拆成帧 在同一连接上交错并行]
G --> H[没有队头阻塞 谁先就绪谁先回]
除了多路复用,HTTP/2 还顺带做了两件事。一是头部压缩(HPACK):HTTP 请求头里有大量重复内容(User-Agent、Cookie 等),HTTP/1.1 每个请求都把它们完整发一遍,HTTP/2 用 HPACK 把它们压缩,只发增量。二是二进制分帧:HTTP/1.1 是文本协议,HTTP/2 是二进制协议,解析更快、更不容易出歧义。
这里要建立的认知是:HTTP/1.1 到 HTTP/2 的这次升级,值得你记住的不是"多路复用"这个名词,而是它解决问题的思路——面对"一条资源上多个任务互相阻塞"这个困境,有两种截然不同的应对。第一种,是 HTTP/1.1 和第一版的我所做的:接受"一个任务必须完整占用资源直到结束"这个前提不变,然后想办法在它周围打补丁——开 6 条连接是多搞几份资源,合并文件是把多个任务硬凑成一个任务。这种思路的天花板很低,因为它从没质疑那个根本前提,只是在那个糟糕前提的约束下反复腾挪。第二种,是 HTTP/2 所做的:直接把那个前提本身砸掉——它把"任务"这个粗粒度的、不可分割的单位,拆成了"帧"这种细粒度的、可交错的单位,一旦任务可以被切成碎片交错执行,"互相阻塞"这个困境就从根上消失了。这个"把粗粒度不可分割的单位,拆成细粒度可交错的单位"的思路,威力极大,而且到处都是它的身影——操作系统把 CPU 时间切成时间片让多进程交错,所以你不用等一个进程跑完;它和上一篇我们讲大模型推理时的 continuous batching,内核是同一个。所以当你下次遇到"一堆任务在抢一个资源、互相卡住"时,别急着去加资源、去打补丁,先问一句:这些任务,是不是可以被切碎、然后交错执行?能,往往就是数量级的提升。
三、怎么启用 HTTP/2:它几乎是白送的
认清了价值,启用 HTTP/2 反而是这篇文章里最简单的一步——它几乎是白送的:你不用改一行业务代码,只要改服务器配置。唯一的前提是 HTTPS:所有主流浏览器都只在 TLS 加密连接上启用 HTTP/2。如果你的站点已经是 HTTPS(2021 年以后基本都是),那这个前提早就满足了。
# 启用 HTTP/2:在 nginx 里就是加一句话的事
# 写法一:较新版本的 nginx(1.25.1+),用独立的 http2 指令
server {
listen 443 ssl;
http2 on; # 就这一句,开启 HTTP/2
server_name www.example.com;
ssl_certificate /etc/nginx/cert.pem;
ssl_certificate_key /etc/nginx/key.pem;
root /var/www/site;
}
# 写法二:较老版本的 nginx,把 http2 写在 listen 行上
# listen 443 ssl http2;
改完 reload 一下,就该验证它真的生效了——千万别假设"配了就一定生效"。用 curl 看响应到底走的是哪个协议:
# 验证 HTTP/2 是否真的生效:--http2 让 curl 尝试用 HTTP/2
curl -s -o /dev/null --http2 \
-w "协议版本=%{http_version}\n" \
https://www.example.com/
# 输出 "协议版本=2" → HTTP/2 已生效
# 输出 "协议版本=1.1" → 还在 1.1,检查 nginx 配置和版本
# 也可以用 -I 看响应,HTTP/2 的状态行长这样:
curl -I --http2 https://www.example.com/
# HTTP/2 200 ← 注意这里是 "HTTP/2",不是 "HTTP/1.1 200 OK"
在浏览器里,你还可以用代码直接读出每个资源用的什么协议——PerformanceResourceTiming 的 nextHopProtocol 字段会告诉你:
// 在浏览器控制台跑:看页面上每个资源到底走的什么协议
performance.getEntriesByType('resource').forEach(entry => {
// nextHopProtocol: "h2" 表示 HTTP/2,"http/1.1" 表示还在 1.1
console.log(entry.nextHopProtocol, entry.name);
});
// 如果一片资源还显示 "http/1.1",通常是它们挂在
// 一个没开 HTTP/2 的子域名 / 第三方域名上 —— 逐个域名去排查
这里要建立的认知是:HTTP/2 这个案例,藏着一个特别值得记住的、关于"投入产出比"的判断。第一版的我,把大量时间花在了合并、雪碧图、域名分片上——这些事每一件都很费工:合并要配置打包工具、雪碧图要画图算坐标、域名分片要改一大批资源 URL,还都带着副作用、要持续维护。可它们换来的收益,是在一个糟糕协议的约束下"挤"出来的一点点提升。而真正能带来数量级提升的 HTTP/2,投入小到几乎可笑——服务器配置加一行、reload 一下、验证一下,业务代码一行不改。这就是一个典型的"高收益、极低成本,却长期被忽略"的优化点。它为什么被忽略?因为它不在大多数人习惯的那个优化层面上:大家习惯优化自己写的代码、自己打的包,而 HTTP/2 是"基础设施层"的事,它不在前端的日常视野里,于是一个一行配置就能拿到的巨大收益,被晾在那里没人捡。所以这里要建立的认知是:做优化时,别只在你最熟悉的那一层(业务代码)里反复打磨,要养成习惯,定期抬头扫一遍你脚下的每一层基础设施——协议、服务器、网络、运行时——问一句"这一层有没有什么开箱即用的能力,我还没开?"。基础设施层的升级,往往就是这种一行配置撬动数量级收益的好生意,而它最大的成本,仅仅是"你没想到去看它"。
四、HTTP/2 下要"反向优化":撤掉那些土办法
这一节最反直觉,但极其重要:升级到 HTTP/2 之后,你过去为 HTTP/1.1 做的那些优化,有一部分不仅没用了,反而变成了负担,要主动撤掉。原因很简单——那些土办法的全部目的,都是绕开 HTTP/1.1 的 6 连接上限,而 HTTP/2 已经从根上消灭了那个限制。
第一个要撤的是域名分片,它在 HTTP/2 下是纯粹有害的。HTTP/2 的多路复用,威力建立在"所有请求共用同一条连接"之上——一条连接才能交错调度。你为了 HTTP/1.1 把资源分散到 img1、img2 几个子域名,等于强迫浏览器开好几条独立连接,每条都要单独 DNS、单独 TCP、单独 TLS 握手,而且把本该在一条连接上协同的多路复用,硬生生切割开了。在 HTTP/2 下,正确做法是反过来——把资源尽量收拢到同一个域名。
<!-- 反面教材:为 HTTP/1.1 做的域名分片,在 HTTP/2 下要撤掉 -->
<img src="https://img1.example.com/a.png">
<img src="https://img2.example.com/b.png">
<script src="https://static.example.com/app.js"></script>
<!-- HTTP/2 下的正确做法:资源收拢到同一个域名,共用一条连接、共享多路复用 -->
<img src="https://www.example.com/img/a.png">
<img src="https://www.example.com/img/b.png">
<script src="https://www.example.com/js/app.js"></script>
第二个要重新权衡的是把所有文件合并成一个巨型 bundle。在 HTTP/1.1 下,合并是为了减少请求数(少排队)。但 HTTP/2 下请求数已经不那么贵了(多路复用 + HPACK 头部压缩),而巨型 bundle 的副作用却很重——它毁掉了缓存的颗粒度:你改一行代码,整个 bundle 的哈希就变了,用户缓存的几百 KB 全部失效、要重下。HTTP/2 下,合理的做法是适度拆分:
// HTTP/2 下的打包策略:不再追求"合并成一个大包",而是合理拆分
// 以 webpack 的 splitChunks 为例
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// 把不常变的第三方库单独拆出来,它的缓存能长期复用
vendor: { test: /node_modules/, name: 'vendor', priority: 10 },
// 业务代码按路由/模块适度拆分,改一处只失效一小块缓存
common: { minChunks: 2, name: 'common', priority: 5 },
},
},
},
};
// HTTP/2 下多发几个请求几乎没代价,换来的是精细的、命中率高的缓存
这里要建立的认知是:这一节真正要讲的,是一个特别容易被忽视、却代价高昂的现象——"过时的优化"。我们总以为优化手段是中性的、累加的:做了总比不做好,留着也无所谓。可域名分片和巨型 bundle 这两个例子,清清楚楚地告诉你不是这样。任何一个优化手段,都不是凭空成立的,它的成立,严格依赖于它当初所处的那个环境和那组约束——域名分片之所以是优化,前提是"HTTP/1.1 限制每域名 6 连接";巨型 bundle 之所以是优化,前提是"HTTP/1.1 下每个请求都很贵"。一旦这个前提被打掉(你升级到了 HTTP/2),这些手段赖以成立的地基就没了,而它们当初为了换取收益而付出的代价——多余的握手、被毁掉的缓存颗粒度——却原封不动地留了下来。于是它们从"优化"反转成了"负担",而且是隐形的负担,因为没人会想到去审视一个"曾经正确"的东西。这就引出一条重要的工程纪律:当你对系统做了一次基础性的升级(换协议、换框架、换架构),你的工作绝不能停在"把新东西接上"——你必须回过头,把那些为了适配旧环境而存在的老优化、老补丁、老 workaround,一个一个拎出来重新审视:它当初解决的问题还存在吗?不存在了,就要有勇气、也要有纪律地把它删掉。技术债不只来自你写得不好的代码,更来自那些"曾经正确、如今过时、却没人敢动"的代码。识别并清理过时的优化,和引入新的优化同样重要。
五、HTTP/2 没解决的:TCP 队头阻塞与 HTTP/3
把 HTTP/2 说得完美无缺也不诚实。它消灭了 HTTP 层的队头阻塞,但还剩一个更深的、藏在 TCP 层的队头阻塞没解决。
问题出在:HTTP/2 的几十个流,虽然在 HTTP 层互不阻塞,但它们终究全都跑在同一条 TCP 连接上。而 TCP 这个协议,为了保证数据"按顺序、不丢失"地交付,有一条铁规矩:如果中间某个数据包丢了,TCP 必须停下来,等这个包重传成功,在它到达之前,后面所有已经收到的数据都不能交给上层——哪怕那些数据属于完全不同的流。
HTTP/2 的 TCP 队头阻塞:
一条 TCP 连接,承载着 流1 流2 流3 的帧:
[流1帧] [流2帧] [流3帧] [流1帧] [流2帧] ...
↑ 这个属于流2 的 TCP 包,在网络上丢了
后果:TCP 必须等流2 这个包重传回来,在那之前,
排在它后面、已经到达的 流1 流3 的帧,统统不能交付
→ HTTP 层不阻塞了,但 TCP 层又把大家一起阻塞了一次
这个问题 HTTP/2 自己解决不了,因为它的根在 TCP。HTTP/3 给出的答案很彻底:不再用 TCP,改用基于 UDP 的 QUIC 协议。QUIC 在自己内部实现了流的概念,而且让每个流独立地处理丢包——流2 丢了包,只阻塞流2,流1 流3 照常交付。HTTP/3 的启用同样是配置层面的事:
# HTTP/3:基于 QUIC,需要较新的 nginx(1.25+)且编译时带 QUIC 支持
server {
listen 443 ssl;
listen 443 quic reuseport; # QUIC 跑在 UDP 443 上
http2 on;
http3 on; # 开启 HTTP/3
server_name www.example.com;
ssl_certificate /etc/nginx/cert.pem;
ssl_certificate_key /etc/nginx/key.pem;
# 用 Alt-Svc 响应头告诉浏览器"我支持 HTTP/3,下次可以用它来连"
add_header Alt-Svc 'h3=":443"; ma=86400';
root /var/www/site;
}
这里要建立的认知是:HTTP/1.1 → HTTP/2 → HTTP/3 这条演进线,本身就是一堂极好的工程课,它讲的是"瓶颈是会转移的"。一开始,瓶颈在 HTTP 层——单连接串行、6 连接上限。HTTP/2 用多路复用把这个瓶颈解决了。可瓶颈并没有消失,它只是下沉了:当 HTTP 层不再阻塞,那条承载一切的 TCP 连接,它本身"丢包即全体等待"的特性,就浮现成了新的、更深一层的瓶颈。HTTP/3 于是再往下一层,把 TCP 都换掉。这里有两个认知要建立。第一个:解决一个瓶颈,常常不是"消灭"了它,而是把系统的压力推到了下一个更深的、原本不显眼的环节,让那个环节成为新瓶颈——所以性能优化不是一锤子买卖,而是一场"找到当前瓶颈、解决它、再找下一个"的持续接力。第二个、也是更要紧的:认清瓶颈如今在哪一层,直接决定了你该不该升级。如果你的用户大多在稳定的网络上,丢包率很低,那么 TCP 队头阻塞对你几乎不构成困扰,你的瓶颈还停在别处,HTTP/2 对你就完全够用,贸然上 HTTP/3 是把工程精力投在了一个你根本没遇到的问题上。反过来,如果你的用户大量在丢包频繁的移动网络、弱网环境下,TCP 队头阻塞就是实实在在咬你的瓶颈,HTTP/3 才值得投入。技术演进给了你 HTTP/3 这个更新的选项,但"要不要用最新的",答案永远不是"是,因为它新",而是"先搞清楚我当前的瓶颈在哪一层,再看这个新技术针对的是不是那一层"。
六、工程里那些 HTTP/2 的坑
HTTP/2 的主体收益很扎实,但落地时还有几个工程细节容易被忽略。第一个,HTTP/2 不会让"慢的后端接口"变快。多路复用优化的是"多个资源在网络上的传输调度",它解决的是传输层的排队;如果你的某个 API 本身要在服务端跑 3 秒,HTTP/2 一秒都帮不了你——别把它当成万灵药。第二个,第三方资源不归你管。你的页面引了一堆第三方的统计、广告、字体脚本,它们走不走 HTTP/2,取决于那些第三方的服务器,你开了自己的 HTTP/2 也管不到它们,用上一节那段 nextHopProtocol 代码能逐个查出来。第三个,Server Push 不要用。HTTP/2 早期有个"服务端推送"特性,实践证明它弊大于利,主流浏览器已经移除支持,需要提前加载的资源改用 <link rel="preload">。第四个,连接合并要注意证书:HTTP/2 允许浏览器把"同 IP、且 TLS 证书覆盖了多个域名"的请求合并到一条连接,这能省连接,但前提是证书配置得当。第五个,上线后要持续验证:nginx 升级、配置变更、加了新的子域名,都可能让一部分流量悄悄掉回 HTTP/1.1,要把"HTTP/2 占比"做进监控。
这里要建立的认知是:这一节的坑,几乎全都指向同一个根源——人们一旦接受了"HTTP/2 是个好东西"这个结论,就很容易滑进一种思维惰性,把它当成一颗"装上就万事大吉"的银弹,而停止了思考。可这一节的每一个坑,都是在提醒你:HTTP/2 的能力是有明确边界的。它的边界在哪?它优化的是"资源在浏览器和服务器之间传输时的调度效率",仅此而已。一旦你把这个边界刻在脑子里,这些坑就全都不言自明了:它管不了你后端接口本身的慢,因为那不是"传输调度",是"服务端计算";它管不了第三方资源,因为那些资源的传输发生在你和第三方服务器之间,不归你的服务器调度;它和缓存策略、和打包策略是正交的两件事,各自要单独做对。把一个技术用好的前提,从来不是知道"它能干什么",而是同时清楚地知道"它不能干什么"——后者甚至更重要。一个只知道某技术优点的人,会在它的能力边界之外对它抱有不切实际的期待,然后在那里栽跟头;一个同时清楚它边界的人,才能让它在该发挥作用的地方发挥到极致,同时不在它管不到的地方浪费时间、错置精力。对任何一个你打算长期依赖的技术,都去主动地、刻意地搞清楚它的能力边界——那条边界,比它的功能列表更值得你记住。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| HTTP/1.1 队头阻塞 | 一条连接同一时刻只能跑一个请求-响应 | 前一个响应没回完 后面的请求只能干等 |
| 每域名 6 连接上限 | 浏览器对单个域名最多开 6 条 TCP 连接 | 几十个资源就要在 6 条队里慢慢排 |
| 多路复用 multiplexing | 一条连接上多个流的帧交错并行传输 | HTTP/2 的核心 从根上消灭 HTTP 层队头阻塞 |
| 帧与流 | 请求被切成帧 每个帧标记所属的流 | 帧可交错 让请求从粗粒度变细粒度 |
| HPACK 头部压缩 | 压缩重复的请求头 只传增量 | 让 HTTP/2 下多发请求的代价大幅降低 |
| 启用 HTTP/2 | nginx 加一句 http2 on 即可 前提是 HTTPS | 不改业务代码 一行配置撬动数量级收益 |
| 域名分片要撤掉 | HTTP/1.1 的土办法 在 HTTP/2 下纯有害 | 它切割了多路复用 还多付握手成本 |
| 巨型 bundle 要拆分 | HTTP/2 下请求不贵 合并反毁缓存颗粒度 | 适度拆分 改一处只失效一小块缓存 |
| TCP 队头阻塞 | 同一 TCP 连接丢一个包 全部流一起等重传 | HTTP/2 没解决 根在 TCP 层 |
| HTTP/3 与 QUIC | 改用基于 UDP 的 QUIC 每个流独立处理丢包 | 弱网环境收益大 稳定网络 HTTP/2 已够用 |
避坑清单
- 优化加载慢之前,先确认用的是不是 HTTP/2。瓶颈常在协议的连接模型,不在文件大小。
- nginx 加 http2 on 启用 HTTP/2,不改业务代码,前提是站点已是 HTTPS。
- 配完一定要用 curl 或 nextHopProtocol 验证,别假设"配了就生效"。
- 升级 HTTP/2 后撤掉域名分片,把资源收拢到同一域名,多路复用才能发挥。
- 别再追求巨型 bundle,HTTP/2 下适度拆分,换取精细、命中率高的缓存。
- 不要用 HTTP/2 Server Push,浏览器已移除支持,改用 link rel=preload。
- 别指望 HTTP/2 让慢的后端接口变快,它只优化传输调度,不优化服务端计算。
- 第三方资源走不走 HTTP/2 你管不到,用 nextHopProtocol 逐个域名排查。
- 评估 HTTP/3 前先看用户网络环境,弱网丢包多才值得上,稳定网络 HTTP/2 够用。
- 把 HTTP/2 占比做进监控,配置变更或新子域名可能让流量悄悄掉回 1.1。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为页面慢就是"文件又多又大",所有优化都该对着文件本身使劲。可慢的真正根子,在我脚下那个被我当成透明管子的 HTTP/1.1——它单连接串行、每域名只准开 6 条连接、有队头阻塞。我做的合并、雪碧图、域名分片,全是在这根窄管子的约束下打补丁,治的是"请求排不过来"的症状,没人去治"协议本身就不该让请求这样排队"的病根。问题从来不在"文件还不够小",而在我从没抬头看一眼,传输这些文件的那套底层机制,本身就已经过时了。
真正把页面加载做快,工作量小得惊人——它不在"把文件优化到极致",而在一次视角的转变:把目光从"被传输的内容"上抬起来,投向"传输内容的那套协议"。一旦你愿意看协议这一层,该做的事就都浮现出来了:在 nginx 里加一行 http2 on 启用多路复用、验证它真的生效、然后回过头把那些为 HTTP/1.1 而生、如今已成负担的域名分片和巨型 bundle 反向撤掉。每一步都不复杂,难的是先承认:你能优化的,从来不只是你写的代码和你打的包,还有你脚下那一层一层的、平时根本不去看的基础设施。
我后来常拿银行柜台来想这件事。HTTP/1.1 像一家老银行:它只开 6 个窗口(6 条连接),每个窗口有一条铁规矩——必须把当前这位客户的所有事情全部办完,才能叫下一位。于是只要队头那位要办一笔复杂的大业务,他身后排着的所有人,哪怕只是来盖个章,也得陪着干等。我当年做的优化,就像是劝大家把好几件小事凑成一件大事一次办完(合并文件),或者去别的支行排队(域名分片)——全是在"6 个窗口、必须办完才叫下一个"这个糟糕规则下的无奈腾挪。而 HTTP/2 做的,是直接改掉那条铁规矩:每个客户的业务被拆成一个个小步骤,柜员可以办一下你的、再办一下他的、交错着来,谁的材料先备齐就先办谁的——队头那位办大业务,再也挡不住后面人盖个章了。多路复用,就是这条新规矩。
这类问题最咬人的地方,在于它在本地开发时几乎永远是"对"的:localhost 上没有真实的网络延迟,一个请求几毫秒就回来了,队头阻塞和 6 连接上限造成的排队,被低延迟彻底掩盖,你的页面在本地快得无可挑剔。它只在真实的网络、真实的延迟、几十个资源一起加载的线上环境里才暴露——而那正是"本地飞快、上线就慢"的全部真相。所以别等线上用户抱怨慢、别等自己在合并和分片的土办法里越陷越深,才想起去看协议:做一个面向公网的站点,第一天就该把"我跑在 HTTP 的哪个版本上"当成和写页面同等重要的事来确认——启用 HTTP/2 不该是一个"以后有空再说"的优化项,它该是你部署第一个站点时就该拨好的那个开关。把这件事在一开始就确认清楚,你才算真正跳出了那个人人都在埋头优化文件、却没人抬头看一眼协议的坑。
—— 别看了 · 2026