我们的站点上线后有个顽疾:白天偶尔 502,高峰期响应变慢,而且每次发版都会有几秒钟不可用。
我当时对 gunicorn 的理解只有一条网上抄来的公式:
workers = CPU 核数 × 2 + 1。机器 8 核,于是配了 17。这个数字说不出道理,但"大家都这么配"。后来我花了一天做压测,才明白这里头其实是四件不同的事:worker 数量(并发能力)、worker 类型(同步还是协程)、超时与回收(防泄漏、防卡死)、重启方式(决定发版是否中断)。这篇把压测数据、最终配置和零停机发布流程都写下来。
TL;DR:同步 worker 的数量取决于"请求平均耗时和你能接受多少并发",不是死记公式——同步模式下 worker 数 = 同时能处理的请求数。IO 密集(大量外部 API、SSE)用 gevent worker,CPU 密集用 sync。三个必配:
max_requests(防内存泄漏)、graceful_timeout(优雅收尾)、timeout(杀掉卡死的请求)。零停机发布用 SIGHUP 平滑重载 或 双端口滚动切换,配合 nginx 的健康检查。改配置前先用wrk压一遍,别凭感觉调。
目录
- 一、先理解 worker 模型
- 二、worker 数到底怎么定
- 三、worker 类型:sync、threads 还是 gevent
- 四、四个必配的超时参数
- 五、preload_app:省内存但有代价
- 六、和 nginx 配合的那些参数
- 七、压测:怎么知道调好了
- 八、零停机部署:两种做法
- 九、我们最终的配置
- 十、监控:worker 的健康度
- 十一、坑清单
一、先理解 worker 模型
gunicorn 是 master + worker 架构:
gunicorn master(只管进程,不处理请求)
├── worker 1
├── worker 2
├── ...
└── worker N
master 负责:拉起 worker、监控 worker 存活、worker 挂了自动补一个、接收信号(重载/停止)。
关键认知:同步(sync)worker 模式下,一个 worker 进程同时只能处理一个请求。
这意味着:
worker 数 = 8 → 最多同时处理 8 个请求
第 9 个请求 → 排队等
所以如果你的接口平均耗时 200ms,8 个 worker 的理论 QPS 上限是:
8 / 0.2 = 40 QPS
超过这个数就开始排队,队列积压后要么超时,要么 nginx 那边报 502/504。这就是我们白天 502 的原因:某些接口耗时飙到 1~2 秒,把 worker 全占住了。
理解这一点之后,"worker 数 = CPU × 2 + 1"这个公式就站不住脚了——这个公式来自"CPU 密集 + 有 IO 等待"的假设,而它到底该是多少,取决于你的请求有多耗时。
二、worker 数到底怎么定
正确的思路是从目标倒推:
需要的 worker 数 ≈ 目标并发 × 平均响应时间
举例:
| 平均响应时间 | 目标并发 | 需要 worker 数(sync) |
|---|---|---|
| 50 ms | 20 | 1 |
| 200 ms | 20 | 4 |
| 500 ms | 40 | 20 |
| 2 s(慢接口) | 20 | 40 ← 已经不现实了 |
当"需要的 worker 数"变得很大时,说明同步模式不合适了,应该换 gevent(见第三节)。
几个约束:
-
内存:每个 worker 是一个完整的 Python 进程,加载了整个 Django。我们实测每个 worker 常驻 180MB(用了 numpy/pandas 之后更大)。8GB 内存的机器,扣除系统和其他服务,能跑的 worker 数是有上限的:
(总内存 8GB - 系统 1GB - Redis/Celery 2GB) / 180MB ≈ 27 个
内存往往比 CPU 更早成为瓶颈。 -
数据库连接:每个 worker 可能持有 1~2 个数据库连接。worker 数 × 2 不能超过 PostgreSQL 的
max_connections(默认 100)。我们在第一批那篇事故复盘里提到过——worker 数失控直接把数据库连接打满。 -
CPU:同步 worker 处理 CPU 密集请求时,超过核数之后上下文切换开销增大,吞吐反而下降。
我的实测(8 核 16G,接口平均 180ms,混有少量慢请求):
| worker 数 | QPS(P95 < 1s 为准) | 内存占用 | 结论 |
|---|---|---|---|
| 4 | 22 | 0.9 GB | 不够 |
| 8 | 43 | 1.6 GB | 还行 |
| 12 | 61 | 2.3 GB | 甜点 |
| 17(原配置) | 63 | 3.2 GB | 边际收益为 0,纯浪费内存 |
| 24 | 58 | 4.4 GB | 下降(上下文切换 + 内存压力) |
最终从 17 降到 12,QPS 一样,内存省了 1GB。原来那 5 个 worker 纯属浪费,还占用了数据库连接。
给个起点建议(之后用压测调):
- CPU 密集为主:
workers = CPU 核数 - 混合型:
workers = CPU 核数 × 1.5 - IO 密集为主:换 gevent,别硬堆 sync worker
三、worker 类型:sync、threads 还是 gevent
| 类型 | 并发模型 | 每进程并发 | 适合 | 注意 |
|---|---|---|---|---|
sync(默认) |
多进程 | 1 | CPU 密集、简单场景 | 慢请求会占满 |
threads |
多线程 | threads 值 |
中等 IO | GIL 限制,注意线程安全 |
gevent |
协程 | 上千 | IO 密集、长连接(SSE) | 需要 monkey patch,C 扩展兼容问题 |
eventlet |
协程 | 上千 | 同 gevent | 维护不如 gevent 活跃 |
gthread |
多进程+多线程 | cores × threads | 折中 |
我们的场景里有个关键角色:SSE 长连接(任务进度推送)。一个 SSE 连接会占住一个 sync worker 直到任务结束——几个用户同时下载就把所有 worker 占满了。这就是最初 502 的直接原因。
解决办法:给 SSE 单独跑一组 gevent worker,通过 nginx 把 SSE 路径路由过去:
# 主服务:sync,12 个 worker,处理普通请求
gunicorn -c gunicorn.conf.py video_downloader.wsgi:application
# SSE 服务:gevent,4 个 worker 就能扛上千长连接
gunicorn -k gevent --worker-connections 1000 -w 4 \
-b 127.0.0.1:8001 video_downloader.wsgi:application
nginx 按路径分流:
upstream django_main { server 127.0.0.1:8000; }
upstream django_sse { server 127.0.0.1:8001; }
location /task/ {
proxy_pass http://django_sse;
...
}
location / {
proxy_pass http://django_main;
...
}
gevent 的注意事项(我踩过的):
- 必须尽早 monkey patch。gunicorn 的 gevent worker 会自动做,但如果你自己写脚本启动就要手动:
python from gevent import monkey monkey.patch_all() - psycopg2 是 C 扩展,monkey patch 对它无效——所以数据库连接在 gevent 下不是协程化的。这意味着 gevent worker 里的数据库查询仍然会阻塞整个 worker。解决办法:
- 用
psycogreen做 psycopg2 的协程化;或者 -
SSE 服务不查数据库(我们最后走的是这条路:SSE 只订阅 Redis 频道,不做任何数据库查询)。
-
用 gevent 时不要开
preload_app+ 某些库在 fork 后行为异常,具体问题具体测。
四、四个必配的超时参数
这几个参数不配,服务跑久了一定会出问题:
# gunicorn.conf.py
timeout = 60 # worker 超过 60 秒没响应就被 master 杀掉重启
graceful_timeout = 30 # 收到停止信号后,给 30 秒处理完手上的请求
max_requests = 2000 # 每个 worker 处理 2000 个请求后自动重启
max_requests_jitter = 200 # 加随机抖动,避免所有 worker 同时重启
timeout(默认 30 秒):worker 处理一个请求超过这个时间,master 认为它卡死了,直接 kill 并重拉一个。
- 设太短:正常的慢请求(比如生成报表)被杀掉,用户看到 502;
- 设太长:真卡死的 worker 占着位置不释放。
- 如果你的业务里有耗时超过 30 秒的请求,要么调大这个值,要么把那个功能改成异步任务。后者是正确的做法(同步请求不该跑几十秒)。
graceful_timeout:发 SIGTERM(停止)之后,master 给 worker 这么多时间去把手上的请求处理完。默认 30 秒通常够。如果是长连接(SSE),这个值要设大一些,否则每次重启都会粗暴断开所有 SSE 连接。
max_requests + max_requests_jitter:每个 worker 处理 N 个请求后自动重启。这是防内存泄漏的标准做法——Python 应用跑久了内存会缓慢增长(循环引用、第三方库的缓存等),定期重启 worker 能把内存打回基线。
jitter 很重要:不加抖动的话,所有 worker 会在同一时刻达到 2000 请求然后集体重启,那一瞬间服务能力掉到 0。加了抖动就让重启分散开。
我们加上 max_requests 之后的效果:worker 内存从"三天涨到 600MB"变成"稳定在 180~220MB"。
五、preload_app:省内存但有代价
preload_app = True 的意思是:在 master 进程里先加载应用代码,再 fork 出 worker。
好处(写时复制 Copy-on-Write):
- 所有 worker 共享同一份代码内存,省很多;
- 启动更快(代码只加载一次)。
我们实测:12 个 worker,不 preload 是 2.3GB,preload 之后 1.1GB。省一半。
代价:
- 改代码必须重启 master,不能靠 SIGHUP 平滑重载(fork 出来的 worker 继承的是旧代码);
- 应用在 fork 之前建立的连接会被所有 worker 继承——比如你如果在模块级别建了 Redis 连接或者数据库连接,fork 之后多个进程共用一个 socket,会出现各种诡异的错误;
- 一些库在 fork 后不安全(比如某些使用了线程的代码)。
安全用法:preload 只加载代码,不要在模块级别建立任何连接。Django 的惰性连接机制(用到时才连数据库)恰好符合这一点,所以 preload_app=True 对 Django 通常是安全的。
如果你遇到"preload 之后出现奇怪的连接错误",第一件事就是检查有没有模块级连接。
六、和 nginx 配合的那些参数
几个必须对齐的地方:
upstream django {
server 127.0.0.1:8000 max_fails=3 fail_timeout=10s;
keepalive 32; # 复用到上游的连接
}
location / {
proxy_pass http://django;
proxy_http_version 1.1; # keepalive 需要 1.1
proxy_set_header Connection "";
# 超时:要 >= gunicorn 的 timeout,否则 nginx 先掐断
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲:上传大文件时关掉请求体缓冲,避免写满磁盘
client_max_body_size 100m;
proxy_request_buffering on; # 默认开;上传很大时才关
}
三个对齐原则:
-
proxy_read_timeout≥ gunicorn 的timeout。如果 nginx 先超时,用户拿到 504,但 gunicorn 还在处理,worker 白白占用。反过来 gunicorn 先超时,用户拿到 502。一般让 nginx 稍微长一点(比如 gunicorn 60s,nginx 65s),这样超时由 gunicorn 控制,行为更可控。 -
keepalive:nginx 和 gunicorn 之间复用连接,省掉每次请求的 TCP 握手。跨机器部署时(nginx 和 Django 不在一台机器)收益明显。 -
Unix socket vs TCP:同机部署可以用 unix socket(省一点开销):
python bind = 'unix:/run/gunicorn.sock'
代价是 nginx 要有该 socket 的读权限,且容器部署时共享 socket 文件比较麻烦。我们最后用回 TCP(127.0.0.1:8000),因为容器化更方便,性能差异可以忽略。
七、压测:怎么知道调好了
别凭感觉,用工具压。我用 wrk(也可以用 hey,更简单):
# 12 线程,200 并发,压 30 秒
wrk -t12 -c200 -d30s --latency http://127.0.0.1:8000/api/health
# 带 POST 的压测
wrk -t8 -c100 -d30s -s post.lua http://127.0.0.1:8000/api/parse
看三个指标:
| 指标 | 关注点 |
|---|---|
| Requests/sec | 吞吐,越高越好 |
| Latency P99 / P95 | 长尾延迟,比平均值重要得多 |
| Socket errors / non-2xx | 出错就说明已经过载了 |
正确的压测方法:逐步加并发,找到"错误率开始上升"的那个拐点,然后在拐点的 70% 处作为生产配置。
我们那次的压测结果(sync,12 worker):
| 并发 | QPS | P95 | 错误率 |
|---|---|---|---|
| 50 | 61 | 320 ms | 0 |
| 100 | 63 | 610 ms | 0 |
| 200 | 64 | 1.4 s | 0.3% |
| 400 | 58 | 4.2 s | 4.1% ← 拐点 |
| 800 | 41 | 12 s | 18% |
结论:并发 100 左右是舒适区,200 开始排队。所以我们的生产配置留了余量,并且在 nginx 层做了并发限制,超过阈值就快速失败(返回 503 + "稍后重试")而不是让所有人一起卡死。
注意压测要压"真实接口",别只压 /health。健康检查接口不查数据库,压出来的数字是假的。我一般挑三个接口:首页(有数据库查询)、列表页(查询较重)、一个写接口。
八、零停机部署:两种做法
做法一:SIGHUP 平滑重载
gunicorn 的 master 收到 SIGHUP 会:启动新 worker(用新代码),等旧 worker 处理完手上的请求后优雅退出。
# 1. 拉新代码
git pull
# 2. 通知 master 平滑重载
kill -HUP $(cat /run/gunicorn.pid)
# 或者 systemd
systemctl reload gunicorn
前提:
- 不要用
preload_app(用了的话新代码不会加载); graceful_timeout要够长(让旧请求能处理完);- 长连接(SSE)会在重载时断开,客户端要能自动重连(SSE 原生支持)。
优点:一条命令,几秒钟完成。缺点:重载期间新旧代码同时存在(有几秒钟两个版本在跑),如果这次发布涉及数据库 schema 变更,要格外小心(见下)。
做法二:双端口滚动切换(更稳)
适合有 schema 迁移、或者不想有任何新旧混跑的发布:
#!/bin/bash
# deploy.sh —— 零停机发布
set -euo pipefail
NEW_PORT=${NEW_PORT:-8001}
OLD_PORT=${OLD_PORT:-8000}
APP_ROOT=/opt/viddown
# 1. 新代码部署到独立目录(不影响正在跑的)
rsync -a --delete --exclude '.git' ./ "$APP_ROOT-new/"
# 2. 起新实例(新端口)
cd "$APP_ROOT-new"
gunicorn -c gunicorn.conf.py --bind 127.0.0.1:$NEW_PORT --pid /run/gunicorn-new.pid --daemon
# 3. 等它活过来(健康检查,最多等 60 秒)
for i in $(seq 1 60); do
if curl -fsS --max-time 3 http://127.0.0.1:$NEW_PORT/health >/dev/null; then
echo "new instance is up"
break
fi
sleep 1
done
curl -fsS http://127.0.0.1:$NEW_PORT/health || { echo "health check failed"; exit 1; }
# 4. 切 nginx(reload 是平滑的,不断连接)
sed -i "s/127.0.0.1:$OLD_PORT/127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
nginx -t && nginx -s reload
# 5. 等旧实例的请求处理完
sleep 15
# 6. 停旧实例(SIGTERM,优雅退出)
kill -TERM $(cat /run/gunicorn-old.pid) || true
# 7. 目录轮换
mv "$APP_ROOT" "$APP_ROOT-old" && mv "$APP_ROOT-new" "$APP_ROOT"
关键在第 3 步的健康检查。没有它,新实例如果启动失败(比如配置写错),nginx 一切过去就是全站 502。
数据库迁移的顺序(重要)
如果发布包含 Django migrations,顺序错了一定会出事:
- 先跑迁移(向前兼容的迁移),此时新旧代码都能正常工作;
- 再滚动发布新代码;
- 清理性的迁移(删字段、改名)等发布完成后再做。
所谓"向前兼容"是指:旧代码在新 schema 下也能跑。比如:
- ✅ 加一个可空字段(旧代码不知道它,不影响);
- ✅ 加索引(旧代码无感知;但大表要用 CONCURRENTLY,见第一批那篇);
- ❌ 删字段(旧代码还在查它,立刻报错);
- ❌ 把字段改成非空(旧代码插入时不给值,报错)。
破坏性变更要拆成两次发布:第一次加新字段 + 双写,第二次切读,第三次删旧字段。听起来麻烦,但这是不停机改 schema 的唯一稳妥办法。
九、我们最终的配置
# gunicorn.conf.py(生产,简化版)
import multiprocessing
bind = '127.0.0.1:8000'
workers = 12 # 8 核机器,压测后定的
worker_class = 'sync'
worker_connections = 1000 # 仅 gevent 用
# 超时与回收
timeout = 60
graceful_timeout = 30
max_requests = 2000
max_requests_jitter = 200
# preload 省内存(确认无模块级连接后才开)
preload_app = True
# 进程与日志
pidfile = '/run/gunicorn.pid'
user = 'www-data'
accesslog = '/var/log/gunicorn/access.log'
errorlog = '/var/log/gunicorn/error.log'
loglevel = 'info'
access_log_format = '%({x-request-id}i)s %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" %(L)s'
# 部署相关
reload = False # 生产绝不开
threads = 1 # sync 模式下无意义
access_log_format 里的 %(L)s 是请求耗时(秒),%({x-request-id}i)s 是我们自己的 request id——有了这两个,日志能直接对接第一批那篇里讲的排查流程。
SSE 服务单独一份配置:
# gunicorn_sse.conf.py
bind = '127.0.0.1:8001'
workers = 4
worker_class = 'gevent'
worker_connections = 1000
timeout = 0 # SSE 是长连接,不要超时杀掉
graceful_timeout = 120
max_requests = 0 # 长连接服务不按请求数重启
SSE 的 timeout = 0(禁用超时)是必须的,否则长连接会被 master 当成卡死杀掉。max_requests = 0 同理。
十、监控:worker 的健康度
加了这几条监控之后,502 从"事后才知道"变成了"提前预警":
#!/bin/bash
# check_gunicorn.sh
MASTER_PID=$(cat /run/gunicorn.pid)
EXPECTED=12
# 1. worker 数量对不对
ACTUAL=$(pgrep -P "$MASTER_PID" | wc -l)
if [ "$ACTUAL" -ne "$EXPECTED" ]; then
echo "WARN: gunicorn workers $ACTUAL != $EXPECTED"
fi
# 2. master 活着吗
if ! kill -0 "$MASTER_PID" 2>/dev/null; then
echo "ALERT: gunicorn master is dead"
fi
# 3. 最近有没有 worker 被 timeout 杀掉(说明有慢请求)
KILLED=$(grep -c 'WORKER TIMEOUT' /var/log/gunicorn/error.log 2>/dev/null || echo 0)
echo "worker timeout count: $KILLED"
"WORKER TIMEOUT" 这个指标特别值得监控。它意味着有请求超过了你设的 timeout——要么是慢查询,要么是外部服务卡住,要么是 worker 不够。每次我们优化完,这个数字都会明显下降。
看日志里的重启记录:
grep -E 'Booting worker|Handling signal|WORKER TIMEOUT' /var/log/gunicorn/error.log | tail -20
十一、坑清单
- 照抄
CPU × 2 + 1→ 没有依据,不是太多(浪费内存和连接)就是太少(502)。 - 同步 worker 处理长连接(SSE/WebSocket) → 一个连接占一个 worker。用 gevent 单独跑。
- 不设
max_requests→ 内存缓慢泄漏,几天后 OOM。 - 设了
max_requests不加 jitter → 所有 worker 同时重启,瞬间服务能力归零。 timeout设太短 → 正常慢请求被杀,用户 502。timeout设太长 → 真卡死的 worker 占着不释放。- nginx
proxy_read_timeout< gunicorntimeout→ nginx 先掐断,用户 504 但 worker 还在忙。 - 开了
preload_app还想用 SIGHUP 重载 → 新代码不生效(以为发布了其实没发布)。 preload_app+ 模块级数据库连接 → fork 后多进程共享 socket,诡异错误。- 发版直接
kill -9→ 进行中的请求全部中断,用户看到错误。用 SIGTERM 或 SIGHUP。 - 发布不做健康检查就切流量 → 新实例启动失败,全站 502。
- 破坏性 schema 迁移和代码同时发布 → 旧代码在新 schema 下报错。拆成多次发布。
- 大表加索引没用 CONCURRENTLY(在迁移里)→ 锁表几十秒,写入全阻塞。
- 压测只压健康检查接口 → 数字是假的。
reload = True留在生产配置里 → 每改一个文件就重启,性能极差且不可控。
总结一下这次调优最大的收获:worker 数不是一个"配置技巧",它是你对自己服务并发模型的量化认识。
我现在配任何服务都会先问三个问题:
- 请求的平均耗时是多少?(决定了单个 worker 的吞吐)
- 峰值并发是多少?(决定了需要多少 worker)
- 有多少请求是长连接 / 慢请求?(决定要不要拆出来用协程)
把这三个数字搞清楚,worker 数自然就出来了,不需要任何公式。而压测的作用,就是验证你的估算——我估算 10 个 worker 够用,压测发现 12 个是甜点,差得不远但确实需要实测校准。
最后一句:发版方式比配置更重要。参数配得再好,每次发版都中断 5 秒,用户体验也是差的。零停机发布这套流程(健康检查 + 平滑切换 + 优雅退出)实现一次大概半天,但它会一直用下去,而且顺便让你对"服务是怎么起来的"有了完整的理解——这比记住几个配置参数有价值得多。