用户说"网站慢",你打开监控发现接口 p99 只有 80 ms,nginx 日志里 $request_time 也才 0.09 秒。两边都正常,页面却要三秒——慢在计量之外的地方:DNS、握手、渲染、子资源。
把一次访问拆成八段,每段单独计量,才能回答"到底哪一段在吃掉时间"。
1. 八段链路与各自的量级
| # | 段 | 典型耗时量级 | 主要变量 | 可优化点 |
|---|---|---|---|---|
| 1 | 浏览器缓存 + DNS | 0–1 ms(命中)~ 100+ ms(递归) | TTL、解析器距离 | 降 TTL 换灵活 / 升 TTL 换速度 |
| 2 | TCP 握手 | 1 RTT | 物理距离、丢包率 | 长连接、TFO、就近接入 |
| 3 | TLS 握手 | TLS 1.3 = 1 RTT(复用 0-RTT);1.2 = 2 RTT | 证书链长度、OCSP | 会话复用、缩短证书链、OCSP stapling |
| 4 | 请求到达 nginx | < 1 ms | location 匹配、rewrite |
减少正则、避免多余内部跳转 |
| 5 | 反向代理转发 | < 1 ms(同机) | upstream 连接复用 | keepalive 到 upstream |
| 6 | 应用处理 | 10–500 ms | 线程池、连接池、DB 往返 | 池大小、索引、批量查询 |
| 7 | 响应回传 | 与体积/带宽成正比 | 压缩、分块 | gzip/brotli、CDN |
| 8 | 浏览器解析渲染 + 子资源 | 100 ms–2 s | 阻塞资源、DOM 规模 | 关键 CSS 内联、脚本 defer |
这张表的用法是先量再猜:拿 curl -w 看 1–7 段,拿浏览器 DevTools 的 Performance 面板看第 8 段。两边的数字加起来必须能解释用户感知的总时长,否则就是有一段没算进去。
$ curl -o /dev/null -s -w 'dns:%{time_namelookup} connect:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' https://example.com/
dns:0.004 connect:0.021 tls:0.048 ttfb:0.112 total:0.118
time_appconnect - time_connect 就是 TLS 那一段,time_starttransfer - time_appconnect 是"请求发出到首字节",包含 4–6 段之和。三个差值把问题切成三块,再往下钻。
2. DNS:缓存层级决定这一段是 0 ms 还是 150 ms
解析是一条自上而下的查缓存链,任何一层命中就停:
| 层 | 位置 | 命中耗时 | 失效条件 |
|---|---|---|---|
| 浏览器 DNS 缓存 | 浏览器进程 | ~0 ms | 有自己的过期策略,通常几分钟 |
| OS 缓存 | systemd-resolved / nscd | < 1 ms | 按 TTL |
/etc/hosts |
本机文件 | < 1 ms | 手动改 |
| 递归解析器缓存 | 运营商 / 8.8.8.8 / 内网 DNS | 1–20 ms | 按 TTL 过期 |
| 根 → TLD → 权威 | 公网 | 50–300 ms | 每次缓存未命中都要走一趟 |
$ dig +trace example.com # 从根开始逐级问,看每一步耗时与委派
$ dig example.com | grep -E "Query time|SERVER"
$ getent hosts example.com # 走的是系统解析链(含 hosts),和 dig 不一样
两个高频误区:
dig不等于应用看到的解析。dig直接问 DNS 服务器,绕过/etc/hosts与nsswitch.conf的顺序;应用走getaddrinfo。要复现应用的解析行为,用getent hosts或curl -v看实际连的 IP。- TTL 是双刃剑。TTL 300 意味着切换最多 5 分钟生效,也意味着每 5 分钟一次递归查询。做灰度时提前把 TTL 降到 60、切换完调回去,比事后改解析靠谱。
3. TCP 与 TLS:往返次数是硬成本
TCP 三次握手 = 1 个 RTT:SYN → SYN-ACK → ACK,第三个 ACK 之后才能发数据。跨洲 RTT 常在 150–250 ms,所以"新建连接"本身就至少花掉一个 RTT,与服务器快慢无关。
TLS 的往返数取决于版本与是否复用:
| 场景 | 往返次数 | 说明 |
|---|---|---|
| TLS 1.2 完整握手 | 2 RTT | ClientHello/Flight → ServerHello+证书 → 密钥交换 |
| TLS 1.3 完整握手 | 1 RTT | 密钥交换并入首轮,证书加密传输 |
| TLS 1.3 + 会话复用(PSK) | 1 RTT | 省略密钥协商 |
| TLS 1.3 + 0-RTT | 0 RTT(重放风险) | 早期数据,仅适合幂等请求 |
| TCP + TLS 1.3 全新连接 | 约 2 RTT | 1 个 TCP + 1 个 TLS |
$ openssl s_client -connect example.com:443 -tls1_3 -servername example.com </dev/null 2>/dev/null | grep -E "Protocol|Cipher"
$ openssl s_client -connect example.com:443 -tls1_2 </dev/null 2>/dev/null | grep Protocol
握手之外还有两个隐形往返:证书链(一次发完,链越长首包越大)与 OCSP 查询(未 stapling 时浏览器要去 CA 拉状态,可能再吃一个 RTT)。所以"升 TLS 1.3 + 开 stapling + 精简证书链"往往比加 CPU 见效快。
4. nginx 收到请求之后
请求进到 nginx,先决定交给哪个 location。匹配有严格优先级:
| 顺序 | 语法 | 规则 |
|---|---|---|
| 1 | location = /path |
精确匹配,命中即停 |
| 2 | location ^~ /prefix/ |
前缀匹配且不再尝试正则 |
| 3 | location ~ \.php$ / ~* |
正则按配置书写顺序,先中先用 |
| 4 | location /prefix/ |
最长前缀匹配,仅在没有正则命中时生效 |
proxy_pass 带不带 URI 会改变转发的路径,这是配置事故的高发区:
# 带 URI:/api/ 被替换成 /,后端收到 /users
location /api/ {
proxy_pass http://backend/;
}
# 不带 URI:原样转发,后端收到 /api/users
location /api/ {
proxy_pass http://backend;
}
upstream backend {
server 127.0.0.1:8080;
keepalive 32; # 到 upstream 的长连接池
}
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1; # keepalive 必需,默认 1.0 不复用
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
}
最后一行常被忽略:proxy_http_version 默认 1.0,而 HTTP/1.0 不支持长连接,keepalive 32 等于白配——每个请求仍新建一条到后端的 TCP。
5. 服务端处理:池子决定上限
这一段是"看着快、并发上来就慢"的典型,三个池子要一起看:
| 池 | Spring Boot 默认 | 满了的表现 | 调整依据 |
|---|---|---|---|
| Tomcat 工作线程 | 200 | 请求排队,$request_time 上升而 CPU 不高 |
下游等待时间 × QPS |
| HikariCP 连接数 | 10 | 拿连接超时 Connection is not available |
DB 核数 × 2~4,别超过 DB 上限 |
| 到下游 HTTP 连接池 | 视客户端 | 建连风暴 | 与下游容量匹配 |
server:
tomcat:
threads:
max: 200
min-spare: 20
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 3000
leak-detection-threshold: 20000
一次请求里的数据库往返次数常被低估:列表页先查总数、再查列表、再逐行补关联数据,就是 2 + N 次往返。把逐行补数据改成一次 IN 查询,比调大连接池有效得多。
6. 浏览器:解析、CSSOM、阻塞与重排
浏览器拿到 HTML 之后的流水线是:解析 HTML 建 DOM → 解析 CSS 建 CSSOM → 合并成渲染树 → 布局(layout)→ 绘制(paint)→ 合成(composite)。
| 资源 | 是否阻塞 | 阻塞什么 | 处理 |
|---|---|---|---|
<script src>(无 defer/async) |
是 | HTML 解析(且必须等 CSSOM 完成) | 加 defer,或移到 body 末尾 |
<link rel="stylesheet"> |
是 | 渲染,且阻塞后续脚本执行 | 关键 CSS 内联,非关键异步加载 |
<img> |
否 | 不阻塞解析,但占位缺失会导致布局抖动 | 写死 width/height |
| 字体 | 视 font-display |
可能造成 FOIT | swap 或预加载 |
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<script src="/app.js" defer></script>
"重排(reflow)"是几何计算,代价高;"重绘(repaint)"只重画像素。交替读写布局属性会强制同步重排,正确写法是先集中读、再集中写:
const w = el.offsetWidth; // 先读
el.style.width = (w + 10) + 'px'; // 后写
7. 子资源并发:协议版本决定上限
| 协议 | 单域并发 | 复用方式 | 头开销 |
|---|---|---|---|
| HTTP/1.1 | 浏览器通常 6 条连接 | 无(队头阻塞在连接内) | 每次请求重复头部 |
| HTTP/2 | 1 条连接多路复用 | 流(stream)并行 | HPACK 压缩头部 |
| HTTP/3(QUIC) | 1 条连接,基于 UDP | 流并行,丢包不影响其他流 | QPACK |
这也解释了"40 个小图标合成雪碧图"为何在 HTTP/1.1 是优化、在 HTTP/2 下可能更慢:一张大图会阻塞关键渲染。
8. 排错:六种"慢"的定位
| 现象 | 大概率在哪一段 | 定位手段 |
|---|---|---|
| 首字节就很慢,后端日志里的处理时间很短 | 段 4–5:nginx 到 upstream 的连接建立 | $upstream_connect_time、$upstream_response_time |
| 首字节正常,页面白屏很久 | 段 8:阻塞渲染的脚本或样式 | DevTools Performance,看 FCP/LCP |
| 首访慢、刷新快 | 段 1–3:DNS 与握手(首访无缓存) | curl -w 两次对比 |
| 移动网络明显慢于 WiFi | 段 2–3:RTT 与丢包 | 抓包看握手重传 |
| 偶发 502/504 | 段 5:upstream 超时或连接被重置 | nginx error.log 的 upstream timed out |
| 静态资源慢、接口快 | 段 7:单域并发被打满或没走 CDN | 看 Network 面板的排队时间 |
# nginx 侧把时间拆到 upstream 层
log_format timing '$remote_addr $request_uri rt=$request_time '
'uct=$upstream_connect_time urt=$upstream_response_time';
$request_time 大而 $upstream_response_time 小,说明时间花在 nginx 与客户端之间(慢客户端、大响应体);反之则在后端。
9. 边界:哪些段优化收益最低
| 场景 | 更值得做的事 | 理由 |
|---|---|---|
| 后端 80 ms,页面 3 s | 先做前端性能剖析 | 段 8 占比最大,改后端是浪费 |
| 纯静态站 / 内网同城 | 上 CDN 与长缓存,别碰应用层 | 段 4–6 不存在,RTT 本来很小 |
| 单页应用首屏慢 | 代码分割 + 预加载路由 | 首包体积是主要成本 |
一句话:"页面慢"是个八段求和的问题,只量其中一段就下结论,等于闭着眼睛调参。
10. 自测
curl -w的time_appconnect - time_connect对应链路的哪一段?它包含几次往返?- 为什么
dig查到的 IP 和应用实际连的 IP 可能不同? location = /a、location ^~ /a/、location ~ ^/a同时存在,请求/a/命中哪个?proxy_pass带斜杠与不带斜杠,后端收到的路径差在哪?- TLS 1.3 为什么比 1.2 少一个 RTT?0-RTT 的风险是什么?
- 页面白屏 2 秒但 TTFB 只有 100 ms,下一步该看哪个面板、看哪个指标?