根本原因是连接泄漏而非并发高:CONN_MAX_AGE设为正数但未匹配MySQL wait_timeout(如CONN_MAX_AGE=60而wait_timeout=30),导致连接被服务端提前断开并残留;Celery任务、自定义命令及原生cursor使用后未显式close,造成连接“借出不还”,尤其在无request生命周期的非HTTP路径中泄漏隐蔽、复现难。

根本原因不是并发高,而是连接“借出去就忘了还”——尤其在非 HTTP 请求路径里,Django 和异步框架默认不自动回收连接,泄漏点藏得深、复现难。
CONN_MAX_AGE 设为正数却没配 wait_timeout
Django 的 CONN_MAX_AGE 控制连接复用时长,但 MySQL 服务端会按自己的 wait_timeout(默认 28800 秒)主动断开空闲连接。如果 CONN_MAX_AGE=60 而 MySQL 的 wait_timeout=30,连接大概率在复用前就被 server 断掉,Django 拿到的是失效连接,后续请求触发重连+旧连接残留,数量滚雪球上涨。
- 查 MySQL 当前值:
SHOW VARIABLES LIKE 'wait_timeout'; -
CONN_MAX_AGE必须严格小于wait_timeout,建议留 10% 余量 - 设
CONN_MAX_AGE=0是最简方案,禁用持久连接,适合多数中小项目
Celery 任务或自定义命令里 ORM 查询未触发执行
Django QuerySet 是惰性求值的。User.objects.filter(active=True) 不建连接,但一旦调用 .list()、.first()、.count() 或进 for 循环,连接就建立并卡住——如果任务没显式关闭,这个连接会一直挂在进程里。
- 所有 Celery task 函数末尾加
from django.db import connection; connection.close() - 自定义管理命令中,循环处理大批数据时,每批后手动
connection.close() - 避免在
try/except中漏掉清理逻辑;用finally或上下文管理器包裹
原生 cursor 使用后没 close(),比 ORM 更危险
ORM 至少有 query 生命周期兜底,而手写 connection.cursor() 后忘调 cursor.close() 或 connection.close(),连接就彻底“失联”。这种泄漏不会报错,只默默堆积。
立即学习“Python免费学习笔记(深入)”;
- 必须成对使用:
cursor = connection.cursor(); ...; cursor.close(); connection.close() - 更安全写法:
with connection.cursor() as cursor:,退出作用域自动关闭 - 注意:PyMySQL/MySQLdb 的
cursor.close()不等于connection.close(),两者都要关
asyncpg / aiomysql 连接池里 max_size 算错总数
async 框架的连接池是 per-worker 实例的。Gunicorn 开 4 个 worker,每个 pool max_size=20,实际就占了 80 个连接。若 MySQL max_connections=100,再叠加监控、备份等其他客户端,很容易打满。
- 计算公式:
总连接上限 ≈ worker 数 × pool.max_size ≤ MySQL max_connections × 0.7 - 别信“CPU 核数 = worker 数”,Python 异步 I/O 不吃 CPU,worker 数通常 2–4 就够
- 压测时盯紧
pg_stat_activity(PostgreSQL)或SHOW PROCESSLIST(MySQL)里的 active 连接数
真正难排查的从来不是“连接数超了”,而是“哪个连接在哪儿没还”。它往往不出现在主业务链路,而藏在定时任务、异常分支、中间件 or 信号 handler 里——这些地方没有 request/response 生命周期兜底,全靠你手动关。


















