gunicorn 上线后总 504:worker 数、worker_class 和 timeout 我是怎么定的

举报
phantomxjc 发表于 2026/09/23 09:07:41 2026/09/23
【摘要】 我们那套不动产共享监控系统是Flask写的,开发机上跑flaskrun好好的,迁到服务器上用gunicorn起,头一个月几乎每天早上九点前后被业务科室投诉"页面转圈然后报错"。nginx的错误日志里一

我们那套不动产共享监控系统是 Flask 写的,开发机上跑 flask run 好好的,迁到服务器上用 gunicorn 起,头一个月几乎每天早上九点前后被业务科室投诉"页面转圈然后报错"。

nginx 的错误日志里一片:

upstream prematurely closed connection while reading response header from upstream

和

upstream timed out (110: Connection timed out) while reading response header from upstream

这两种写法混在一起的时候,最容易误判。我一开始以为是数据库慢,去翻慢查询翻了两天,什么也没翻到。后来才反应过来:超时不一定发生在数据库,可能根本没轮到数据库。

先分清是 nginx 超时还是 gunicorn 超时

upstream timed out 是 nginx 等太久了,通常是 proxy_read_timeout 到了(默认 60 秒)。 upstream prematurely closed connection 是后端自己把连接掐了,几乎一定是 gunicorn 的 timeout 触发,worker 被 master 杀掉重拉。

gunicorn 的 timeout 默认是 30 秒。也就是说,我们那个导出 Excel 的接口只要超过 30 秒,worker 就当场被杀,nginx 那边收到的就是"连接被提前关闭"。业务看到的却是 504。

确认方法很简单,把 gunicorn 的日志级别调上来:

gunicorn -w 4 -b 127.0.0.1:5000 --timeout 60 --log-level info app:app

worker 被杀时日志里会有这么一行,非常直白:

[CRITICAL] WORKER TIMEOUT (pid:12345)

看到这行就不用再怀疑数据库了。

worker 数量不是越多越好

网上到处都是 (2 * CPU核心数) + 1 这个公式。它的原话出自 gunicorn 官方文档,但原文有个前提被大家省略了——它假设你的 worker 是异步的。对默认的 sync worker,官方的说法是"大概 2-4 倍 CPU 核数,取决于 IO 等待",而且加了句"别盲目加大,内存会爆"。

我们服务器是 4 核 8G,一开始我按公式开了 9 个 worker。结果每个 worker 加载完 Flask + SQLAlchemy + PyMySQL 之后常驻内存大概 180MB,9 个就是 1.6G,加上系统和其他服务,内存水位长期在 70%。更麻烦的是高峰期 9 个 worker 全堵在同步数据库查询上,反而更慢。

后来改成这样定:

nproc          # 先看核数

然后 sync 模式先给 4 个(等于核数),压一段时间看 CPU:如果 CPU 打不满但请求在排队,说明是 IO 阻塞为主,加 worker 有意义;如果 CPU 已经 80% 以上还排队,加 worker 只会让上下文切换更糟,这时候该换 worker_class。

什么时候换 gevent

我们的接口大部分时间是等数据库返回,典型的 IO 密集型。这种场景 sync worker 一个进程同时只能处理一个请求,非常浪费。换成 gevent 之后,一个 worker 能并发处理成百上千个连接。

pip install gevent
gunicorn -w 2 -k gevent --worker-connections 1000 --timeout 60 -b 127.0.0.1:5000 app:app

注意 worker 数要跟着降下来。异步模式下 4 核给 2 个 worker 就够,再乘以 worker_connections 才是并发上限。

这里有个坑我踩过:换 gevent 之后 PyMySQL 还是阻塞的。gevent 只能 monkey patch 掉标准库的 socket,如果你的数据库驱动是 C 扩展实现且没做 gevent 适配,它照样会把整个 worker 卡住。

验证方法很土但有效——在接口里故意 time.sleep(5),看两个并发请求是不是 5 秒一起返回(说明协程生效了)还是 10 秒(说明没生效)。sleep 能 patch 掉,真实数据库查询不一定。

如果数据库驱动确实不配合,退而求其次用 gthread:

gunicorn -w 4 -k gthread --threads 4 --timeout 60 -b 127.0.0.1:5000 app:app

gthread 是线程池,不需要 monkey patch,对驱动没要求,代价是每个请求占一个线程,并发上限就是 worker数 × threads。

timeout 要和 nginx 对齐

这是我最想强调的一点。很多人(包括当时的我)只改了 gunicorn 的 timeout,没动 nginx,或者反过来。

原则是:nginx 的 proxy_read_timeout 必须大于 gunicorn 的 timeout,否则 gunicorn 还有 10 秒就要出结果了,nginx 先掐断,日志里留下一堆 upstream timed out,gunicorn 那边却一切正常,查半天查不到。

location / {
    proxy_pass http://127.0.0.1:5000;
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
    proxy_connect_timeout 5s;
}

gunicorn 那边:

gunicorn -w 2 -k gevent --timeout 90 --graceful-timeout 30 -b 127.0.0.1:5000 app:app

注意 90 < 120,留了 30 秒缓冲给 nginx 的调度开销。

graceful-timeout 是另一个容易被忽略的参数。默认 30 秒。它管的是"收到停止信号后,worker 最多再干多久就被强杀"。如果你有长任务,重启服务时正在跑的请求会被硬切,业务那边就是一次 502。设长一点,或者干脆把长任务丢给 Celery,别在请求里跑。

卡住的 worker 到底在干嘛

有时候 worker 超时不是因为慢,是因为死锁或者外部调用挂住了(比如调某个第三方接口,对方不响应也不超时)。这种情况加 timeout 只是让它死得快一点,根因还在。

我常用的办法是 py-spy:

pip install py-spy
py-spy dump --pid 12345

它会直接把那个卡住的 worker 的 Python 调用栈打出来,一眼就能看到停在 socket.recv 还是某个锁上。不用改代码不用重启,生产环境可以直接用。

我现在的最终配置

写成了配置文件,比命令行好维护:


bind = "127.0.0.1:5000"
workers = 2
worker_class = "gevent"
worker_connections = 500
timeout = 90
graceful_timeout = 30
keepalive = 5
max_requests = 2000
max_requests_jitter = 200
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
capture_output = True

max_requests 是让 worker 处理完一定数量请求后自动重启。Python 应用跑久了难免有内存缓慢增长,这招比定期重启整个服务温和得多。加 jitter 是为了避免所有 worker 同时重启造成瞬时空档。

改完之后,早上那波 504 再没出现过。回头看,真正解决问题的不是把 timeout 从 30 加到 90,而是想清楚了这个应用是 IO 密集型、该用异步 worker——加 timeout 只是让症状没那么难看。

所以下次遇到 504,先别急着调大数字。先看日志是 timed out 还是 prematurely closed,确定是哪一层的超时,再决定动手的地方。

作者:phantomxjc

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。