Django-让网站访问更快

这篇讲什么

  • 「网站慢」这件事,八成不在 Django 本身,而在重复计算、重复查询、重复回源。这篇按四层来梳理:先量出瓶颈(SQL 条数、响应体积、响应头),再用 Django 的三档缓存把重复劳动省掉,接着让浏览器和 CDN 少回源,最后回到数据库层把 N+1 干掉。
  • 所有数字都是本机实测的,包括一个很容易踩的坑:用 locmem 缓存时,两个 worker 各有一份缓存,命中率直接砍半。
  • 实测环境:WSL2 Ubuntu 26.04.1 LTS + Python 3.14.4 + Django 5.2.19 LTS + MySQL 9.7.4 + Gunicorn 26.2.0(sync worker)+ Nginx 1.28.3 + Redis(私有实例,端口 6390)+ redis-py 8.1.0。
  • CDN 控制台、云厂商加速这些必须真域名才能操作的,按官方文档写并标注未在本机验证;但「Django 该给什么样的响应头」这部分全是实测。

先量一次,再谈优化

数 SQL 条数

Django 自带 CaptureQueriesContext,不用装任何插件:

1
2
3
4
5
6
from django.db import connection
from django.test.utils import CaptureQueriesContext

with CaptureQueriesContext(connection) as ctx:
total = sum(len(u.groups.all()) for u in User.objects.filter(username__startswith="u"))
print(len(ctx.captured_queries))

实测(30 个用户,每人 2 个组):

1
2
N+1 写法        :关系数 60,SQL 数 31
prefetch_related:关系数 60,SQL 数 2

31 条和 2 条的差距,就是「页面慢」最常见的来源。

看体积与响应头

1
2
3
4
$ curl -s -o /dev/null -w "%{size_download}B %{time_total}s\n" http://127.0.0.1:8031/
333B 0.012s
$ curl -s -o /dev/null -D - -H "Accept-Encoding: gzip" http://127.0.0.1:8091/ | grep -i content-encoding
Content-Encoding: gzip

响应头里最该盯的三个是 Cache-Control、ETag / Last-Modified、Content-Encoding,它们决定了「这次请求到底有没有真的干活」。

Django 缓存的三档粒度

2.1 低层 API:自己控制 key 和 TTL

1
2
3
4
5
6
from django.core.cache import cache

cache.set("home:v1:page", html, 60) # 第三个参数是秒
value = cache.get("home:v1:page")
value = cache.get("home:v1:page", "默认值") # 读不到不抛异常
cache.delete("home:v1:page")

实测的失效行为:

1
2
两个版本并存 = 内容版本 1 / 内容版本 2
删掉 v1 之后 = None | v2 仍在 = 内容版本 2

key 里带版本号是最好用的失效策略:改逻辑时把 v1 换成 v2,旧缓存自然作废,不用去扫 key。

incr / decr 只能用在整数上,实测三种情况:

1
2
3
整数计数 incr 两次 = 2
对字符串 incr -> TypeError : can only concatenate str (not "int") to str
不存在的 key incr -> ValueError : Key ':1:missing' not found

顺带看到 Redis 里 key 的真实形态是 :1:missing——Django 会给每个 key 加上「缓存版本号 + 前缀」,所以直接在 redis-cli 里 GET missing 是查不到的,要 KEYS '*missing*'。

视图级:cache_page

views.py
1
2
3
4
5
6
from django.views.decorators.cache import cache_page


@cache_page(60)
def cached(request):
return HttpResponse(str(time.time()))

实测:

1
2
3
两次 /cached/ 内容相同 = True | 值 = 1793831121.0332549
1.2 秒后仍然相同(60s TTL 内)= True
响应头 Cache-Control = max-age=60

cache_page 会自动把结果写进缓存并给浏览器 max-age,所以它同时省了服务端和浏览器两边。注意它按 URL(含 query string)分 key,/cached/?p=2 和 /cached/?p=3 是两份。

模板片段:{% cache %}

1
2
3
4
{% load cache %}
{% cache 60 sidebar request.user.id %}
<p>耗时模块</p>
{% endcache %}

实测:

1
2
两次渲染输出相同 = True | 缓存住的是 = 1793831280.5131445
clear() 之后换了新值 = True

两个容易撞的坑:

  • 忘了 {% load cache %} 会直接 TemplateSyntaxError('cache' is not a registered tag)。
  • 用 django.template.Template("...") 手工编译时也没有 cache 标签,它属于 builtins 之外的库标签;要用 engines["django"].from_string(...) 走项目配置才认。
  • 片段缓存 key 默认包含所有传入的变量,request.user.id 这种「按人分桶」要显式写出来,否则匿名和登录用户会共享同一份。

整页缓存:per-site 中间件

settings.py
1
2
3
4
5
6
7
8
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware", # 必须排第一
# ... 其它中间件 ...
"django.middleware.cache.FetchFromCacheMiddleware", # 必须排最后
]

CACHE_MIDDLEWARE_SECONDS = 45
CACHE_MIDDLEWARE_KEY_PREFIX = "site"

实测(只有这两行中间件 + 45 秒):

1
整页缓存两次正文相同 = True | Cache-Control = max-age=45

顺序错了不会报错,但会「缓存不生效」:Update 不在最前就存不进去,Fetch 不在最后就取不出来。整页缓存对带 session/CSRF 的登录页基本不能用,只适合内容页。

三、缓存后端选型(实测对比)

后端 跨进程共享 2000 次 set 2000 次 get 什么时候用
LocMemCache 否 0.006s 0.004s 单进程开发、跑测试
FileBasedCache 是 0.798s 0.014s 没有 Redis 时的过渡方案
RedisCache 是 0.258s 0.242s 生产默认选择(Django 内置,官方推荐)

三组实测细节:

  • locmem 不跨进程:同一进程读到 hello-from-pid-5613,另一个进程读同一个 key 得到 None。更直观的是多 worker 实验:
1
locmem + 2 个 gunicorn worker:同一个 /cached/ 打 8 次,出现 2 个不同值

也就是说每个 worker 各自缓存了一份,命中率按 worker 数打折,还可能出现「用户刷新一下页面内容变了」。生产上要么用 Redis,要么用文件缓存。

  • file 后端写慢读快(0.798s vs 0.014s),因为它每次 set 都要落盘;跨进程能读到(另一个进程读到 hello-from-pid-5617)。
  • RedisCache 要装 redis-py:pip install redis(实测装的是 8.1.0,不动 Django),配置:
settings.py
1
2
3
4
5
6
7
8
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6390/1",
"KEY_PREFIX": "dj",
"TIMEOUT": 300,
}
}
  • OPTIONS 是后端相关的,别把 locmem 的参数抄给 Redis。实测抄了 MAX_ENTRIES 之后:
1
TypeError: AbstractConnection.__init__() got an unexpected keyword argument 'MAX_ENTRIES'

Redis 侧的容量与淘汰策略应该配在 redis.conf(maxmemory + maxmemory-policy allkeys-lru),细节见《Redis入门》。

  • 生产上 Redis 只监听内网/回环、加 requirepass,别裸奔到公网——这条在《Django-生产环境中的安全》里也强调过。

让浏览器和 CDN 少回源

4.1 Cache-Control 的三种实测结果

views.py
1
2
3
4
5
6
7
8
9
10
11
from django.views.decorators.cache import cache_control, never_cache


@cache_control(max_age=120, public=True)
def cc(request):
return HttpResponse("ok")


@never_cache
def nc(request):
return HttpResponse("ok")
1
2
3
cache_control -> Cache-Control = max-age=120, public
never_cache -> Cache-Control = max-age=0, no-cache, no-store, must-revalidate, private
cache_page(60) -> Cache-Control = max-age=60

选择原则:

  • 纯静态、和登录状态无关的接口:public, max-age 长一点。
  • 带用户内容的页面:private,或者干脆 no-store。
  • 验证码、图片流这类动态二进制:必须 no-store(《Django-验证码》里实测过被缓存后「刷新不出新码」的现象)。

ETag 与 304:默认没有,要中间件

实测默认配置下:

1
2
第一次:ETag=None Last-Modified=None 状态=200
带 If-None-Match -> 200 (没有 304)

加上 ConditionalGetMiddleware 之后:

1
2
加中间件后 ETag = "c7f53e6d6321406ceb9db734144d3ecd"
If-None-Match -> 304 正文长度 = 0

304 没有正文,省的是带宽和渲染时间,但仍然要跑一次视图(ETag 是内容算出来的)。要真正不执行视图,得靠上一节的缓存或 CDN。

4.3 CDN 的两种用法(概念按官方文档,控制台操作未在本机验证)

老笔记里「CDN 访问的两阶段」这个说法仍然值得记住:一次访问先查 DNS/边缘节点是否命中,命中就直接返回,未命中才回源取并缓存。用 CDN 有两种思路:

思路 做法 适合
拉取(回源) CDN 配源站地址,首次访问回源取内容并按规则缓存 静态资源、图片、JS/CSS
推送 主动把文件推到边缘节点 大版本发布、需要立刻生效的资源

配合 Django 侧要做的事:

  1. 静态资源用带哈希的文件名(见第五节),Cache-Control: public, max-age=31536000, immutable,这样 CDN 和浏览器都能存一年。
  2. 动态接口给 no-store,避免 CDN 缓存出错页面。
  3. 图片域名与主站隔离(static.example.com),别让静态请求带上会话 cookie。
  4. 发布时清一次 CDN 缓存(或换哈希名,就不用清)。

云厂商的缓存规则、刷新 API、HTTPS 证书部署都属于控制台操作,本机没有真域名和云账号,未验证,具体以你所用厂商的官方文档为准。

静态资源:哈希名与 Web 服务器

5.1 collectstatic 与 ManifestStaticFilesStorage

1
2
3
$ python manage.py collectstatic --noinput --settings=proj.settings.prod
128 static files copied to '/mnt/e/Desktop/Test/django-deploy-probe/staticfiles',
128 post-processed.

磁盘上同时有原名和哈希名,外加一张清单:

1
2
3
site.css                    165 字节
site.b9bcd8def6c5.css 165 字节
staticfiles.json 10441 字节

一个细节值得记住:哈希名不是文件内容的 sha256 前 12 位。实测源文件的 sha256[:12] 是 43ce33c28689,而 Django 生成的哈希名是 b9bcd8def6c5——因为 ManifestStaticFilesStorage 计算哈希时会把该文件引用到的其它资源一起算进去,改一张图也会让引用它的 CSS 换名。

交给 Nginx,别让 Django 管

1
2
3
4
5
location /static/ {
alias /srv/django/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}

实测最有说服力的一条:把后端两个 gunicorn 实例全杀掉之后——

1
2
3
4
/static/site.css -> 200(静态照常)
/ -> 502(动态挂了)

error.log: no live upstreams while connecting to upstream

静态资源的可用性和应用进程解耦,这正是把它交给 Web 服务器(或 CDN)的理由。Django 侧还有个折中方案 WhiteNoise(第三方,能在没有 Nginx 时直接由应用带哈希服务静态文件),单机小站可以用,属备选。

压缩:交给 Web 服务器

实测同一路径三种情况:

1
2
3
4
直连后端,不带 Accept-Encoding: 333B
直连后端,带 gzip 请求头: 333B # Django/gunicorn 默认不压缩
经 Nginx(gzip on),带 gzip 头: 290B # 省了 13%
响应头 Content-Encoding = Content-Encoding: gzip

Nginx 侧配置:

1
2
3
4
gzip on;
gzip_types text/css application/json application/javascript;
gzip_min_length 200;
gzip_comp_level 5;

两个实测到的注意点:

  • gzip_types 里不要再写 text/html,它会默认开启,写了会有一条 warn:
1
[duplicate MIME type "text/html" in /root/probe-gzip/nginx.conf:7]
  • 太小不值得压(上面 HTML 才 333 字节,压完 290 字节)。真正收益大的是 JS/CSS/JSON,几十 KB 起步能省 60~80%。
  • Django 也有 GZipMiddleware,但它是每次请求现算,CPU 花在应用侧;有 Nginx 就别用它。

数据库层:查询数、列数、索引

实测:

1
2
3
4
LogEntry 取 user.username(无 select_related):SQL 数 6
同样数据加 select_related:SQL 数 1,取到 5 个用户名
N+1 写法 :关系数 60,SQL 数 31
prefetch_related:关系数 60,SQL 数 2

prefetch_related 发出的两条 SQL 长这样:

1
2
SELECT `auth_user`.`id`, `auth_user`.`password`, ... FROM `auth_user` WHERE `auth_user`.`username` LIKE 'u%' ;
SELECT (`auth_user_groups`.`user_id`) AS `_prefetch_related_val_user_id`, `auth_group`.`id`, ... ;

规则一句话:多对一 / 一对一用 select_related(JOIN),多对多 / 反向一对多用 prefetch_related(两条 SQL + 内存拼接)。用错会直接报错,实测:

1
2
select_related('groups') 直接报错:FieldError Invalid field name(s) given in select_related:
'groups'. Choices are: (none)

少取列、少取行

1
2
3
qs = DeployItem.objects.only("name", "hits")          # 延迟加载其余列
qs = DeployItem.objects.defer("created_at") # 只推迟某一列
qs = DeployItem.objects.values_list("name", flat=True) # 只要一列,别拿对象

列表页只展示名字时,values_list 比对象列表省内存也省序列化;但要记得 only()/defer() 之后访问被延迟的列会补一条 SQL。

其它数据库侧手段

  • UNIQUE/外键约束由迁移生成,实测 DDL 里能看到 CHECK 与 UNSIGNED(见《Django-生产环境要注意哪些事项》3.4)。慢查询先看有没有走索引,用 EXPLAIN 判断。
  • CONN_MAX_AGE 持久连接(实测本机 30 次请求 0.049s → 0.039s,跨机房收益更大)。
  • 计数很贵:Paginator 每次都会 COUNT(*)。大表列表页要么缓存计数,要么用「是否有下一页」代替总数(list(per_page + 1) 判断)。
  • 真正耗时的动作(发信、生成报表、图片处理)挪进 Celery,见《Django-Celery》。

排查对照表

症状 先看哪里 实测用的命令/工具
首屏慢、接口 200 但耗时高 SQL 条数 CaptureQueriesContext、prefetch_related
刷新后内容会变 多 worker 各自缓存 换 RedisCache,验证跨进程读到同一个 key
用户重复访问仍然慢 Cache-Control 有没有给 curl -D -、cache_page / cache_control
静态资源每次都回源 文件名有没有哈希 collectstatic + Nginx expires
带宽高 有没有压缩 Nginx gzip on + Content-Encoding
图片/验证码「不刷新」 动态内容被缓存 never_cache / no-store
页面偶发 502 后端全挂还是被限流 Nginx error.log、limit_req

参考

本文结束 感谢您的阅读
正在加载今日诗词....