一个很常见的运维现象:白天流量高峰时一切正常,凌晨监控才开始报警,提示数据库连接数接近上限。查慢查询没有结果,CPU 和内存都闲着,唯一不对劲的是连接数——它不但没随流量降下去,反而一直停在某个数字上不动。
这类问题的根子通常在连接的生命周期上。很多项目对数据库连接的管理只有一个开关:CONN_MAX_AGE。开着觉得万事大吉,关着又觉得每个请求都握手太亏。这篇文章把「一条连接该活多久」拆成三个决定:连多久、谁先断、要不要池化——顺带拆掉三个流传很广的误区。
一、背景与约束:连接是有配额的稀缺资源
先记住两个不能改的数字。PostgreSQL 的 max_connections 默认通常是 100,其中 3 个槽位还保留给超级用户;MySQL 从 5.5 起 max_connections 默认是 151。这是「整个数据库实例同时能接受多少个客户端连接」的天花板,跟每秒能处理多少请求是两回事。
约束要先列清楚,因为它决定打法:
- 实例能接受的连接数是硬上限,托管型数据库往往比默认值更小;
- 应用侧的每个 worker 进程、每个后台任务进程都可能各占一份;
- 数据库服务器通常会主动断开长时间空闲的连接,应用必须能承受这件事。
所以连接管理的目标不是「尽可能久地握着连接」,而是在不超过配额的前提下减少重建连接的次数。这两件事经常被混为一谈,后面三个误区都源于这个混淆。
二、核心拆解
决定一:连多久——CONN_MAX_AGE 的真实语义
Django 文档写得很直接:CONN_MAX_AGE 定义一条连接的最长存活时间,默认值是 0,即沿用「每个请求结束就关闭连接」的历史行为;设成正整数就是秒数;设成 None 表示永不关闭。
它提供的是复用,不是池。差别在于:
- 没有共享的连接集合,没有上限,没有排队,也没有获取超时;
- 每个线程维护自己的一条连接,文档原话是「数据库必须支持至少与你 worker 线程数一样多的并发连接」;
- 连接数在低谷期等于 worker 进程数,而不是并发数。
配置本身很短:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
# ...
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
},
}
为什么给一个具体秒数而不是 None:60 秒既能覆盖同一批连续请求,又给了服务端一次自然换血的机会;None 意味着这条连接永远不会被应用主动替换,一旦中间的 NAT 网关或负载均衡把空闲 TCP 悄悄清掉,应用手里就留了一条已经死掉的连接。
算术要按进程算,不按请求算:4 个 worker 乘 3 个实例等于 12 条常驻连接,流量为零的时候也是 12 条。后台任务如果共用同一份配置,还要再加一份。
决定二:谁先断——服务端超时与健康检查
应用想把连接留着,数据库却会把空闲连接踢掉。MySQL 的 wait_timeout 与 interactive_timeout 默认都是 28800 秒,也就是 8 小时;云数据库经常把它调到几百秒。空闲超过这个时间的连接被服务端单方面关闭,客户端下一次查询就会拿到 2006「MySQL server has gone away」。PostgreSQL 侧同理,不少实例会设置 idle_session_timeout;托管型数据库还会因为维护、重启清掉全部连接。
正确的做法不是把 CONN_MAX_AGE 往大调,而是让它小于服务端的空闲超时。Django 文档的建议正是:如果你的数据库会终止空闲连接,就把 CONN_MAX_AGE 设成较低的值,这样 Django 不会去使用一条已经被服务端终止的连接。注意文档还补了一句——这个问题主要影响低流量站点,恰恰印证了「连接数在夜里出问题」的现象。
再加一道保险:CONN_HEALTH_CHECKS(Django 4.1 引入)设为 True 后,持久连接在每次请求复用前会被检查一次,检查失败就重建连接,并且不会让这个请求失败。文档点名的场景是数据库重启。两个细节值得注意:健康检查每次请求只做一次,且只在本次请求确实访问数据库时才做;而它的默认值是 False——也就是说,开了持久连接却不做健康检查,等于把「这条连接还活着吗」完全交给运气。
还有一类隐蔽的坑写在文档的注意事项里:如果连接是在请求周期之外的长驻进程里建立的,它会一直开着,直到被显式关闭或超时。
决定三:要不要池化——真正的池长什么样
先说结论:worker 数不多、实例只有一个时,持久连接够用;当 worker 数乘上实例数逼近数据库上限,或者要把连接数从几百压到几十,才需要真正的池化,而且池的位置应该尽量靠近数据库。
Django 5.1 起,PostgreSQL 后端可以直接开连接池,前提是驱动换成 psycopg 3(用 psycopg2 时这个选项会被忽略):
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {
"pool": {"min_size": 2, "max_size": 4, "timeout": 10},
},
},
}
pool 也可以直接写 True 使用默认值;它需要安装 psycopg[pool] 或 psycopg-pool。这套语义和 CONN_MAX_AGE 完全不同:池在 min_size 和 max_size 之间维护连接,池里没有空闲连接时调用方排队等待,超时抛 PoolTimeout;升到 max_size 以上的空闲连接会被回收,连接还可以配 max_lifetime 上限;官方文档特别提醒,池不会主动检查连接状态,遇到服务端空闲超时踢连接时应用可能拿到坏连接,需要自己配 check 回调。想观测就调 get_stats(),其中排队请求数 requests_waiting 是最该盯的指标。
另一种做法是在数据库前面放 PgBouncer,应用侧只要把 HOST 和 PORT 指过去。它的 pool_mode 决定一条服务端连接什么时候还回去:
| 模式 | 归还时机 | 特性兼容 | 适用场景 |
|---|---|---|---|
| session(默认) | 客户端断开时 | 支持全部 PostgreSQL 特性 | 需要会话状态、连接数不紧张 |
| transaction | 事务结束时 | 不支持 SET、LISTEN、PREPARE、WITH HOLD 游标、会话级咨询锁 |
绝大多数 Web 应用 |
| statement | 单条语句结束时 | 不允许跨语句事务 | 几乎不用,ORM 场景基本不可行 |
选 transaction 模式后,应用侧有两个必须配合的动作。第一,把 CONN_MAX_AGE 设回 0——池化已经交给 PgBouncer,再让每个 worker 握着客户端连接只是白占资源。第二,把 DISABLE_SERVER_SIDE_CURSORS 设为 True:Django 文档解释得很清楚,QuerySet.iterator() 会打开服务端游标,而服务端游标只在自己被创建的那条连接上有效,事务池模式下下一个事务可能换一条连接,引用旧游标就会直接报错。
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": "127.0.0.1",
"PORT": "6432",
"CONN_MAX_AGE": 0,
"DISABLE_SERVER_SIDE_CURSORS": True,
},
}
顺带记住 PgBouncer 的两个默认值:default_pool_size 是 20,max_client_conn 是 100。池化之后,应用能开多少个客户端连接由 PgBouncer 说了算,跟数据库的 max_connections 不再是同一个数字——这也是很多团队上了池化却仍然报连接不够的原因。
三、踩坑与教训
坑一:把 CONN_MAX_AGE 当成连接池
现象是流量最低的凌晨连接数反而顶到上限,运维第一反应是怀疑慢查询堆积。
根因是连接数的增长逻辑变了:持久连接模式下,连接数跟着进程数走、不跟着流量走,每个 worker 都握着一条,夜里也不例外;而且它没有上限和排队机制,压不住总量。
修正方式是先按「worker 数 × 实例数 + 后台任务进程数」把预期连接数算出来,跟 max_connections 对一遍;超了就降 CONN_MAX_AGE,或者上池化,而不是去调大数据库上限。
判断标准:如果问题出现的时间点是没流量的时候,八成不是慢查询,而是常驻连接。
坑二:fork 之后的连接是继承来的,不是新建的
现象是偶发的、看着像驱动损坏的报错:查询结果串了行、SSL 解密失败、请求无故挂住。
根因在 prefork 模型。uWSGI 默认是「父进程先把应用加载完,再 fork 出 worker」,Gunicorn 的 preload_app 默认为 False(文档描述是「在 worker 进程被 fork 之前加载应用代码」,显式打开后同样会预热应用代码)。如果连接是在 fork 之前建立的,每个子进程手里拿到的是同一个 socket 的引用,多个进程往同一条 TCP 连接里写,协议就乱了——报错信息自然跟连接毫无关系。
修正方式是 Gunicorn 不要为了省内存打开 preload;uWSGI 用 lazy-apps 让应用在 fork 之后再加载;不管用哪个,都在子进程初始化时把继承来的连接关掉。
判断标准:报错里出现「不属于本次查询的结果」时,优先怀疑 fork,而不是换驱动版本。
坑三:请求周期之外的长驻进程不会自己还连接
现象是 Celery worker 跑几天后开始报数据库相关错误,常驻脚本则「跑一夜才出错」。
根因是 Django 的连接回收挂在请求周期上——请求结束时会关闭超龄或不可用的连接,而后台任务循环里没有这个边界,第一条任务借到的连接可能一直被攥着。
修正方式是任务进程初始化时关闭继承来的连接,并在每个任务前后调用 close_old_connections()。用它是为了尊重 CONN_MAX_AGE:短任务仍能复用热连接,不必每个任务都重建。
from celery.signals import task_postrun, worker_process_init
from django.db import close_old_connections, connections
@worker_process_init.connect
def reset_inherited_connections(**kwargs):
for conn in connections.all():
conn.close()
@task_postrun.connect
def close_expired_connections(**kwargs):
close_old_connections()
判断标准:任何「进程活得比请求久」的场景,都要自己安排连接的回收时机。
坑四:ASGI 部署下沿用 WSGI 的经验
这一条容易被忽略,因为它是文档里的一句话:使用 ASGI 时应当禁用持久连接,改用数据库后端自带的连接池,或按需引入第三方池化方案。
根因是异步请求与线程模型的对应关系变了,持久连接依赖的「每线程一条连接」这个前提不再成立。修正方式是按文档指的方向走池化,而不是把 WSGI 的配置直接搬过去。
四、结尾
连接管理要记的不是一个数字,而是三件事:应用这边有多少个进程会各握一条连接、数据库那边允许多少条、以及谁负责在连接坏掉时把它换掉。这三件事想清楚了,CONN_MAX_AGE 设多少、要不要开健康检查、要不要池化,都是自然结论。
如果同事也遇到过「凌晨连接数顶格」或者「跑几天才出一次数据库报错」,可以把这篇转给他一起对一遍配置。
参考文章
- Django 文档:Databases——持久连接、连接池与事务池下的服务端游标
- Django 5.1 发行说明:PostgreSQL connection pools
- psycopg 3 文档:Connection pools
- PgBouncer 文档:Features——池化模式与特性兼容表
- Gunicorn 文档:Settings——preload_app
- 数据库官方文档:MySQL 服务器系统变量(wait_timeout、interactive_timeout)与 PostgreSQL 连接配置(max_connections)