先看场景:什么情况下容易出问题
Flask 搭配 Gunicorn 多进程跑久了内存涨上去不降,一般不是框架本身的问题,而是代码里有些对象没让 GC 及时回收。多进程模式下每个 worker 独立跑,泄漏只影响当前 worker,不会跨进程传染,但一个 worker 内存吃到接近系统上限就会被 kill,导致请求失败。
在Flask应用中使用Gunicorn的多进程模式时,内存泄漏的常见原因包括全局变量或缓存的无限制增长、数据库连接池未正确释放、以及第三方库中隐藏的对象引用。例如,如果在请求处理中向全局列表追加数据,且未在请求结束时清理,该列表会随进程持续膨胀。另一种典型场景是SQLAlchemy的Session对象未在每次请求后关闭,导致数据库连接占用内存无法回收。
如果你的应用在稳定请求下,RSS 每隔几小时就翻倍,基本可以往这些方向排查。可以先关掉所有第三方插件,只跑最简单路由试试,看内存是否还涨,缩小范围。
用 max-requests 强制重启,但要看重启成本
Gunicorn提供了--max-requests和--max-requests-jitter参数,可让worker在处理指定数量请求后主动重启,强制释放累积的内存。例如,设置--max-requests=1000 --max-requests-jitter=200,使worker在800到1200请求间随机重启。此方法能有效规避长期运行导致的内存膨胀,但会略微增加请求延迟。注意,若应用程序在启动时加载了大型预训练模型,重启worker的代价较高,此时应谨慎设置max-requests的值。
具体操作:在 gunicorn 启动命令或配置文件中加上这两个参数。比如 gunicorn -w 4 app:app --max-requests 1000 --max-requests-jitter 200。重启后观察内存曲线,如果重启后内存立刻降下来,说明确实有累积内存。但假如每次重启后内存还是逐渐涨到同样高度,说明泄漏速度很快,或者重启间隔太长,可以把 max-requests 调小到 200–300 再试。注意:如果 worker 重启时正好有长连接(比如 WebSocket),可能导致连接断开,需要确认业务是否能接受。
数据库连接池泄漏:最常见也最容易漏掉
当Flask应用使用SQLAlchemy且未配置scope时,每个请求可能创建多个数据库连接且不归还。典型表现是Gunicorn worker的内存占用持续增长,即使请求量稳定。检查方法是在进程中执行ps aux查看RSS列,或用memory_profiler逐行分析路由函数。修复措施包括在Flask的teardown_appcontext钩子中调用db.session.remove(),并确保SQLAlchemy的pool_recycle参数小于数据库端连接超时时间(如MySQL的wait_timeout),避免使用过期连接。
如果你的应用用了 Flask-SQLAlchemy,它默认已经绑定了请求上下文,我见过很多泄漏其实是自己额外创建的 engine 或 session 没清理。先在 teardown_appcontext 里加上 db.session.remove(),然后在 create_engine 时设置 pool_size=5, max_overflow=10, pool_recycle=3600。如果跑在 MySQL 上,用 SHOW PROCESSLIST 看连接数是否持续增长;如果是,说明连接没归还。注意 pool_recycle 必须小于数据库端 wait_timeout,否则连接被服务端断开后客户端还在用,报错后重建连接又会泄漏。
preload_app 和 gevent worker 的边界
启用 Gunicorn 的 preload_app 可以加快 worker 启动,但如果应用在导入阶段创建了全局缓存或打开文件描述符,所有 worker 会共享那一时刻的内存快照,后续修改不会同步,反而让内存基数变大。更危险的是,线程不安全的单例对象可能引发数据错乱。所以 preload_app 只适合启动无副作用的应用,比如只读配置的 API 网关。如果你的应用初始化时加载了模型字典或读取大文件,建议关闭 preload_app,或者结合 --max-requests 让 worker 定期重启以释放预加载占用的内存。
如果选了 gevent worker,要注意协程里的局部变量可能因异常捕获的 traceback 或闭包而泄漏。建议在路由处理结束后显式调用 gc.collect() 并观察对象数量,同时避免在协程间传递未加锁的可变对象。gevent 下 SQLAlchemy 的连接池管理方式和同步模式不同,建议在 request 钩子中强制清理 session。
容易忽略的检查点
上面提到的配置改完后,不要只看内存绝对值,要看趋势。用 watch -n 60 'ps aux --sort=-%mem | head -20' 观察 worker 的 RSS 在重启前后的变化。如果重启后 RSS 降到初始值,说明重启有效;如果每次重启后 RSS 初始值都比上一次高,说明泄漏发生在 worker 初始化阶段(比如加载了动态数据)。这时候需要检查 on_starting、on_exit 钩子中是否有资源没释放。
还可以在代码中临时加 gc.set_debug(gc.DEBUG_LEAK),观察标准错误输出是否有未回收的循环引用对象。但注意生产环境谨慎开启,会打大量日志。更保守的做法:在路由函数前后打印 len(gc.get_objects()),看差值是否越来越大。如果差值是某个第三方库的实例数,就去查该库的 issue 或升级版本。
最后一点:用 gunicorn --preload 时,如果父进程内存一直高,子进程启动时虽然 copy-on-write,但写操作会翻倍内存,所以 preload_app 并不一定减少总内存,甚至可能更高。建议先不开启 preload,对比一下内存基线再决定。