事情要从上周说起。

有个朋友给我发消息:“你那个静流书站book.coffeedeals.club)还挺好用的,就是打开有点慢,每次点详情页都要转一两秒。”

我心想不至于吧,服务器虽然是个1核2G的乞丐版,但也不至于这么拉胯。打开Chrome的Network面板一测,傻眼了:首屏加载1.8秒,详情页2.1秒。这种速度放到2024年,用户早跑没影了。

于是就有了这次“性能优化十日谈”(实际只用了三天)。

第一刀:数据库查询的N+1问题

先上djangodebugtoolbar,这是Django性能优化的标配。一查详情页,好家伙,42条SQL查询。

核心原因在于我的Book模型关联了作者(多对多)和出版社(外键)。在详情页模板里,我写了类似这样的代码:

<h1>{{ book.title }}</h1>
<p>出版社:{{ book.publisher.name }}</p>  <! 这里会触发一次查询 >
<p>作者:
{% for author in book.authors.all %}    <! 这里又会触发N次查询 >
    {{ author.name }}{% if not forloop.last %}、{% endif %}
{% endfor %}
</p>

看起来没毛病,但实际上Django的ORM是惰性求值的——只有在用到关联数据的时候才会去查数据库。于是,一个详情页触发了:1次查book + 1次查publisher + N次查author(N=作者数量)。

这就是经典的N+1问题。

解决方案也简单:用select_relatedprefetch_related提前加载。

 views.py 优化前
book = Book.objects.get(id=book_id)

 优化后
from django.shortcuts import get_object_or_404

book = get_object_or_404(
    Book.objects
    .select_related('publisher')   外键用这个,一次JOIN搞定
    .prefetch_related('authors'),   多对多用这个,分两次查询但避免了循环
    id=book_id
)

就这么两行改动,详情页的SQL查询从42条降到了3条。响应时间从2.1秒掉到1.2秒。

但还不够。
9low

第二刀:缓存模板片段

1.2秒依然不能忍。这次我盯上了模板渲染。

详情页的大部分内容其实是相对静态的——一本书的信息可能几个月都不会变。每次用户访问都重新从数据库捞数据、重新渲染模板,太浪费了。

Django的模板片段缓存({% cache %})正好派上用场。

{% load cache %}

<! 缓存整个书籍信息块,缓存键基于book.id和last_updated >
{% cache 86400 book_detail book.id book.last_updated|date:"U" %}
<div class="bookinfo">
    <h1>{{ book.title }}</h1>
    <p>作者:
    {% for author in book.authors.all %}
        {{ author.name }}{% if not forloop.last %}、{% endif %}
    {% endfor %}
    </p>
    <p>出版社:{{ book.publisher.name }}</p>
    <p>出版日期:{{ book.pub_date|date:"Y年m月" }}</p>
    <p>ISBN:{{ book.isbn }}</p>
    {% if book.summary %}
    <div class="summary">
        <h3>内容简介</h3>
        <p>{{ book.summary|linebreaksbr }}</p>
    </div>
    {% endif %}
</div>
{% endcache %}

这里有个小技巧:缓存键里加上了book.last_updated|date:"U"(最后更新时间的时间戳)。这样当书籍信息更新时,时间戳变了,缓存会自动失效,下次访问就会重新生成。

Django的缓存后端我配的是Redis,比文件缓存和数据库缓存快得多:

 settings.py
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.redis.RedisCache',
        'LOCATION': 'redis://127.0.0.1:6379/1',
        'TIMEOUT': 86400,   24小时默认超时
        'OPTIONS': {
            'MAX_ENTRIES': 10000,   最多存1万个缓存项
        },
    }
}

加上模板缓存后,详情页响应时间降到了400毫秒左右。

第三刀:全页面缓存

400毫秒还是不够极致。因为即使用了模板缓存,Django还是要走一遍完整的请求生命周期——加载中间件、路由匹配、执行视图函数、渲染未缓存的部分。

对于首页、榜单页这种变化不频繁但又访问量大的页面,我决定上全页面缓存。

Django的cache_page装饰器就是干这个的:

 urls.py
from django.views.decorators.cache import cache_page
from django.urls import path
from . import views

urlpatterns = [
     首页缓存15分钟
    path('', cache_page(6015)(views.HomeView.as_view()), name='home'),
    
     分类页缓存10分钟,但根据不同的分类分开缓存
    path('category/<slug:slug>/', 
         cache_page(6010, key_prefix='category')(views.CategoryView.as_view()), 
         name='category'),
    
     详情页缓存24小时,但配合模板缓存使用
    path('book/<slug:isbn>/', views.BookDetailView.as_view(), name='book_detail'),
]

注意这里详情页我没有用全页面缓存,而是用了前面的模板缓存。因为全页面缓存一旦开启,整个响应都会被缓存起来,如果用户登录了(虽然我的站没做登录功能),或者要显示个性化的东西,就不太合适了。

全页面缓存加上之后,首页响应时间直接掉到80毫秒左右——这基本就是Redis读个字符串的时间。

第四刀:浏览器缓存

后端优化得差不多了,再看看前端。

静态资源(CSS、JS、图片)完全可以用浏览器缓存。配置Nginx很简单:

 nginx配置
location ~ \.(jpg|jpeg|png|gif|ico|css|js)$ {
    expires 30d;
    add_header CacheControl "public, notransform";
}

 对于HTML页面,设置较短的缓存,或者用协商缓存
location / {
    expires 1h;
    add_header CacheControl "public, mustrevalidate";
}

但有个坑:如果改了CSS文件,怎么让浏览器及时更新?我的方案是在Django模板里给静态文件加版本号:

<link rel="stylesheet" href="{% static 'css/main.css' %}?v={{ STATIC_VERSION }}">

每次部署时,通过环境变量改变STATIC_VERSION,就能强制浏览器重新加载最新的CSS。

优化成果

一顿操作下来,用Chrome Lighthouse重新跑分:

首屏加载:从1.8秒 → 280ms
最大内容绘制(LCP):从2.5秒 → 0.9秒
累计布局偏移(CLS):从0.15 → 0.02(主要是给图片加了宽高)
总阻塞时间:从320ms → 45ms

朋友再打开静流书站(book.coffeedeals.club)的时候,发来消息:“卧槽,你是不是换服务器了?”

我说没有,还是那个1核2G的小破机器,只是把“懒加载”换成了“预加载”,把“即用即查”换成了“能存就存”。

一点感悟

做技术优化,很多时候不是用多么高深的技术栈,而是把基础的事情做到极致。select_relatedprefetch_relatedcache_page{% cache %},这些都是Django文档里写得清清楚楚的东西,只是我们平时写代码太急,没顾上细看。

静流书站目前还在慢慢迭代,数据量大概几十万册,主要收录豆瓣高分和各大出版社推荐的书单。如果你也在折腾Django,或者对性能优化有兴趣,欢迎来逛逛,顺便看看有没有你喜欢的书。

代码写得一般,但踩过的坑都记着呢,欢迎评论区交流。
静流书站

posted on 2026-03-03 13:28  yqqwe  阅读(2)  评论(0)    收藏  举报