网站响应速度慢,不能只看 Lighthouse 的一个分数。一次页面访问要经过 DNS、TLS、网络传输、Nginx、uWSGI、Django 中间件、数据库、模板渲染、静态资源和浏览器绘制;其中任何一层变慢,都可能让用户感觉“网站打开慢”。
本文给出一套从服务器到浏览器的排查流程,重点回答三个问题:慢发生在哪一层、怎样用数据证明、修复后如何确认没有回归。第 305 篇已经专门讲过 LCP、CLS 和 INP,本文把重点放在这些指标之前的请求链路和 Django 部署细节;图片上传与派生图则见第 304 篇。
文中示例命令使用本站公开文章作为演示页面,但示例输出不会冒充本站实测结果。测试时应记录时间、网络、设备、缓存状态和代码版本,不要把一次本地测试写成所有用户都能获得的性能承诺。
一、先把“慢”拆成可测量的阶段
浏览器看到一个页面,至少涉及下面几段时间:
| 阶段 | 它回答的问题 | 常见证据 |
|---|---|---|
| DNS | 域名解析用了多久? | 浏览器瀑布、time_namelookup |
| TCP/TLS | 建立连接和加密握手是否慢? | time_connect、time_appconnect |
| 请求等待 | 服务器何时开始返回响应? | TTFB、time_starttransfer |
| 下载 | 响应体传输是否受带宽或压缩影响? | time_total、响应大小 |
| 服务器处理 | Django、数据库和模板花了多久? | 应用日志、Server-Timing、数据库查询 |
| 浏览器渲染 | 内容何时可见,页面是否移动? | LCP、CLS、INP、Performance 面板 |
不要把 TTFB、页面完全加载时间和 LCP 当成同一个指标。TTFB 只说明浏览器收到响应第一个字节前等待了多久;即使 TTFB 很好,大图片、阻塞 CSS 或第三方脚本仍可能让 LCP 很差。反过来,页面已经开始绘制时,服务器响应体可能还没有完全下载。
二、第一步:固定测试条件并建立基线
性能比较必须先固定条件,否则每次结果都不可比。至少记录:
- URL、页面版本和测试时间;
- 移动或桌面设备、CPU 和网络模拟;
- 是否冷缓存、是否登录、是否带查询参数;
- 页面是否有动态推荐、评论或统计脚本;
- 测试次数和采用的中位数或分位数。
用 curl 测服务端时间
curl 适合先判断 DNS、连接、TLS、首字节和总时间。下面的命令只下载响应头和正文到空设备,不会把页面内容保存到磁盘:
url=https://blog.zenleak.cn/post/305/
for i in 1 2 3 4 5; do
curl --silent --show-error --location --output /dev/null \
--write-out 'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' \
"$url"
done
五次结果只能作为一个小样本基线。若 time_starttransfer 很高,优先检查代理到应用、Django 和数据库;若 TTFB 正常但 time_total 很高,再检查响应体大小、压缩和网络;若命令很快而浏览器仍慢,重点转向 CSS、字体、图片、脚本和布局。
可以用 --header 查看缓存和压缩线索:
curl --silent --show-error --head https://blog.zenleak.cn/post/305/
不要仅凭 Cache-Control: max-age 就认定页面一定命中了缓存。还要检查代理的 Age、站点自定义的缓存命中头、请求是否带 Cookie,以及响应内容是否真的来自预期的缓存层。
用 Lighthouse 看浏览器侧问题
Lighthouse 是实验室测试。它能帮助定位候选元素和阻塞资源,但不能替代真实用户数据。执行时固定设备和网络,并至少运行多次:
mkdir -p reports
npx lighthouse https://blog.zenleak.cn/post/305/ \
--form-factor=mobile \
--output=json \
--output-path=./reports/post-305-mobile.json \
--chrome-flags='--headless' \
--quiet
报告先看诊断证据:LCP 元素是谁、阻塞渲染的资源是什么、未使用 CSS 有多少、主线程长任务来自哪里。不要先追逐分数,再寻找理由。第 305 篇已经详细介绍 Core Web Vitals;本文接下来会把浏览器侧的现象和服务端证据对齐。
三、第二步:先判断是服务器慢还是浏览器慢
把同一个 URL 的 curl、浏览器 Network 和 Lighthouse 结果放在一起看:
| 现象 | 优先怀疑 | 下一步 |
|---|---|---|
| curl 的 TTFB 就很高 | 应用、数据库、代理或锁等待 | 查应用日志和数据库查询 |
| TTFB 正常,下载时间长 | 响应体过大、压缩或带宽 | 查 Content-Length、Content-Encoding 和大字段 |
| HTML 很快,LCP 很慢 | 首屏图片、字体、关键 CSS 或脚本 | 看 LCP 元素与瀑布图 |
| 只有登录后台慢 | 管理列表查询、权限和统计聚合 | 单独测后台,不把它当公共页面数据 |
| 偶尔很慢,平时正常 | SQLite 锁、冷缓存、上游网络或批处理 | 对齐慢请求时间和进程日志 |
| 移动端慢、桌面端正常 | 图片、字体、CPU 和移动网络 | 固定移动模拟并检查资源体积 |
一次请求可能同时满足多个现象。排查时先找最靠前、最稳定的瓶颈,避免先压缩图片,却忽略每次请求都在等待数据库锁。
四、第三步:检查 Nginx 到 uWSGI 的请求链路
生产环境通常由 Nginx 负责 TLS、静态资源和反向代理,再把动态请求交给 uWSGI 或其他应用服务器。检查重点不是盲目增加 worker,而是确认每层的超时、缓冲和日志能对齐同一次请求。
静态资源不要经过 Django
CSS、JavaScript、字体、图片和媒体文件如果每次都进入 Django,会增加 Python 进程和访问记录的压力。生产 Nginx 应使用明确的静态目录或对象存储,检查:
/static/和/media/是否返回正确的Content-Type;- 静态文件是否被错误地重定向到登录页;
- 压缩和长期缓存是否只用于带指纹的资源;
- 用户上传的媒体是否有独立的权限和缓存策略;
- 静态请求是否被误计入应用访问记录。
长期缓存适合文件名带内容指纹的资源,例如 style.abc123.css。如果文件名固定,发布新版本后浏览器可能继续使用旧 CSS。文章页和带用户状态的响应不要简单套用静态资源的长期缓存。
代理配置要和应用实际协议一致
如果 Nginx 终止 HTTPS,再通过 HTTP 转发到 uWSGI,应用必须收到正确的 X-Forwarded-Proto,否则可能发生错误的 HTTPS 重定向、Cookie 判断或 canonical 生成。可以从响应头和应用日志核对:
curl --silent --show-error --head https://blog.zenleak.cn/post/305/ \
| grep -Ei '^(HTTP/|location:|cache-control:|content-encoding:|content-length:|server-timing:)'
不要为了“提高速度”关闭 HTTPS 重定向、缓存控制或安全头。重定向链和缓存策略应先用真实请求验证,再做局部调整。
uWSGI worker 数量不是越多越快
worker 增多可以提高并发处理能力,但也会增加内存、SQLite 写竞争和上下文切换。当前项目使用 uWSGI 多进程和多线程,数据库是 SQLite;如果访问记录中间件对每个公共请求写入数据库,盲目增加 worker 可能让锁等待更严重。
验证方法是同时观察:
- uWSGI 请求耗时和 worker 是否长期满载;
- SQLite 是否出现
database is locked; - Nginx 连接数和上游响应时间;
- 应用日志中的慢请求路径;
- 内存是否因为 worker 数量增加而持续上涨。
只有确认 CPU 或并发队列是瓶颈,才考虑调整 worker。修改后要用相同的请求样本回归,不能只看服务器负载下降。
五、第四步:用 Django 记录服务端耗时
浏览器指标适合观察用户体验,服务端日志适合回答“代码在哪一段耗时”。不要把数据库查询时间、应用耗时和完整页面加载时间混为一谈。
使用 Server-Timing 暴露可诊断分段
开发或受限的诊断环境可以通过 Server-Timing 向浏览器暴露服务端分段。示例中只记录数据库和应用总耗时,生产环境不应把 SQL、用户 ID 或敏感参数写进响应头:
# 仅示例:放在受控的 Django 中间件中
import time
from django.db import connection
class TimingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
started = time.perf_counter()
before_queries = len(connection.queries)
response = self.get_response(request)
app_ms = (time.perf_counter() - started) * 1000
query_count = len(connection.queries) - before_queries
response["Server-Timing"] = (
f"app;dur={app_ms:.1f},db;desc=queries;dur={query_count}"
)
return response
这个示例把查询次数塞进了 dur,只是为了说明响应头的结构;真实项目应把“查询数量”和“耗时”分别记录,不能把数量伪装成毫秒。更稳妥的生产实现是从数据库连接或请求日志中取得实际查询耗时,并限制只对管理员或诊断请求启用。
用日志关联一次请求
没有请求 ID 时,Nginx、uWSGI 和 Django 日志很难确认是不是同一次访问。可以为每个请求生成短 ID,并在应用日志和响应头中使用它:
import uuid
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
response["X-Request-ID"] = request_id
如果请求经过可信代理,应用可以保留代理传入的 ID,但应限制长度和字符集,不能把任意响应头内容原样写入日志。日志字段至少包括时间、路径、状态码、响应耗时和请求 ID;User-Agent、IP 和来源应按隐私政策及留存期限处理。
六、第五步:排查 Django 和数据库查询
先检查 N+1,而不是先加缓存
文章列表显示作者、分类和标签时,如果每行都触发额外查询,数据库请求数会随着列表长度增长。常见原则是:
- 外键和一对一使用
select_related; - 多对多和一对多使用
prefetch_related; - 列表接口设置分页上限;
- 只取模板真正使用的字段;
- 用 Django Debug Toolbar 或测试中的
assertNumQueries验证查询数量。
posts = (
Post.objects
.filter(status="published")
.select_related("author", "category")
.prefetch_related("tags")
.only(
"id", "post_id", "title", "excerpt", "published_at",
"author__id", "author__username", "category__id", "category__name",
)
.order_by("-published_at", "-id")[:20]
)
only() 不是越多越好。如果模板访问了没有预取的字段,Django 可能再次查询数据库,结果比完整查询更慢。优化后应重新检查实际 SQL 数量。
用 EXPLAIN 验证索引是否被使用
看到 SQL 很慢,不等于马上创建索引。先看过滤条件、排序字段和数据量,再查看查询计划:
query = (
Post.objects
.filter(status="published")
.order_by("-published_at", "-id")
)
print(query.explain())
SQLite 的执行计划和 PostgreSQL、MySQL 不同。索引设计要结合真实查询,不要照搬其他数据库的配置。新增索引会增加写入和备份成本;访问记录表尤其要关注时间、类型、状态码等常用筛选是否真的命中索引。
SQLite 锁也是响应慢的来源
SQLite 适合轻量内容站和低写入场景,但多个 uWSGI worker 同时写文章浏览量、访问记录或后台统计时,会出现锁等待。排查时记录:
database is locked的发生时间和请求路径;- 同时运行的清理、补充 GeoIP 或批量任务;
- 写入是否发生在每个公共 GET 请求;
- 查询是否在事务中持锁过久;
- 读写是否可以拆到异步队列或汇总表。
把 SQLite 的超时从 20 秒改成更大,只会让请求等待更久,不能消除写竞争。先减少不必要的写入、缩短事务,再考虑数据库迁移或架构调整。
七、第六步:缓存要区分“内容缓存”和“进程缓存”
缓存命中不等于所有 worker 共享缓存。Django 的 LocMemCache 是每个进程独立的内存缓存;uWSGI 多进程时,一个 worker 写入的缓存,其他 worker 可能看不到。它适合单进程或低风险的短缓存,不适合需要跨 worker 一致性的公共页面缓存。
选择缓存位置时,先明确目标:
| 目标 | 更适合的层 | 注意事项 |
|---|---|---|
| 静态 CSS、JS、字体 | 浏览器、Nginx 或 CDN | 文件名要有版本指纹 |
| 公共文章 HTML | 反向代理或共享缓存 | 发布和更新时要主动失效 |
| 数据库查询结果 | Redis 等共享缓存 | 键要包含版本和筛选条件 |
| 登录态页面 | 短缓存或不缓存 | 避免把用户内容发给别人 |
| 单个 worker 的临时结果 | LocMemCache | 不保证跨进程共享 |
文章页使用缓存前,要先确认评论、浏览量、登录状态和统计脚本是否会改变响应。缓存公共 HTML 时,发布新文章、修改专题页或更新导航后应清理相关键;只清 Django 进程缓存,不一定清掉 Nginx 或 CDN 的旧响应。
八、第七步:图片、字体和第三方脚本
服务端已经很快时,浏览器侧通常被资源拖慢。结合第 304、305 篇的内容,按优先级检查:
- 首屏图片是否使用合适的尺寸和格式,是否写了宽高或稳定宽高比;
- 非首屏图片是否延迟加载,首屏关键图是否被错误地延迟;
- 字体是否阻塞正文,是否加载了不需要的字重和字符集;
- CSS 是否存在阻塞渲染的远程资源;
- 统计、评论、分享和广告脚本是否在首屏同步执行;
- 第三方域名是否稳定,失败时页面是否仍可阅读。
不要把所有脚本都改成 async 或 defer 就宣布修复。脚本可能依赖执行顺序,也可能需要在用户交互前完成初始化。修改后要检查目录、评论、复制按钮和统计采集是否仍然正常。
九、如何验证修复真的有效
每次只集中改一类问题,记录改动时间和页面版本。验证至少分三层:
1. 服务端层
重复运行 curl,比较 TTFB、总时间、响应大小和状态码。测试应使用相同 URL、相同请求头和相同缓存条件。若只测到一次较快结果,不能证明稳定性提高。
2. 浏览器层
重新运行 Lighthouse,并观察 LCP 候选、资源瀑布和布局移动。检查移动端实际页面:标题、表格、代码块和图片不能横向溢出,字体加载失败时正文仍应可读。
3. 真实用户层
如果已接入 RUM 或统计事件,按设备和页面观察真实用户的 LCP、INP、错误率和退出情况。百度统计的 PV、UV 与性能指标不是同一类数据,不能把访问量上涨当作性能修复证据。性能修复也不等于排名必然上涨,搜索表现需要单独按完整周期复盘。
可以把结果记录成下面的表:
| 项目 | 修改前 | 修改后 | 测试条件 | 结论 |
|---|---|---|---|---|
| TTFB | 实测中位数 | 实测中位数 | 同 URL、同缓存 | 服务端等待是否减少 |
| HTML 大小 | 实测值 | 实测值 | 同响应范围 | 压缩或字段是否变化 |
| LCP | 实验室或 RUM | 实验室或 RUM | 同设备和网络 | 主要内容是否更快出现 |
| CLS | 实验室或 RUM | 实验室或 RUM | 同页面版本 | 是否仍有布局移动 |
| 5xx/超时 | 日志汇总 | 日志汇总 | 同时间窗口 | 稳定性是否改善 |
| 数据库查询 | 查询数和耗时 | 查询数和耗时 | 同数据量 | 是否减少重复工作 |
缺少修改前数据时,写“建立基线”,不要补造百分比。样本太小时,保留绝对数量和测试条件比只报一个平均值更有用。
十、常见误区
误区一:只看 Lighthouse 分数
Lighthouse 是实验室诊断工具,受设备、网络和缓存影响。它适合发现候选问题,不代表所有真实用户的体验分布。
误区二:只加 worker 就能提速
如果瓶颈在 SQLite 写锁、慢 SQL、上游接口或带宽,增加 worker 可能让资源竞争更严重。先用日志证明瓶颈位置。
误区三:把所有页面都缓存
登录状态、评论、后台和带个性化内容的页面可能泄露数据或显示旧内容。缓存前先画出页面的公共和私有部分。
误区四:把压缩当成万能方案
压缩适合文本响应,不能解决慢查询、首屏图片过大、字体阻塞或第三方脚本超时。还要确认压缩没有让 CPU 成为新瓶颈。
误区五:用平均数掩盖长尾
平均响应时间可能看起来很好,但少量 5 秒请求仍会影响用户。报告中应同时观察中位数、P95 或 P99,以及超时和错误数量。
发布前检查清单
- [ ] 固定 URL、设备、网络和缓存条件,建立至少三次基线。
- [ ] 用
curl分离 DNS、TLS、TTFB、下载和总时间。 - [ ] 用浏览器 Network 确认 LCP 候选和阻塞资源。
- [ ] 检查静态资源是否绕过 Django,媒体 URL 和 Content-Type 是否正确。
- [ ] 检查 Nginx、uWSGI、Django 日志能否关联同一次请求。
- [ ] 检查列表查询是否存在 N+1、分页过大和未使用索引。
- [ ] 检查 SQLite 锁、批处理任务和访问记录写入压力。
- [ ] 区分 LocMemCache、共享缓存、反向代理缓存和浏览器缓存。
- [ ] 修改后重复验证服务端、浏览器和真实用户三层数据。
- [ ] 记录测试条件、版本、绝对值和限制,不虚构性能提升百分比。
网站性能优化的起点不是“把分数刷高”,而是找到用户等待的具体阶段。先用 TTFB 和日志判断服务端,再用查询计划、缓存和资源瀑布定位原因,最后用 LCP、CLS、INP 和真实用户数据验证体验。这样得到的性能结论才可复查,也不会因为换了服务器、缓存或测试设备就失去意义。
参考资料
- web.dev:Optimize Largest Contentful Paint:LCP 诊断和优化方向。
- web.dev:Learn Performance:浏览器加载和资源性能基础。
- Django:数据库访问优化:查询、索引和
select_related等实践。 - Django:缓存框架:缓存后端与失效边界。
- MDN:Server-Timing:向浏览器暴露服务端计时信息。
- 《Django 文章页 Core Web Vitals 实战》:LCP、CLS、INP 与 Lighthouse。
- 《Django 图片处理流水线》:图片上传、派生图和媒体交付。
- 《Django API 分页、缓存与数据库查询优化》:接口分页、查询和缓存边界。
评论 (0)
暂无评论,快来抢沙发吧!