一个 HTTP 请求的一生:从输入 URL 到页面渲染
读者画像:你能熟练写业务代码、调 API,但一被问到「在浏览器地址栏输入
www.example.com回车之后,到底发生了什么」,就只能背出零散的几个词——DNS、三次握手、渲染——中间的因果链却串不起来。这也是最经典的面试题之一。<br><br>读完这篇文章,你会得到一条完整、按时间顺序、每一环都能量化耗时数量级的心智地图。核心思路是:一个 HTTP 请求的绝大部分时间不是花在「传数据」上,而是花在「等」上——等 DNS 查完、等握手来回、等阻塞的资源下载完。理解了「每一次网络往返(RTT)都是一次昂贵的等待」,这条链路的性能优化就几乎全都是「消灭往返」或「把往返藏起来」。
全文的数字都标了数量级和量纲,尽量给出「典型值」而非「精确值」——真实数字取决于物理距离、网络质量和服务端负载,但数量级的直觉(是 1ms 还是 100ms 还是 1s)比精确值重要得多。文中涉及协议细节处标注了来源。
目录
- 一张全景图与一个核心量纲:RTT
- URL 解析与 DNS:把域名翻译成 IP
- 建立连接:TCP 三次握手 + TLS 握手
- HTTP 请求与响应:请求行、状态码、缓存、Cookie
- 协议演进:HTTP/1.1 → HTTP/2 → HTTP/3,队头阻塞的三次围剿
- 到达服务器之前:CDN、反向代理、负载均衡
- 浏览器渲染:从字节流到屏幕像素
- 性能视角:每一环能怎么优化
- 常见困惑澄清
- 一句话收尾
1. 一张全景图与一个核心量纲:RTT
在钻进细节之前,先立一个全局框架。从你按下回车到页面「基本可看」,时间被切成这么几段:
输入 URL
│
├─ ① DNS 解析 域名 → IP (缓存命中 <1ms;冷查询 20~120ms)
│
├─ ② 建立连接
│ ├─ TCP 三次握手 (1 个 RTT)
│ └─ TLS 握手(HTTPS) (TLS 1.3 = 1 个 RTT;TLS 1.2 = 2 个 RTT)
│
├─ ③ 发送 HTTP 请求 → 服务端处理 → 返回响应
│ (首字节到达 = TTFB,含 1 个 RTT + 服务端处理时间)
│
├─ ④ 下载 HTML,边下边解析
│ └─ 遇到 CSS/JS/图片 → 回到 ②/③ 拉更多资源
│
└─ ⑤ 浏览器渲染:DOM → CSSOM → 渲染树 → 布局 → 绘制 → 合成 → 屏幕上有东西了
贯穿全文的核心量纲是 RTT(Round-Trip Time,往返时延):一个数据包从客户端到服务端再回来的时间。它由物理距离决定,且有硬下限——光在光纤里的速度约 20 万 km/s,北京到上海直线约 1000km,光纤绕路后一个来回的物理极限就有约 10ms,加上路由设备转发,实际同城 RTT 常在 5~30ms,跨国 RTT 轻松到 100~300ms。
为什么揪着 RTT 不放? 因为握手、请求都是「发出去 → 等回来」的往返模式,每多一个往返,就多一个 RTT 的墙上时钟时间,而这段时间里 CPU 和带宽基本是闲着的。带宽可以花钱买,RTT 买不来(受光速限制)。所以你会看到:这条链路上几乎所有严肃的优化,本质都是在减少 RTT 的个数,或者把 RTT 藏在用户察觉不到的地方。 记住这句话,后面每一节都是它的注脚。
2. URL 解析与 DNS:把域名翻译成 IP
2.1 先拆 URL
浏览器拿到 https://www.example.com:443/path?q=1#top,第一步是解析这个字符串:
| 部分 | 值 | 作用 |
|---|---|---|
| scheme(协议) | https | 决定用 TLS、默认端口 443 |
| host(主机名) | www.example.com | 要做 DNS 解析的目标 |
| port(端口) | 443(https 默认,可省略) | TCP 连接到哪个端口 |
| path(路径) | /path | 发给服务器的资源路径 |
| query(查询串) | ?q=1 | 附带参数 |
| fragment(锚点) | #top | 只在浏览器本地生效,不会发给服务器 |
一个容易忽略的点:#top 锚点是纯客户端的,浏览器用它来滚动定位,永远不会出现在发往服务器的 HTTP 请求里。这也是为什么单页应用早期用 # 做路由能避免请求服务器。
计算机之间只认 IP 不认域名,所以下一步必须把 www.example.com 翻译成一个 IP 地址(如 93.184.216.34)。这就是 DNS 干的事。
2.2 递归查询 vs 迭代查询:谁替谁跑腿
DNS 的完整解析涉及一整套层级服务器:根服务器(.)→ 顶级域 TLD 服务器(.com)→ 权威服务器(example.com 的 NS)。关键要区分两种查询方式:
- 递归查询(Recursive):你(客户端)对着本地递归解析器(Local Resolver,通常是 ISP 或 8.8.8.8 这类公共 DNS)说:「给我
www.example.com的 IP,我不管你怎么查,查到了直接告诉我结果。」——你把责任全权委托出去了,只等一个最终答案。 - 迭代查询(Iterative):递归解析器替你去跑腿时用的方式。它先问根服务器,根说「我不知道,但
.com的 TLD 服务器地址在这」;再问 TLD,TLD 说「我不知道,但example.com的权威服务器在这」;最后问权威服务器,才拿到真正的 IP。每一步都是「不直接给答案,只给下一跳线索」。
一句话记牢:客户端 → 递归解析器是「递归」(我只要结果);递归解析器 → 各级权威服务器是「迭代」(一跳一跳自己问)。 面试里把这两个方向说反是高频失分点。
2.3 缓存层级:绝大多数查询根本走不到根服务器
如果每次访问网站都要跑一遍「根 → TLD → 权威」,DNS 早就崩了。真相是:这套完整流程极少发生,因为每一层都有缓存。一次解析请求会依次撞上这些缓存层,任一层命中就直接返回:
浏览器自身 DNS 缓存(Chrome 内部有几十条、几分钟)
↓ 未命中
操作系统 DNS 缓存(macOS 的 mDNSResponder / Windows 的 DNS Client 服务;本机 hosts 文件也在这一层)
↓ 未命中
路由器缓存(家用路由器常兼做本地 DNS 缓存)
↓ 未命中
ISP / 公共递归解析器缓存(命中率最高的一层,海量用户共享)
↓ 未命中(才真正开始迭代查询)
根服务器 → TLD 服务器 → 权威服务器
每一层缓存都受该记录的 TTL(Time To Live) 约束——权威服务器在返回记录时会附带一个 TTL(比如 300 秒),各级缓存最多缓存这么久。TTL 设短,切换 IP(比如故障转移)快,但缓存命中率低;设长则相反。这是运维配 DNS 时的经典权衡。
耗时数量级:
| 情况 | 典型耗时 |
|---|---|
| 浏览器/OS 缓存命中 | < 1ms(就是查个本地哈希表) |
| ISP 递归解析器缓存命中 | 一个到解析器的 RTT,约 1~20ms |
| 完全冷查询(走完根→TLD→权威) | 20~120ms,跨国或权威服务器远时更久 |
2.4 提前量:dns-prefetch 与 preconnect
既然 DNS 是链路开头的一段串行等待,一个自然的优化是:在真正需要之前就把它做掉。HTML 里可以放这两种提示:
<!-- 只提前做 DNS 解析,成本极低,适合"可能会用到"的第三方域名 -->
<link rel="dns-prefetch" href="https://cdn.example.com">
<!-- 一步到位:DNS + TCP + TLS 全部提前建好,适合"马上一定会用"的关键域名 -->
<link rel="preconnect" href="https://cdn.example.com">
区别在于「提前多少」:dns-prefetch 只提前做 DNS 解析(省 20~120ms),成本几乎为零,可以撒网式地对多个域名用;preconnect 把 DNS + TCP 握手 + TLS 握手整条建连流程都提前跑完(省的时间多得多,可达上百 ms),但每个 preconnect 都会真的占用一个连接和握手开销,所以只对「确定马上要用」的少数关键域名用(一般不超过 4~6 个),滥用反而拖慢首屏。
补充:DoH(DNS over HTTPS)/ DoT(DNS over TLS) 是近年 DNS 的重要演进——传统 DNS 查询是明文 UDP,任何中间人都能看到甚至篡改你在访问哪些域名。DoH 把 DNS 查询封装进 HTTPS,DoT 封装进 TLS,都为了隐私和防篡改。Chrome、Firefox 已默认或可选启用。代价是多了一层加密开销,且让运营商/企业更难做 DNS 层面的管控。
3. 建立连接:TCP 三次握手 + TLS 握手
拿到 IP,就要和服务器建立一条可靠的通信管道。对 HTTPS 网站,这分两步:先建 TCP 连接(可靠传输),再建 TLS 连接(加密)。
3.1 TCP 三次握手:确认「双方都能收发」
TCP 是可靠传输协议,通信前必须先「握手」,确认双方的收发能力都正常。经典的三次握手:
客户端 服务端
│ ── SYN (seq=x) ──────────────▶ │ "我想连你,我的初始序号是 x"
│ │
│ ◀──── SYN+ACK (seq=y, ack=x+1) ── │ "收到;我也想连你,我的序号是 y,
│ │ 确认你的 x 收到了"
│ ── ACK (ack=y+1) ────────────▶ │ "收到你的 y 了,开始通信吧"
│ │
连接建立(客户端在发出第三个 ACK 后即可开始发数据)
为什么必须是三次,不能两次或四次?
- 两次不够:只有「客户端 SYN → 服务端 SYN+ACK」的话,服务端无法确认「客户端到底收到了我的 SYN+ACK 没有」,也就无法确认客户端的接收能力和服务端的发送能力是好的。第三次 ACK 就是补上这个确认。经典的反例是:一个因为网络延迟而滞留的旧 SYN 报文,如果只有两次握手,服务端会误以为客户端要建连而白白建立一个连接。三次握手能让客户端拒绝这个失效的旧连接。
- 四次多余:第二步里服务端把「确认你的 SYN(ACK)」和「我也要建连(SYN)」合并成一个包发了,所以不需要四次。
耗时:三次握手中,客户端在收到第二个包(SYN+ACK)后就可以开始发数据了,所以建立 TCP 连接对首个请求的开销是「1 个 RTT」。 记住这个 1-RTT。
3.2 TLS 握手:协商加密,1.2 与 1.3 的往返差异
HTTP 是明文的,HTTPS = HTTP + TLS。TCP 管道建好后,还要在上面再做一次 TLS 握手,目的是:验证服务器身份(证书)+ 协商出一把双方都知道、别人不知道的对称加密密钥。 这一步是「HTTPS 比 HTTP 慢一点」的主要来源。
TLS 1.2(旧,2 个 RTT):完整握手要两个来回——第一个 RTT 交换 ClientHello/ServerHello、证书、密钥交换参数;第二个 RTT 完成密钥确认(Finished)。也就是说,光 TLS 握手就要等 2 个 RTT。
TLS 1.3(新,1 个 RTT,2018 年 RFC 8446 标准化):这是重大改进。TLS 1.3 把握手压缩到 1 个 RTT——客户端在第一个 ClientHello 里就直接把密钥交换参数(key share)一起发出去,服务端一个来回就能把密钥定下来并开始加密通信。它还砍掉了 TLS 1.2 里一堆不安全或冗余的东西:淘汰了 RSA 密钥交换、静态 DH、旧的分组密码模式,密码套件从上百种精简到寥寥几种,既更快又更安全。
0-RTT(TLS 1.3 的进阶,会话恢复场景):如果之前连过这台服务器,客户端可以用缓存的会话信息,在第一个包里就带上应用数据(early data),实现 0 个 RTT 的数据发送。代价是 0-RTT 数据有重放攻击风险(攻击者可以截获并重复发送这个包),所以协议规定只有幂等操作(如 GET)才适合用 0-RTT,服务端也要做重放防护。
量化「HTTPS 慢一点」到底慢多少:
| 场景 | 建连总往返 | 相比纯 HTTP 多出的开销 |
|---|---|---|
| HTTP(无 TLS) | TCP 1 RTT | — |
| HTTPS + TLS 1.2 | TCP 1 RTT + TLS 2 RTT = 3 RTT | +2 RTT |
| HTTPS + TLS 1.3 | TCP 1 RTT + TLS 1 RTT = 2 RTT | +1 RTT |
| HTTPS + TLS 1.3 会话恢复(0-RTT) | 趋近 TCP 1 RTT | 几乎为零 |
假设同城 RTT 20ms,TLS 1.2 首次建连比 HTTP 多约 40ms,TLS 1.3 多约 20ms;跨国 RTT 150ms 时,这就是多 300ms vs 150ms 的差距——这也是为什么升级到 TLS 1.3、复用连接、就近接入 CDN 对 HTTPS 站点如此重要。
顺带说:证书里非对称加密(RSA/ECDHE)只用在握手阶段协商密钥,一旦密钥协商好,后续的实际数据传输用的是对称加密(AES 等),因为对称加密快得多。「非对称交换密钥 + 对称传数据」是所有 TLS 的基本套路。
3.3 连接复用与 Keep-Alive:不要每个请求都重新握手
上面算下来,一次 HTTPS 建连要 2~3 个 RTT,如果每请求一个资源就重新建连、断连,代价高得离谱(一个网页动辄几十上百个资源)。
HTTP Keep-Alive(持久连接) 就是解法:一个 TCP 连接在完成一次请求-响应后不立即关闭,而是保持一段时间,让后续请求复用同一条连接,从而只在第一个请求付握手的 RTT,后面的请求直接省掉建连开销。HTTP/1.1 默认开启持久连接(Connection: keep-alive),这是相对 HTTP/1.0 的一大改进。
但 HTTP/1.1 的连接复用有个天花板:同一条连接上,请求必须一个接一个串行发(一个响应没回来,下一个不能发), 这就引出了下一节的主角——队头阻塞。为绕开它,浏览器对同一个域名会并发建立多条连接(Chrome 等主流浏览器通常上限 6 条),让 6 个请求能同时跑。这也是「域名分片」(把资源分散到多个子域名以突破 6 连接限制)这种老优化手段的由来——不过在 HTTP/2 之后它已经过时甚至有害了。
4. HTTP 请求与响应:请求行、状态码、缓存、Cookie
连接建好,终于可以说「人话」了——发一个 HTTP 请求。
4.1 请求与响应的结构
一个 HTTP 请求由三部分组成:
GET /path?q=1 HTTP/1.1 ← 请求行:方法 + 路径 + 协议版本
Host: www.example.com ← 请求头(多行 key: value)
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session=abc123
← 空行,分隔头和体
(请求体:GET 通常没有;POST/PUT 在这里放数据)
响应结构对称:
HTTP/1.1 200 OK ← 状态行:协议版本 + 状态码 + 原因短语
Content-Type: text/html; charset=utf-8 ← 响应头
Content-Length: 1256
Cache-Control: max-age=3600
Set-Cookie: session=abc123; HttpOnly
← 空行
<html>...</html> ← 响应体:真正的内容
从请求发出到响应第一个字节到达客户端,这段时间叫 TTFB(Time To First Byte)。它 ≈ 1 个 RTT(请求过去、响应回来)+ 服务端处理时间(查数据库、渲染模板等)。TTFB 是衡量「后端 + 网络」快不快的关键指标,前端优化管不到它,得靠后端和 CDN。
4.2 常见状态码:分类即语义
状态码的第一位数字就定了大类,记住分类比背具体码更有用:
| 类别 | 含义 | 高频具体码 |
|---|---|---|
| 1xx 信息 | 请求已收到,处理中 | 100 Continue(可以继续发请求体)、101 Switching Protocols(如升级到 WebSocket) |
| 2xx 成功 | 请求成功处理 | 200 OK、201 Created(POST 新建成功)、204 No Content(成功但无返回体) |
| 3xx 重定向 | 需要进一步动作,通常是跳转 | 301 Moved Permanently(永久重定向,会被缓存)、302 Found(临时重定向)、304 Not Modified(协商缓存命中,见下文) |
| 4xx 客户端错误 | 请求本身有问题 | 400 Bad Request、401 Unauthorized(未认证)、403 Forbidden(已认证但无权限)、404 Not Found、429 Too Many Requests(限流) |
| 5xx 服务端错误 | 服务器处理时出错 | 500 Internal Server Error、502 Bad Gateway(网关/代理拿到上游坏响应)、503 Service Unavailable(过载/维护)、504 Gateway Timeout(上游超时) |
几个高频混淆点:401(你没登录/凭证无效)vs 403(你登录了但没权限);502(代理连上游但上游给了坏响应)vs 504(代理连上游但上游超时没响应);301(永久,浏览器会缓存跳转、对 SEO 传递权重)vs 302(临时,不该被长期缓存)。
4.3 缓存:强缓存与协商缓存
缓存是 Web 性能的重头戏,核心是避免重复下载没变的资源。分两级:
① 强缓存(本地直接用,连请求都不发) —— 由响应头 Cache-Control 控制:
Cache-Control: max-age=3600 ← 3600 秒内,浏览器直接用本地副本,不发任何请求
常见指令:
| 指令 | 含义 |
|---|---|
max-age=N | 资源在 N 秒内视为新鲜,直接用缓存 |
no-cache | 可以缓存,但每次用前必须找服务器验证(走协商缓存,别被名字骗了) |
no-store | 彻底不缓存,每次都完整重新下载(用于敏感数据) |
public | 允许任何中间节点(如 CDN)缓存 |
private | 只允许浏览器缓存,中间代理不能缓存(含用户私有数据时用) |
must-revalidate | 缓存过期后,必须去服务器验证,不能用过期副本凑合 |
② 协商缓存(发个请求问服务器"变了没",没变就回 304) —— 当强缓存过期(或设了 no-cache),浏览器带着「缓存标识」去问服务器,服务器判断资源没变就回一个 304 Not Modified(不带响应体),浏览器继续用本地副本。省的是响应体的下载(往返还是有的,但不用重传 body)。两套机制:
Last-Modified/If-Modified-Since(基于时间):服务器首次返回Last-Modified: <时间>;下次请求浏览器带If-Modified-Since: <那个时间>,服务器比对文件修改时间,没变就 304。缺点:精度只到秒(1 秒内多次修改识别不出);且文件内容没变、仅修改时间变了(比如重新部署)会误判为「变了」;分布式多机部署时各机器文件时间还可能不一致。ETag/If-None-Match(基于内容指纹):服务器首次返回ETag: "<内容哈希>";下次请求浏览器带If-None-Match: "<那个哈希>",服务器比对指纹,一致就 304。ETag 直接反映内容本身,绕开了Last-Modified的秒级精度和时钟不一致问题,更精确,是现代实践的首选(两者可同时存在,ETag 优先级更高)。
一图看懂缓存决策流:
请求资源
│
├─ 强缓存(Cache-Control/max-age)未过期?── 是 ──▶ 直接用本地副本(连请求都不发,最快)
│ 否 │
│ ▼
├─ 带 ETag/Last-Modified 去问服务器(协商缓存)
│ │
│ ├─ 服务器:没变 → 304 Not Modified ──▶ 用本地副本(省了 body 下载)
│ └─ 服务器:变了 → 200 + 新内容 ──▶ 下载新副本并更新缓存
4.4 Cookie:给无状态的 HTTP 补上"记忆"
HTTP 本身是无状态的——服务器默认不记得你上个请求是谁。Cookie 就是补丁:服务器通过响应头 Set-Cookie 塞一小段数据给浏览器,浏览器存下来,之后每次请求同一站点都自动带上 Cookie 请求头,服务器借此识别用户(如登录态)。关键属性:HttpOnly(JS 读不到,防 XSS 窃取)、Secure(只在 HTTPS 下发送)、SameSite(限制跨站携带,防 CSRF)。注意 Cookie 会随每个请求上行,塞太多会拖慢每个请求,敏感/大数据应放别处(如 localStorage、服务端 session)。
5. 协议演进:HTTP/1.1 → HTTP/2 → HTTP/3,队头阻塞的三次围剿
HTTP 协议这些年的演进,可以用一条主线串起来:不断地消灭「队头阻塞(Head-of-Line Blocking)」——排在队伍最前面的那个慢家伙,卡住了后面所有人。这个问题在三个层次上出现,也被三代协议依次解决。
5.1 HTTP/1.1 的痛:应用层队头阻塞
HTTP/1.1 一条 TCP 连接上,请求-响应必须严格串行:发出请求 A,必须等 A 的响应完全回来,才能发请求 B。(曾有个叫「管线化 Pipelining」的机制想让请求连续发,但响应仍须按序返回,且实现问题多,基本没被采用。)
结果:如果响应 A 是个很大的、很慢的资源,它就卡住了后面所有请求——这就是应用层的队头阻塞。浏览器的应对是「人海战术」:对同一域名并发开 6 条 TCP 连接(见 3.3),让 6 个请求并行。但一个页面几十个资源,6 条连接还是不够用,剩下的仍得排队,且每条连接都有独立的握手成本。
5.2 HTTP/2:二进制分帧 + 多路复用,干掉应用层队头阻塞
HTTP/2(2015 年 RFC 7540)的核心武器是多路复用(Multiplexing):
- 二进制分帧:不再用文本传输,而是把消息拆成一个个二进制「帧(frame)」,每个帧标注了自己属于哪个「流(stream)」。
- 多路复用:一条 TCP 连接上可以同时跑多个流,各请求的帧交错着传输,到对端再按流 ID 重组。这样一条连接就能并发处理成百上千个请求,应用层队头阻塞被彻底解决,也不再需要开 6 条连接和域名分片了。
- HPACK 头部压缩:HTTP 请求头有大量重复内容(每个请求都带一样的 User-Agent、Cookie)。HPACK 用静态表 + 动态表 + 哈夫曼编码压缩头部,省下可观带宽。
- Server Push(服务端推送):服务端可以在客户端还没请求时就主动推送资源。但实践证明它很难用对(容易推送客户端已缓存的东西,浪费带宽,实测采用率仅约 1.25% 且常导致性能倒退),Chrome 已于 106 版本(2022 年 10 月)默认禁用并移除了 HTTP/2 Server Push,业界转向用
<link rel="preload">和103 Early Hints等更可控的方式。这是一个「看起来很美但被淘汰」的特性——写「现状」时别再说它在广泛使用。
但 HTTP/2 没解决更底层的一个问题:它所有的流都跑在同一条 TCP 连接上。而 TCP 为了保证「可靠、按序」,一旦某个 TCP 数据包丢了,TCP 就必须停下来等它重传,在它到达之前,后面所有已经到达的数据都不能交给上层——哪怕那些数据属于别的、完全无关的流。这就是TCP 层面的队头阻塞:应用层不阻塞了,传输层又来阻塞。丢包率越高(弱网、移动网络),这个问题越致命,HTTP/2 在弱网下甚至可能比 HTTP/1.1 还慢。
5.3 HTTP/3(QUIC):换掉 TCP,连传输层队头阻塞一起消灭
HTTP/3(2022 年 RFC 9114)的做法很激进:抛弃 TCP,改用基于 UDP 的 QUIC 协议。QUIC 在 UDP 之上自己重新实现了可靠传输、拥塞控制、加密。它带来三个关键好处:
- 真正独立的流,消灭传输层队头阻塞:QUIC 里每个流的丢包重传是互相独立的——流 A 的包丢了,只有流 A 停下来等重传,流 B、C 的数据照常向上层交付,互不影响。这就从根上解决了 HTTP/2 的 TCP 队头阻塞。
- 建连更快,内置 TLS 1.3:QUIC 把「传输层握手」和「TLS 1.3 加密握手」合并成一次,首次连接就是 1-RTT,会话恢复时甚至 0-RTT(对比 HTTPS over TCP 的 TCP 1-RTT + TLS 1-RTT = 2-RTT)。加密不再是可选项,而是协议内建。
- 连接迁移(Connection Migration):传统 TCP 连接由「源IP+源端口+目的IP+目的端口」四元组标识,你从 WiFi 切到 4G,IP 变了,连接就断了、得重连。QUIC 用一个独立的 Connection ID 标识连接,与 IP 无关,所以网络切换时连接不中断——这对手机用户体验是巨大提升。
代价:QUIC 在用户态实现(不像 TCP 在内核),CPU 开销略高;且 UDP 在一些老旧网络设备上会被限速或拦截。但收益远大于代价,目前 Google、Cloudflare、主流 CDN 和 Chrome/Firefox/Edge 都已广泛支持 HTTP/3,它已经是新一代 Web 的默认方向。
三代协议对比:
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 底层传输 | TCP | TCP | QUIC(基于 UDP) |
| 应用层队头阻塞 | ❌ 有(串行请求) | ✅ 多路复用解决 | ✅ 解决 |
| 传输层队头阻塞 | 有(但每连接一请求,感知弱) | ❌ 仍有(TCP 层丢包阻塞所有流) | ✅ 流独立,解决 |
| 首次建连 | TCP 1-RTT (+TLS) | TCP 1-RTT (+TLS) | 1-RTT(含加密) |
| 头部压缩 | 无 | HPACK | QPACK |
| 网络切换 | 断连重连 | 断连重连 | ✅ 连接迁移,不断连 |
6. 到达服务器之前:CDN、反向代理、负载均衡
前面默认「请求直达源服务器」,但真实世界里,请求在到达业务代码之前,往往先经过好几层基础设施。
6.1 CDN:把内容搬到离用户最近的地方
回想第 1 节的结论:RTT 由物理距离决定,且无法突破光速。 如果你的服务器在美国,中国用户每个请求都要跨太平洋(RTT 150ms+),体验很差。CDN(内容分发网络) 的核心思想就是:在全球各地部署大量边缘节点(edge),把静态内容(图片、CSS、JS、视频,乃至缓存的页面)复制到离用户最近的节点,用户就近取用,把长距离 RTT 变成短距离 RTT。
「就近接入」通常靠两种技术实现:基于 DNS 的调度(DNS 解析时根据用户地理位置返回最近节点的 IP)或 Anycast(多个节点共用一个 IP,网络路由天然把用户导向最近的那个)。当边缘节点没有缓存所需内容时(缓存未命中),它会去回源——向源服务器请求一次并缓存下来,供后续用户直接命中。
CDN 带来的不只是快:它还挡在源站前面吸收了绝大部分流量(减轻源站压力)、抵御 DDoS、就近做 TLS 握手(缩短握手 RTT)。这也是为什么「静态资源全上 CDN」是几乎所有网站的标配。
6.2 反向代理与负载均衡
到达数据中心后,请求通常先撞上一个反向代理(Reverse Proxy,如 Nginx)。它对外是唯一入口,对内把请求转发给后面一堆真实的应用服务器。它顺手干很多活:TLS 终结(在这里解密,后端跑明文省 CPU)、静态文件直接返回、gzip 压缩、限流、缓存。
反向代理背后往往连着负载均衡(Load Balancer),负责把海量请求均匀分发到多台后端服务器,避免某台被打爆。常见分发算法:
| 算法 | 做法 | 适用 |
|---|---|---|
| 轮询(Round Robin) | 依次轮流分给每台 | 后端配置一致时最简单 |
| 加权轮询(Weighted RR) | 按机器性能配权重,强的多分 | 后端配置不均时 |
| 最少连接(Least Connections) | 分给当前连接数最少的 | 请求处理时长差异大时 |
| IP Hash | 按客户端 IP 哈希固定分到某台 | 需要会话保持(同一用户总落同一台) |
| 一致性哈希(Consistent Hashing) | 哈希环分配,增删节点时只影响相邻 | 缓存类服务,减少节点变动导致的缓存失效 |
这一整套(CDN → 反向代理 → 负载均衡 → 应用服务器)就是「服务端处理时间」这段黑盒的展开。它们能让 TTFB 更短、系统更抗压。
7. 浏览器渲染:从字节流到屏幕像素
HTML 字节流终于回到浏览器了。但「拿到 HTML」离「屏幕上出现页面」还差一整套流程,这套流程叫关键渲染路径(Critical Rendering Path)。
7.1 六个步骤:解析 → 构建 → 布局 → 绘制 → 合成
HTML 字节流 ──解析──▶ DOM 树(文档对象模型,页面的结构)
│
CSS 字节流 ──解析──▶ CSSOM 树(每个节点的样式)
│
DOM + CSSOM ──合并──▶ 渲染树(Render Tree,只含"要显示"的节点)
│
──布局(Layout/Reflow)──▶ 算出每个节点的几何位置和大小(在哪、多大)
│
──绘制(Paint)──▶ 把每个节点画成一层层像素(颜色、文字、边框、阴影)
│
──合成(Composite)──▶ 把多个图层按正确顺序合并、交给 GPU 输出到屏幕
逐个说清楚每一步「在干什么」:
- 构建 DOM:浏览器边接收 HTML 字节,边把标签解析成一棵树状结构 DOM(Document Object Model),它是页面结构的内存表示。这个过程是增量的——不用等 HTML 全下载完就开始。
- 构建 CSSOM:把 CSS 解析成 CSSOM(CSS Object Model),记录每个节点最终的样式。注意 CSS 是"渲染阻塞资源"——因为在样式没算全之前渲染出来的东西可能是错的(比如背景色、布局全变),浏览器宁愿等 CSSOM 就绪也不愿先渲染再重画。所以慢的 CSS 会拖住首屏。
- 合成渲染树:把 DOM 和 CSSOM 合并。关键:
display: none的节点不进渲染树(它压根不占位、不显示);而visibility: hidden的节点仍在渲染树里(它占位,只是看不见)。渲染树只包含「需要显示的内容」。 - 布局(Layout / Reflow):计算渲染树里每个节点的精确几何信息——在视口的什么位置、多宽多高。这一步很贵,因为一个节点的位置可能牵动一大片。
- 绘制(Paint):把每个节点转成实际的像素——填色、画文字、描边框、画阴影,通常分成多个图层。
- 合成(Composite):把这些图层按正确的层叠顺序合并成最终画面,交给 GPU 快速输出到屏幕。像
transform、opacity这类动画之所以流畅,正是因为它们只触发合成、不触发布局和绘制,可以走 GPU,不用重算几何。
7.2 为什么 JS 会阻塞渲染,以及 defer / async
HTML 解析过程中遇到 <script>(无标记的普通脚本)时,浏览器会暂停 HTML 解析,先下载并执行完这段 JS,再继续解析 HTML。这叫解析器阻塞(parser-blocking)。原因是:JS 可能通过 document.write 等改写文档结构,浏览器不敢一边改一边解析,只能停下来等它跑完。所以把大 <script> 放在 <head> 里会严重拖慢首屏——HTML 还没解析到 body,就卡在脚本上了。经典建议「把 script 放 body 底部」正是为此。
现代做法是用 defer 或 async 属性,让脚本下载不阻塞 HTML 解析:
| 下载时机 | 执行时机 | 保证顺序? | |
|---|---|---|---|
<script>(普通) | 遇到就下载 | 下载后立即执行,阻塞解析 | — |
<script async> | 并行下载,不阻塞解析 | 下载完立即执行(可能打断解析),谁先下完谁先跑 | ❌ 不保证 |
<script defer> | 并行下载,不阻塞解析 | 等 HTML 解析完成后、DOMContentLoaded 前按顺序执行 | ✅ 保证书写顺序 |
选择:有依赖关系、要按顺序跑、要操作 DOM 的脚本用 defer(比如你自己的应用代码);独立、不依赖别人、不依赖 DOM 的用 async(比如埋点统计脚本)。
顺带两个高频面试点——两个生命周期事件的区别:
DOMContentLoaded:DOM 树构建完成、defer脚本执行完就触发,不等图片、CSS、字体等外部资源。load:等页面所有资源(图片、样式、iframe……)都加载完才触发,通常晚得多。
7.3 预扫描器:浏览器偷偷提前下资源
前面说 JS 会阻塞解析,那岂不是解析一停,下面的图片、CSS 都不下载了?现代浏览器(Chrome 的 Blink 引擎、V8 执行 JS)有个聪明的优化叫预加载扫描器(Preload Scanner):当主解析器被某个脚本卡住时,有一个轻量的辅助扫描器会继续往后扫 HTML,把后面出现的 CSS、JS、图片等资源的 URL 提前发现、提前开始下载。这样即使主解析被阻塞,网络也没闲着。这是浏览器为「阻塞」打的一个重要补丁,也是为什么把资源写在 HTML 里(而非 JS 动态插入)更利于被提前发现。
7.4 衡量渲染快不快:几个关键指标
- TTFB(Time To First Byte):首字节到达时间,反映网络+后端。它是诊断指标(不属于 Core Web Vitals),但直接影响后面的 FCP、LCP。
- FCP(First Contentful Paint):首次内容绘制——用户第一次看到任何内容(文字、图片)的时刻。同样是诊断指标。
真正被 Google 用来给网站体验打分的是 Core Web Vitals(核心网页指标)。这里有一个重要的时效性坑:2024 年 3 月起,Core Web Vitals 三件套是 LCP / INP / CLS——原来的 FID 已被 INP 正式取代,很多老文章还写 FID,别跟着错。
| 指标 | 衡量什么 | 「良好」阈值(75 分位) |
|---|---|---|
| LCP(Largest Contentful Paint,最大内容绘制) | 页面主要内容(通常是最大的图或标题块)渲染完成的时刻——「加载快不快」 | ≤ 2.5s |
| INP(Interaction to Next Paint,交互到下次绘制) | 用户交互后页面响应的延迟——「点了有没有反应」(2024 年替代了 FID) | ≤ 200ms |
| CLS(Cumulative Layout Shift,累积布局偏移) | 页面元素意外跳动的程度——「稳不稳、会不会点错」 | ≤ 0.1 |
这些指标把「渲染」和「交互」这些抽象过程变成了可量化、可优化的数字,是前端性能工作的抓手。
8. 性能视角:每一环能怎么优化
把整条链路铺开,每一环都有对应的优化手段。通读这张表,你会发现绝大多数手段的本质,都是本文开头那句话——减少 RTT,或者把 RTT 藏起来(提前做/并行做/就近做/复用连接/干脆用缓存跳过)。
| 环节 | 瓶颈所在 | 优化手段 | 本质 |
|---|---|---|---|
| DNS 解析 | 冷查询要跑多级服务器(20~120ms) | dns-prefetch/preconnect 提前解析;合理设 TTL;用快的公共 DNS | 提前做 / 用缓存 |
| TCP 握手 | 每次建连 1 RTT | Keep-Alive 复用连接;HTTP/2 一条连接跑多请求 | 复用,消灭重复往返 |
| TLS 握手 | TLS 1.2 要 2 RTT | 升级 TLS 1.3(1 RTT);会话恢复用 0-RTT;OCSP Stapling | 减少往返 |
| 建连整体 | 首次访问握手开销大 | CDN 就近接入(缩短每个 RTT);HTTP/3(1-RTT 含加密) | 缩短 RTT / 合并往返 |
| TTFB | 后端处理慢 | 后端缓存、DB 优化、CDN 缓存整页、边缘计算 | 减少服务端处理时间 |
| 传输内容 | 体积大、传得慢 | gzip/Brotli 压缩;图片用 WebP/AVIF;按需加载;HTTP/2 多路复用 | 减少字节 + 并行 |
| 重复请求 | 没变的资源反复下载 | 强缓存(Cache-Control) + 协商缓存(ETag);文件名带 hash 做长缓存 | 用缓存跳过往返 |
| 协议层 | HTTP/1.1 队头阻塞、6 连接上限 | 升级 HTTP/2(多路复用)→ HTTP/3(消灭 TCP 队头阻塞、弱网友好、连接迁移) | 消灭队头阻塞 |
| 弱网/移动 | 丢包、网络切换断连 | HTTP/3 的流独立重传 + 连接迁移 | 传输层解耦 |
| 渲染-CSS | CSS 阻塞渲染 | 关键 CSS 内联、其余异步加载;减小 CSS 体积 | 缩短阻塞时间 |
| 渲染-JS | JS 阻塞 HTML 解析 | defer/async;代码分割;脚本放底部;SSR 首屏直出 | 不阻塞解析 |
| 渲染-布局 | 频繁 reflow 卡顿 | 减少强制同步布局;动画用 transform/opacity(只触发合成) | 避免重排重绘 |
| 首屏体感 | 关键资源发现晚 | <link rel="preload"> 关键资源;资源写进 HTML 让预扫描器提前发现 | 提前下载 |
9. 常见困惑澄清
9.1 「递归查询」和「迭代查询」到底谁递归谁迭代
再强调一次这个高频失分点:递归发生在「客户端 ↔ 本地递归解析器」之间——你只管要最终结果,解析器全权负责查到底。迭代发生在「递归解析器 ↔ 各级 DNS 服务器(根/TLD/权威)」之间——解析器一跳一跳地问,每个服务器只回「下一跳去哪问」而不直接给答案。方向记反就全错了。
9.2 为什么 HTTPS 首次慢,但整体不一定慢
HTTPS 首次建连比 HTTP 多 1~2 个 RTT 的握手(TLS)。但这只在首次建连付出,一旦连接建立并复用(Keep-Alive / HTTP/2 多路复用),后续请求都不再握手。而且 HTTPS 是 HTTP/2、HTTP/3 的前提(浏览器只在 HTTPS 上启用它们),HTTP/2 的多路复用带来的收益,往往远超那点握手开销。所以「HTTPS 慢」只在冷启动的第一个请求上成立,全局看现代 HTTPS 站点通常更快。
9.3 HTTP/2 已经多路复用了,为什么还需要 HTTP/3
因为 HTTP/2 只解决了应用层队头阻塞,没解决传输层(TCP)队头阻塞:所有流共用一条 TCP 连接,TCP 一旦丢包就得停下来等重传,把所有流一起卡住。HTTP/3 换用基于 UDP 的 QUIC,让每个流独立重传,才真正把队头阻塞连根拔起——在丢包率高的弱网/移动场景,这个差别尤其明显(HTTP/2 在弱网下甚至可能不如 HTTP/1.1)。
9.4 no-cache 不是「不缓存」
这是命名坑:Cache-Control: no-cache 的意思是「可以缓存,但每次用之前必须去服务器验证」(走协商缓存),真正的「完全不缓存」是 no-store。想让敏感数据不落地,用 no-store,别用 no-cache。
9.5 display:none 和 visibility:hidden 在渲染上的区别
display: none 的节点不进渲染树——不占位、不布局、不绘制,改它会触发重排。visibility: hidden 的节点仍在渲染树里——正常占位和布局,只是绘制成「透明看不见」。所以前者是「彻底不存在于布局」,后者是「占着位置但隐身」,这在性能和布局行为上是两回事。
9.6 defer 和 async 别用混
都不阻塞 HTML 解析的下载,区别在执行:async 下载完立刻执行(可能打断解析、且多个 async 谁先下完谁先跑,不保证顺序);defer 等 HTML 解析完再按书写顺序执行(在 DOMContentLoaded 之前)。要顺序、要操作 DOM 的用 defer;纯独立的埋点/统计用 async。
10. 一句话收尾
如果这篇长文只能留下一句话,那就是:
一个 HTTP 请求的一生,大部分时间不是在「算」,而是在「等」——等 DNS 查完、等握手来回、等阻塞的资源下完。每一次网络往返(RTT)都是一堵受光速限制、花钱也买不穿的墙。所以从 DNS 预解析、连接复用、TLS 1.3、HTTP/3,到 CDN 就近接入、缓存、
defer——这条链路上几乎所有优化,本质都是同一件事:减少往返的次数,或者把往返藏在用户察觉不到的地方。
下次再被问到「输入 URL 之后发生了什么」,你不用背流程了——顺着「解析地址 → 建立连接 → 收发数据 → 渲染画面」这四步走,每一步问自己「这里有几个 RTT,能不能省」,整条链路和它的优化就都长在同一棵逻辑树上了。