2678 字
约 8 分钟
13
S02-X05 · 从输入网址到页面呈现:一次访问的完整链路

用户说"网站慢",你打开监控发现接口 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. 自测

  1. curl -w 的 time_appconnect - time_connect 对应链路的哪一段?它包含几次往返?
  2. 为什么 dig 查到的 IP 和应用实际连的 IP 可能不同?
  3. location = /a、location ^~ /a/、location ~ ^/a 同时存在,请求 /a/ 命中哪个?
  4. proxy_pass 带斜杠与不带斜杠,后端收到的路径差在哪?
  5. TLS 1.3 为什么比 1.2 少一个 RTT?0-RTT 的风险是什么?
  6. 页面白屏 2 秒但 TTFB 只有 100 ms,下一步该看哪个面板、看哪个指标?
S02-X05 · 从输入网址到页面呈现:一次访问的完整链路
https://www.clxhxhhr.top/posts/5795/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。