在地址栏敲下一个 URL、按下回车,页面就出现了。这两个动作之间的那段距离,是互联网上被走得最多的一条路,也是我们大多数人学得最模糊的一条:浏览器缓存、DNS 解析、TCP 握手、TLS 握手、请求、渲染。本文就是一幅“在地址栏敲下 URL 之后发生了什么”的地图——一遍走完全程,每一层都链到讲那一层的深度文章。它是有意写成地图而不是长篇小说的:足够让你在页面变慢或出错时知道该怀疑哪一跳,也知道接着去哪儿读。
地图:在地址栏敲下 URL 之后发生了什么
每个请求都是同一趟、同样的顺序。右边的数字在热缓存下极小,在冷缓存下会迅速叠加。
你输入 https://blog.example.com/posts/ 并按下回车
|
├─ 1. 浏览器缓存 / HSTS / Service Worker ~0 ms (也许)
├─ 2. DNS 解析 存根 -> 递归 -> 权威 ~0-50 ms (有缓存时 ~0)
├─ 3. TCP 握手 SYN -> SYN-ACK -> ACK ~1 RTT
├─ 4. TLS 握手 SNI、证书、key_share ~1 RTT (TLS 1.3)
├─ 5. HTTP 请求 GET /posts/ HTTP/2 ~1 RTT
├─ 6. 代理这一跳 -> 应用 -> 数据库 应用耗时
├─ 7. HTTP 响应 头部 + 正文,压缩后 字节数 / RTT
└─ 8. 解析 -> 渲染 DOM、CSS、JS、绘制 CPU + 子资源
RTT 是一次往返——一个包发出去、答案回来的时间。如果你的服务器在 40 ms 之外,那就是每一个需要全新往返的步骤的下限;一次冷握手要花掉三到四个往返,才等到第一个有用的字节。
网络之前:浏览器缓存、HSTS 和地址栏
最初几跳根本没离开你的机器,所以常被人跳过——也因此格外重要。浏览器会拿自己的 HTTP 缓存去对照响应里的 Cache-Control 和 ETag。命中就意味着下面整趟全部跳过,页面直接从磁盘重绘。HSTS 可能在发任何包之前就把 http:// 改写成 https://,省掉一次重定向往返。Service Worker 可以完全不走网络,直接用脚本缓存作答。而冷启动会跳过这一切——这正是“为什么第一次慢、之后秒开”最诚实的答案。
DNS 解析:存根、递归、权威
URL 里是名字,网络却按地址路由。DNS 解析把一个换成另一个,而且每一层都缓存——浏览器、操作系统的存根解析器、以及递归解析器——各按记录的 TTL 缓存。大多数时候你一分钱不花,因为不久前查过的结果还热着。
这几个角色值得点一次名:操作系统里的存根解析器只负责把问题转发出去;递归解析器负责走完整棵树(根、再到顶级域、再到拥有该 zone 的权威服务器),并把它学到的一切缓存下来;权威服务器是终点,从不问别人。完整走查——包括 CNAME 和 DNSSEC 的坑——另有一篇专文:DNS 到底是怎么解析的,一步一步来。
# 自己跟着委派链走一遍,绕过沿途所有缓存:
dig +trace +nodnssec blog.example.com
# 一次真实的缓存未命中要花多少,看真正重要的两个数:
dig blog.example.com | grep -E 'Query time|SERVER'
TCP 握手:SYN、SYN-ACK、ACK
手里有了地址,客户端就开一条连接。TCP 握手是三个包——客户端发 SYN,服务器回 SYN-ACK,客户端再以 ACK 收尾——在任何一个数据字节移动之前,先花掉一整个 RTT。连接也可能干脆建不起来:丢 SYN 的防火墙、路由黑洞、或被打满的 backlog,在这里都会以超时的样子出现。
人们常跳过的是握手之后发生的事。TCP 起步很慢,会先试探路径:慢启动大约每个 RTT 把拥塞窗口翻一倍,所以首批响应只限于几个包,只有靠后的响应才吃满带宽。在高延迟链路上这是一笔可观的税——这就是为什么把 CDN 放到用户附近(缩短每一次 RTT)比单纯堆服务器 CPU 更重要。
TLS 握手:SNI、证书、密钥交换
接着把连接加密。在 TLS 握手里,客户端的 ClientHello 带着 SNI——明文的主机名——这使一个 IP 能服务很多站点,也告诉服务器该出示哪张证书。服务器返回它的证书链并证明自己持有私钥;双方用一次临时的密钥交换(key_share,通常 X25519)协商出共享的会话密钥。
TLS 1.3 把这一切压进一个往返:客户端预先猜好群并提前发出自己的密钥份额,于是服务器从第一条回复起就能加密。相比 TLS 1.2,每建一条全新连接就省下一个 RTT。0-RTT 能在恢复连接时再省一个,代价是重放风险。逐条消息的版本、包括如何在 openssl s_client 里读懂它,都在 TLS 1.3 握手走查那篇里。
# 看这次带 SNI 的握手,以及实际应答的那张证书:
openssl s_client -connect blog.example.com:443 \
-servername blog.example.com -tls1_3 </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
HTTP 请求与代理这一跳
现在,终于轮到请求:一个方法和路径、一个 Host 头,以及页面所需的一切(Accept、cookie、认证)。在任何真实部署里,连接都不会直接落到应用上,而是落到一个反向代理——nginx、Caddy、Traefik——由它终止 TLS、改写头部(X-Forwarded-For、X-Forwarded-Proto)、提供静态文件和缓存响应,只把必须由应用回答的部分转发过去。这就是教科书图里被抹掉的那一跳,也是大量延迟和 bug 的藏身处。
服务器:反向代理、应用、数据库
过了代理:应用(或一个静态文件)算出响应。在一个动态应用里,这通常意味着一次或多次数据库查询、也许还有一次缓存查找(命中是亚毫秒级,未命中再加一次往返就不是了),以及一次模板渲染。这是唯一步以 CPU 时间而非 RTT 计量的步骤,也是工程师真正能刨根问底的地方——strace、查询日志、APM 的 span。
响应与浏览器渲染
响应带着状态码、头部和正文回来。Content-Encoding(gzip、Brotli、zstd)把字节数压小;Cache-Control 和 ETag 决定浏览器下次能否跳过整趟。然后浏览器渲染开始:把 HTML 解析成 DOM,构建 CSSOM,形成渲染树,布局、绘制、合成——同时还要去取 HTML 引用的 CSS、JS、字体和图片。每个子资源都可能触发它自己的 DNS、TCP 和 TLS 步骤,这就是为什么即便首字节很快,瀑布图依然很长。一个阻塞渲染的脚本会一直卡住布局,直到它下载并且执行完毕。
HTTP/2、HTTP/3 与连接复用
上面这条路描述的是全新连接。真实页面会复用:keep-alive 让 TCP 保持打开,HTTP/2 在一条连接上多路复用许多请求,终结了 HTTP 层的队头阻塞。HTTP/3 跑在 QUIC 而不是 TCP 之上,消除了传输层的队头阻塞,并把传输握手和 TLS 握手合并——首次连接可以一个往返建立,恢复连接则是零往返。这就是同一个页面放在好 CDN 上、应用一行代码不改也能明显更快的原因。
延迟到底从哪来
把它们加起来,目标就很清楚了:
- 冷、远、且只加密一次:DNS 未命中 + TCP + TLS + 请求 = 拿到内容前的三到四个 RTT。热且复用时:一个都不用。
- 距离:每一次 RTT,而不是每一个字节,才是那笔税。把服务器搬近一点,或者在前面放个 CDN。
- 慢启动对首个响应的吞吐限制,比带宽本身更狠。
- 代理这一跳和数据库表现为服务器时间,而非网络时间——往往是动态页面里最大的那个数字。
- 渲染:每个子资源都会把整条序列再乘一遍。
# 把一次请求按阶段拆开——看时间花在哪,这是最快的办法:
curl -o /dev/null -s -w \
'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} ttfb %{time_starttransfer} total %{time_total}\n' \
https://blog.example.com/
一段话总结
按下 URL 走的是一条固定的路:浏览器缓存也许就地作答;DNS 解析经存根解析器和递归解析器把名字变成地址;TCP 握手用一个 RTT 打开连接,慢启动掐住最初的字节;TLS 握手在 1.3 里只需一个 RTT,通过 SNI 和证书认证服务器并协商出密钥;HTTP 请求抵达反向代理,再由它转发给应用及其数据库;响应带着缓存头回来,决定下次是否跳过整趟;最后浏览器解析、渲染并拉取子资源。延迟 = 往返次数 + 服务器时间 + CPU 时间——所以:激进地缓存、把服务搬近、用 HTTP/2 和 HTTP/3 复用连接,并且永远怀疑你看不见的那一跳。