2026 年 5 月初某天下午 16:50,我刚准备下班,Slack 上 oncall 群冒出来一条告警:"asset-service 健康检查超时,12 个 Pod 中 9 个 503"。我盯着监控面板看了两秒就明白发生了什么——这是这个月第四次同一类型的事故。前三次我们都是 rolling restart 救场,这次我决定不再让它过去:打开终端,记下时间,准备死磕到底。接下来 4 天,我们带着两个后端把 FastAPI + SQLAlchemy 2.x async 的连接池行为研究了个底朝天,定位到的是一个所有 async Python 后端开发者都该警惕的反模式——FastAPI 的 Depends 用了 return 而不是 yield,加上 asyncio CancelledError 在中间件层被吞掉,导致 SQLAlchemy 的 AsyncSession 在某些路径下永远不会被 release,连接池缓慢但稳定地泄漏。
这篇是完整复盘,涵盖 SQLAlchemy 2.x 异步连接池的内部机制、FastAPI Depends 的 yield/return 语义差异、asyncio 任务取消的传播规则、4 种修复方案的取舍,以及落地的《async Python 资源管理纪律》。如果你的 FastAPI 服务也出现过"每隔几小时变慢"或者"长时间运行后数据库报 QueuePool limit"的现象,这篇大概率能帮你找到原因。
服务背景:这个看起来无害的资产查询服务
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 资产管理后端,提供资产列表 / 详情 / 统计 / 导出 API |
| 规模 | 日均请求 580 万,P99 120ms,峰值 QPS 1800 |
| 技术栈 | Python 3.12 + FastAPI 0.110 + SQLAlchemy 2.0.30(async) + asyncpg 0.29 |
| 部署 | K8s,12 个 Pod,每 Pod 2 vCPU + 2GB,uvicorn 单 worker |
| 数据库 | PostgreSQL 15 主从,连接池 pool_size=20, max_overflow=10 |
| 事故前现象 | 每隔 5 ~ 8 小时,某个 Pod 慢慢卡死(连接池打满),需要 rolling restart 续命 |
| 过去 30 天事故 | 4 次,平均影响 P99 持续 8 分钟 |
这个服务从去年 10 月上线,一直跑得"基本稳定"——之所以加引号是因为大家心里都知道它有问题,但每次抓现场又抓不到。直到 5 月这次,我决心不再 rolling restart,而是让它"自然死亡",看看到底在死的瞬间发生了什么。
事故时间线:从死磕到根因的 4 天
| 时刻 | 事件 |
|---|---|
| 05-04 16:50 | asset-service 9 个 Pod 健康检查超时,告警触发 |
| 05-04 17:00 | 我把告警 silence 30 分钟,故意不重启,准备抓现场 |
| 05-04 17:05 | 进一个 Pod,跑 pg_stat_activity 看数据库侧连接:Pod 占用 30 个连接(pool_size + max_overflow),全部 state='idle in transaction' |
| 05-04 17:20 | 用 py-spy dump 进程栈,看到 9 个协程卡在 asyncio.Lock.acquire,另外 21 个看起来"正常空闲" |
| 05-04 17:35 | 用 SQLAlchemy event listener 临时打补丁,记录每次 session checkout / checkin,确认有大量"checkout 后从未 checkin"的 session |
| 05-04 18:00 | 初步定位:某条特定路径的请求会泄漏 session |
| 05-05 | 写复现脚本,本地稳定复现:并发请求 + 客户端在中间断开,服务端 session 不释放 |
| 05-06 | 读 FastAPI Depends 源码 + SQLAlchemy AsyncSession 源码 + asyncio CancelledError 文档,理解整个机制 |
| 05-07 上午 | 设计修复方案——Depends 改 yield + 中间件统一捕获 CancelledError + 连接池监控告警 |
| 05-07 下午 | 预发跑 24 小时混沌测试(客户端随机断开 + 高并发),连接池稳定无泄漏 |
| 05-08 | 分批灰度上线,事后写《async Python 资源管理纪律》 |
第一反应:"是不是慢查询挡住了"
过去 4 次事故,我们的复盘记录里都写着"疑似慢查询导致连接耗尽,加索引/优化 SQL 后观察"。每次也确实找了几条慢 SQL 顺手优化,但下次还是会出。这次我下决心不再被"慢查询"这个看似合理的解释忽悠,坚持把"为什么连接不释放"挖到底。
第一个关键信号是数据库侧的 pg_stat_activity:
SELECT pid, usename, application_name, state, query_start,
NOW() - state_change AS idle_duration, query
FROM pg_stat_activity
WHERE datname = 'assets'
AND application_name LIKE '%asset-service%'
ORDER BY state_change;
-- 输出 (节选)
pid | state | idle_duration | query
-------+----------------------+-----------------+------------------------------------
18234 | idle in transaction | 02:14:32 | SELECT * FROM assets WHERE id = $1
18241 | idle in transaction | 01:58:11 | SELECT COUNT(*) FROM asset_tags
18256 | idle in transaction | 01:42:09 | (no query)
18267 | idle in transaction | 01:23:47 | SELECT * FROM users WHERE id = $1
... (共 30 行, 全是 idle in transaction, idle_duration 从 7 分钟到 2 小时不等)
30 个连接全部是 idle in transaction,这是一个非常明确的信号:有事务被打开但从未提交或回滚。在 SQLAlchemy 里这等同于 async with session.begin() 进去了但 __aexit__ 没被调用——session 句柄没被释放回连接池。这不是慢查询,是连接泄漏。
真凶 1:FastAPI Depends 用 return 而不是 yield 的陷阱
翻 asset-service 的 dependency 代码,session 是这么注入的:
# dependencies.py - 看起来很自然的写法
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncSession:
session = SessionLocal()
try:
return session # ❌ 这里是 return, 不是 yield
except Exception:
await session.rollback()
raise
# 路由
@app.get("/assets/{asset_id}")
async def get_asset(asset_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Asset).where(Asset.id == asset_id))
return result.scalar_one_or_none()
这段代码看起来一点问题没有——异常时 rollback,正常时返回 session。但是FastAPI 的 Depends 对待 return 和 yield 完全不同:
| 形式 | FastAPI 行为 | 清理时机 |
|---|---|---|
| return value | 把 value 注入到路由参数,无后置处理 | 从来不清理,依赖 GC |
| yield value | 注入 value,路由返回后继续执行 yield 后面的代码 | 路由结束后立即执行 cleanup |
关键点:用 return 时,FastAPI 完全不知道这个资源需要清理。session 对象注入到路由,路由执行完后,session 引用消失,等 Python GC 触发时才有可能调用 session.__del__——而 AsyncSession 的 __del__ 只是发个 warning,不会 await close,异步资源(连接、事务)就永远挂在那里。
正确写法是用 yield:
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
# async with 退出时会自动 close session, 把连接还回池
这个差异在 FastAPI 0.x 早期文档里其实写得很清楚("Use yield for cleanup"),但很多教程为了简洁用了 return,导致这个反模式广为流传。我们这个项目最早的 dependency 就是某个团队成员从一个 Medium 教程抄的——一抄就是 6 个月。
真凶 2:客户端断开连接触发 CancelledError 被吞掉
但单单"用了 return"还不足以解释泄漏速度——按理说 Python GC 总会触发,session 总会被回收。我们测试:用正确的 yield 写法,模拟 1000 QPS 跑 1 小时,连接池稳定;用 return 写法,同样跑 1 小时,连接池泄漏 ~50 个连接。差异虽然有,但还没到"6 小时打满"的程度。
下一个真凶是 asyncio 的 CancelledError 行为。当客户端在请求处理到一半断开 TCP 连接(浏览器关页面、移动端切后台、长查询超时),uvicorn 会向当前协程发送 cancel 信号,触发 asyncio.CancelledError 异常。这个异常按设计应该一路向上传播,触发各层 cleanup。但我们项目里有一个看起来无害的中间件:
# middlewares.py - 把这个反模式钉在耻辱柱上
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
try:
response = await call_next(request)
return response
except Exception as e: # ❌ 这里 catch 了所有 Exception, 包括 CancelledError
logger.exception(f"Request failed: {request.url}")
return JSONResponse(
{"error": "internal error"},
status_code=500
)
finally:
logger.info(f"Request {request.url} took {time.time() - start:.2f}s")
问题来了:Python 3.8 起,asyncio.CancelledError 不再继承自 Exception,而是直接继承 BaseException——所以 except Exception 理论上抓不到。但这段代码运行环境是 Python 3.12,加上 FastAPI 0.110 的内部 wrapping,CancelledError 会在某些路径下被包装成 RuntimeError 或被 uvicorn 转译成 ConnectionResetError——这些都会被 except Exception 抓住,然后被吞掉变成普通的 500 响应。
结果就是:
- 客户端断开连接,asyncio 发 cancel
- 路由协程被取消,正在执行的
await db.execute(...)抛 CancelledError - 异常向上传播,被中间件的
except Exception错误抓住 - 中间件返回了一个普通 500 响应,异常被吞掉
- 路由函数没有"正常结束",FastAPI 不知道需要执行 Depends 的 cleanup
- session 永远挂在那里,事务未提交,连接占用
每一次客户端中途断开都会泄漏一个连接。我们的服务对接的是一个移动端 SDK,有大量"用户切后台→请求中断"的场景,泄漏速率随用户活跃度变化,典型工作日早上 9-10 点最严重——和我们看到的"早上更容易卡"现象完全吻合。
真凶 3:SQLAlchemy 2.x async 连接池的 release 时机
挖到这里还有一个细节让我们困惑了半天:既然 yield 写法能在路由结束时执行 cleanup,那为什么 yield 写法在被 cancel 的场景下也偶尔泄漏?
读 SQLAlchemy 2.x 的源码才搞明白,AsyncSession 的 close() 实际上分两步:
| 步骤 | 动作 | 是否 async |
|---|---|---|
| 1 | session.close():重置内部状态,触发 connection.invalidate(如果有事务) | 同步 |
| 2 | session._connection.close():实际归还连接到池 | 异步(需要 await) |
当 yield 的 generator 被 cancel 时,Python 会调用 aclose() 触发 GeneratorExit/CancelledError,执行 finally / async with __aexit__——但如果在这个 cleanup 过程中再次发生 CancelledError(比如 close 自己也需要 await,被新一轮 cancel 打断),清理就不完整。
这种场景在高并发 + 频繁 cancel 的服务里时有发生。修法是显式 shielded cleanup:
async def get_db() -> AsyncIterator[AsyncSession]:
session = SessionLocal()
try:
yield session
finally:
# 用 asyncio.shield 保护 close, 不被新的 cancel 打断
try:
await asyncio.shield(session.close())
except Exception as e:
logger.warning(f"Session close failed: {e}")
这个细节在 SQLAlchemy 文档里没强调,但在 GitHub issue 里被多次讨论过,是 async ORM 的"老坑"。
修法:Depends 重写 + 中间件 + 监控三件套
修法 1:get_db 改成 yield + shielded close
from contextlib import asynccontextmanager
import asyncio
async def get_db() -> AsyncIterator[AsyncSession]:
session = SessionLocal()
try:
yield session
# 显式 commit/rollback 由业务层决定, 这里不做
except Exception:
await asyncio.shield(session.rollback())
raise
finally:
await asyncio.shield(session.close())
修法 2:中间件不再吞 CancelledError
import asyncio
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
try:
response = await call_next(request)
return response
except asyncio.CancelledError:
# 让 CancelledError 透传, FastAPI 自己处理
logger.info(f"Request cancelled: {request.url}")
raise
except Exception as e:
logger.exception(f"Request failed: {request.url}")
return JSONResponse({"error": "internal error"}, status_code=500)
finally:
logger.info(f"Request {request.url} took {time.time() - start:.2f}s")
这里要点:把 CancelledError 单独拎出来 re-raise,不让它被通用的 except 吞掉。这种"故意不处理的异常类型"在所有 async 服务的中间件里都应该有。
修法 3:连接池实时监控 + 告警
SQLAlchemy 2.x 的 engine 提供了 pool 属性,可以拿到连接池的实时状态。我们写了一个 Prometheus exporter,每 10 秒推一次:
from prometheus_client import Gauge
pool_size = Gauge('db_pool_size', 'Configured pool size')
pool_checked_out = Gauge('db_pool_checked_out', 'Currently checked-out connections')
pool_overflow = Gauge('db_pool_overflow', 'Overflow connections in use')
pool_idle = Gauge('db_pool_idle', 'Idle connections in pool')
async def collect_pool_metrics():
while True:
pool = engine.pool
pool_size.set(pool.size())
pool_checked_out.set(pool.checkedout())
pool_overflow.set(pool.overflow())
pool_idle.set(pool.checkedin())
await asyncio.sleep(10)
# 启动时挂上后台任务
@app.on_event("startup")
async def start_metrics():
asyncio.create_task(collect_pool_metrics())
配合 Grafana 看板和告警规则:
| 告警 | 规则 | 动作 |
|---|---|---|
| P3 - 通知 | checked_out / pool_size > 50% 持续 5 分钟 | 飞书通知 |
| P2 - 警告 | checked_out / pool_size > 80% 持续 2 分钟 | oncall 收到 |
| P1 - 紧急 | checked_out == pool_size + overflow 持续 30 秒 | 电话呼叫 |
| 趋势 | checked_out 6 小时内增长 > 30% | P3 通知,可能是泄漏 |
修法 4:数据库侧兜底
在 PostgreSQL 上配置 idle_in_transaction_session_timeout,任何 idle in transaction 超过 5 分钟的会话自动 kill:
-- 在 postgresql.conf
idle_in_transaction_session_timeout = 300000 -- 5 分钟 (单位 ms)
-- 或者在 SQLAlchemy 侧, 连接初始化时设
@event.listens_for(engine.sync_engine, "connect")
def set_pg_session_params(dbapi_conn, _):
cursor = dbapi_conn.cursor()
cursor.execute("SET idle_in_transaction_session_timeout = '300s'")
cursor.close()
这是最后的兜底——就算应用层完全失控,数据库自己也会 5 分钟后把"僵尸事务"清掉。代价是被 kill 的请求会收到 server closed the connection 错误,但比连接池打满后整个服务雪崩好得多。
验证:24 小时混沌测试
预发环境我们设计了一个"故意找茬"的测试:
| 测试场景 | 配置 |
|---|---|
| 正常流量 | 800 QPS,持续 24 小时 |
| 随机断开 | 15% 的请求在 100ms ~ 2s 之间随机时刻关闭客户端连接 |
| 慢查询注入 | 每分钟有 5 个请求人为 sleep 10s 模拟慢查询 |
| 数据库主从切换 | 每 4 小时触发一次 PG 主备切换 |
| 监控 | 每分钟记录连接池状态、pg_stat_activity idle 数量、P99 延迟 |
对比数据:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 24 小时后连接池 checked_out | 30/30(打满) | 2/20(正常) |
| 24 小时内最高 idle_in_transaction 数 | 30 个 | 1 个(瞬时) |
| P99 延迟漂移 | 120ms → 8s | 120ms → 145ms |
| 500 错误次数 | 87 次 | 0 次 |
修复后 P99 略升的 25ms 是因为我们加了显式 shielded close 的同步等待开销,可以接受。
顺手扫到的另外 7 个类似隐患
事后我让基础架构组在公司所有 Python 后端项目里 grep 几个关键模式:
| 反模式 | 命中项目数 | 风险 |
|---|---|---|
| get_db 用 return 而非 yield | 5 个 | 高(连接泄漏) |
| 中间件 except Exception 没单独处理 CancelledError | 11 个 | 中(取消信号被吞) |
| except: (裸 except) | 3 个 | 极高(连 SystemExit / KeyboardInterrupt 都吞) |
| 没显式 await session.close() | 7 个 | 中(依赖 GC,在 cancel 场景泄漏) |
| 用 asyncio.create_task 但没存引用 | 14 个 | 中(task 可能被 GC 提前取消) |
| 没设 idle_in_transaction_session_timeout | 全部 | 中(缺兜底) |
| 连接池监控没接入告警 | 22 个(全部) | 高(出问题靠人肉发现) |
这次事故顺手推动公司所有 Python 后端服务做了一轮"async 资源治理",合计修复 40+ 个潜在 issue。
立的《async Python 资源管理纪律》
- 任何需要 cleanup 的 Depends 必须用 yield,return 只能用于"无副作用的纯值注入"。
- 中间件的 except 必须显式区分 CancelledError,要么 re-raise,要么走专门的 cancel 处理路径,绝不允许被通用 except 吞掉。
- 异步资源 close 必须用 asyncio.shield 保护,防止 cleanup 过程被新的 cancel 打断。
- asyncio.create_task 必须保存引用(放到一个 set 或 weakref 里),否则 task 可能被 GC 取消。
- SQLAlchemy 连接池必须有实时监控:checked_out / overflow / pool_size 接入 Prometheus,三级告警必备。
- PostgreSQL 侧必须配 idle_in_transaction_session_timeout(5 分钟),作为应用层失控时的最后兜底。
- 新项目必须用统一的 FastAPI 模板(我们提供了 cookiecutter),里面 get_db / 中间件 / 监控都已配好。
- 任何"需要定期重启才能维持运行"的 Python 服务都是高优先级整改对象,3 个月内必须找到根因。
给读者的几条自查清单
- 打开你的 FastAPI 项目,搜
def get_db,看是不是用了 return。如果是,改成 async with + yield。 - 搜所有
@app.middleware和except Exception,确认有没有把 CancelledError 误吞。Python 3.8+ 的 CancelledError 不是 Exception 子类,但实际项目里因为各种 wrapping 经常被抓——保险起见显式 re-raise。 - 跑一下:
pg_stat_activity查 idle_in_transaction 数量。如果有持续超过几分钟的,基本是连接没释放。 - 检查 SQLAlchemy engine 的 pool_size 和 max_overflow,配置 Prometheus 监控,看一周的 checked_out 趋势。如果是锯齿状(QPS 高时上升、低时回落)说明正常;如果是单调上升,有泄漏。
- 用
py-spy dump --pid <pid>在卡死的 Pod 上抓协程栈,看是不是大量协程卡在 acquire / wait_for。 - 跑一次"客户端主动断开"的压测,看连接池行为。最简单的方法:用
ab -t 60 -c 50+ 中途 Ctrl+C,然后看 pg_stat_activity。
这次事故让我再次确认一件事:async Python 的复杂度大头不在"业务逻辑",在"资源生命周期"。同步代码里,with 块结束自然清理,几乎不会出大问题;async 代码里,cancel 可以发生在任意 await 点,清理逻辑被打断的可能性时刻存在。这意味着每一个需要 cleanup 的资源(session、文件、socket、锁),都要假设 cleanup 本身可能被打断,设计要承受得住"清理失败"。
这也是为什么 Go 的 defer / Rust 的 Drop 这种"语言级的清理保证"看着土,实际上比 Python async 的 try/finally 可靠得多。Python 没法改这个,但我们可以在代码层面建立纪律——这次事故就是一次代价不小的纪律建立机会。
问题本质:CancelledError 传播的"双链断裂"
整个事故的根本机制是两条传播链同时断了——FastAPI Depends 用 return 让 cleanup 链路没建立,中间件用 except Exception 把 CancelledError 信号链吞掉。两条链一断,session 就只能等 Python GC,而 GC 在 async 场景下不可靠:
给读者的 async Python 自查清单
看完这篇可以马上对自己项目做的几个动作:
第一:grep def get_db(或类似 DI factory)看是不是用了 return。是的话立刻改 async with + yield。这是 FastAPI 项目最常见的反模式。
第二:搜所有中间件的 except 块,看有没有把 CancelledError 误吞。Python 3.8+ CancelledError 是 BaseException 子类,但实际项目里因为各种 wrapping 经常被普通 except 抓——保险起见显式 re-raise。
第三:跑 SELECT * FROM pg_stat_activity WHERE state='idle in transaction'。出现持续超过几分钟的 idle in transaction,基本就是 session 没释放。
第四:接 Prometheus 监控 SQLAlchemy pool 的 checked_out / overflow / pool_size。看一周趋势,锯齿型正常,单调上涨就有泄漏。
第五:模拟"客户端主动断开"压测——用 ab + 中途 Ctrl+C,然后看 pg_stat_activity。这是验证 cleanup 链路是否正确的最朴素方法。
团队后续治理
事故后我们做了几件事让这类问题不再潜伏:所有 FastAPI 项目用统一模板(cookiecutter),get_db 已经正确;CI 加 ruff 规则,把"def get_db ... return"标为错误;PG 侧配 idle_in_transaction_session_timeout=300s 兜底;监控加连接池趋势告警。三个月后再没出现类似事故。
更深的收获是组内对 async Python 的理解——大家不再把它当成"同步代码加 async 关键字",而是认真对待"任意 await 点都可能被 cancel"的事实。这种 mindset 转变让后续 async 代码的质量显著提升。
—— 别看了 · 2026