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 这几个字母,页面放在地球另一边的一台机器上。中间隔着一串路由器,它们会丢包、会乱序,谁路过都能偷看。下面五个问题,每个都对应一层技术。

https://lwn.net/⏎ 页面还是白的 中间隔着十几台路由器,每台只看包上写的 IP 地址 一个包过去再回来 ≈ 250 ms,这叫 RTT(往返时间) lwn.net 的服务器 173.255.236.65 Apache,现场生成 HTML 只知道名字 lwn.net它在哪台机器上?→ DNS:名字换成 IP 网络会丢包、乱序怎么保证一字不差?→ TCP:可靠字节流 路上谁都能看能改怎么不被偷看、冒充?→ TLS:加密 + 验身份 要哪个页面?双方按什么格式说话?→ HTTP:请求与响应 收到的是一堆文字屏幕要的是像素→ 渲染:HTML → 像素 本机实测 · 打开 lwn.net 首页(curl,5 次平均) TCP 握手 248 ms TLS 1.3 握手 252 ms 请求 → 首字节 254 ms 下载 31 KB 248 ms DNS 5 ms(系统缓存命中) 1 RTT2 RTT3 RTT4 RTT ≈ 1.0 s
最下面那条按时间比例画:一秒钟几乎全在等来回。服务器生成页面只用了几毫秒(首字节那段比 1 个 RTT 多出约 6 ms),31 KB 的字节本身按 100 Mbps 算只要 2.5 ms;“下载”那段多出的 1 个 RTT,是 TCP 刚建好时不敢一口气发太多(④ 里讲)。

②朴素做法:用你会的 Java 写一个“浏览器”

Java 后端天天调别的服务,最朴素的写法就是开一个 Socket,写一行请求,把回来的东西打印出来。下面是真跑的结果。

朴素版Naive.java(JDK 11+ 可以直接 java Naive.java)
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/
new Socket("lwn.net", 80) write("GET / HTTP/1.1…") readLine() 读回文本 打印到控制台 ✕ 藏着 DNS + TCP 握手每次都从头来,至少 1 RTT→ ③ 复用连接 · ④ 机制 2 ✕ 明文,路上能看能改lwn.net 直接回 301 → https→ ④ 机制 2:TLS ✕ 一问一答,用完就关CSS、图片只能排队或再连→ ③ HTTP/2 多路复用 ✕ 只有文字,没有画面<html>… 没人把它画出来→ ④ 机制 4:渲染 四个痛点里有三个是“多跑了来回”:握手、重定向、排队。这就是要找的精华。
朴素版能跑通(换成 443 端口再自己做 TLS 就能拿到页面,⑤ 就是这么做的),但它把浏览器的活只干了一小半,而且干得很慢。
精华

时间 ≈ 来回次数 × RTT + 服务器耗时 + 字节数 ÷ 带宽,而在大多数网页上,第一项远大于后两项。DNS、TCP、TLS、HTTP 请求,每一步都至少要一个来回;浏览器和协议这些年的大部分改进,都是在减少来回的次数。

不懂这一点的典型误判见下面三张卡:一慢就加带宽、一慢就查接口、以为升级协议一定快。

误判 1页面慢,加带宽
为什么像对的
“下载”听起来就是带宽的事。
实际
RTT 100 ms、HTML 30 KB 时,带宽从 10 Mbps 提到 100 Mbps,首次绘制只快 不到 1%(⑥ 实验台 A 第 1 题)。几十 KB 的页面,卡的是来回,不是管子粗细。
误判 2页面慢,先查接口
为什么像对的
后端只看得到自己的日志。
实际
lwn.net 那一秒里,服务器只占约 6 ms,其余全是来回。先用 curl -w 或浏览器的 Timing 面板拆开看是哪一段(④ 末尾),再决定查谁。
误判 3升级到 HTTP/2,一定快
为什么像对的
新协议握手少、能多路复用。
实际
省掉的握手只对新连接有效;冷连接上一口气拉很多大文件时,6 条 HTTP/1.1 连接反而更快(⑥ 第三圈实测 1.45 s 对 1.65 s)。
误判 4“https 慢,内网就用 http”
为什么像对的
多一次握手、多了加解密。
实际
TLS 1.3 只多 1 个 RTT,连接复用后就没有了;加解密本身很便宜(④ 设计取舍 B:本机 AES-GCM 每秒约 2.9 GB)。贵的是每次都新建连接。

③几种思路:怎样少跑几趟

按“新建一条连接、到拿到第一个字节”数来回次数。每往下一行,就少一次握手;最后两行是“干脆不握手”。

01 RTT2 RTT3 RTT4 RTT HTTP/1.1 + TLS 1.2 TCP 握手 TLS 1.2 握手 · 实测 505 ms 请求 → 首字节 4 HTTP/2 + TLS 1.3 TCP 握手 TLS 1.3 · 实测 252 ms 请求 → 首字节 3 HTTP/3(QUIC,走 UDP) QUIC 握手(含 TLS) 请求 → 首字节 2 HTTP/3 回访(0-RTT) 请求跟握手一起发 1 已有连接(keep-alive) 直接发请求 1实测:同一条连接的第 2 个请求 262 ms,第 1 个 1048 ms 本地缓存还新鲜 强缓存命中:不发请求,0 个来回
都假设 DNS 已经缓存,没缓存时每行再加约 1 个来回。实测线路是本机到 lwn.net,RTT 约 250 ms:TLS 1.2 握手 505 ms,TLS 1.3 握手 252 ms,正好差 1 个 RTT。拿到首字节之后还有下载和渲染,④ 和 ⑤ 接着讲。
思路新连接到首字节同时要 10 个文件路上丢了一个包代价
HTTP/1.1 + TLS 1.24 个来回一条连接同一时间只跑一个请求;浏览器对同一个域名最多开 6 条连接只卡这条连接连接多,每条都要握手
HTTP/2 + TLS 1.33 个来回一条连接多路复用,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 段地盘

浏览器进程 + 网络服务 ① 解析输入网址还是搜索词?补 https · 查 HSTS ② 查缓存HTTP 缓存还新鲜?新鲜就跳到 ⑦ ③ DNS名字 → IP命中缓存 ≈ 0 ④ TCP 连接三次握手1 RTT ⑤ TLS 握手换密钥 + 验证书1 RTT(1.2 要 2) ⑥ 发请求GET / HTTP/1.11 RTT + 服务器 请求到达 服务器:后端的地盘 CDN / LB就近、分流 Nginx常在这里解密 Tomcat线程池接活 Spring MVCController MySQLRedis 等 响应:状态行 + 头 + HTML,边收边往下交 发现 CSS、JS、图片:子资源再走 ③~⑥ 同一主机能复用连接,就跳过 ③④⑤ 渲染进程(每个标签页一般一个) ⑦ 解析 HTML边收边建 DOM 树同步 JS 会叫停 ⑧ CSS → CSSOM每个元素长啥样没好就不画 ⑨ 样式计算DOM 配上 CSSOM得到渲染树 ⑩ 布局 Layout算位置和大小改宽高要重来 ⑪ 绘制 Paint生成绘制指令改颜色要重来 ⑫ 合成分层、栅格化GPU 拼好上屏 ⑦~⑪ 跑在渲染进程的主线程上,页面里的 JS 也在这条线程上跑;⑫ 交给合成线程和 GPU。 首次绘制(FCP)= 第一次走完 ⑨~⑫,把内容送上屏幕。
三段地盘:上面一排是浏览器在联网(来回次数在这里),中间是后端(TTFB 里只有服务器耗时这一截归你),下面一排是浏览器在画(同步 JS 和 CSS 会卡住它)。虚线是这张图里最容易被忽略的一条:HTML 只是开头,每个子资源都要再走一遍网络。
时间 ≈ 来回次数 × RTT + 服务器耗时 + 字节数 ÷ 带宽

下面这把“尺子”是在本机到 lwn.net(RTT ≈ 250 ms)这条线路上量的。每一段都是整数个 RTT,零头只有几毫秒。

哪一段几个来回实测怎么量的
TCP 握手1248 mscurl time_connect − time_namelookup,5 次平均
TLS 1.3 握手1252 mscurl --tlsv1.3 -k,time_appconnect − time_connect
TLS 1.2 握手2505 ms同上,--tlsv1.2 --tls-max 1.2
请求 → 首字节1 + 服务器254 mstime_starttransfer − time_appconnect,服务器约 6 ms
下载 31 KB 的 HTML+1248 ms首字节到最后一个字节;机制 4 解释为什么是 1 个来回
同一页 gzip 后 5.6 KB(HN 首页)00.2 ms不压缩的 34 KB 是 205~285 ms
同一条连接再要一次1262 mscurl 一条命令请求两次,第二次 num_connects=0
DNS 冷查询不固定795 / 837 msdig 两个没查过的域名;紧接着再查都是 0 ms

机制 2DNS:一层层缓存,最后才一级级去问

浏览器缓存Chrome 自己记一份命中就不出门 操作系统hosts 文件、本地缓存本机:127.0.0.53 递归解析器运营商 / 公司 / 8.8.8.8没缓存就替你去问 没有就问下一层;答案原路返回, 每一层都按 TTL 缓存一份 根服务器(.)答:去问 .com 的服务器本机 dig +trace:2 ms .com 顶级域服务器答:去问 awsdns-48.org76 ms ycombinator.com 权威答:A 209.216.230.2071 ms 1 2 3 实测 dig www.dragonflybsd.org: 第一次 837 ms,再查 0 ms, TTL 从 3600 变成 3599
例子是 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 握手:三个来回之后才拿到第一个字节

浏览器 lwn.net SYN seq = x SYN-ACK seq = y,ack = x+1 ACK ack = y+1 + ClientHello支持的算法 · 临时公钥 A · SNI = lwn.net ServerHello(临时公钥 B)+ {证书, 签名, Finished}{ } 里的内容已经加密 {Finished} + {GET / HTTP/1.1 Host: lwn.net} {HTTP/1.1 200 OK …第一批数据} 1 RTT · TCP 1 RTT · TLS 1.3 1 RTT · 首字节 挑自己的起始序号 y 用 A 和私钥 b 算出会话密钥 证书 + 签名:证明我是 lwn.net 的主人 处理请求,约 6 ms TLS 1.2 这里要多一个来回:先商量算法,再交换密钥(实测 505 ms 对 252 ms)。
浏览器的 ACK 不用等,和 ClientHello 一起发出去;请求也不用等,和 Finished 一起发。所以新连接到首字节是 3 个来回,正好对上 ⑤ 实测的 TCP 1.0、TLS 1.0、首字节 1.0 个 RTT。花括号里的内容都已经用会话密钥加密,路由器只看得到 IP、端口和 SNI 里的域名。

两个临时公钥,换出同一把密钥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叶子证书,服务器发来 Let's Encrypt YE2中间 CA,服务器也要发 ISRG Root YE根 CA,客户端本地有 用上一级的公钥验下一级的签名 缺了中间证书:Java 报 PKIX(⑥ 坑 3) 操作系统、浏览器、JDK 的 cacerts 里预装

实测 lwn.net 一共发来 4 张:叶子、YE2、Root YE,再加一张由 ISRG Root X2 交叉签名的证书,给还没内置新根的老客户端用。

机制 4慢启动:新连接不敢一口气发太多

拥塞窗口每格 1 个包 ≈ 1.4 KB 第 1 批(随首字节一起到)第 2 批第 3 批 +1 RTT +1 RTT 10 个包 · 14.6 KB20 个 · 累计 43.8 KB40 个 · 累计 102 KB HN 首页 gzip 后 5.6 KB 第 1 批就装下 → 下载段 ≈ 0(实测 0.2 ms) lwn.net 31 KB +1 RTT(实测 248 ms) 复用连接再要 31 KB 一批发完实测整次 258 ms
Linux 默认的初始拥塞窗口是 10 个包(RFC 6928)。每收到一批确认,窗口就变大,大约每个 RTT 翻一倍,直到丢包或碰到带宽上限。所以几十 KB 的页面,多一批就多一个 RTT:压缩省下的是来回,不只是字节。“复用连接”那行来自 ↺ 练习 1 的程序:同一条连接上第二次请求同一页,整个请求只花 1 个 RTT。

机制 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渲染:两棵树合起来才能画,同步脚本会叫停解析

同步 <script>执行前先等前面的 CSS 叫停解析:下载 + 执行完才继续 预加载扫描器解析器停下时先往后扫,提前发出下载 HTML 字节边下载边交过来 解析器分词、建节点 DOM 树有哪些元素 CSS 文件要整份到齐 解析规则选择器 + 声明 CSSOM每个元素的样式 渲染树样式计算 布局位置、大小 绘制绘制指令 合成上屏 CSSOM 没准备好:渲染树不建,页面不画(白屏) CSS 不挡解析,挡的是“画”和后面的同步脚本 async 脚本:下完就插队执行,不等 CSS、不等解析完。defer 脚本:解析完再按顺序执行。⑥ 实验台 B 可以对比四种写法。
HTML 可以边下边解析、边解析边画;CSS 必须整份到齐才能用,所以它挡住首次绘制。同步脚本挡住解析,是因为它可能用 document.write 往后面插内容,也可能读样式和尺寸(为什么必须这样,见下面的设计取舍 C)。预加载扫描器是对这两条规则的补救:解析器停下时,下载不停。

为什么这样设计三条约束,各逼出一个设计

A · 为什么是三次握手,不是两次

被放弃的:两次握手

客户端服务器 迷路很久的旧 SYN seq=100 SYN-ACK ack=101 已建立 不认识,不理 服务器以为连上了,分配缓冲区干等 它也不知道自己的序号 y 对方收到没有

现行:三次握手

客户端服务器 迷路很久的旧 SYN seq=100 SYN-ACK ack=101 还没算建立 RST:我没发过 丢掉 正常情况:第 3 个包 ACK ack=y+1 到了才算建立

约束:网络会把旧包延迟、重复地送到;双方的起始序号都要被对方确认收到。两次握手只确认了客户端的序号,服务器也分不清新旧 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 缓存刚好过期
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 和渲染两步是估算。

第 0 / 7 步

回车。

从回车算起0 ms时序图向下就是时间
跑了几个来回0RTT ≈ 235 ms
服务器耗时0 ms后端能改的部分
真正传字节0 ms按 100 Mbps 估
浏览器 系统 DNS lwn.net lwn.net → 173.255.236.65 · 5 ms,系统缓存命中 SYN SYN-ACK TCP 1 RTT 239 ms ACK + ClientHello(临时公钥 A) ServerHello + {证书, Finished} TLS 1 RTT 477 ms {Finished} + {GET / HTTP/1.1} 生成页面约 6 ms 第 1 批:10 个包 ≈ 14.6 KB(首字节) 首字节 1 RTT 715 ms ACK:前面的收到了 窗口翻倍 第 2 批:剩下的 17 KB 下载 +1 RTT 950 ms 边收边解析,读到 <link /CSS/lwn> GET /CSS/lwn(等 HTML 下完,复用这条连接) 静态文件 CSS 19 KB:窗口已经涨大,一批到齐 CSS 1 RTT ≈ 1185 ms 样式 → 布局 → 绘制 → 合成 首次绘制 ≈ 1.2 s(渲染按 30 ms 估) 图片在 static.lwn.net:另一个域名,要重新 DNS + TCP + TLS,但不挡首次绘制 t = 0
自己算RTT ≈ 235 ms:TCP 1 + TLS 1 + 首字节 1 + 下载 1 + CSS 1 = 5 个来回 ≈ 1.18 s,再加 DNS 5 ms、服务器 6 ms、渲染约 30 ms,约 1.2 s。真正传字节(31 KB + 19 KB,按 100 Mbps)只要 4 ms 左右。如果 DNS 没缓存(冷查询实测 427~837 ms),或者用户敲的是 http://(先 301,再多 2 个来回),就到 2 秒了。

动手:MiniBrowser.java,把前五步真的走一遍不用任何 HTTP 库

代码MiniBrowser.javaJDK 11+:java MiniBrowser.java https://lwn.net/ JDK 8:javac MiniBrowser.java && java MiniBrowser https://lwn.net/ 第二个参数可写 TLSv1.2
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));
    }
}
输出实测OpenJDK 21.0.12 · 本机 → lwn.net
第 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 8u512Temurin 17.0.21OpenJDK 21.0.12
默认启用的客户端协议TLSv1.3, TLSv1.2,和 lwn.net 协商出 TLSv1.3实测
第 1 次 TLS(JVM 冷)461 ms · 1.9 RTT422 ms · 1.7 RTT478 ms · 2.0 RTT
第 2 次 TCP / TLS / 首字节 / 下载253 / 273 / 260 / 256257 / 269 / 248 / 262234 / 239 / 237 / 235
第 2 次合计1043 ms1036 ms945 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 个来回。

到这里已经能交差了:面试时能按 12 站把过程讲完,并说清每一站要几个来回;线上“页面慢”时,能用 curl -w 或 Timing 面板把时间拆到 DNS、连接、TLS、TTFB、下载这几段,再判断是网络、协议还是后端的问题。
已知还没处理的细节

⑥边界和变体:哪些地方会多跑来回,哪些地方会出事

按三圈排:先是不知道就会出事故的坑,每条都标了根源在 ④ 的哪一条;再是团队里该怎么配;最后是协议和浏览器的深处,两个实验台都在第三圈。

第一圈

注意事项

坑 1切了 DNS,Java 服务还连着旧 IP← ④ 机制 2 每层都缓存
现象
数据库主备切换或服务迁移后改了 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 服务器挂了时可以先用过期的结果顶一阵。
坑 2用户敲 lwn.net,先被 301 一趟← ④ 机制 1 ① 解析输入
现象
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。
坑 3Java 调 HTTPS 报 PKIX path building failed,浏览器却打得开← ④ 机制 3 证书链
现象
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 的身份验证关了。
坑 4白屏很久,接口明明很快← ④ 机制 6 渲染阻塞
现象
TTFB 只有一两百毫秒,首次绘制却要两三秒;DevTools 的 Performance 面板里,开头一大段在等脚本下载或执行。
根源
<head> 里的同步 <script> 叫停了解析,body 还没解析,就没东西可画;或者 CSS 很大、放在慢的域名上,CSSOM 迟迟不好。
做法
要等 DOM 的脚本加 defer,统计这类独立脚本用 async;首屏用不到的 CSS 拆出去,关键 CSS 尽量小。下面的实验台 B 可以对比四种写法。
坑 5发版后,用户看到的还是旧页面、旧 JS← ④ 机制 1 ② 查缓存
现象
后端发了新版本,部分用户页面报错或样式错乱,强制刷新就好了。
根源
强缓存(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(⑤ 实测)。
坑 6自己写的 Socket 客户端,每个请求都慢 1 个来回← ④ 机制 3 TCP
现象
把 ⑤ 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。
坑 7Nginx 转给 Tomcat,每个请求都新建连接← ④ 机制 3 握手的代价
现象
压测时机器上一大片 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(官方文档)。
坑 8请求一多,后面的全在排队← ③ HTTP/1.1 一问一答
现象
一个页面从同一个域名拉几十个小图标、接口,Timing 面板里后面的请求有一大段 Queueing / Stalled。
根源
HTTP/1.1 的一条连接同一时间只跑一个请求,浏览器对同一个域名最多开 6 条,第 7 个只能等前面谁空出来。每条新连接还要各自握手。
做法
入口(Nginx、CDN、负载均衡)开 HTTP/2,一条连接同时跑所有请求;或者合并请求、减少数量。别再用“拆好几个域名”绕开 6 条的限制,那在 HTTP/2 下反而多了握手。
第二圈

使用规范

全站 https,加 HSTS

Strict-Transport-Security: max-age=31536000; includeSubDomains,链接直接写 https。

每次先 301,多跑 2 个来回(坑 2)

静态文件名带 hash、长缓存;HTML 用 no-cache

构建工具生成 app.[hash].js,Nginx 按路径配两种 Cache-Control。

发版后用户拿到旧文件(坑 5)

入口开 TLS 1.3 和 HTTP/2

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)

服务间的 HTTP 客户端:单例 + 连接池

JDK HttpClient、OkHttp、Apache HttpClient 都是一个实例一个连接池,每次 new 一个就等于每次都握手。

每次调用多 2~3 个来回(curl 实测复用后 262 ms,新建 1048 ms)

JVM 显式设置 DNS 缓存时间

-Dsun.net.inetaddr.ttl=60,连接池配 maxLifetime。

切了 DNS 不生效(坑 1)

证书部署完整链

部署 fullchain,上线后用 openssl s_client -showcerts 看服务器到底发了几张。

Java 客户端报 PKIX(坑 3)

Nginx 到后端复用连接

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:服务器生成页面的那几百毫秒里,先告诉浏览器去连、去下载。

版本差异

东西版本和本页有关的变化
TLS1.2 → 1.3(2018,RFC 8446)完整握手 2 个来回 → 1 个(本机实测 505 → 252 ms);去掉了 RSA 密钥交换,只留有前向保密的 (EC)DHE;新增 0-RTT
HTTP1.1 → 2(2015)→ 3(2022,RFC 9114)多路复用、头部压缩;HTTP/3 换成 QUIC over UDP,握手合并、按流重传
JDK8 / 17 / 21JDK 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
nginx1.25.1 / 1.29.71.25.1 起用 http2 on; 开 HTTP/2;1.29.7 起反代默认 HTTP/1.1,upstream 默认 keepalive 32
Spring Boot2 / 3server.compression.enabled 默认 false、阈值 2 KB;内嵌服务器的 HTTP/2 要配 server.http2.enabled=true(默认 false),通常还要配好 TLS

实验台 A · 首访一个页面,时间花在哪先猜,再运行

RTT(往返时间)100 ms
服务器耗时50 ms
带宽
HTML 大小30 KB
CSS 大小(0 = 没有外链 CSS)20 KB
DNS · 入口
协议
先在上面选一个答案。运行后滑块随便拖,下面会一直和基线对比。
DNShttp:// 先 301TCPTLS / QUIC请求 → 首字节下载首次绘制基线的首次绘制
先选一个答案,再点“运行这个改动”。

模型:每个握手按整数个 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> 放哪儿先猜,再运行

CSS 下载要多久300 ms
JS 下载要多久800 ms
JS 执行要多久100 ms
HTML 在 0 ms 到齐;预加载扫描器让 CSS 和 JS 同时开始下载。解析 head 10 ms,解析 body 60 ms。
CSS 下载JS 下载解析 HTML执行 JS首次绘制DOMContentLoaded
先选一个答案,再点“运行”。

模型按 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