Django-应用水平扩展

这篇讲什么

  • 单机扛不住的时候,正确的做法不是把一台机器换得更贵,而是让应用变成「可以多开几份」的样子,再用负载均衡把流量分下去。这篇讲三件事:Django 侧要满足什么条件才能水平扩展(无状态),Nginx 的四种分流策略各自会把请求分到哪(实测数字),以及后端挂掉时流量怎么自动绕开(实测)。
  • Tengine 是 Nginx 的超集,配置写法通用;它独有的主动健康检查属于外部组件,本机没有安装 Tengine,这部分按官方文档写并标注未验证。
  • 实测环境:WSL2 Ubuntu 26.04.1 LTS + Nginx 1.28.3 + Gunicorn 26.2.0(3 个后端实例,每个 2 个 sync worker)+ Django 5.2.19 LTS + MySQL 9.7.4。
  • 所有端口都是临时的(8061/8062/8063 后端,8071~8075 负载均衡入口),测完全部关闭。

先搞清「水平扩展」和「垂直扩展」

方式 做法 优点 代价
垂直扩展 给单机加 CPU / 内存 不用改代码、不用改架构 有上限、停机窗口、单点故障
水平扩展 多开几台一样的应用实例 + 负载均衡 近乎线性扩容、天然容错 必须无状态,共享资源要外置

Django 应用要能水平扩展,前提是实例之间不共享任何本地状态。下面这几项是最常见的隐形状态。

Django 侧的无状态改造清单

状态 单机默认做法 多实例的正确做法 实测/依据
会话 db / cached_db 都行 数据库或 Redis,别用 signed_cookies 存大对象 见《Django-生产环境要注意哪些事项》
缓存 LocMemCache(进程内) RedisCache / FileBasedCache 实测 locmem 在 2 个 worker 下同一 URL 出现 2 个不同值
上传文件 MEDIA_ROOT 本地目录 共享盘(NFS)或对象存储(OSS/S3) 否则 A 机上传的文件 B 机读不到
静态资源 各自 collectstatic 同一份共享目录或 CDN 哈希名一致即可安全共享
定时任务 应用内线程 / crontab Celery beat 单实例 + worker 多实例 见《Django-Celery》
限流计数 进程内存 Redis 计数器或 Nginx limit_req 实测见《Django-生产环境中的安全》
日志 本地文件 写 stdout 交给 systemd/采集端 实测 journald 能直接收到 gunicorn 访问行

其中缓存那条最容易翻车,实测很直观:

1
locmem + 2 个 gunicorn worker:同一个 /cached/ 打 8 次,出现 2 个不同值

换成 Redis 之后所有实例共享一份,命中率与内容才稳定。

三、最简单的形态:Nginx 反代一个 Gunicorn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server {
listen 80;
server_name recruit.mycompany.com;

location / {
proxy_pass http://127.0.0.1:8000;

proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# 客户端地址 + 各级代理 IP 的完整链
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 覆盖而不是透传,防止客户端伪造 scheme
proxy_set_header X-Forwarded-Proto $scheme;
}
}

三个要点:

  • X-Forwarded-Proto 要写 $scheme,不要写 $http_x_forwarded_proto,后者等于把伪造权交给客户端。
  • gunicorn 只绑 127.0.0.1,公网入口只有 Nginx。
  • Host 用 $http_host(保留端口)或 $host,配合 Django 的 ALLOWED_HOSTS 使用。

实测这一层跑通后的样子(后端 3 worker,/slow/ 固定 0.2s):

1
2
10 个并发请求墙钟 0.86s(3 个 sync worker)
10 个并发请求墙钟 2.11s(1 个 sync worker,完全串行)

也就是说单机的并发上限约等于 worker 数,加机器之前先把 worker 数配对。

负载均衡:四种分流策略实测

三个后端实例(8061/8062/8063,每个 2 worker),Nginx 里同时配四组 upstream,各开一个入口端口:

1
2
3
4
upstream rr  { server 127.0.0.1:8061; server 127.0.0.1:8062; server 127.0.0.1:8063; }
upstream wt { server 127.0.0.1:8061 weight=3; server 127.0.0.1:8062 weight=1; server 127.0.0.1:8063 weight=1; }
upstream ipp { ip_hash; server 127.0.0.1:8061; server 127.0.0.1:8062; server 127.0.0.1:8063; }
upstream lc { least_conn; server 127.0.0.1:8061; server 127.0.0.1:8062; server 127.0.0.1:8063; }

同一客户端 IP 连打 30 次,各实例的 access log 计数:

策略 配置 实测分布(8061 / 8062 / 8063) 说明
轮询(默认) 无指令 10 / 10 / 10 严格均匀,不看负载
加权轮询 weight=3:1:1 18 / 6 / 6 新机器给高权重
IP 哈希 ip_hash 0 / 0 / 30 同一客户端永远同一台
最少连接 least_conn 4 / 4 / 4(12 并发慢请求) 谁空给谁,长请求更明显

Tengine

4.1 怎么选

  • 默认用轮询或 least_conn:应用无状态时最稳,长请求和短请求混在一起时 least_conn 更公平。
  • ip_hash 只在「实在没法无状态」时当创可贴:它按客户端 IP 分桶,遇到 NAT/公司出口/移动网络会让流量集中到一台,扩容加机器也分不走老用户。
  • 权重适合机器规格不一致的过渡期,同规格就别写权重,徒增维护成本。

会话保持的两种做法

做法 位置 问题
ip_hash / cookie sticky 负载均衡层 扩容和故障切换会抖,IP 分布不均
session 存 Redis / 数据库 应用层 需要一次网络往返,但实例可随意增减

推荐后者。实测把 SESSION_ENGINE 指到缓存后端时,Redis 没起来会直接在读写 session 时抛连接错误——这意味着 Redis 变成了核心依赖,必须配高可用(哨兵或集群),不能当成可选组件。

健康检查与自动容错

5.1 Nginx 的被动健康检查(实测)

1
2
3
4
5
upstream hc {
server 127.0.0.1:8061 max_fails=2 fail_timeout=5s;
server 127.0.0.1:8062 max_fails=2 fail_timeout=5s;
server 127.0.0.1:8063 down;
}
  • max_fails=2:连续失败 2 次就把这台摘掉。
  • fail_timeout=5s:摘掉 5 秒,之后再试探。
  • down:手工标记不参与分发(维护时用)。

实测把 8061 直接 kill -9,然后连打 10 次:

1
连打 10 次(8061 已死、8063 标了 down):200 200 200 200 200 200 200 200 200 200

客户端完全没看到失败,因为 Nginx 默认会在失败后重试下一个 upstream(proxy_next_upstream)。真实证据在错误日志里:

1
connect() failed (111: Connection refused) while connecting to upstream, client: 127.0.0.1

等 fail_timeout 过了再打 4 次,仍然全 200(8061 已死透,Nginx 又把它标失败并跳过):

1
结果:200 200 200 200

全部后端都挂时会怎样

实测把两个 upstream 都杀掉:

1
2
3
两个 upstream 都停 -> / 502
error.log: no live upstreams while connecting to upstream
/static/site.css -> 200(静态与后端无关)

no live upstreams 就是「所有节点都在黑名单里」的信号,排查时先搜这句。

5.3 Tengine 的主动健康检查(官方文档,未在本机验证)

Nginx 开源版只有被动检查;Tengine 提供 ngx_http_upstream_check_module,可以主动探测后端:

1
2
3
4
5
6
7
8
9
upstream django-server {
server 192.168.1.2:8001 max_fails=3 fail_timeout=20s;
server 192.168.1.2:8002 max_fails=3 fail_timeout=20s;
server 192.168.1.3:8001 max_fails=3 fail_timeout=20s;
server 192.168.1.3:8002 max_fails=3 fail_timeout=20s;
check interval=3000 rise=2 fall=3 timeout=1000 type=http;
check_http_send "GET /healthz HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx;
}

本机没装 Tengine,这段配置没有实测过,参数含义以官方文档为准。自建健康检查接口在 Django 侧很简单:

core/views.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import json

from django.db import connection
from django.http import JsonResponse


def healthz(request):
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return JsonResponse({"status": "ok"})
except Exception as exc:

return JsonResponse({"status": "error", "why": str(exc)[:120]}, status=500)

注意健康检查别写重逻辑(查大表、调外部 API),否则探测本身会把后端拖垮。

完整配置样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# /etc/nginx/conf.d/django.conf
upstream django-app {
least_conn;
server 10.0.0.11:8001 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8001 max_fails=3 fail_timeout=30s;
server 10.0.0.13:8001 max_fails=3 fail_timeout=30s backup;
keepalive 32;
}

server {
listen 80;
server_name recruit.example.com;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
server_name recruit.example.com;

ssl_certificate /etc/letsencrypt/live/recruit.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/recruit.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;

access_log /var/log/nginx/django-access.log;
error_log /var/log/nginx/django-error.log warn;

location = /healthz {
proxy_pass http://django-app;
access_log off;
}

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

location /media/ {
alias /srv/django/media/;
expires 7d;
}

location / {
proxy_pass http://django-app;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
client_max_body_size 20m;
}
}

几个和 Django 直接相关的点:

  • keepalive 32 要配 proxy_http_version 1.1 + Connection "",否则复用上不了连接。
  • client_max_body_size 默认 1m,上传文件功能会被它在 Nginx 层直接 413,跟 Django 的 DATA_UPLOAD_MAX_MEMORY_SIZE / FILE_UPLOAD_MAX_MEMORY_SIZE 是两回事。
  • backup 节点只在其它都不可用时启用。
  • 改完配置先 nginx -t 再 nginx -s reload(实测 reload 不断连接)。

容量估算与压测方法

worker 数怎么定

Gunicorn 官方给的起点是 2 × CPU 核数 + 1,本机 8 核 → 17。但这是 CPU 密集的经验值,实际要按接口形态调:

1
2
sync worker:并发上限 = worker 数(实测 1 worker 打 10 个 0.2s 请求 = 2.11s)
uvicorn(ASGI):同步视图跑在 asgiref 线程池里,1 个 worker 也能并发(实测 10 个 0.2s 请求 = 0.29s)

所以选 sync 还是 async,直接决定 worker 数量与数据库连接数预算:

1
2
数据库连接占用 ≈ 实例数 × worker 数 × 每 worker 并发数
8 核单机 17 sync worker + CONN_MAX_AGE=60 → 常驻约 17 条连接

MySQL 侧要相应看 max_connections(实测本机默认 151)。

一条命令做粗压测

没有 ab/wrk 也能拿到数量级:

1
2
$ t0=$(date +%s.%N); for i in $(seq 1 12); do curl -s -o /dev/null http://127.0.0.1:8074/slow/ & done; wait
$ t1=$(date +%s.%N); python3 -c "print('%.2fs' % ($t1-$t0))"

实测数据(/slow/ 每次 sleep(0.2)):

1
2
3
1 个 sync worker        2.11s(串行)
3 个 sync worker 0.86s
least_conn + 3×2 worker 4/4/4 均匀分布,12 并发全部 200

粗测的意义是看趋势和瓶颈位置,不是拿来做容量承诺;正式压测要用 wrk/locust 并盯着数据库与外部依赖。

Tengine 是什么,要不要换

Tengine 是淘宝发起的 Nginx 分支,兼容 Nginx 配置,在此之上加了:

  • 主动健康检查(upstream_check)、一致性 hash、会话保持模块。
  • 动态解析 upstream 域名、异步打印日志与回滚、内存监控。
  • 合并多个 CSS/JS 请求(concat)、去除空白与注释(trim)、按关键字收集运行状态(reqstat)。
  • SSL 异步与硬件加速(QAT)、更强的限流模块。

本机没有安装 Tengine,以上特性均未实测,判断依据只有官方文档与配置兼容性。选型建议:

  • 你的需求能被 Nginx 开源版覆盖(反代、负载均衡、静态、限流、TLS)就别换,运维资料多。
  • 明确需要主动健康检查、concat/trim、一致性 hash 时再考虑 Tengine,或者用 Nginx Plus / OpenResty 的对应能力。

8.1 源码安装 Tengine(官方文档步骤,未在本机执行)

1
2
3
4
5
6
7
8
9
10
11
12
13
$ wget https://ftp.pcre.org/pub/pcre/pcre-8.44.tar.gz
$ tar zxvf pcre-8.44.tar.gz && cd pcre-8.44
$ ./configure --prefix=/usr/local/pcre && make && make install

$ wget https://tengine.taobao.org/download/tengine-2.3.2.tar.gz

$ tar zxvf tengine-2.3.2.tar.gz && cd tengine-2.3.2
$ ./configure --prefix=/data/tengine/ \
--with-http_realip_module \
--with-http_gzip_static_module \
--with-http_upstream_check_module \
--with-pcre=/usr/local/src/pcre-8.44
$ make && make install

老笔记里那行 cd /data/tengine-2.3.0 与解压出的 tengine-2.3.2 目录名不一致,照抄会 No such file or directory;--with-pcre 要指向 pcre 源码目录而不是安装前缀,这些都以官方文档为准。

上线前的检查清单

  1. 会话与缓存都不落本地(Redis 或数据库),Redis 自身有高可用。
  2. 上传文件写共享存储/对象存储,MEDIA_ROOT 不再是本地目录。
  3. collectstatic 输出到共享路径,哈希名一致,Nginx 直接 alias。
  4. 定时任务只在一个实例上跑(Celery beat 单实例)。
  5. 限流计数放 Redis 或 Nginx,不放进程内存。
  6. ALLOWED_HOSTS 覆盖所有对外域名,CSRF_TRUSTED_ORIGINS 带上 https:// 前缀。
  7. X-Forwarded-Proto 由 Nginx 覆盖写 $scheme,后端只监听内网。
  8. 健康检查接口只做 SELECT 1,探测频率与 max_fails/fail_timeout 匹配。
  9. 日志写 stdout 交给 journald/采集端,不各写各的本地文件。
  10. 加机器前先量:worker 数、SQL 条数、慢接口,别用扩容掩盖 N+1。

参考

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