在地址栏里敲下一串字符、按下回车,然后页面亮起来——这个动作每天要重复几百次,但这条链路里塞着至少七八个值得单独拿出来讲半天的环节:URL 解析、DNS 解析、TCP 三次握手、TLS 握手、HTTP 报文往返、服务端处理、浏览器解析渲染。任何一环卡住,用户看到的就是白屏、转圈或者"打不开"。这篇文章面向刚入行的前端、后端、测试、运维同学,也适合写了几年业务代码但一直没把整条链路串起来的人。我把它拆成一条可以自己动手复现的路径,每一步都给出能敲的命令、判断依据,以及我在真实排查里踩过的坑。读完你至少能做到两件事:知道某个页面慢了到底慢在哪一层,以及面对一个"打不开"的报障,能按顺序把范围缩小到具体环节。
1. 整条链路的全景:先建立一张能对号入座的地图
1.1 为什么必须先搞清"分层"这件事
我见过太多排查现场是这样的:页面打不开,第一反应是"后端挂了",然后后端同学查了半天日志说请求根本没到我这。或者页面很慢,前端说资源加载慢,运维说带宽够用,最后发现是一个 302 重定向绕了三圈。
问题的根源在于,从输入网址到页面显示,中间隔着的不是一条直线,而是一串职责完全不同的层。每一层有自己的协议、自己的超时、自己的错误表现。你不分层,就永远只能用"试试重启"这种办法。
我习惯把它切成四段来看。第一段是"找地址",也就是浏览器怎么理解你输入的字符串,以及怎么把这个字符串变成一个 IP 地址,核心是 URL 解析和 DNS。第二段是"修路",也就是在客户端和服务端之间建立一条可靠的、加密的通道,核心是 TCP 握手和 TLS 握手。第三段是"送货",也就是 HTTP 请求发出去、服务端处理完、响应回来,核心是报文结构和状态码。第四段是"装修",也就是浏览器拿到字节流以后怎么把它变成你看得见的像素,核心是解析、布局、绘制、合成。
这四段的分界线非常清晰:DNS 出错,你连 IP 都没有;TCP 出错,你连不上端口;TLS 出错,握手阶段就断了;HTTP 出错,你能连上但拿不到正确内容;渲染出错,数据都到了但屏幕上不对。只要你能判断当前卡在第几段,排查范围立刻收窄九成。
顺带说一句,很多人把这段链路背成"DNS → TCP → HTTP → 渲染",这个背法本身没错,但它漏掉了两个在现代 Web 里权重越来越高的角色:缓存和 TLS。缓存可能在 DNS 之前就把整个流程掐断(直接读本地缓存返回),TLS 在今天的 HTTPS 普及率下几乎是必经环节,握手往返次数直接决定了首字节时间。
1.2 一次加载里到底有几个角色在干活
把角色列清楚,后面讲每一步的时候你就知道谁在负责。我用一张表来对齐,这张表我在内部做新人培训时用了很多次,效果比讲概念好。
| 阶段 | 主要参与方 | 典型耗时量级 | 出问题的表现 |
|---|---|---|---|
| URL 解析与缓存检查 | 浏览器进程 | 微秒到毫秒 | 输入被当成搜索词、跳转到了错误站点 |
| DNS 解析 | 浏览器、操作系统、递归解析器、权威服务器 | 几毫秒到几百毫秒 | 域名解析失败、解析到旧地址 |
| TCP 握手 | 客户端内核、服务端 | 一个 RTT 起 | 连接超时、连接被重置 |
| TLS 握手 | 客户端、服务端 | TLS 1.3 约 1 个 RTT | 证书错误、握手失败 |
| HTTP 请求/响应 | 浏览器、网关、应用服务 | 几十毫秒到几秒 | 4xx/5xx、重定向循环 |
| 服务端处理 | 负载均衡、应用、缓存、数据库 | 视业务而定 | 502/504、响应体为空 |
| 解析与渲染 | 浏览器的渲染进程 | 几百毫秒到几秒 | 白屏、布局抖动、字体闪烁 |
这里有个细节值得强调:表中的耗时量级是"理想网络下的数量级参考",不是绝对值。跨洲访问时,一个 RTT 可能就是 200 毫秒,那么 TCP 加 TLS 两次往返就是 400 毫秒起步,还没开始传数据。这就是为什么 CDN 和连接复用对体验的影响比很多人想象的大得多——它们省的不是带宽,是往返次数。
还有一个容易被忽略的点:现代浏览器对同一个域名会预先建立连接。你在地址栏输入的时候,它就已经在后台做 DNS 预解析和 TCP 预连接了。所以你在开发者工具里看到的"实际耗时",往往比理论计算要短,因为一部分工作被提前做了。反过来,如果你在压测工具里测同一个接口,拿到的数字可能比真实用户感知更差,因为压测工具通常没有这套预热机制。这个差异我在做性能基线对比时被坑过一次,后面会细说。
2. 地址栏里的那串字符:URL 解析与浏览器内部的第一轮决策
2.1 一个 URL 被拆成了哪几块
大多数人对 URL 的理解停留在"网址"两个字。实际上它有严格的结构,每一段都影响着后续行为。以https://user:pass@www.example.com:8443/a/b?x=1&y=2#top为例,拆解如下:
https是 scheme,决定了用哪个协议栈,也决定了默认端口(https 是 443,http 是 80)。user:pass是 userinfo,现在基本没人用了,但某些老系统还在用,浏览器对它的处理是"能用但会警告"。www.example.com是 host,这是后面 DNS 要解析的目标。8443是 port,一旦显式指定,就不再走默认端口。/a/b是 path,服务端路由的依据。x=1&y=2是 query,注意它不参与缓存键的默认计算(这点后面讲缓存时会展开)。#top是 fragment,它只存在于浏览器本地,压根不会发给服务端。
我特意把 fragment 单独拎出来说,因为这是新人最容易误解的地方。很多人以为#后面的内容会传到后端,实际上不会。单页应用的路由之所以能工作,就是靠前端监听 hash 变化。同样,如果你在排查"为什么后端收不到这个参数",先确认它是不是被写在了#后面。
还有一个实操层面的判断:当你在地址栏输入的内容既不像合法域名也不像 IP 时,浏览器会把它交给默认搜索引擎。这个判断逻辑各浏览器略有差异,但大体是"有没有空格""有没有点号""是不是已知协议头"。这就是为什么你在内网输入一个单词主机名有时会被当成搜索词,得手动补上http://前缀才行。这个坑在内网环境里极其常见,我第一次遇到时排查了半小时才发现请求压根没发出去。
2.2 回车之后、发包之前:浏览器先做了四件事
很多人以为按下回车就是"发请求",其实在真正发包之前,浏览器有一整套筛选动作。这四件事任何一件命中,后面的 DNS、TCP、HTTP 可能全都不用走了。
第一件是安全策略检查。浏览器会看这个域名是不是在强制 HTTPS 的预置列表里,如果是,即使你输入的是http://,它也会在本地直接把协议升级成 https,然后才开始连接。这个机制的好处是杜绝了首次明文请求被劫持的可能,副作用是你偶尔会看到"该站点不支持 HTTPS"这种报错——不是网络问题,是站点根本没配证书。
第二件是 Service Worker 拦截。如果这个域名之前注册过 Service Worker,浏览器会先唤醒它,让它决定这次请求是走缓存、走网络,还是自己造一个响应。这就是为什么有些页面断网还能打开——不是浏览器缓存,是 Service Worker 在接管。排查这类问题时,记得在开发者工具的 Application 面板里看一下 Service Worker 的状态,我遇到过一次"改了代码线上不生效",最后发现是旧的 Service Worker 把请求全拦了。
第三件是 HTTP 缓存查找。浏览器会按 URL 去缓存里找,命中强缓存的话直接返回,连网络层都不碰。你在网络面板里看到的状态是 200 但传输大小显示为 0,来源标着 memory cache 或 disk cache,就是这种情况。这里有个细节:memory cache 的生命周期跟标签页绑定,刷新就没了;disk cache 是持久化的,但受响应头里的缓存指令约束。
第四件是预连接。如果页面里已经声明了预连接提示,或者浏览器自己预测你接下来可能访问某个域名,它会提前把 DNS 解析和 TCP 连接做掉。这一步对用户是不可见的,但你在网络面板的连接阶段能看到耗时被压缩得很短。
注意:判断"请求到底有没有发出去",最可靠的办法不是看网络面板的列表,而是看服务端有没有收到。网络面板有时会把缓存命中的请求也列出来,初学者容易误以为"请求成功了"。我习惯在服务端加一个带时间戳的访问日志,两边对时间,一比就知道。
3. DNS 解析:把好记的名字换成能路由的地址
3.1 递归查询的四段跳,以及每一跳在干什么
浏览器拿到 host 之后,要把它变成一个 IP 地址。这个过程叫域名解析,走的是 DNS 协议。它本质是一个分布式的、层层委派的查询系统。
第一跳是本地缓存。浏览器自己有 DNS 缓存,操作系统也有,再往下还有 hosts 文件。这三层的顺序大体是 hosts 优先于系统缓存,系统缓存优先于网络查询。很多内网开发环境就是靠改 hosts 把域名指到本机,改完不生效的原因通常是系统缓存还没过期。
第二跳是递归解析器。本地缓存没命中,请求就发给配置的递归解析器。这个解析器通常由网络接入方提供,也可以是用户自己指定的公共服务。它的职责是"替你把问题问到底",然后把最终答案带回来。
第三跳是根域名服务器集群。递归解析器先问根,根不直接给答案,只告诉你"这个顶级域归谁管",返回一条委派记录。
第四跳是顶级域服务器,再往下是权威域名服务器。权威服务器才是真正持有这条记录的地方,它给出的答案带 TTL,表示"这个结果你可以缓存多久"。
整个过程如果全部冷启动,可能要几百毫秒。但实际使用中,绝大部分查询在第一跳或第二跳就被截断了,所以体感很快。这也是为什么"域名解析慢"这个问题往往只在特定条件下暴露:比如新域名第一次访问、TTL 设得很短、或者用户换了个网络环境。
记录类型这块,日常打交道最多的是三种。A 记录把域名指向 IPv4 地址,AAAA 指向 IPv6,CNAME 把域名指向另一个域名。CNAME 的好处是解耦,你可以把业务域名 CNAME 到一个由第三方托管的域名上,对方换 IP 你不用动。坏处是多一次解析,而且 CNAME 不能和某些其他记录共存于同一个名字下。还有一个 TXT 记录,现在主要用于域名所有权验证和邮件相关的策略声明,做站点验证时经常要加一条。
3.2 TTL 设多少,是个需要权衡的工程决策
TTL 这个参数看着不起眼,但它直接决定了你"改完解析多久生效"。
设得长,比如 24 小时,好处是解析结果被大量缓存,递归解析器压力小,用户解析快;坏处是你一旦要切换 IP,最长要等一天才能全网生效。设得短,比如 60 秒,好处是灵活,坏处是解析请求量上升,而且用户每次访问都可能要重新解析,首屏多一次往返。
我的经验做法是分场景:
- 长期稳定的域名,TTL 设 3600 秒到 86400 秒,减少解析开销。
- 有计划迁移的域名,提前 24 到 48 小时把 TTL 降到 60 到 300 秒,等旧记录自然过期后再切,切完观察一段时间再调回去。
- 做灰度或多活调度、需要快速摘除节点的,TTL 保持在 60 到 120 秒,配合健康检查。
这里有个血泪教训:我见过一次迁移事故,原因是只改了一台权威服务器上的记录,另一台还是旧值。因为递归解析器是随机问的,导致一部分用户解析到新地址、一部分解析到老地址,表现为"时好时坏"。这种问题极难复现,因为要看你本地递归解析器的缓存。所以改解析前,务必确认所有权威服务器上的记录一致。
提示:验证解析变更是否生效,别只在自己电脑上敲一次查询就下结论。你本地缓存可能还是旧的,而且你问的递归解析器也只是众多解析器中的一个。多换几个解析器、多换几个网络环境看,才靠得住。
3.3 动手看一遍:几条命令把解析过程摊开
概念讲完,落到能敲的东西上。下面这几条我在排查时几乎每次都用:
# 看域名最终解析到哪些地址(A 和 AAAA 都看) dig www.example.com A dig www.example.com AAAA # 指定解析器查询,绕过本地配置 dig @223.5.5.5 www.example.com # 只看最简答案,方便脚本处理 dig +short www.example.com # 追踪完整解析路径,从根一路问到权威 dig +trace www.example.com # 查 CNAME 链,看中间跳了几层 dig www.example.com CNAME +short # 看 TTL 还剩多少秒(第二列就是) dig www.example.com | grep -A 5 "ANSWER SECTION"+trace这条命令特别值得多用。它会把根、顶级域、权威服务器挨个问一遍,你能直观看到委派关系。我第一次用它的时候才真正理解"DNS 是分布式数据库"这句话是什么意思——没有一个中心节点知道你域名的 IP,是层层转包问出来的。
Windows 环境下用nslookup,虽然功能弱一些,但看基本解析够用。想清空本机 DNS 缓存,Linux 上取决于用的是什么解析服务,macOS 上有专门的清缓存命令,Windows 上是ipconfig /flushdns。排查"解析改了但本机还是旧地址"时,先清缓存再测。
还有一个小技巧:dig的默认输出里,status字段能直接告诉你查询结果的性质。NOERROR是有答案,NXDOMAIN是域名不存在,SERVFAIL是解析器自身出问题。这三个状态含义完全不同,别混着看。NXDOMAIN说明记录真的没配或者配错了,SERVFAIL往往是上游权威服务器不可达,跟你的配置可能一点关系都没有。
4. 连接建立:TCP 握手和 TLS 握手究竟在协商什么
4.1 三次握手不是"打招呼",是在对齐序号
拿到 IP 之后,客户端要和服务端建立一条可靠的传输通道。这就是 TCP 的活儿。
三次握手的经典描述是 SYN、SYN+ACK、ACK。但很多人只知道"三次",不知道为什么要三次。核心原因有两个:一是双方要确认彼此的收发能力都正常,二是要同步初始序列号。
第二次握手之所以必须,是因为服务端要告诉客户端"我收到了你的序号 X,我的起始序号是 Y"。第三次之所以必须,是因为客户端要让服务端确认"我收到了你的 Y"。如果只有两次,服务端无法确认客户端收到了自己的序号,历史连接残留下的旧报文就可能被误认为是新连接的数据。这就是所谓的"防止历史连接"。
初始序列号不是从 0 开始的,而是随机生成的。这不是为了安全上的加密,而是为了避免旧连接的报文在新连接里被误接受。这一点在排查"连接建立成功但数据错乱"这类问题时偶尔会用到。
握手过程里还有个容易忽略的工程细节:服务端维护着半连接队列和全连接队列。SYN 包到了,连接进入半连接状态,等最后一次 ACK 到了才转入全连接。如果队列满了,新的握手会被丢弃或者拒绝。表现为客户端看到"连接超时",而服务端 CPU 和内存看起来都很健康。这类问题在流量突增时特别典型,光看应用日志是看不出来的,得看内核层面的统计。
握手完成之后,还有一个"慢启动"的机制。TCP 不会一上来就用最大速率发数据,而是从一个较小的拥塞窗口开始,每收到确认就翻倍增长。这意味着首屏加载的最初几十 KB 数据,实际传输速率是偏低的。这也是为什么把关键资源做小、做少,比单纯堆带宽更能改善首屏体验。
4.2 TLS 1.3 把往返砍到了 1 次,但代价你得知道
HTTP 走 HTTPS 之后,TCP 握手完成还要再来一轮 TLS 握手。这一轮的目标有三个:协商加密套件、验证服务端身份、生成会话密钥。
老版本的 TLS 1.2 需要两次往返才能完成握手,也就是在 TCP 三次握手之后再加 2 个 RTT。TLS 1.3 把这件事压缩到了 1 个 RTT,客户端在第一个消息里就把密钥交换的素材发过去了,服务端直接回自己的素材和证书,双方各自算出会话密钥。
更激进的是 TLS 1.3 的会话复用,可以让第二次连接做到 0 RTT——客户端在第一个消息里就直接带上加密的应用数据。听起来很美,但它有个隐患:0 RTT 数据可以被重放。所以只适合幂等请求,比如 GET 拿静态资源,不能用于下单、支付这类操作。这一点在协议设计里是靠"早期数据"的机制约束的,服务端可以选择拒绝。
证书验证这一环是最常见的故障点。客户端拿到证书后要做几件事:验签(确认证书是受信任的签发方签的)、看有效期、看域名是否匹配、看证书链是否完整。任何一个失败,握手就中断。
我遇到过两类特别隐蔽的证书问题。第一类是证书链不完整:某些客户端能过,某些客户端不行。原因是部分客户端会自动补全中间证书,部分不会。所以部署证书时一定要带上完整的中间证书链,别只配一张叶子证书。第二类是服务器时间不准:证书本身没问题,但服务器时钟偏差过大,导致客户端判断"证书尚未生效"或"已过期"。这类问题排查时容易被忽略,因为大家默认服务器时间是对的。
4.3 握手阶段的真实耗时,怎么量化
光讲理论没感觉,我给一个可以自己算的模型。
假设客户端到服务端的往返时延是 100 毫秒。
- 纯 HTTP:TCP 三次握手大概是 1 个 RTT,然后发请求等响应 1 个 RTT,首字节时间大约 200 毫秒。
- HTTPS + TLS 1.2:TCP 1 个 RTT + TLS 2 个 RTT + 请求 1 个 RTT,大约 400 毫秒。
- HTTPS + TLS 1.3:TCP 1 个 RTT + TLS 1 个 RTT + 请求 1 个 RTT,大约 300 毫秒。
- HTTPS + TLS 1.3 + 会话复用:TCP 1 个 RTT + 请求 1 个 RTT,大约 200 毫秒,甚至能压到 1 个 RTT 出头。
看出来了吧,同样一条链路,协议版本和复用策略能把首字节时间差出一倍。这还没算 DNS 的时间。
想实测这些阶段,可以这样敲:
# 看 DNS、TCP、TLS、首字节、总耗时各是多少 curl -o /dev/null -s -w " dns: %{time_namelookup}s connect: %{time_connect}s tls: %{time_appconnect}s ttfb: %{time_starttransfer}s total: %{time_total}s " https://www.example.com/ # 看握手细节,包括协议版本和加密套件 openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null | head -30 # 强制走 TLS 1.3,对比握手耗时 curl -o /dev/null -s -w "%{time_appconnect}\n" --tlsv1.3 https://www.example.com/time_appconnect减去time_connect,就是 TLS 握手花的时间。如果这个差值异常大,通常是证书链太长或者要多跳一次去取中间证书。我见过一个站点 TLS 握手花了 800 毫秒,最后查出来是证书链里塞了四张证书,而且其中一张是从一个很慢的地址拉取的。把证书链精简成两张之后,握手时间降到 100 多毫秒。
5. HTTP 这一层:请求发出去之后到底发生了什么
5.1 一个请求报文里,哪些字段在真正起作用
连接建好,客户端开始发 HTTP 请求。请求报文分三部分:请求行、请求头、请求体。请求行是方法、路径、协议版本。真正决定服务端行为的,大部分在请求头里。
我挑几个必须理解的:
Host:HTTP/1.1 起就是必填。一个 IP 上可能挂了几十个站点,服务端靠它来区分。这就是虚拟主机的实现基础。排查"访问到了错误的站点"时,第一件事就是看 Host 对不对。User-Agent:客户端标识。现在主要用于统计和兼容性分支,不建议用它做安全判断,因为它可以随便伪造。Accept系列:告诉服务端"我能接受什么格式"。Accept-Encoding特别重要,它决定了服务端会不会对响应做压缩。文本资源不压缩,体积可能是压缩后的三到五倍。Cookie:会话状态的载体。这里有个硬限制需要注意——单个 Cookie 有大小上限,同一域名下的 Cookie 总量也有上限。我之前遇到过一个问题:埋点脚本往 Cookie 里塞了一堆数据,把 Cookie 撑得很大,导致请求头超限,服务端直接返回 431。表现是"偶发打不开",排查了很久才定位到。Referer:来源页。做防盗链和来源统计会用。注意它的策略是可以在页面里通过响应头或标签属性控制的,不是永远都有。- 条件请求头:
If-None-Match、If-Modified-Since。这两个是协商缓存的关键,配合响应端的ETag、Last-Modified使用,命中时服务端返回 304,省掉响应体的传输。
响应头这边,重点看几个:
Content-Type:决定浏览器怎么解析响应体。这个头写错了,会出现"图片显示成乱码""JSON 被当 HTML 渲染"这类诡异现象。字符集也在这里声明,不声明的话浏览器会自己猜,猜错就是乱码。Content-Encoding:表示响应体用了什么压缩算法。如果服务端压缩了但没声明,客户端拿到的是二进制乱码。Cache-Control:缓存的最高优先级指令。max-age决定强缓存时长,no-cache是"可以缓存但每次要验证",no-store是"完全不缓存"。这三个词很多人分不清,我在面试里问过不下二十个人,能准确说清no-cache和no-store区别的不到一半。ETag:资源指纹。它的生成策略由服务端决定,可以是内容哈希,也可以是版本号。Set-Cookie:下发 Cookie,可以带HttpOnly、Secure、SameSite属性。HttpOnly防止脚本读取,Secure限制只在加密连接上传输,SameSite控制跨站携带策略。这三个属性配不齐,就是常见的安全隐患。Transfer-Encoding和Content-Length:二选一。前者用于流式响应(分块传输),后者用于已知长度的响应。两个同时出现是协议错误。
5.2 状态码不是背出来的,是分类用的
状态码有几十个,背下来没意义,按语义分类记就行。
2xx 是成功。日常只关注 200 和 206(范围请求,视频拖动进度条会用到)。
3xx 是重定向。这里有几个必须分清:
| 状态码 | 语义 | 方法是否保持 | 典型场景 |
|---|---|---|---|
| 301 | 永久重定向 | 可能被改成 GET | 域名永久迁移 |
| 302 | 临时重定向 | 可能被改成 GET | 登录后跳回 |
| 307 | 临时重定向 | 严格保持 | 需要保方法的临时跳转 |
| 308 | 永久重定向 | 严格保持 | 需要保方法的永久迁移 |
| 304 | 资源未修改 | 不适用 | 协商缓存命中 |
301 和 302 的历史遗留问题是:很多客户端会把 POST 改成 GET 再跳。如果你需要保持方法,用 307 或 308。这个坑在做开放接口对接时特别容易踩——对方用 302 做了个跳转,你的 POST 变成 GET,参数全丢了,但状态码看着是成功的。
还有一个必须警惕的现象是重定向循环。A 跳 B,B 跳回 A,浏览器跑到上限就报错。这类问题的诱因往往是协议或域名的规范化规则打架,比如服务端要求必须带 www,客户端或中间层又强制去掉 www。排查时看网络面板里的跳转链条,一目了然。
4xx 是客户端问题。400 是请求格式不对,401 是没认证,403 是认证了但没权限,404 是资源不存在,405 是方法不允许,429 是限流。这几个的区分对定位问题很有帮助:看到 401 就去查凭证,看到 403 就去查权限配置,别一上来就怀疑网络。
5xx 是服务端问题。500 是应用内部异常,502 是网关拿不到上游的有效响应,503 是服务不可用(常见于过载或维护),504 是网关等上游超时。502 和 504 的区别很实用:502 通常是上游进程挂了或者返回了非法响应,504 是上游还活着但太慢。这两个错误经常被笼统地报成"服务器错误",但在排查方向上完全不同。
5.3 服务端那一侧:请求到了之后又走了哪些路
请求发出去,落到服务端,这里还有一条不短的链路。虽然这篇文章的主线是客户端视角,但不知道服务端发生了什么,排查时就没法判断"慢在谁身上"。
典型链路是这样的:请求先到四层负载均衡(按 IP 和端口转发,速度快,但看不到应用层内容),再到七层网关(能看 URL、Header,做路由、限流、鉴权、压缩),然后进应用服务。应用服务里通常还会先查一层缓存,缓存没有再查数据库。
每一层都有各自的超时配置。这里有个非常常见的坑:超时时间配置不匹配。比如网关设置 3 秒超时,应用设置 5 秒,数据库连接池等待 10 秒。结果是网关 3 秒就断开返回 504,但应用还在傻乎乎地等数据库,白干一场还占着线程。正确做法是从外到内逐层递减,让最外层最先放弃,内层能及时感知到取消。
还有一个现象叫"惊群"或者叫"雪崩":某个热点缓存过期,大量请求同时穿透到数据库,数据库扛不住变慢,应用线程池被占满,然后整个服务不可用。表现是监控上看到响应时间陡增、错误率飙升。这类问题靠加机器解决不了,得靠缓存预热、请求合并、熔断降级这些手段。
我印象最深的一次排查是一个"偶发 502"。抓包发现服务端在收到请求后 1 秒内就重置了连接。最后定位到是七层网关里配了一个很小的请求体上限,而某个接口的请求体刚好超了。网关直接断开,客户端看到的就是 502。这种问题在开发环境永远复现不了,因为测试数据量小。所以上线前一定要用接近真实的数据量压一遍。
6. 字节流变成像素:浏览器渲染这一公里的活
6.1 从收到第一个字节开始,浏览器在并行做很多事
响应体开始到达,浏览器进入渲染阶段。这里最容易产生的误解是"等 HTML 全部下载完才开始渲染"。实际上浏览器是流式解析的,收到一块解析一块。
大致的流程是:字节流先按响应头里声明的字符集解码成字符,再由 HTML 解析器构造成 DOM 树。解析过程中遇到 CSS 就发起请求,遇到同步脚本就暂停解析去执行。
这里有两个关键概念:CSS 阻塞渲染,JS 阻塞解析。
CSS 阻塞渲染的原因是,浏览器要等样式表加载完才能确定每个元素长什么样,贸然渲染会先闪一下无样式的内容。所以样式表是"阻塞渲染但不阻塞解析"的——HTML 还能继续往下解析,只是不往屏幕上画。
JS 阻塞解析的原因是,脚本可能修改 DOM 结构,所以解析器必须停下来等脚本执行完。这就是为什么<script>标签的位置如此重要。放在 head 里的同步脚本,会让整个页面的解析卡在那一行。加上defer或async属性就能解决,两者的区别是:defer保证按顺序、在 DOM 解析完成后执行;async是下载完就执行,顺序不保证。需要在 DOM 就绪后按依赖顺序执行的用defer,独立无依赖的统计脚本用async。
CSS 和 JS 组合起来还有一个更隐蔽的问题:如果脚本前面有一个还没加载完的样式表,浏览器会等样式表加载完再执行脚本。因为脚本里可能读取元素的计算样式,必须等样式就绪。这意味着一个慢样式表会拖慢后续所有脚本的执行。这也是为什么把关键 CSS 内联、非关键 CSS 异步加载,能明显改善首屏。
6.2 布局、绘制、合成:三棵树的接力赛
DOM 树和 CSSOM 树都构建好之后,合并成渲染树。渲染树只包含可见元素,display: none的节点不会进去,visibility: hidden的会进去(因为它占位)。
接下来是布局,也叫回流。浏览器计算每个元素的精确位置和尺寸。这一步的代价很高,因为它牵一发动全身——改一个元素的宽度,可能影响它所有兄弟节点和祖先节点的尺寸。
然后是绘制,把每个元素画成像素。再之后是合成,把页面分成多个图层,交给合成线程去合并。
为什么要分层?因为分层之后,某些变化(比如滚动、transform 动画)只需要合成线程重新合成,完全不用走布局和绘制。这就是为什么用transform做动画比改left、top流畅得多——前者触发合成,后者触发布局。
我在实际项目里总结出一个判断规则,很好用:
| 你改的属性 | 触发的阶段 | 代价 |
|---|---|---|
| width、height、margin、padding、font-size | 布局 + 绘制 + 合成 | 最高 |
| color、background、box-shadow | 绘制 + 合成 | 中 |
| transform、opacity | 合成 | 最低 |
所以动画优先用transform和opacity,这不是玄学,是渲染管线决定的。
还有一个高频问题叫布局抖动。典型写法是在循环里先读布局属性再写样式:读一次offsetHeight就强制浏览器把待处理的样式变更全部算一遍,写一次又让缓存失效,下次读再来一遍。循环一百次就是两百次布局计算。解决办法是读写分离,先批量读,再批量写。
6.3 渲染阶段的性能指标,看哪几个就够
指标一大堆,日常盯住四个就能覆盖大部分问题。
- FCP(首次内容绘制):用户第一次看到内容的时间点。它反映的是"页面有没有反应"。
- LCP(最大内容绘制):视口内最大元素的绘制时间。它更贴近"用户觉得页面打开了"的感受。
- CLS(累计布局偏移):页面元素意外位移的累计值。图片没写尺寸、字体加载后替换,都会导致这个值升高,用户体感就是"点到一半按钮跑了"。
- TBT(总阻塞时间):主线程被长任务占住的总时长。它反映的是"页面卡不卡"。
这四个指标里,LCP 是最值得优先优化的。优化手段无非几类:让关键资源更早被发现(预加载)、让资源更小(压缩、按需加载)、让关键路径更短(减少阻塞资源)、让服务器更快(缓存、CDN)。
我在一次优化里做过对比:一个首屏 LCP 4.2 秒的页面,做了三件事之后降到 1.8 秒。第一件是把首屏图片从一张大图换成合适尺寸加现代格式,体积从 900KB 降到 120KB。第二件是把首屏不需要的脚本全部改成defer,其中有两个统计脚本改成了空闲时加载。第三件是把首屏关键样式内联进 HTML,省掉一次往返。三件事加起来改了两百多行,收益相当可观。
需要注意的是,优化前后一定要在同一个网络条件、同一台设备上测。我见过有人拿开发机上连着千兆网的数字,跟线上 4G 环境的数字比,得出"优化了 5 倍"的结论,纯属自欺欺人。测的时候给网络和 CPU 都加上限速降速,更接近真实用户。
7. 排查实录:这些坑我踩过,你可以绕开
7.1 一张按现象定位的速查表
下面这张表是我这些年攒下来的,按"用户看到什么"来组织,比按协议层次组织更好用。
| 现象 | 优先怀疑 | 快速验证方式 |
|---|---|---|
| 完全打不开,提示找不到服务器 | DNS 解析失败 | 用dig查解析,换个解析器对比 |
| 能 ping 通但页面打不开 | 端口不通或应用未监听 | curl -v看连接阶段卡在哪 |
| 提示连接超时 | 网络策略拦截或服务端队列满 | curl --connect-timeout 3看是否稳定超时 |
| 提示证书错误 | 证书过期、域名不匹配、链不完整 | openssl s_client看证书详情 |
| 页面打开了但白屏 | JS 报错或资源 404 | 看控制台和网络面板的失败项 |
| 首屏很慢但服务端日志很快 | 客户端到网关这一段慢 | 对比服务端耗时和time_starttransfer |
| 偶发 502 | 上游进程崩溃或返回非法响应 | 查网关日志和上游健康状态 |
| 偶发 504 | 上游处理超时 | 检查各层超时配置是否递减 |
| 改了解析但部分用户不生效 | 多台权威服务器记录不一致 | 分别向各权威服务器查询 |
| 改了代码线上不生效 | 旧 Service Worker 或强缓存 | 看 Application 面板和缓存来源 |
7.2 三个印象最深的排查过程
第一个是"只在部分用户的手机上打不开"。报障描述很模糊,说"有时候能开有时候不行"。我先按常规思路查了服务可用性和 CDN 回源,都没问题。后来让用户报了一下网络环境,发现集中在某一类接入方式上。再往下查,发现是那个网络环境下的递归解析器把域名解析到了一个已经下线的旧地址,而旧地址的机器还在,只是没有服务,所以表现为超时。根本原因是迁移时 TTL 设得长,加上部分解析器不严格遵守 TTL,缓存了很久。解决方法是在旧地址上保留一个跳转服务,撑过缓存周期。
第二个是"首屏图片偶尔不显示"。这个问题的迷惑性在于不可稳定复现。最后抓包发现是图片请求返回了个不完整的数据。往下挖,是服务端开启了分块传输,但中间某一层对响应做了缓冲,缓冲有大小限制,超过就被截断,而客户端收到的是"看起来完整"的流。这类问题的判断方式是对比Content-Length和实际收到的字节数,或者直接看响应体是否完整。中间层缓冲截断这种事,光看状态码是永远查不出来的。
第三个是"页面加载完了但点不动"。用户反馈页面是打开的,但所有按钮都没反应。这其实是典型的主线程被占满。打开性能面板一看,有个脚本在同步处理几万条数据,占了一秒多。这类问题在开发机上永远感觉不到,因为开发机性能好,数据量也小。我给的建议是把这批数据的处理拆成小块,或者放到后台线程里做,主线程只负责渲染结果。
7.3 几条不太写在文档里的经验
第一,排查顺序比排查工具重要。我见过很多人一上来就抓包,抓到几千个包不知道怎么读。正确的做法是先确定卡在哪个阶段,再针对那一阶段用工具。确定阶段的方法很简单:打开网络面板看请求的时间线,哪一段是空白,问题就在哪一段。
第二,永远先排除"自己这边"的原因。缓存、插件、旧版本、写错的地址,这四类原因加起来占了"页面有问题"报障的一大半。我现在的习惯是先开一个干净的窗口,用最简单的参数复测一遍,再开始查服务端。
第三,日志的时间要对齐。客户端和服务端的时钟可能有偏差,跨机器对比日志时先说清楚时区。我就因为时区问题白查了半天——服务端日志用的是另一个时区的时间,跟客户端对不上,误以为是延迟。
第四,别迷信"重启就好了"。重启解决的是状态残留,如果问题会复发,说明有更深层的原因。把每次重启前收集的信息记下来,几次之后规律就出来了。
第五,性能优化要有基线。优化之前先测,优化之后用同样的条件再测,把数字记下来。没有基线的优化,最后只能靠感觉说"好像快了一点"。
8. 把它变成你自己的东西:一套可以反复用的验证流程
前面讲的都是知识点,最后说一套我日常用的验证流程,把这套流程跑几遍,整条链路就长在你脑子里了。
想验证 DNS,用一个不存在的子域名去查,看返回的是不是"域名不存在";用一个存在的域名,加上+trace看完整路径。想验证 TCP,用curl连一个不开放的端口,看报错是拒绝还是超时,这两种含义完全不同。想验证 TLS,用openssl连上去看协议版本、加密套件、证书有效期和签发链,然后故意连一个域名不匹配的地址,观察报错文案。
想验证 HTTP 缓存,做一个带强缓存的静态资源,刷新页面看来源是不是 memory cache,改文件内容再看是否还是旧内容,然后调整缓存指令再测一遍。想验证协商缓存,看响应头里有没有ETag,第二次请求看状态码是不是 304。这一套跑下来,缓存这块基本就通了。
想验证渲染,写一个极简页面,先在 head 里放一个同步的大脚本,看首屏被卡多久;改成defer再测一次;再在脚本前放一个慢样式表,观察脚本执行被推迟。这三个对比做完,你对阻塞渲染的理解会比看十篇文章都深。
想要更接近真实网络,就在工具里把网络限速到 3G 水平,把 CPU 降速四倍。这时候你会看到很多在高速网络下看不见的问题浮出来:字体闪烁、布局跳动、动画卡顿。真机上的用户大概率就处在这种环境下。
我自己有个习惯,每接手一个新项目,第一件事就是把这条链路对着实际站点走一遍,用命令行把 DNS、连接、TLS、首字节、总耗时全打出来,记在一个小本子上。这个基线看起来没什么用,但等到线上出问题、大家都在猜的时候,你手里有数据,判断速度会快很多。这个习惯坚持下来,最大的收获不是记住了多少协议细节,而是形成了一种"分段定位"的直觉——看到一个现象,脑子里自动就往某一层去想了。