DNS · TCP · TLS 1.2 / 1.3 · HTTP/1.1 · HTTP/2 · HTTP/3 · DOM · CSSOM · LAYOUT · PAINT · COMPOSITE
回车之后的那一秒,花在了哪
从回车到看见页面,浏览器要做五件事:先问路(DNS),再拉线(TCP),再对暗号(TLS),然后开口要(HTTP),最后把文字画成像素(渲染)。页面快不快,主要看要来回跑几趟(RTT),而不是网有多粗。DNS、TCP、TLS 各要来回,TLS 1.3、HTTP/2、HTTP/3 和每一层缓存,都是在砍掉其中几趟。
假设场景面试必问这道题;线上用户又反馈“页面要等好几秒才出来”,你得说清这几秒卡在哪一段、哪一段归后端管。所以本页每一段都配了实测数字,⑥ 末尾有面试题。标实测的数字都是在一台 2 核 ARM 云主机(新加坡)上量的:curl 7.81,Java 程序在 Temurin 8u512、17.0.21 和 Ubuntu OpenJDK 21.0.12 上都跑过。浏览器内部那一段本机没法跑,按 HTML 规范和 Chrome 的公开文档来讲。
①要解决的问题:只有一个名字,要变成一屏像素
你手里只有 lwn.net 这几个字母,页面放在地球另一边的一台机器上。中间隔着一串路由器,它们会丢包、会乱序,谁路过都能偷看。下面五个问题,每个都对应一层技术。
②朴素做法:用你会的 Java 写一个“浏览器”
Java 后端天天调别的服务,最朴素的写法就是开一个 Socket,写一行请求,把回来的东西打印出来。下面是真跑的结果。
try (Socket s = new Socket("lwn.net", 80)) { // 这一行里藏着 DNS 和 TCP 握手
s.getOutputStream().write(
("GET / HTTP/1.1\r\n" +
"Host: lwn.net\r\n" +
"Connection: close\r\n\r\n").getBytes());
BufferedReader r = new BufferedReader(
new InputStreamReader(s.getInputStream()));
for (String line; (line = r.readLine()) != null; )
System.out.println(line);
}
HTTP/1.1 301 Moved Permanently Server: Apache/2.4.62 (CentOS Stream) OpenSSL/3.5.9 Cache-Control: max-age=0, no-cache Location: https://lwn.net/ Content-Length: 59 Connection: close Content-Type: text/plain; charset=utf-8 Your browser should have redirected you to https://lwn.net/
时间 ≈ 来回次数 × RTT + 服务器耗时 + 字节数 ÷ 带宽,而在大多数网页上,第一项远大于后两项。DNS、TCP、TLS、HTTP 请求,每一步都至少要一个来回;浏览器和协议这些年的大部分改进,都是在减少来回的次数。
不懂这一点的典型误判见下面三张卡:一慢就加带宽、一慢就查接口、以为升级协议一定快。
- 为什么像对的
- “下载”听起来就是带宽的事。
- 实际
- RTT 100 ms、HTML 30 KB 时,带宽从 10 Mbps 提到 100 Mbps,首次绘制只快 不到 1%(⑥ 实验台 A 第 1 题)。几十 KB 的页面,卡的是来回,不是管子粗细。
- 为什么像对的
- 后端只看得到自己的日志。
- 实际
- lwn.net 那一秒里,服务器只占约 6 ms,其余全是来回。先用
curl -w或浏览器的 Timing 面板拆开看是哪一段(④ 末尾),再决定查谁。
- 为什么像对的
- 新协议握手少、能多路复用。
- 实际
- 省掉的握手只对新连接有效;冷连接上一口气拉很多大文件时,6 条 HTTP/1.1 连接反而更快(⑥ 第三圈实测 1.45 s 对 1.65 s)。
- 为什么像对的
- 多一次握手、多了加解密。
- 实际
- TLS 1.3 只多 1 个 RTT,连接复用后就没有了;加解密本身很便宜(④ 设计取舍 B:本机 AES-GCM 每秒约 2.9 GB)。贵的是每次都新建连接。
③几种思路:怎样少跑几趟
按“新建一条连接、到拿到第一个字节”数来回次数。每往下一行,就少一次握手;最后两行是“干脆不握手”。
| 思路 | 新连接到首字节 | 同时要 10 个文件 | 路上丢了一个包 | 代价 |
|---|---|---|---|---|
| HTTP/1.1 + TLS 1.2 | 4 个来回 | 一条连接同一时间只跑一个请求;浏览器对同一个域名最多开 6 条连接 | 只卡这条连接 | 连接多,每条都要握手 |
| HTTP/2 + TLS 1.3 | 3 个来回 | 一条连接多路复用,10 个请求同时在路上 | 这条连接上所有请求一起等重传(TCP 层的队头阻塞) | 只能跑在 TLS 上(浏览器只支持加密的 HTTP/2) |
| HTTP/3(QUIC) | 2 个来回,回访可 1 个 | 多路复用 | 只卡丢包的那个请求 | 走 UDP,有的公司网络会拦;0-RTT 的数据可能被重放 |
| 复用 + 缓存 | 1 个或 0 个 | 看底下是哪个协议 | 看底下是哪个协议 | 要后端把 keep-alive、Cache-Control 配对 |
选择规则:能不发请求就用缓存,能复用连接就复用,必须新建连接时,用握手更少的协议。本页主线按 TCP + TLS 1.3 + HTTP/1.1 讲:报文是文本,最容易看清,⑤ 的 Java 程序就是这么走的。HTTP/2、HTTP/3 改了哪些地方,放在 ⑥ 第三圈。
④关键洞察:一条流水线,每一站都在数来回
先看全景,再逐站拆开。每一站都标了要几个来回,这是这一页最重要的一把尺子。参数、版本差异这些细节,放到 ⑥。
先知道这些
IP 和端口
IP 找到机器,端口找到机器上的进程:https 默认 443,http 默认 80,Tomcat 默认 8080。路由器只看 IP,不认识域名。
RTT
一个包到对面、再收到回音的时间。同城 1~5 ms,本机到谷歌的就近节点约 2 ms,到 lwn.net 约 250 ms。它受光速和路由限制,加带宽降不下来。
分层
每层只管一件事,靠下一层干活:HTTP 跑在 TLS 上,TLS 跑在 TCP 上,TCP 跑在 IP 上。坏处是每层各握各的手,来回次数加在一起。
包
数据被切成约 1.4 KB 一个的 TCP 包(MSS = 1460 字节)。几十 KB 的网页就是几十个包,能一次发多少个由“拥塞窗口”决定,机制 4 讲。
机制 1全景:12 站,3 段地盘
下面这把“尺子”是在本机到 lwn.net(RTT ≈ 250 ms)这条线路上量的。每一段都是整数个 RTT,零头只有几毫秒。
| 哪一段 | 几个来回 | 实测 | 怎么量的 |
|---|---|---|---|
| TCP 握手 | 1 | 248 ms | curl time_connect − time_namelookup,5 次平均 |
| TLS 1.3 握手 | 1 | 252 ms | curl --tlsv1.3 -k,time_appconnect − time_connect |
| TLS 1.2 握手 | 2 | 505 ms | 同上,--tlsv1.2 --tls-max 1.2 |
| 请求 → 首字节 | 1 + 服务器 | 254 ms | time_starttransfer − time_appconnect,服务器约 6 ms |
| 下载 31 KB 的 HTML | +1 | 248 ms | 首字节到最后一个字节;机制 4 解释为什么是 1 个来回 |
| 同一页 gzip 后 5.6 KB(HN 首页) | 0 | 0.2 ms | 不压缩的 34 KB 是 205~285 ms |
| 同一条连接再要一次 | 1 | 262 ms | curl 一条命令请求两次,第二次 num_connects=0 |
| DNS 冷查询 | 不固定 | 795 / 837 ms | dig 两个没查过的域名;紧接着再查都是 0 ms |
机制 2DNS:一层层缓存,最后才一级级去问
dig +trace news.ycombinator.com 的真实路径。DNS 默认走 UDP 53 端口,一问一答各一个包。很多网站的名字会先 CNAME 到 CDN:www.netbsd.org 实测指向 dualstack.o.ssl.global.fastly.net,CDN 再按你在哪返回最近的节点 IP。TTL 的代价是改了记录要等它过期,⑥ 坑 1 是 Java 里的版本。机制 3TCP 握手 + TLS 1.3 握手:三个来回之后才拿到第一个字节
两个临时公钥,换出同一把密钥ECDHE
- 浏览器随机挑私钥 a,发出公钥 A = a·G;服务器挑私钥 b,发出 B = b·G
- 双方各自算 a·B 和 b·A,结果相同,再从它派生出 AES 密钥
- 路上的人只看到 A 和 B,算不出 a·b·G(椭圆曲线离散对数问题)
实测 lwn.net:openssl s_client -brief 显示 Protocol version: TLSv1.3、Server Temp Key: X25519、套件 TLS_AES_256_GCM_SHA384。
证书链:凭什么相信它是 lwn.net
实测 lwn.net 一共发来 4 张:叶子、YE2、Root YE,再加一张由 ISRG Root X2 交叉签名的证书,给还没内置新根的老客户端用。
机制 4慢启动:新连接不敢一口气发太多
机制 5服务器那一截:TTFB = 1 RTT + 服务器耗时后端真正能改的部分
Nginx / 负载均衡
常在这里解开 TLS,再用普通 HTTP 转给后端。它和后端之间的连接也有握手成本,老版本默认每个请求都新建,见 ⑥ 坑 7。
Tomcat
Acceptor 线程收连接,Poller 用 epoll 盯着哪个 socket 可读,再把请求交给线程池。线程池怎么调度见 ThreadPoolExecutor 那页,epoll 见 Redis 线程模型。
Spring MVC
DispatcherServlet 找到 Controller、调用、把返回值写成响应体。整条链路见 Spring MVC 请求流程。
数据库
从连接池借连接。连接池存在的理由和 keep-alive 一样:建连接要握手、要认证,太贵。见 连接池与事务性能。
并发一高,服务器耗时里会多出排队时间,TTFB 跟着变长,见 响应时间为什么越压越长。
机制 6渲染:两棵树合起来才能画,同步脚本会叫停解析
document.write 往后面插内容,也可能读样式和尺寸(为什么必须这样,见下面的设计取舍 C)。预加载扫描器是对这两条规则的补救:解析器停下时,下载不停。为什么这样设计三条约束,各逼出一个设计
A · 为什么是三次握手,不是两次
被放弃的:两次握手
现行:三次握手
约束:网络会把旧包延迟、重复地送到;双方的起始序号都要被对方确认收到。两次握手只确认了客户端的序号,服务器也分不清新旧 SYN(RFC 793 写明三次握手主要就是为了防旧的重复连接请求)。四次也没必要:服务器的 SYN 和 ACK 可以合在一个包里。
B · 为什么 TLS 先用非对称、再用对称
被放弃的:全程非对称
- 本机一个核:RSA-2048 签名每秒 350 次,X25519 密钥交换每秒约 7 500 次
- 同一个核用 AES-128-GCM 加密 16 KB 只要约 5 µs(每秒约 2.9 GB)
被放弃的:事先约好密钥
- 要和世界上每个网站提前约一把密钥,做不到
- 密钥一旦泄露,以前录下来的流量全能解开
现行:握手非对称,数据对称
- 握手:临时公钥换出会话密钥(ECDHE),证书加签名证明身份
- 之后每条记录用 AES-GCM 这类对称算法加密,快
- 每条连接的密钥都是新的,服务器私钥以后泄露也解不开以前的流量(前向保密)
约束:要在一条谁都能偷听的路上,和一台从没打过交道的服务器商量出密钥;后面几 MB 的数据又要加解密得足够快。非对称解决“怎么商量”,对称解决“怎么快”。数字来自本机 openssl speed。
C · 为什么同步脚本要叫停解析、CSS 要挡住首屏
被放弃的:边执行脚本边往下解析
<script>
document.write('<table>'); // 往后面插标签
let w = box.offsetWidth; // 读尺寸,要先有样式
</script>
- 解析器不等它,后面的 DOM 长什么样就看谁跑得快,每次打开都可能不一样
- 不等 CSS 就画:先闪一下没样式的页面(FOUC),CSS 到了再整页重排
现行:遇到同步脚本就停,CSS 没好不画
- 结果确定:脚本看到的 DOM 和样式,正好是它前面那部分
- 代价是首屏变慢。于是又加了三样东西补救:
defer、async和预加载扫描器,规则不变,下载提前
约束:要兼容 90 年代就有的 document.write,又要页面每次打开都长得一样。代价落在首屏上,所以首屏优化的大半功夫都是“别让脚本挡路、让 CSS 早点到”。
最少的“语法”:拆开看每一段的工具
# ① 各段耗时(都是从开始累计的秒数)
curl -s -o /dev/null -w 'dns %{time_namelookup}\ntcp %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\ntotal %{time_total}\n' https://lwn.net/
# ② DNS 一级一级问下去
dig +trace lwn.net
# ③ TLS 版本、密钥交换、证书链
openssl s_client -connect lwn.net:443 -servername lwn.net -brief </dev/null
# ④ 浏览器:DevTools → Network → 点一个请求 → Timing
dns 0.427562 ← 427 ms:系统缓存过期,重新去问 tcp 0.660846 ← 233 ms = 1 RTT tls 0.939383 ← 279 ms = 1 RTT + 加载 CA 约 37 ms ttfb 1.178104 ← 239 ms = 1 RTT + 服务器 total 1.411235 ← 233 ms:慢启动多 1 批
| Chrome Timing 里的名字 | 对应全景图 | 说明 |
|---|---|---|
| Queueing / Stalled | 还没出门 | 在排队:HTTP/1.1 下同一域名 6 条连接都忙,或者在等磁盘缓存 |
| DNS Lookup | ③ | 命中缓存时不显示 |
| Initial connection | ④ + ⑤ | 包含 TCP 和 TLS;其中 TLS 那部分另外显示成 SSL |
| Request sent | ⑥ | 把请求写出去,通常不到 1 ms |
| Waiting for server response | ⑥ → 服务器 → 首字节 | 就是 TTFB:1 RTT + 服务器耗时 |
| Content Download | 首字节 → 最后一个字节 | 慢启动的那几批在这里 |
放到 ⑥HTTP/2 和 HTTP/3 的帧、TCP 的重传和挥手、各种缓存头的细节、浏览器内部的进程分工,先不展开。curl 的 time_appconnect 里还混着本地加载 CA 证书包的时间(本机 OpenSSL 3.0 约 37 ms),比较握手快慢时加 -k 或者看差值。
⑤手推一遍:本机打开 lwn.net,每一步多少毫秒
先跟着时序图一步步走,用“来回次数”自己算总时间;再跑一个 80 行的 Java 程序,把前五步真的走一遍。图里联网的数字都是这个程序实测的,CSS 和渲染两步是估算。
回车。
动手:MiniBrowser.java,把前五步真的走一遍不用任何 HTTP 库
import javax.net.ssl.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.regex.*;
/** 不用任何 HTTP 库,把浏览器联网那半段自己走一遍。JDK 8 起都能编译运行。 */
public class MiniBrowser {
public static void main(String[] args) throws Exception {
URI url = URI.create(args.length > 0 ? args[0] : "https://lwn.net/");
String tlsVer = args.length > 1 ? args[1] : null; // 可选:TLSv1.2 / TLSv1.3
SSLSocketFactory factory = SSLContext.getDefault().getSocketFactory(); // 加载信任库,不计时
for (int round = 1; round <= 2; round++) fetch(url, tlsVer, factory, round);
}
static void fetch(URI url, String tlsVer, SSLSocketFactory factory, int round) throws Exception {
String host = url.getHost();
int port = url.getPort() == -1 ? 443 : url.getPort();
String path = url.getRawPath().isEmpty() ? "/" : url.getRawPath();
long[] us = new long[5]; // 每一段的耗时,单位微秒
long t = System.nanoTime();
InetAddress ip = InetAddress.getByName(host); // ① DNS:名字 → IP
us[0] = lap(t); t = System.nanoTime();
Socket tcp = new Socket();
tcp.setTcpNoDelay(true); // 关掉 Nagle,浏览器也是这样(⑥ 坑 6)
tcp.connect(new InetSocketAddress(ip, port), 5000); // ② TCP 三次握手
us[1] = lap(t); t = System.nanoTime();
SSLSocket s = (SSLSocket) factory.createSocket(tcp, host, port, true); // ③ TLS 握手,SNI = host
if (tlsVer != null) s.setEnabledProtocols(new String[]{tlsVer});
s.startHandshake();
us[2] = lap(t); t = System.nanoTime();
OutputStream out = s.getOutputStream(); // ④ HTTP 请求就是几行文本
out.write(("GET " + path + " HTTP/1.1\r\n"
+ "Host: " + host + "\r\n"
+ "User-Agent: MiniBrowser/1.0\r\n"
+ "Accept: text/html\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
out.flush();
InputStream in = s.getInputStream();
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(in.read()); // 阻塞到第一个字节回来
us[3] = lap(t); t = System.nanoTime();
byte[] buf = new byte[16384]; // ⑤ 读到服务器关连接为止
for (int n; (n = in.read(buf)) != -1; ) body.write(buf, 0, n);
us[4] = lap(t);
String proto = s.getSession().getProtocol();
s.close();
String[] name = {"DNS", "TCP", "TLS", "首字节", "下载完"};
StringBuilder line = new StringBuilder("第 " + round + " 次 ");
long sum = 0;
for (int i = 0; i < 5; i++) {
sum += us[i];
line.append(String.format(" %s %.1f", name[i], us[i] / 1000.0));
if (i > 0) line.append(String.format("(%.1f RTT)", (double) us[i] / us[1]));
}
System.out.println(line + String.format(" 合计 %.1f ms", sum / 1000.0));
if (round == 2) report(host, ip, proto, body.toString("UTF-8"));
}
static long lap(long start) { return (System.nanoTime() - start) / 1000; }
static void report(String host, InetAddress ip, String proto, String resp) {
int split = resp.indexOf("\r\n\r\n");
String[] head = resp.substring(0, split).split("\r\n");
String html = resp.substring(split + 4);
System.out.println("\n" + host + " → " + ip.getHostAddress() + " · " + proto);
System.out.println(head[0] + " · 响应 " + resp.length() + " 字节");
for (String h : head)
if (h.matches("(?i)(content-type|cache-control|content-encoding|transfer-encoding):.*"))
System.out.println(" " + h);
System.out.println("HTML 里还引用了(浏览器接着要去下载):"); // ⑥ 读 HTML,发现子资源
Matcher m = Pattern.compile("<(link|script|img)\\b[^>]*?\\b(?:href|src)=\"([^\"]+)\"",
Pattern.CASE_INSENSITIVE).matcher(html);
while (m.find()) System.out.println(" <" + m.group(1) + "> " + m.group(2));
}
}
第 1 次 DNS 5.1 TCP 239.8(1.0 RTT) TLS 478.0(2.0 RTT) 首字节 262.1(1.1 RTT) 下载完 234.1(1.0 RTT) 合计 1219.2 ms 第 2 次 DNS 0.0 TCP 233.5(1.0 RTT) TLS 238.5(1.0 RTT) 首字节 237.4(1.0 RTT) 下载完 235.3(1.0 RTT) 合计 944.7 ms lwn.net → 173.255.236.65 · TLSv1.3 HTTP/1.1 200 OK · 响应 32247 字节 Cache-Control: max-age=0, no-cache Transfer-Encoding: chunked Content-Type: text/html; charset=utf-8 HTML 里还引用了(浏览器接着要去下载): <link> https://static.lwn.net/images/favicon.png <link> https://lwn.net/headlines/rss <link> /CSS/lwn <img> https://static.lwn.net/images/logo/barepenguin-70.webp <img> https://static.lwn.net/images/lcorner-ss.png
- 1第 2 次每一段都是 1.0 个 RTT:TCP、TLS 1.3、首字节、下载,和 ④ 的尺子一样。31 KB 下载那段多出来的 1 个来回就是慢启动(④ 机制 4)。
- 2第 1 次的 TLS 是 2.0 个 RTT:不是协议多了一个来回,而是 JVM 第一次握手要加载、解释执行加密代码,多花了约 240 ms,第 2 次就没有了。服务刚启动时第一批 HTTPS 调用偏慢,一部分原因在这里。
- 3第 2 次的 DNS 是 0.0 ms:JVM 自己缓存了解析结果,默认 30 秒(⑥ 坑 1)。第 1 次的 5 ms 是问操作系统,系统缓存命中。
- 4响应头里
Transfer-Encoding: chunked:服务器边生成边发,没法提前给Content-Length,程序把分块标记也算进了 32247 字节。/CSS/lwn是 ⑥ 实验台 B 里那种“挡住首次绘制”的 CSS。
同一份 class(--release 8 编译) | Temurin 8u512 | Temurin 17.0.21 | OpenJDK 21.0.12 |
|---|---|---|---|
| 默认启用的客户端协议 | TLSv1.3, TLSv1.2,和 lwn.net 协商出 TLSv1.3实测 | ||
| 第 1 次 TLS(JVM 冷) | 461 ms · 1.9 RTT | 422 ms · 1.7 RTT | 478 ms · 2.0 RTT |
| 第 2 次 TCP / TLS / 首字节 / 下载 | 253 / 273 / 260 / 256 | 257 / 269 / 248 / 262 | 234 / 239 / 237 / 235 |
| 第 2 次合计 | 1043 ms | 1036 ms | 945 ms |
| JVM DNS 缓存默认值 | 成功结果 30 秒、失败结果 10 秒(三个版本反射读 sun.net.InetAddressCachePolicy 都是这个值) | ||
三个版本的差别在线路抖动的范围之内(RTT 在 230~260 ms 之间跳)。OpenJDK 8 从 8u272 起带 TLS 1.3,但客户端默认关着;8u352 起默认打开(JDK-8245263),更老的 JDK 8 和这台服务器只能谈出 TLS 1.2,握手会是 2 个来回。
curl -w 或 Timing 面板把时间拆到 DNS、连接、TLS、TTFB、下载这几段,再判断是网络、协议还是后端的问题。⑥边界和变体:哪些地方会多跑来回,哪些地方会出事
按三圈排:先是不知道就会出事故的坑,每条都标了根源在 ④ 的哪一条;再是团队里该怎么配;最后是协议和浏览器的深处,两个实验台都在第三圈。
注意事项
- 现象
- 数据库主备切换或服务迁移后改了 DNS,别的客户端都切过去了,Java 进程还在连旧地址,过一阵才好,有的一直不好。
- 根源
- JVM 自己也是一层 DNS 缓存:没装 SecurityManager 时,成功结果默认缓存 30 秒、失败结果 10 秒(JDK 8 / 17 / 21 实测相同);装了 SecurityManager 又没配,就永久缓存。更常见的是连接池里的老连接:连接不断,就根本不会重新解析。
- 做法
- 启动参数
-Dsun.net.inetaddr.ttl=60,或在第一次解析之前Security.setProperty("networkaddress.cache.ttl", "60");连接池设最大寿命(HikariCP 的maxLifetime),老连接到期重建。JDK 21 新增networkaddress.cache.stale.ttl,DNS 服务器挂了时可以先用过期的结果顶一阵。
- 现象
curl -L http://lwn.net/实测:重定向那一跳花了 511 ms,正好 2 个来回(连 80 端口 + 请求拿到 301),之后才开始 https 的握手。实测- 根源
- 浏览器不知道这个网站只支持 https,只能先用 http 试;301 本身就是一次完整的请求。
- 做法
- 响应加
Strict-Transport-Security: max-age=31536000(lwn.net 实测就带了),浏览器记住后直接走 https;想连第一次访问也省掉,就申请加入 HSTS preload 名单。页面和接口里的链接直接写 https。
- 现象
SSLHandshakeException: PKIX path building failed … unable to find valid certification path to requested target- 根源
- 两种常见情况。一是服务器只发了叶子证书,漏了中间证书:浏览器常常会自己补(顺着证书里的 AIA 地址去下载,或者用缓存过的中间证书),JDK 默认不补(
com.sun.security.enableAIAcaIssuers默认 false,JDK 8 和 21 源码都是)。二是公司内网 CA 或自签证书,不在 JDK 的 cacerts 里。 - 做法
- 服务器配完整链(Nginx 的
ssl_certificate指向 fullchain);内网 CA 用keytool -importcert导入信任库。别写“信任所有证书”的 TrustManager 糊过去,那等于把 TLS 的身份验证关了。
- 现象
- TTFB 只有一两百毫秒,首次绘制却要两三秒;DevTools 的 Performance 面板里,开头一大段在等脚本下载或执行。
- 根源
<head>里的同步<script>叫停了解析,body 还没解析,就没东西可画;或者 CSS 很大、放在慢的域名上,CSSOM 迟迟不好。- 做法
- 要等 DOM 的脚本加
defer,统计这类独立脚本用async;首屏用不到的 CSS 拆出去,关键 CSS 尽量小。下面的实验台 B 可以对比四种写法。
- 现象
- 后端发了新版本,部分用户页面报错或样式错乱,强制刷新就好了。
- 根源
- 强缓存(
Cache-Control: max-age=…)有效期内,浏览器根本不发请求,连 304 都不问。HTML 被强缓存,或者 JS 改了内容、文件名没变,就会拿到旧的。 - 做法
- JS、CSS 的文件名带内容 hash(
app.3f9c2a.js),配Cache-Control: max-age=31536000, immutable;HTML 用no-cache,每次带If-None-Match回来问,没变就回 304。lwn.net 的首页就是no-cache(⑤ 实测)。
- 现象
- 把 ⑤ MiniBrowser 里的
setTcpNoDelay(true)删掉再跑,第 2 次的首字节从 1.0 个 RTT 变成 2.2 个,连跑三遍都这样;curl 和浏览器没有这个问题。实测 JDK 21 - 根源
- Nagle 算法:前面发出的数据还没被确认时,新的小数据先攒着不发。TLS 1.3 客户端最后发的 Finished 还没被确认,紧接着写的 GET 就被攒住,要等服务器的 ACK 回来才发出去。
- 做法
- 自己用 Socket 写客户端或 RPC 时设
setTcpNoDelay(true)。JDK 的HttpURLConnection和 JDK 11+ 的HttpClient源码里已经设了,Netty 默认打开,Tomcat 连接器的tcpNoDelay默认也是 true。
- 现象
- 压测时机器上一大片 TIME_WAIT,Nginx 日志偶尔报
connect() failed (99: Cannot assign requested address)(Nginx 本地端口用光);后端 RT 里每次都多一次握手。 - 根源
- nginx 1.29.7 之前,
proxy_http_version默认 1.0,upstream 也默认不缓存连接,每个请求都和后端握手、用完就断。TCP 断开时先关的一方要在 TIME_WAIT 里待 60 秒(Linux),先关的是 Nginx 时,它的本地端口就一个个被占住。 - 做法
- 老版本:
upstream app { server 10.0.0.5:8080; keepalive 32; },location 里加proxy_http_version 1.1;和proxy_set_header Connection "";。nginx 1.29.7 起(1.30 稳定版)默认就是 HTTP/1.1 加keepalive 32(官方文档)。
- 现象
- 一个页面从同一个域名拉几十个小图标、接口,Timing 面板里后面的请求有一大段 Queueing / Stalled。
- 根源
- HTTP/1.1 的一条连接同一时间只跑一个请求,浏览器对同一个域名最多开 6 条,第 7 个只能等前面谁空出来。每条新连接还要各自握手。
- 做法
- 入口(Nginx、CDN、负载均衡)开 HTTP/2,一条连接同时跑所有请求;或者合并请求、减少数量。别再用“拆好几个域名”绕开 6 条的限制,那在 HTTP/2 下反而多了握手。
使用规范
Strict-Transport-Security: max-age=31536000; includeSubDomains,链接直接写 https。
每次先 301,多跑 2 个来回(坑 2)
构建工具生成 app.[hash].js,Nginx 按路径配两种 Cache-Control。
发版后用户拿到旧文件(坑 5)
Nginx:ssl_protocols TLSv1.2 TLSv1.3;;1.25.1 起写 http2 on;,更老的版本写在 listen 443 ssl http2; 上。
新连接多 1 个来回、同域名请求排队(坑 8)
Spring Boot:server.compression.enabled=true,默认是关的,开了以后默认只压 2 KB 以上的 html、css、js、json、xml(3.5.16 配置元数据);Nginx:gzip on;。
HTML 超出第一批,多等 1 个来回(HN 实测 34 KB → 5.6 KB,下载段 1 RTT → 0)
JDK HttpClient、OkHttp、Apache HttpClient 都是一个实例一个连接池,每次 new 一个就等于每次都握手。
每次调用多 2~3 个来回(curl 实测复用后 262 ms,新建 1048 ms)
-Dsun.net.inetaddr.ttl=60,连接池配 maxLifetime。
切了 DNS 不生效(坑 1)
部署 fullchain,上线后用 openssl s_client -showcerts 看服务器到底发了几张。
Java 客户端报 PKIX(坑 3)
nginx 1.29.7 之前手动配 keepalive + proxy_http_version 1.1 + 清空 Connection 头。
TIME_WAIT 堆积、端口耗尽(坑 7)
深处:协议的代际、浏览器内部、两个实验台
HTTP/1.1 → 2 → 3:队头阻塞挪了两次家
- HTTP/1.1:一条连接上请求一个接一个,前一个慢,后面全等(HTTP 层的队头阻塞),所以浏览器开 6 条。
- HTTP/2:请求切成帧,多个流在一条连接上交错,头部用 HPACK 压缩。但 TCP 只有一条字节流,丢一个包,所有流一起等重传(TCP 层的队头阻塞)。
- HTTP/3:QUIC 在 UDP 上自己做可靠传输和加密,每个流单独重传;传输和 TLS 1.3 的握手合成 1 个来回;Wi-Fi 切 4G 时连接不断,因为它用连接 ID 而不是“IP + 端口”认连接。
实测:HTTP/2 不一定更快实测
curl --parallel --parallel-max 6,本机到 haskell.org(RTT 约 232 ms),各跑 3 遍:
- 18 个 11.5 KB 的响应:HTTP/1.1(6 条连接)1.41~1.50 s,HTTP/2(1 条连接)1.64~1.70 s
- 18 个 146 B 的响应:HTTP/2 1.15~1.17 s,HTTP/1.1 1.20~1.25 s
冷启动时每条 TCP 连接各有一个 10 包的初始窗口,6 条就是 60 个包;HTTP/2 只有一条连接的 10 个包,要多翻几倍才追上(④ 机制 4)。响应很小时窗口不是瓶颈,HTTP/2 省下的握手占上风。curl 自己的调度方式也会影响结果,这组数字说明的是方向。
0-RTT 的代价:请求可能被重放
TLS 1.3 和 QUIC 回访时,可以把请求跟第一个握手包一起发出去,少 1 个来回。可这份“早到的数据”能被人录下来再发一遍,服务器分不出来。所以它只能用于幂等的请求(GET 查询),下单、支付不能走 0-RTT。Nginx 的 ssl_early_data 默认是 off。
四次挥手和 TIME_WAIT
断开时两个方向各关各的:FIN → ACK → FIN → ACK。先关的一方进 TIME_WAIT,等 2MSL(Linux 固定 60 秒)再真正释放,原因有两个:最后那个 ACK 丢了,对方会重发 FIN,得有人回;旧连接迟到的包要等它在网络里消失,免得串进用同一组 IP 和端口的新连接。谁先关谁背 TIME_WAIT,所以短连接多的一方要注意(坑 7)。
浏览器内部:几个进程分工
- 浏览器进程:地址栏、导航、权限、管理其他进程。
- 网络服务:DNS、连接池、HTTP 缓存(Chrome 里通常单独一个进程)。
- 渲染进程:一般每个站点一个,在沙箱里跑解析、JS、样式、布局、绘制。
- GPU 进程:合成上屏。
这样拆,一个页面崩了不拖垮别的;渲染进程跑的是不可信的网页代码,要关在沙箱里。
回流和重绘:改样式的代价不一样
- 改
width、top、字体:布局 + 绘制 + 合成都重来(回流)。 - 改
color、background:绘制 + 合成(重绘)。 - 改
transform、opacity(元素在单独的合成层上):只合成,主线程不用动,动画首选。 - 读
offsetWidth会逼浏览器马上算一次布局;循环里“写一次、读一次”最慢。
SSR、CSR、SSG:首屏差几个来回
- CSR(单页应用):空壳 HTML → 下载 JS → 执行 → 再调接口拿 JSON → 渲染。比 SSR 至少多两段串行的来回。
- SSR(Thymeleaf、JSP、Next.js 等):HTML 里直接带内容,CSS 到了就能画。
- SSG:提前生成好放 CDN 边缘,RTT 最短,适合内容不常变的页面。
提前做:资源提示
<link rel="preconnect" href="https://static.example.com">:提前做完 DNS + TCP + TLS。dns-prefetch:只提前做 DNS,更省。<link rel="preload" as="style">:提前下载后面才会被发现的关键资源。103 Early Hints:服务器生成页面的那几百毫秒里,先告诉浏览器去连、去下载。
版本差异
| 东西 | 版本 | 和本页有关的变化 |
|---|---|---|
| TLS | 1.2 → 1.3(2018,RFC 8446) | 完整握手 2 个来回 → 1 个(本机实测 505 → 252 ms);去掉了 RSA 密钥交换,只留有前向保密的 (EC)DHE;新增 0-RTT |
| HTTP | 1.1 → 2(2015)→ 3(2022,RFC 9114) | 多路复用、头部压缩;HTTP/3 换成 QUIC over UDP,握手合并、按流重传 |
| JDK | 8 / 17 / 21 | JDK 8 只有 HttpURLConnection(HTTP/1.1);JDK 11 加入 java.net.http.HttpClient,默认先试 HTTP/2,17、21 都有。TLS 1.3:JDK 11 起自带;OpenJDK 8 从 8u272 起有、8u352 起客户端默认打开。DNS 缓存默认值三个版本相同;JDK 21 新增 networkaddress.cache.stale.ttl |
| nginx | 1.25.1 / 1.29.7 | 1.25.1 起用 http2 on; 开 HTTP/2;1.29.7 起反代默认 HTTP/1.1,upstream 默认 keepalive 32 |
| Spring Boot | 2 / 3 | server.compression.enabled 默认 false、阈值 2 KB;内嵌服务器的 HTTP/2 要配 server.http2.enabled=true(默认 false),通常还要配好 TLS |
实验台 A · 首访一个页面,时间花在哪先猜,再运行
模型:每个握手按整数个 RTT 算;下载按慢启动算(初始窗口 10 个包,每个 RTT 翻倍,超过“带宽 × RTT”后按带宽算);CSS 放在 <head>,首字节一到就被发现。HTTP/1.1 下 CSS 取“新开一条连接”和“等 HTML 下完复用”里先到的那个,复用时窗口已经涨大;HTTP/2、3 在同一条连接上并发,不算和 HTML 抢窗口。不算丢包、TCP 排队和浏览器自己的计算时间。参数按 ⑤ 的 lwn.net 设(第 3 题),算出首字节 755 ms、HTML 到齐 1006 ms,和实测的 1.0 s 对得上。同一套逻辑在 node 里随机测过 2 万组:首次绘制不早于首字节,带宽加大不会变慢,RTT 加大不会变快。
实验台 B · <script> 放哪儿先猜,再运行
模型按 HTML 规范的阻塞规则推:同步脚本叫停解析,执行前要等它前面的样式表;defer 在解析完以后、DOMContentLoaded 之前按顺序执行,也要等样式表;async 下完就执行,不等样式表;CSS 不到不画。不模拟真实浏览器的分段绘制。有了预加载扫描器,“head + defer”和“body 末尾同步”的时间基本一样,区别是 defer 不挡住它后面的内容。
面试官会怎么问
为什么是三次握手,不是两次?
双方都要确认“对方收到了我的起始序号”,两次握手只确认了客户端的;更要紧的是防旧的重复 SYN:两次握手下,迷路的旧 SYN 一到,服务器就会建立一个没人用的连接。三次握手里客户端会对不认识的 SYN-ACK 回 RST。服务器的 SYN 和 ACK 能合成一个包,所以不用四次。
← ④ 设计取舍 A
四次挥手为什么要 TIME_WAIT,为什么等 2MSL?
TCP 是全双工,两个方向各自关闭,所以是 FIN/ACK 两对。先关的一方等 2MSL:一是最后一个 ACK 丢了时,还能回应对方重发的 FIN;二是让这条连接迟到的包在网络里消失,免得被同一组 IP 和端口的新连接收到。Linux 固定等 60 秒。
← 第三圈 · 四次挥手
HTTPS 的握手过程?对称加密和非对称加密各用在哪?
TLS 1.3:ClientHello 带上支持的算法和临时公钥;ServerHello 回临时公钥,双方算出同一把会话密钥,服务器接着发证书、用私钥签名、Finished;客户端验证书链、回 Finished,同时发出请求,一共 1 个来回。非对称用在握手(密钥交换 + 身份证明),对称用在之后所有数据。TLS 1.2 要 2 个来回,而且允许不带前向保密的 RSA 密钥交换。
← ④ 机制 3 + 设计取舍 B
DNS 用 UDP 还是 TCP?
默认 UDP 53 端口,一问一答各一个包,省掉握手。响应太大被截断(TC 位)时改用 TCP 重新问,区域传送也用 TCP。浏览器还可以用 DoH(DNS over HTTPS)把查询加密。
← ④ 机制 2
HTTP/1.1、HTTP/2、HTTP/3 有什么区别?
1.1:文本格式,一条连接一次一个请求,浏览器开 6 条。2:二进制帧、多路复用、HPACK 头部压缩,但仍受 TCP 层的队头阻塞。3:基于 QUIC(UDP),握手更少、按流重传、换网络不断线。另外说明“不一定更快”:冷连接拉大量大文件时,6 条 HTTP/1.1 连接的初始窗口总和更大。
← ③ + 第三圈实测
强缓存和协商缓存?
强缓存:Cache-Control: max-age(或老的 Expires)有效期内直接用本地的,不发请求。过期后走协商缓存:带上 If-None-Match(对应 ETag)或 If-Modified-Since 去问,没变就回 304、不带响应体,省的是下载,来回还是要跑一次。
← ④ 机制 1 ② + 坑 5
回流和重绘?怎么减少?
改几何属性触发回流(重新布局,再绘制、合成);只改颜色之类触发重绘。动画用 transform / opacity 只走合成;批量读、批量写,别在循环里交替读写布局属性。
← 第三圈 · 回流和重绘
defer 和 async 有什么区别?
都不挡解析、都提前下载。defer:等解析完,按出现顺序执行,在 DOMContentLoaded 之前。async:谁先下完谁先执行,不保证顺序,可能在解析中途插进来,不等 CSS。依赖 DOM 或彼此有依赖的用 defer,独立的统计脚本用 async。
← ④ 机制 6 + 实验台 B
下一圈
- TCP 拥塞控制:Cubic 和 BBR,丢包、排队时窗口怎么变
- QUIC 内部:连接 ID、0-RTT 防重放、为什么有的网络会限速 UDP
- CDN:怎么选边缘节点、回源、缓存键怎么定
- 浏览器缓存全家:Service Worker、内存缓存、磁盘缓存、前进后退缓存
- Web 性能指标:FCP、LCP、INP、CLS 怎么量、怎么优化
- Java 服务端的 TLS:Tomcat 和 Netty 用 JSSE 还是 OpenSSL、会话复用、证书热更新
- 长连接:WebSocket、SSE,以及 HTTP/2 Server Push 为什么被浏览器放弃
- 抓包排查:tcpdump / Wireshark 看握手、重传和 RST
↺回到第⑤步:改 MiniBrowser
每个练习都写了做对时会看到什么,数字是本机跑出来的,你那边的 RTT 不同,看“几个 RTT”就行。
练习 1 · 加上 keep-alive
去掉 Connection: close,在同一条连接上连发两次 GET /。难点是判断一个响应在哪结束:有 Content-Length 就读够字节数,chunked 就按“十六进制长度 + 数据”一块块读,读到长度 0 为止。
做对了会看到:第 1 次 = 握手 2 个 RTT + 请求 2 个 RTT(首字节 1 个、慢启动 1 个);第 2 次整个请求只要 1 个 RTT,连 31 KB 的下载都在里面,因为窗口已经涨大了。本机:握手 513 ms + 请求 513 ms,对 258 ms。
用到:③ 复用连接、④ 机制 4
练习 2 · 先猜,再用 TLSv1.2 跑
java MiniBrowser.java https://lwn.net/ TLSv1.2。先猜两次的 TLS 段各是几个 RTT。
做对了会看到:第 1 次约 2.9 个 RTT:2 个来回的完整握手,加上 JVM 预热(TLS 1.3 时是 1.7~2.0 个)。第 2 次只有 1.0 个:JDK 自动复用了第 1 次的会话(会话恢复),TLS 1.2 的简短握手只要 1 个来回。连跑 3 遍都是这样。
用到:③ 几种思路、④ 机制 3
练习 3 · 删掉 setTcpNoDelay
把 tcp.setTcpNoDelay(true); 那一行注释掉,用默认的 TLS 1.3 再跑三遍。
做对了会看到:第 2 次的首字节从 1.0 个 RTT 变成约 2.2 个,TCP、TLS、下载三段不变。多出来的那 1 个多来回,是 GET 被 Nagle 攒着、等 Finished 的确认回来才发出去。
用到:坑 6
1. 用一句话说出本页的精华。
打开页面的时间,主要花在来回上:来回次数 × RTT;DNS、TCP、TLS、请求各要来回,协议升级和各层缓存都是在砍来回的次数。
← ② 精华
2. 用 Socket 写的朴素版浏览器,痛在哪?
每次都重新握手、明文被 301 赶走、一问一答用完就关、只有文字没有画面。前三个都是多跑来回,第四个是缺了渲染引擎。
← ②
3. “切了 DNS,Java 服务还连旧 IP”,根源是 ④ 的哪一条?
机制 2:DNS 结果在每一层都按 TTL 缓存,JVM 自己也是一层(默认 30 秒);再加上连接池里的老连接不会重新解析。
← ④ 机制 2 + 坑 1
4. 预测:用户从同城(RTT 20 ms)换到海外(RTT 200 ms),首次绘制大约慢几倍?带宽翻 10 倍能救回来吗?
按实验台 A 的默认参数(TLS 1.3 + HTTP/2、服务器 50 ms、HTML 30 KB、CSS 20 KB、10 Mbps):从约 155 ms 变成约 1055 ms,慢 6~7 倍,因为 5 个来回全部放大了 10 倍,只有服务器那 50 ms 没变。带宽加 10 倍几乎不变,字节本来就只占几毫秒。
← ④ 公式,用实验台 A 验证
5. 预测:入口从 TLS 1.2 升级到 1.3,已经建立好的 keep-alive 连接上的请求会变快吗?
不会。握手只在建连接时做一次,复用的连接上本来就没有握手。省下的那 1 个来回,只有新连接拿得到。
← ④ 机制 3
6. 预测:HTML 从 12 KB 长到 16 KB(模板里多了一段),从首字节到 HTML 下完会慢多少?
不是“多 4 KB 除以带宽”那么一点点:12 KB 第一批就能装下,16 KB 超过了初始窗口的 14.6 KB(还没算响应头),要等第二批,下载段从约 0 跳到 1 个 RTT。
← ④ 机制 4
7. 预测:<head> 里一个同步脚本要 2 秒才下完,CSS 300 ms 就到了。body 里的文字什么时候能看到?改成 defer 呢?
同步:要等脚本下完、执行完、再解析 body,2 秒以后才能看到。defer:解析不被叫停,CSS 一到就能画,约 300 ms;脚本 2 秒后再执行。实验台 B 把 JS 拖到 2000 ms 就能看到。
← ④ 机制 6
8. 预测:一个页面从同一个域名拉 18 个小图标,HTTP/1.1 下浏览器会怎么排?换成 HTTP/2 呢?
HTTP/1.1:最多开 6 条连接、每条各握一次手,18 个分 3 轮跑完,Timing 里后面的请求有 Queueing。HTTP/2:1 条连接、1 次握手,18 个同时发出去。图标很小,窗口不是瓶颈,HTTP/2 会更快一点(第三圈实测 146 B 时 1.15 s 对 1.2 s)。
← ③ + 坑 8