凌晨两点十五分,手机被 Prometheus 告警震得在桌面跳起踢踏舞。数据库连接数踩了油门似的疯涨——半小时前还是稳稳的 32 条基线,一眨眼冲到 198,离 PostgreSQL 的 max_connections 只剩两个余量。切到 Grafana 瞥一眼 p95 延迟,原本贴着 50ms 走了一周的线,从十分钟前开始陡峭爬升,跨过 2s 直接往 5s 冲。生产环境,卒。
不是因为慢查询。慢查询日志干干净净,pg_stat_activity 里全是 idle in transaction 的幽灵连接——拿出去没人还回来。FastAPI 进程数没变,Uvicorn worker 还是 4 个,asyncpg 连接池设的 max_size=20,按理说最多 80 条。当前 198 条,说明有连接没走池子,或者池子里有人在偷偷泄。
凌晨两点的连接数:198 条从哪来的
先按住 panic。SSH 上去拉了一遍容器日志,发现一个规律:每次 POST /orders 请求之后,pg_stat_activity 里就会多一条 idle in transaction 的连接,状态保持 60 秒才被 PostgreSQL 自动杀掉。六十秒,够攒一堆了。高并发下每分钟几百个请求,连接数不炸才怪。
查代码。ORM 层用的是 asyncpg,每个请求在依赖注入里 await pool.acquire(),然后 await conn.fetch(),最后 await pool.release(conn)。逻辑上没问题。但我在 pool.release() 的调用前面加了一行 print("releasing")——跑了几轮,日志里出现 releasing 的次数比 acquire 少了 12%。
这 12% 就是泄漏。再往下挖,看到某个异常处理分支里,except 之后直接 raise,跳过了 finally 块里本该执行的 pool.release()。FastAPI 的依赖注入如果抛了 500,那个 yield 之后的清理代码不会被调用——除非你包在 try/finally 里。我们没有。
另外,pool._queue 的长度在高峰期涨到 80+,所有请求都在等连接。asyncpg 的 Pool 默认 queue_size=10,但排队请求超过这个数会直接触发 的等待。最坑的是 queue_size=0 表示无限排队——当时我们没改,导致等待队列无限累积,延迟自然从毫秒级垮到秒级。连接池不是越大越好——max_size 设到 20,但 PostgreSQL 那边 max_connections=200,两者之间还有 pool 内部的连接复用率要考虑。
修复分两步:第一,所有 conn 的获取和释放套上 try/finally,或者直接用 async with pool.acquire() as conn 语法——少写一行都不行。第二,给 asyncpg.create_pool() 加上 queue_size=50 和 timeout=30,防止队列无限堆积。改完再跑压测,连接数稳定在 22 左右,p95 回到 55ms。
其实回头看,问题核心就两个:代码没兜底异常清理,参数没设排队上限。但生产环境不是 IDE,你永远不知道哪个分支忘了 finally。
asyncpg 配置里那些让你半夜加班的默认值
把连接泄漏堵上只是起点,真正决定高峰期稳不稳的是那几行 pool=asyncpg.create_pool(...) 里的参数。默认值都偏“客气”,一上高并发就露出短板。
先说 min_size。这个参数在创建时建立初始连接数,默认是 1。好处是进程刚启动别急着建连接,坏处是突发流量一来,后面连接要现建,latency 会抖一下。我们后来改成按 CPU×2 预热,比如 8 核就 min_size=16,既不至于把 DB 打爆,也能让首批请求直接吃到现成连接。
max_size 控制同一时刻能持有的最大连接数,默认 10。很多人一看排队就把 max_size 拉到 200,结果 PostgreSQL 那边 max_connections=100,应用层配再大也白搭,还会把上下文切换拖垮。我们的取舍是 max_size=min(CPU×4, postgres_max/2),留一半给其他服务,必要时再逐步上调。
max_inactive_connection_lifetime 这条默认 300 秒,超过就被标记不可用并关闭。有人觉得短了频繁重建,长了占着不用浪费。实际更怕的是事务里 idle 太久被 PG 杀掉,下次发查询才发现 connection gone away。我们把阈值从 300 调到 180,配合应用侧重试幂等查询,抖动明显少了。
压测里最容易忽略的两个点:别忘了数据库自己的 max_connections 和 work_mem、shared_buffers 也要跟着调,否则连接再多也只是排队等锁和 I/O。看指标别只看 p95,连接池 queue 的长度和等待时间一旦抬头,往往是 max_size 不足或某类慢查询回流导致连接长期占用。
泄漏不是 bug,是每个分支都忘了 finally
参数配完了,跑个压力测试,连接数曲线漂亮得像教科书。但我知道,真正的坑不在 create_pool 里,而在每个写 SQL 的地方——只要有一个分支忘了把连接还回去,明早报警群里就有人@你。
第一种场景是 try/except 吃到异常,finally 没写 release。这版代码现在还能在某些项目的旧分支里找到:
async def get_user(user_id):
conn = await pool.acquire()
try:
row = await conn.fetchrow("SELECT * FROM users WHERE id=$1", user_id)
return row
except Exception:
# 日志打了,但连接没放回去
logger.exception("query failed")
return None
# 没有 finally: await pool.release(conn)
你可能会想,“谁会犯这种低级错误?” 我见过不止一次。改 bug 的人加了 try,忘了补 finally;或者觉得 return 会跳出函数,release 写在 return 后面永远不会执行。结果就是每报一次错就吞掉一个连接,等 max_size 耗尽,所有请求开始排队,应用层先挂。
修复很简单,但必须写死:
async def get_user(user_id):
conn = await pool.acquire()
try:
return await conn.fetchrow("SELECT * FROM users WHERE id=$1", user_id)
finally:
await pool.release(conn)
或者直接用 async with 搞定。
第二种场景是 async with 嵌套,内层上下文把连接拐跑了。有人以为用了 async with pool.acquire() as conn 就万事大吉,结果嵌套事务时翻车:
async with pool.acquire() as conn:
async with conn.transaction():
# 执行一堆 SQL
pass
# 事务结束了,但 conn 还在连接池里?对,但如果你在 transaction() 里又干了些奇怪的事…
这段代码本身没问题,问题出在有人自作聪明——在 transaction 块里手动调了 pool.release(conn),或者把 conn 对象传给了另一个协程去释放。async with 的上下文管理器一旦被提前关闭、或者被跨协程共享,连接状态就乱了。释放两次会报错,漏释放就泄漏。
原则是:谁 acquire 谁 release,不要跨函数传 conn 对象。真要传,传 pool 本身,由子函数自己 acquire。
第三种场景是查询超时后,连接还活着但脑子坏了。asyncpg 默认超时是无穷大。如果你设了个 statement_timeout 或给 fetch 加 timeout 参数,超时抛异常后连接可能处于一种“半死不活”的状态——事务没回滚,或者还有未消费的消息。你再拿这个连接去查,直接报 ProtocolError。
更隐蔽的情况是:PG 那边 kill 了某个慢查询,连接断开但 asyncpg 没及时收到通知。下次 acquire 拿到的是个死连接,发查询就报 connection was closed in the middle of operation。
应对办法两个:一是把 max_inactive_connection_lifetime 设小一点,让长期闲置的连接定期被清理;二是每次 acquire 后先发一个 SELECT 1 做健康检查。但后一种会增加延迟,我一般只在关键写操作前做。
统一的解法是用 async with 锁定生命周期。前面三个场景,归根结底都是手动管理 release 出了纰漏。直接一刀切:所有数据库操作都用 async with pool.acquire() as conn,不用手写 try/finally。
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("UPDATE users SET count = count + 1 WHERE id = $1", uid)
acquire 上下文管理器在退出时保证 release,就算中间抛异常也走 __aexit__。唯一例外是你需要在函数里多次用 conn 做不同事,但即使那样,也应该用一个外层 async with 包住整个函数体,而不是拆成多个 acquire。
池子满了之后,等待队列才是真凶
连接泄漏修完了,下一步才是真正的挑战——当请求量再翻一倍,连接池满了,队列排起来了,你该怎么办。
先分清“池满”还是“卡死”。打开两个终端,一个跑压测,另一个执行 SELECT pid, query, state FROM pg_stat_activity WHERE application_name = 'myapp';。若看到大量 active 状态且等待时间集中在某几条 SQL,基本就是池子见底;如果 state 为空却长时间占着 PID,那八成是事件循环被阻塞,连接根本没放回去。再配合 asyncio.all_tasks() 把当前协程栈打出来,谁在跑同步库、谁在算稠密矩阵。
把池子开大并给等待设限。asyncpg 默认 queue_size=10,也就是超过 max_size 的请求会进入 LRU 队列。一旦业务峰值高于预期,排队长度就会指数级膨胀。解决办法很直接:把 max_size 提到 PG 可承载上限(一般每核 2~3),同时显式写出 acquire_timeout 与 release_timeout,防止协程饿死。示例配置:
pool = await asyncpg.create_pool(
dsn,
min_size=5,
max_size=64,
queue_size=50,
command_timeout=60,
connection_class=asyncpg.Connection,
)
别忘了给 FastAPI 也加个 lifespan:在 app startup 时预热若干连接,避免冷启动抖震。
把 pool.get_status() 里返回的 waiting_queue_len 挂到 Prometheus,阈值写两倍平均 QPS。一旦连续三分钟破线,就触发 Ansible 去改环境变量并重启实例。真·云原生玩家可以直接上 RDS Proxy,但记得把 statement_timeout 关掉,否则代理层与数据库双重计时会把你搞疯。
把连接池状态挂上 Grafana,心里才有底
池子调大了、参数也改了,但你真的知道线上连接池此刻在经历什么吗?我见过太多人把 max_size 怼到 128 就心安理得,结果 Grafana 面板上 waiting_queue_len 飙到 50 都没人察觉。没有监控的调优,等于闭着眼开车。
往 asyncpg 里插探针,把池子状态裸给 Prometheus。prometheus_client 装好,在 FastAPI 里注册一个 /metrics 端点。核心是把 pool.get_status() 那三个值——active_connections、free_connections、waiting_queue_len——变成 Gauge 指标。
from prometheus_client import start_http_server, Gauge
import asyncpg
active_gauge = Gauge('asyncpg_active_connections', '当前活跃连接数')
free_gauge = Gauge('asyncpg_free_connections', '当前空闲连接数')
waiting_gauge = Gauge('asyncpg_waiting_queue_len', '等待队列长度')
async def collect_pool_metrics(pool: asyncpg.Pool):
status = pool.get_status()
active_gauge.set(status.active)
free_gauge.set(status.free)
waiting_gauge.set(status.waiting)
再启一个后台任务每 5 秒刷一次,丢到 app 的 lifespan 里。这样 Uvicorn 进程跑起来,Prometheus 就能自动抓了。注意:千万不要在 /metrics 里做数据库查询,否则压测时这个端点本身就会加剧连接竞争。数据从 pool 的内部状态直接读,不走 SQL。
Grafana 告警规则写哪几个阈值?等待队列长度超过 10 且持续 30 秒,说明池子扛不住了,得扩容或限流。连接数接近 max_size 但 waiting 是 0,那没事——可能是某次突发还没来得及排上队。真正要警惕的是 waiting_queue_len 不断上升,同时 free_connections 几乎归零,这时请求已经堆在事件循环里了。我一般设两条报警规则:等待队列大于 10 持续 1 分钟发送 P2 告警,发钉钉;连接数达到 max_size 的 90% 且 waiting 大于 0 发送 P1 告警,同时自动触发扩容脚本。别设得太敏感,80% 利用率就报警会把运维逼疯。要给连接池留出释放连接的缓冲时间。
自愈?别指望全自动,但半自动就够了。让系统自己改 max_size 然后重启进程?风险太高,尤其是 PG 侧连接数上限是死的。我更推荐的做法是:告警触发后,Grafana 的 webhook 调用一个 Ansible playbook,把 max_size 上调 20%,然后优雅重启 Uvicorn worker。重启时利用 preload 和 graceful shutdown,连接池不会断光。
说到底,这套搞下来最值钱的反而不是告警本身,而是你终于有了一张时间线图:每次请求排队都对应着连接池峰值,接下来就能反推是 SQL 慢还是并发量真的涨了。没有数据,你永远在猜。
连上监控,才算真正把连接池交到了自己手里。
把监控面板盯了两周,也经历了一次半夜扩容之后,我才算对“调优完成”这四个字有点实感。实际跑完 locust 那一刻其实没啥仪式感,就是屏幕上的 P99 从 3 秒滑到 80 毫秒,连接数稳稳贴在 50 附近小幅抖动。没有忽高忽低的尖峰,也没有突然把 max_size 顶满又迅速回落的惊悚画面。那一刻我才意识到,前面那些参数反复拉锯没白费,事件循环终于不再被等待队列拖着走。
当 waiting_queue_len 长时间保持为 0,而 free_connections 偶尔轻微波动,Grafana 的阈值规则也不再频繁触发。我把两条报警阈值放在“持续一段时间”而非瞬时值上,值班群里夜里安静了很多。真正有用的不是把阈值设多狠,而是你清楚知道什么时候该扩、什么时候只是短暂抖动。
调优这活儿,做完一次不等于完事。业务峰值在涨,SQL在改,连接池那几个参数——尤其是max_size和acquire_timeout——就得跟着反复调。我现在每季度翻一遍面板曲线,大促前再压一轮预估流量,微调到位。真别指望一套配置撑三年,能把曲线拉平,就算阶段性赢了。
这些细节不落地,性能优化永远只是纸上谈兵。连接池调优说到底,就是给系统留出呼吸的余地——别让它憋得死死,也别让它闲得发慌。
评论