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 | from django.db import connection |
实测(30 个用户,每人 2 个组):
1 | N+1 写法 :关系数 60,SQL 数 31 |
31 条和 2 条的差距,就是「页面慢」最常见的来源。
看体积与响应头
1 | $ curl -s -o /dev/null -w "%{size_download}B %{time_total}s\n" http://127.0.0.1:8031/ |
响应头里最该盯的三个是 Cache-Control、ETag / Last-Modified、Content-Encoding,它们决定了「这次请求到底有没有真的干活」。
Django 缓存的三档粒度
2.1 低层 API:自己控制 key 和 TTL
1 | from django.core.cache import cache |
实测的失效行为:
1 | 两个版本并存 = 内容版本 1 / 内容版本 2 |
key 里带版本号是最好用的失效策略:改逻辑时把 v1 换成 v2,旧缓存自然作废,不用去扫 key。
incr / decr 只能用在整数上,实测三种情况:
1 | 整数计数 incr 两次 = 2 |
顺带看到 Redis 里 key 的真实形态是 :1:missing——Django 会给每个 key 加上「缓存版本号 + 前缀」,所以直接在 redis-cli 里 GET missing 是查不到的,要 KEYS '*missing*'。
视图级:cache_page
1 | from django.views.decorators.cache import cache_page |
实测:
1 | 两次 /cached/ 内容相同 = True | 值 = 1793831121.0332549 |
cache_page 会自动把结果写进缓存并给浏览器 max-age,所以它同时省了服务端和浏览器两边。注意它按 URL(含 query string)分 key,/cached/?p=2 和 /cached/?p=3 是两份。
模板片段:{% cache %}
1 | {% load cache %} |
实测:
1 | 两次渲染输出相同 = True | 缓存住的是 = 1793831280.5131445 |
两个容易撞的坑:
- 忘了
{% load cache %}会直接TemplateSyntaxError('cache' is not a registered tag)。 - 用
django.template.Template("...")手工编译时也没有cache标签,它属于builtins之外的库标签;要用engines["django"].from_string(...)走项目配置才认。 - 片段缓存 key 默认包含所有传入的变量,
request.user.id这种「按人分桶」要显式写出来,否则匿名和登录用户会共享同一份。
整页缓存:per-site 中间件
1 | MIDDLEWARE = [ |
实测(只有这两行中间件 + 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),配置:
1 | CACHES = { |
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 的三种实测结果
1 | from django.views.decorators.cache import cache_control, never_cache |
1 | cache_control -> Cache-Control = max-age=120, public |
选择原则:
- 纯静态、和登录状态无关的接口:
public, max-age长一点。 - 带用户内容的页面:
private,或者干脆no-store。 - 验证码、图片流这类动态二进制:必须
no-store(《Django-验证码》里实测过被缓存后「刷新不出新码」的现象)。
ETag 与 304:默认没有,要中间件
实测默认配置下:
1 | 第一次:ETag=None Last-Modified=None 状态=200 |
加上 ConditionalGetMiddleware 之后:
1 | 加中间件后 ETag = "c7f53e6d6321406ceb9db734144d3ecd" |
304 没有正文,省的是带宽和渲染时间,但仍然要跑一次视图(ETag 是内容算出来的)。要真正不执行视图,得靠上一节的缓存或 CDN。
4.3 CDN 的两种用法(概念按官方文档,控制台操作未在本机验证)
老笔记里「CDN 访问的两阶段」这个说法仍然值得记住:一次访问先查 DNS/边缘节点是否命中,命中就直接返回,未命中才回源取并缓存。用 CDN 有两种思路:
| 思路 | 做法 | 适合 |
|---|---|---|
| 拉取(回源) | CDN 配源站地址,首次访问回源取内容并按规则缓存 | 静态资源、图片、JS/CSS |
| 推送 | 主动把文件推到边缘节点 | 大版本发布、需要立刻生效的资源 |
配合 Django 侧要做的事:
- 静态资源用带哈希的文件名(见第五节),
Cache-Control: public, max-age=31536000, immutable,这样 CDN 和浏览器都能存一年。 - 动态接口给
no-store,避免 CDN 缓存出错页面。 - 图片域名与主站隔离(
static.example.com),别让静态请求带上会话 cookie。 - 发布时清一次 CDN 缓存(或换哈希名,就不用清)。
云厂商的缓存规则、刷新 API、HTTPS 证书部署都属于控制台操作,本机没有真域名和云账号,未验证,具体以你所用厂商的官方文档为准。
静态资源:哈希名与 Web 服务器
5.1 collectstatic 与 ManifestStaticFilesStorage
1 | $ python manage.py collectstatic --noinput --settings=proj.settings.prod |
磁盘上同时有原名和哈希名,外加一张清单:
1 | site.css 165 字节 |
一个细节值得记住:哈希名不是文件内容的 sha256 前 12 位。实测源文件的 sha256[:12] 是 43ce33c28689,而 Django 生成的哈希名是 b9bcd8def6c5——因为 ManifestStaticFilesStorage 计算哈希时会把该文件引用到的其它资源一起算进去,改一张图也会让引用它的 CSS 换名。
交给 Nginx,别让 Django 管
1 | location /static/ { |
实测最有说服力的一条:把后端两个 gunicorn 实例全杀掉之后——
1 | /static/site.css -> 200(静态照常) |
静态资源的可用性和应用进程解耦,这正是把它交给 Web 服务器(或 CDN)的理由。Django 侧还有个折中方案 WhiteNoise(第三方,能在没有 Nginx 时直接由应用带哈希服务静态文件),单机小站可以用,属备选。
压缩:交给 Web 服务器
实测同一路径三种情况:
1 | 直连后端,不带 Accept-Encoding: 333B |
Nginx 侧配置:
1 | gzip on; |
两个实测到的注意点:
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 就别用它。
数据库层:查询数、列数、索引
7.1 select_related 与 prefetch_related 的分工
实测:
1 | LogEntry 取 user.username(无 select_related):SQL 数 6 |
prefetch_related 发出的两条 SQL 长这样:
1 | SELECT `auth_user`.`id`, `auth_user`.`password`, ... FROM `auth_user` WHERE `auth_user`.`username` LIKE 'u%' ; |
规则一句话:多对一 / 一对一用 select_related(JOIN),多对多 / 反向一对多用 prefetch_related(两条 SQL + 内存拼接)。用错会直接报错,实测:
1 | select_related('groups') 直接报错:FieldError Invalid field name(s) given in select_related: |
少取列、少取行
1 | qs = DeployItem.objects.only("name", "hits") # 延迟加载其余列 |
列表页只展示名字时,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 |
参考
- Django 官方:缓存框架:per-site / per-view / 模板片段 / 低层 API 与各后端。
- Django 官方:
RedisCache设置:内置 Redis 后端的连接串与选项。 - Django 官方:条件 GET 视图:
ETag/Last-Modified的用法。 - Django 官方:中间件参考:
ConditionalGetMiddleware、UpdateCacheMiddleware的顺序要求。 - Django 官方:静态文件管理:
collectstatic与ManifestStaticFilesStorage。 - Django 官方:优化数据库访问:
select_related/prefetch_related/only/defer。 - Nginx gzip 模块文档:
gzip_types、gzip_min_length、gzip_comp_level。 - Nginx
limit_req模块文档:缓存未命中时的回源保护。