提示

返回博客列表

gunicorn 到底该开几个 worker:从频繁 502 到稳定承载,以及零停机部署怎么做的

我们的站点上线后有个顽疾:白天偶尔 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 模型

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(见第三节)。

几个约束:

  1. 内存:每个 worker 是一个完整的 Python 进程,加载了整个 Django。我们实测每个 worker 常驻 180MB(用了 numpy/pandas 之后更大)。8GB 内存的机器,扣除系统和其他服务,能跑的 worker 数是有上限的:
    (总内存 8GB - 系统 1GB - Redis/Celery 2GB) / 180MB ≈ 27 个
    内存往往比 CPU 更早成为瓶颈。

  2. 数据库连接:每个 worker 可能持有 1~2 个数据库连接。worker 数 × 2 不能超过 PostgreSQL 的 max_connections(默认 100)。我们在第一批那篇事故复盘里提到过——worker 数失控直接把数据库连接打满。

  3. 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 的注意事项(我踩过的):

  1. 必须尽早 monkey patch。gunicorn 的 gevent worker 会自动做,但如果你自己写脚本启动就要手动:
    python from gevent import monkey monkey.patch_all()
  2. psycopg2 是 C 扩展,monkey patch 对它无效——所以数据库连接在 gevent 下不是协程化的。这意味着 gevent worker 里的数据库查询仍然会阻塞整个 worker。解决办法:
  3. 用 psycogreen 做 psycopg2 的协程化;或者
  4. SSE 服务不查数据库(我们最后走的是这条路:SSE 只订阅 Redis 频道,不做任何数据库查询)。

  5. 用 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。省一半。

代价:

  1. 改代码必须重启 master,不能靠 SIGHUP 平滑重载(fork 出来的 worker 继承的是旧代码);
  2. 应用在 fork 之前建立的连接会被所有 worker 继承——比如你如果在模块级别建了 Redis 连接或者数据库连接,fork 之后多个进程共用一个 socket,会出现各种诡异的错误;
  3. 一些库在 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;    # 默认开;上传很大时才关
}

三个对齐原则:

  1. proxy_read_timeout ≥ gunicorn 的 timeout。如果 nginx 先超时,用户拿到 504,但 gunicorn 还在处理,worker 白白占用。反过来 gunicorn 先超时,用户拿到 502。一般让 nginx 稍微长一点(比如 gunicorn 60s,nginx 65s),这样超时由 gunicorn 控制,行为更可控。

  2. keepalive:nginx 和 gunicorn 之间复用连接,省掉每次请求的 TCP 握手。跨机器部署时(nginx 和 Django 不在一台机器)收益明显。

  3. 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,顺序错了一定会出事:

  1. 先跑迁移(向前兼容的迁移),此时新旧代码都能正常工作;
  2. 再滚动发布新代码;
  3. 清理性的迁移(删字段、改名)等发布完成后再做。

所谓"向前兼容"是指:旧代码在新 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

十一、坑清单

  1. 照抄 CPU × 2 + 1 → 没有依据,不是太多(浪费内存和连接)就是太少(502)。
  2. 同步 worker 处理长连接(SSE/WebSocket) → 一个连接占一个 worker。用 gevent 单独跑。
  3. 不设 max_requests → 内存缓慢泄漏,几天后 OOM。
  4. 设了 max_requests 不加 jitter → 所有 worker 同时重启,瞬间服务能力归零。
  5. timeout 设太短 → 正常慢请求被杀,用户 502。
  6. timeout 设太长 → 真卡死的 worker 占着不释放。
  7. nginx proxy_read_timeout < gunicorn timeout → nginx 先掐断,用户 504 但 worker 还在忙。
  8. 开了 preload_app 还想用 SIGHUP 重载 → 新代码不生效(以为发布了其实没发布)。
  9. preload_app + 模块级数据库连接 → fork 后多进程共享 socket,诡异错误。
  10. 发版直接 kill -9 → 进行中的请求全部中断,用户看到错误。用 SIGTERM 或 SIGHUP。
  11. 发布不做健康检查就切流量 → 新实例启动失败,全站 502。
  12. 破坏性 schema 迁移和代码同时发布 → 旧代码在新 schema 下报错。拆成多次发布。
  13. 大表加索引没用 CONCURRENTLY(在迁移里)→ 锁表几十秒,写入全阻塞。
  14. 压测只压健康检查接口 → 数字是假的。
  15. reload = True 留在生产配置里 → 每改一个文件就重启,性能极差且不可控。

总结一下这次调优最大的收获:worker 数不是一个"配置技巧",它是你对自己服务并发模型的量化认识。

我现在配任何服务都会先问三个问题:

  1. 请求的平均耗时是多少?(决定了单个 worker 的吞吐)
  2. 峰值并发是多少?(决定了需要多少 worker)
  3. 有多少请求是长连接 / 慢请求?(决定要不要拆出来用协程)

把这三个数字搞清楚,worker 数自然就出来了,不需要任何公式。而压测的作用,就是验证你的估算——我估算 10 个 worker 够用,压测发现 12 个是甜点,差得不远但确实需要实测校准。

最后一句:发版方式比配置更重要。参数配得再好,每次发版都中断 5 秒,用户体验也是差的。零停机发布这套流程(健康检查 + 平滑切换 + 优雅退出)实现一次大概半天,但它会一直用下去,而且顺便让你对"服务是怎么起来的"有了完整的理解——这比记住几个配置参数有价值得多。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部