所属专题:SEO 专题导航:从基础到技术实践

网站响应速度慢怎么排查:从 TTFB、LCP 到 Nginx 与 Django 的全链路实战

网站响应速度慢怎么排查:从 TTFB、LCP 到 Nginx 与 Django 的全链路实战 - 暂无配图,技术文章默认封面

网站响应速度慢,不能只看 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 可能让锁等待更严重。

验证方法是同时观察:

  1. uWSGI 请求耗时和 worker 是否长期满载;
  2. SQLite 是否出现 database is locked;
  3. Nginx 连接数和上游响应时间;
  4. 应用日志中的慢请求路径;
  5. 内存是否因为 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 篇的内容,按优先级检查:

  1. 首屏图片是否使用合适的尺寸和格式,是否写了宽高或稳定宽高比;
  2. 非首屏图片是否延迟加载,首屏关键图是否被错误地延迟;
  3. 字体是否阻塞正文,是否加载了不需要的字重和字符集;
  4. CSS 是否存在阻塞渲染的远程资源;
  5. 统计、评论、分享和广告脚本是否在首屏同步执行;
  6. 第三方域名是否稳定,失败时页面是否仍可阅读。

不要把所有脚本都改成 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 和真实用户数据验证体验。这样得到的性能结论才可复查,也不会因为换了服务器、缓存或测试设备就失去意义。

参考资料

分享这篇文章:

评论 (0)

请 登录 后发表评论, 还没有账户?立即注册

暂无评论,快来抢沙发吧!