“网站打开很慢”是一个症状,不是一个结论。
它可能是 DNS 解析慢、TLS 握手慢、后端首字节慢,也可能是页面一次下载了几 MB 的 JavaScript;还可能是资源已经下载完,却被串行依赖、超大 JSON 或主线程长任务拖住。不同原因的修复方法完全不同。
最近我对一个登录后的 Web 应用做了一次完整排查。优化前后的结果如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 冷加载传输量 | 4.57 MB | 1.14 MB |
| FCP | 4.33 秒 | 1.34 秒 |
| LCP | 30.67 秒 | 3.90 秒 |
load 事件 | 30.46 秒 | 3.54 秒 |
| 热加载传输量 | — | 22.7 KB |
| 热加载 LCP | — | 0.83 秒 |
这是一组代表性的单次测量:使用 Chromium 无头模式、1440×1000 视口;冷加载会清空并禁用缓存,热加载则启用缓存。表中为浏览器记录的 encoded bytes,沿用“传输量”这个易读称呼,但它不等于 TLS 帧和网络包层面的全部字节。因此,冷加载的改善不能归因于浏览器缓存,热加载结果才用于验证缓存效果。
这次已经实施并验证的改动包括文本压缩、内容指纹缓存,以及普通请求与 SSE/WebSocket 的代理分流。其中,压缩直接减少了冷加载传输量,缓存让热加载只需传输约 22.7 KB;代理分流通过协议与回归测试验证正确性,但没有做单变量性能对照。一个会话历史 API 原本单次传输 4.25 MB,开启压缩后降到了约 369 KB。
source map 的 gzip 与 5 分钟私有缓存也已经实施,但普通页面在未打开 DevTools 时不会请求它,所以没有计入上表。超大 JSON 的结构治理、非关键请求延迟与增量聚合、CDN 和带宽升级则是进一步优化方向,同样没有被写成已经发生的结果。
本文不只给出一份 Nginx 配置,而是完整拆解这次优化背后的方法:如何判断慢在哪里、应该先改哪一层、每种方案有什么边界,以及怎样证明改动真的生效。
本文数据来自同一台机器上的前后对照,用来说明优化方向,不应被当成所有网站都能达到的性能承诺。单次测量也不能替代多轮测试和分位数统计。
第一步:不要猜,先把“慢”拆成指标
1. 先看入口时间
用 curl 拆开 DNS、TCP、TLS、TTFB 和总耗时:
curl -o /dev/null -sS \
-w 'dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s size=%{size_download}\n' \
https://app.example.com/
即使站点需要登录、最终返回 401,这组数据仍然能帮助判断域名解析、TLS 和鉴权入口是否正常。但它不能代表登录后的业务 API 或页面 TTFB;验证那部分仍需要有效会话,或直接测量可信网络边界内的上游服务。
这次案例里,DNS、TCP、TLS 和入口响应合计不到 100 ms,入口 HTML 与主要静态资源在本机回环地址上的上游响应则是毫秒级。另有一个非关键统计接口冷计算约 5.4 秒,但不足以解释约 30 秒的 LCP。也就是说:首屏的主导慢点发生在收到入口首字节之后。
一个实用判断表:
| 现象 | 优先怀疑 |
|---|---|
| TTFB 本身就很高 | 后端计算、数据库、上游 API、代理排队 |
| TTFB 正常,但下载很久 | 响应体过大、未压缩、带宽不足 |
| FCP 较早,LCP 很晚 | 依赖瀑布、关键资源加载晚、页面后续大块更新 |
| 冷加载慢,热加载快 | 首次传输体积大,但缓存有效 |
| 冷加载和热加载都慢 | 缓存无效、动态请求过重或运行时计算过多 |
2. 再看浏览器网络瀑布
打开 Chrome DevTools 的 Network 面板,重点观察:
- 请求总数与
Transferred总量 - 按 Size 排序后最大的 JS、CSS、JSON 和 source map
- 哪个请求最晚完成
- 后续请求是否必须等某个 bundle 完成才开始
Content-Encoding是否为gzip或brCache-Control、ETag、Last-Modified 是否存在
冷加载测试时可以勾选 Disable cache;测热加载时一定要取消,否则是在主动绕过刚配置好的浏览器缓存。Chrome 官方的 Network 面板参考文档列出了各列指标和缓存行为。
本次冷加载共有 62 个请求,传输约 4.57 MB,其中 40 个 JavaScript 请求占了约 4.4 MB;按传输量和总耗时粗略折算,公网有效吞吐约为 1.2 Mbps。这只是该次加载的端到端有效速率,不等于运营商线路上限。此时最值得做的不是拆一个几十 KB 的组件,而是先减少真实传输字节。

先用 TTFB 分层,再根据 Network 证据选择对应方案;不要跳过测量直接改配置。
方法一:给文本资源启用 gzip
为什么 gzip on 可能仍然没有效果
Nginx 的 gzip_types 默认只有 text/html。如果只写了:
gzip on;
那么以 text/javascript、text/css、application/json 返回的资源仍可能完全不压缩。Nginx 官方文档也明确说明,gzip_types 用于补充 HTML 以外的 MIME 类型:ngx_http_gzip_module。
一份适合 Web 应用的基础配置:
server {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/manifest+json
application/xml
image/svg+xml;
}
几个关键点:
- 同时包含
text/javascript和application/javascript,因为不同服务的 JS MIME 类型可能不同。 gzip_vary on会加入Vary: Accept-Encoding,避免缓存把压缩版和未压缩版混淆。- 不要无脑对所有 MIME 使用
*;图片、压缩包、woff2 等格式本身已经压缩,再压一次收益很小。 - HTTPS 下压缩包含秘密且可被攻击者控制部分内容的响应,需要评估 BREACH 风险。静态 JS/CSS 通常不属于这类响应,但敏感动态页面不能套用同一结论。
怎么验证
curl -sS -D - -o /dev/null --compressed \
https://app.example.com/assets/app.a1b2c3d4e5f6.js \
| grep -iE 'content-type|content-encoding|vary'
期望看到:
content-type: text/javascript; charset=utf-8
vary: Accept-Encoding
content-encoding: gzip
本次案例中,42 个 JS/CSS 文件原始大小约 4.49 MB;用 gzip level 6 离线估算后约为 1.09 MB,压缩率约 76%。浏览器端完整冷加载的实际传输量则以上文的 4.57 MB → 1.14 MB 为准。对不含敏感信息的静态文本资源来说,这是本次收益最高、风险较低的一步。
如果服务器或 CDN 已支持 Brotli,可以进一步测试 br;但 gzip 兼容性最好,适合作为必须先做的基线。
方法二:让浏览器真正复用静态资源
压缩解决“第一次要下载多少”,缓存解决“第二次还要不要下载”。
只有带内容指纹的资源才能长期缓存
以下 URL 的资源如果只由内容决定、且每次内容变化都会更换指纹,就适合长期缓存:
/assets/app.a1b2c3d4e5f6.js
/assets/vendor.8f7e6d5c4b3a.css
/plugins/example/client.js?rev=09ac82fe7d6c
因为内容变化后,文件名或 rev 也会变化。旧缓存不会挡住新版本,这就是 cache busting。
下面所有 Nginx 配置都是增量片段,不是可以替换整份 server 的完整配置。新增 location 可能绕过原有的鉴权、限流、转发头或日志规则;先把每个代理路径都需要的公共配置放进同一片段,并审计原配置中的 auth_request、limit_req 等指令是否仍然生效:
# /etc/nginx/snippets/app-proxy-common.conf(路径按发行版调整)
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 鉴权、限流及下面提到的必要安全响应头,也应在这个公共片段
# 或每个新 location 中完整保留。
这里还有一个容易漏掉的 Nginx 继承规则:只要当前层定义了任意 add_header,默认就不再继承上一层的其他 add_header。因此,下面添加 Cache-Control 的同时,必须在同一 location 或其 include 中完整声明原站必需的 HSTS、CSP、X-Content-Type-Options、CORP 等安全响应头。Nginx 1.29.3 起可以评估 add_header_inherit merge;旧版本不能依赖它。具体继承规则见 ngx_http_headers_module。
即使资源需要登录才能访问,只要字节内容与用户身份无关,也可以只允许浏览器私有缓存。路径匹配必须限定到实际使用的指纹格式,例如:
location ~ "^/assets/.+\.[0-9a-f]{12}\.(?:js|css)$" {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_buffering on;
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
add_header Cache-Control "private, max-age=31536000, immutable";
}
公开且不包含用户数据的静态资源可以使用 public;只允许单个浏览器复用时使用 private。但 private 只阻止共享缓存保存响应,并不能解决同一浏览器切换账号后的隔离问题。只要资源内容会因用户、租户或权限而变化,就不应使用上述长期策略,除非 URL 本身包含可靠的身份命名空间。MDN 对 Cache-Control 的解释中明确区分了浏览器私有缓存和代理/CDN 共享缓存。
如果资源通过查询参数携带版本,可以给“有版本”和“无版本”两种请求不同策略:
map 必须放在 Nginx 的 http 上下文中。这里仅接受 12 位小写十六进制内容指纹;长度和字符集应与自己的构建产物严格一致,不能把“任意非空参数”都视为不可变版本:
# nginx.conf 的 http { ... } 内
map $arg_rev $app_plugin_cache_control {
default "private, max-age=300";
"~^[0-9a-f]{12}$" "private, max-age=31536000, immutable";
}
server {
location ~ ^/plugins/.+/client\.js$ {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_hide_header Cache-Control;
proxy_hide_header Expires;
add_header Cache-Control $app_plugin_cache_control;
}
}
这样,只有符合约定的 ?rev=<内容哈希> 才缓存一年;无版本或格式异常的 client.js 只缓存 5 分钟,不至于长期拿到旧文件。
三个常见误区
no-cache不是“不缓存”:它允许保存,但每次复用前必须回源验证。真正禁止保存的是no-store。- 不要给动态 API 套静态缓存:用户余额、会话历史、权限信息不能因为路径匹配过宽而缓存一年。
- 谨慎使用
add_header ... always:如果认证失败的 401 也被附上一年缓存头,浏览器可能长期保存错误响应。静态缓存规则应该只命中明确路径和成功响应。
Nginx 的 add_header、expires 及其状态码规则可参考 ngx_http_headers_module。
缓存生效后,本次第二次加载只传输了约 22.7 KB;40 个 JS 和 2 个 CSS 请求的网络传输字节都降为 0,LCP 约 0.83 秒。
方法三:治理超大 JSON,而不是只依赖 gzip
这次最大的动态响应是会话历史 API:一次返回约 4.25 MB。开启 gzip 后,线上实际传输约 369 KB,下降超过 90%。JSON 重复键名多、文本相似度高,通常非常适合压缩。
但压缩只是止血。4 MB 的 JSON 即使网络只传几百 KB,浏览器仍然需要:
- 解压完整响应;
- 解析完整 JSON;
- 在内存中持有完整对象;
- 让前端框架处理其中的数据。
因此还应从接口结构继续治理:
- 分页不仅限制“条数”,也要考虑每页最大字节数。
- 列表只返回摘要,超大日志、工具输出和详情在用户展开时再请求。
- 对单字段设置合理上限,避免一条记录就撑满整页。
- 使用游标分页,避免每次都从头扫描或返回完整历史。
- 对长期不变的大对象使用对象存储或独立下载地址。
一个常见误区是“前端已经做了虚拟列表,所以数据量没问题”。虚拟列表只减少 DOM 渲染,不会减少网络下载和 JSON 解析;服务端仍然需要裁剪响应。
方法四:普通请求与 SSE/WebSocket 必须分流
流式接口需要“拿到一段就立刻发一段”,所以常见配置会写:
location / {
proxy_buffering off;
}
问题是它让全站静态资源和普通 API 都采用流式转发策略,普通响应也失去了代理缓冲带来的流量整形与上游连接释放优势。
Nginx 的 proxy_buffering 默认是开启的:开启后,Nginx 会尽快从上游读取响应,放入内存缓冲区,必要时写入临时文件;关闭后,响应会跟随客户端速度同步传输。两种模式的官方语义见 ngx_http_proxy_module。
更合理的做法是明确分流:

按响应类型分流:静态资源、普通 API/JSON 与 SSE/WebSocket 不应共用一套缓冲和缓存策略。
下面的 map 同样必须放在 http 上下文;每个新 location 都要保留前文提到的公共代理配置与原有鉴权规则:
# nginx.conf 的 http { ... } 内
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
# SSE:不缓冲、不压缩,避免事件积压。
location = /events/sse {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_read_timeout 86400s;
proxy_buffering off;
gzip off;
}
# WebSocket:仅在这个端点显式转发 Upgrade。
location = /events/ws {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_read_timeout 86400s;
proxy_buffering off;
gzip off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# 普通页面、静态资源和 API 保留缓冲。
location / {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_buffering on;
}
}
Upgrade 和 Connection 是 hop-by-hop 头,不会被反向代理自动透传,因此 WebSocket 需要显式处理;Nginx 官方的 WebSocket proxying 也推荐用 map 根据客户端是否请求 Upgrade 决定头部值。若应用能够控制上游响应,也可以让 SSE 端点返回 X-Accel-Buffering: no;无论采用哪种方式,都只应作用于明确的流式响应。
路径必须按自己的应用实际请求配置,不能照抄 /events/sse 和 /events/ws。验证时,SSE 应返回 text/event-stream 且没有 Content-Encoding,WebSocket 握手应返回 101。本案例验证了拆分后的协议行为和页面回归,但没有做只改变 proxy_buffering 的单变量测试,因此不把冷加载改善单独归因于请求分流。
方法五(辅助优化,已实施):处理 source map 的额外流量
source map 通常不会被普通用户页面主动加载,但打开 DevTools 后,浏览器可能下载它们。一次实际日志统计中,两分钟内的 source map 流量达到了约 14 MB,比页面本身还大。
根据线上调试方式,可以选择:
- 生产构建不公开 source map;
- 生成 hidden source map,只上传到错误监控平台;
- 对 source map 增加认证或限制访问;
- 保留公开访问,但启用 gzip 和短期私有缓存。
本案例采用了第 4 种方式:保留调试能力,同时为 source map 启用 gzip 和 5 分钟私有缓存。由于这类请求主要在打开 DevTools 时发生,它没有计入普通页面的冷、热加载指标。
例如:
location ~ ^/plugins/.+/client\.js\.map$ {
proxy_pass http://127.0.0.1:3000;
include /etc/nginx/snippets/app-proxy-common.conf;
proxy_hide_header Cache-Control;
add_header Cache-Control "private, max-age=300";
}
gzip 根据响应的 Content-Type 决定是否压缩,而不是根据 .map 扩展名判断。只有上游实际返回 application/json 或其他已列入 gzip_types 的文本类型时,前面的规则才会生效;应使用 curl -I 或 Network 面板检查真实响应头。
不要为了省流量直接删除线上错误监控依赖的 source map。更好的方案是让用户拿不到,但错误平台仍能用它还原堆栈。
方法六(进一步优化):把非关键请求移出首屏关键路径
页面启动时常见一种“好心办坏事”的优化:为了让用户稍后打开某个面板时更快,应用一启动就预取所有数据。
如果这个接口需要扫描大量历史记录,预取会和首屏 JS、会话历史、配置 API 抢带宽与 CPU。本次案例中,一个用量统计接口冷计算约 5.4 秒,却在客户端 bundle 加载后立即触发。
本案例确认了这条请求的冷计算耗时,但没有把下面的应用层改动计入开头的前后对照。第一层优化是把它移出关键路径:
const warmUsage = () => {
fetch('/api/usage').catch(() => {})
}
const scheduleUsageWarmup = () => {
if ('requestIdleCallback' in window) {
requestIdleCallback(warmUsage, { timeout: 3000 })
} else {
setTimeout(warmUsage, 1500)
}
}
if (document.readyState === 'complete') {
scheduleUsageWarmup()
} else {
window.addEventListener('load', scheduleUsageWarmup, { once: true })
}
timeout 只保证回调不会一直等待,浏览器繁忙时它仍可能抢占关键工作。如果数据只有某个 Tab 才需要,更直接、也更可控的方式是用户第一次打开时再请求。
但要注意:延迟调用只改善首屏,不会减少服务端的总计算量。 真正的第二层优化是:
- 保存每个数据源已经处理到的游标;
- 只聚合新增事件;
- 持久化统计结果,避免周期性全量重算;
- 对独立数据源做有上限的并发,而不是无界并发。
先把工作移出关键路径,再消除不必要的工作,两者不能混为一谈。
方法七(进一步优化):最后再考虑 CDN 与带宽升级
CDN 和更大的公网带宽当然有效,但应该放在压缩、缓存和接口治理之后。
原因很简单:
- CDN 不会自动修复错误的
Cache-Control。 private或个性化 API 本来就不应该在共享节点复用。- SSE/WebSocket 与实时 API 仍然需要回源。
- 4 MB 的动态 JSON 即使放到更快链路上,浏览器仍要解析 4 MB。
推荐顺序:
测量 → 压缩 → 浏览器缓存 → API 裁剪 → 关键路径优化
→ 静态 CDN → 评估剩余带宽需求
对于公共静态资源,可以将 Cache-Control 改成 public 并交给 CDN;认证 API 和事件流继续走源站。这样既能获得边缘分发收益,也不会跨用户缓存敏感内容。
一张表决定先做什么
| 证据 | 优先方案 | 不要先做 |
|---|---|---|
| TTFB 高 | 查数据库、上游 API、服务端计算 | 只调静态缓存 |
| TTFB 低、传输体积大 | gzip/Brotli、裁剪响应 | 先升级 CPU |
| 第二次加载仍下载全部 JS | 内容指纹 + 长期缓存 | 只加 CDN |
| 单个 JSON 数 MB | gzip + 分页/懒加载/字段上限 | 只做虚拟列表 |
| SSE 消息延迟成批出现 | 对流式路径关闭 buffering/gzip | 全站关闭 buffering |
| DevTools 打开后流量暴涨 | source map 私有化、压缩或短缓存 | 盲目删除错误监控所需文件 |
| 首屏同时启动重统计接口 | 按需/idle 加载 + 增量聚合 | 无界并发所有请求 |
| 压缩缓存后跨地域仍慢 | CDN、HTTP/3、带宽升级 | 在源站反复微调小文件 |
上线前后的验证清单
修改 Nginx 后先做语法检查,再重载:
sudo nginx -t && sudo systemctl reload nginx
然后逐项验证:
[ ] 未登录访问 HTML、静态资源、插件、SSE 和 WebSocket 时,各自仍保持预期的放行、401 或跳转行为
[ ] 使用有效会话时,页面与业务 API 的 TTFB、状态码和数据隔离正常
[ ] JS/CSS/JSON 返回 Content-Encoding: gzip 或 br
[ ] 压缩响应包含 Vary: Accept-Encoding
[ ] 指纹化静态资源包含长期 Cache-Control
[ ] 无版本、伪造 rev 与个性化资源没有误用 immutable
[ ] 动态 API 没有误用一年期缓存
[ ] 每条新路径仍返回原有的 HSTS、CSP、nosniff、CORP 等安全响应头
[ ] SSE 为 text/event-stream,且没有 Content-Encoding
[ ] WebSocket 握手返回 101
[ ] 冷加载的传输量、FCP、LCP 有实际下降
[ ] 取消 Disable cache 后,热加载确实复用资源
[ ] Nginx error log 没有新增上游错误
性能优化最容易犯的错误,是只验证“页面还能打开”,却没有重新测量原来的瓶颈。每次改动都应该回答两个问题:
- 原来的慢点是否真的变快?
- 认证、实时连接和数据新鲜度是否被破坏?
总结
这次从 30 秒到 4 秒,真正有效的不是某一条神奇配置,而是正确的排查顺序和清晰的归因边界。
已经实施并得到测量或回归验证的是:
- 用 TTFB 排除入口和源站首字节问题;
- 用 Network 瀑布找到真实传输量和串行依赖;
- 用 gzip 显著降低冷加载中的文本传输量;
- 只对身份无关、带内容指纹的资源启用长期私有缓存,使热加载大量复用本地资源;
- 把 SSE/WebSocket 从普通代理路径中分离,并验证实时连接和普通页面都保持正常。
作为普通页面指标之外的辅助优化,source map 已启用 gzip 和 5 分钟私有缓存。基于同一证据链得到的后续路线是:继续裁剪超大 JSON,把非关键计算移出首屏并改为增量聚合;完成这些工作后,再按跨地域实测结果决定是否接入 CDN 或升级带宽。这些后续路线不属于本次 30 秒 → 4 秒的已实施归因。
网站性能问题往往横跨浏览器、HTTP、反向代理和应用数据层。只盯着某一层,很容易得到“配置改了很多,用户还是觉得慢”的结果;建立完整证据链,才是最稳定、也最可复用的优化方法。
