事情要从上周说起。
有个朋友给我发消息:“你那个静流书站(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_related和prefetch_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秒。
但还不够。

第二刀:缓存模板片段
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_related、prefetch_related、cache_page、{% cache %},这些都是Django文档里写得清清楚楚的东西,只是我们平时写代码太急,没顾上细看。
静流书站目前还在慢慢迭代,数据量大概几十万册,主要收录豆瓣高分和各大出版社推荐的书单。如果你也在折腾Django,或者对性能优化有兴趣,欢迎来逛逛,顺便看看有没有你喜欢的书。
代码写得一般,但踩过的坑都记着呢,欢迎评论区交流。
静流书站
浙公网安备 33010602011771号