FastAPI 每隔 6 小时变僵尸:SQLAlchemy async 连接池静默泄漏 4 天复盘

FastAPI + SQLAlchemy 2.x async 服务每 6 小时连接池打满变僵尸。根因:Depends 用 return 而非 yield + 中间件吞掉 CancelledError + SQLAlchemy close 时机微妙。完整复盘 + 5 层修法 + 监控告警。

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 响应。

结果就是:

  1. 客户端断开连接,asyncio 发 cancel
  2. 路由协程被取消,正在执行的 await db.execute(...) 抛 CancelledError
  3. 异常向上传播,被中间件的 except Exception 错误抓住
  4. 中间件返回了一个普通 500 响应,异常被吞掉
  5. 路由函数没有"正常结束",FastAPI 不知道需要执行 Depends 的 cleanup
  6. 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 个月内必须找到根因。

给读者的几条自查清单

  1. 打开你的 FastAPI 项目,搜 def get_db,看是不是用了 return。如果是,改成 async with + yield。
  2. 搜所有 @app.middlewareexcept Exception,确认有没有把 CancelledError 误吞。Python 3.8+ 的 CancelledError 不是 Exception 子类,但实际项目里因为各种 wrapping 经常被抓——保险起见显式 re-raise。
  3. 跑一下:pg_stat_activity 查 idle_in_transaction 数量。如果有持续超过几分钟的,基本是连接没释放。
  4. 检查 SQLAlchemy engine 的 pool_size 和 max_overflow,配置 Prometheus 监控,看一周的 checked_out 趋势。如果是锯齿状(QPS 高时上升、低时回落)说明正常;如果是单调上升,有泄漏。
  5. py-spy dump --pid <pid> 在卡死的 Pod 上抓协程栈,看是不是大量协程卡在 acquire / wait_for。
  6. 跑一次"客户端主动断开"的压测,看连接池行为。最简单的方法:用 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LangGraph 客服 agent 死循环一夜烧 OpenAI 3000 美金:tool-call 失控复盘 + 4 层熔断设计

2026-5-26 11:18:17

技术教程

Node.js stream backpressure 失效导致文件上传中转 24h 三连 OOM 的 4 天复盘:5 种修法 + 8 条工程纪律

2026-5-26 11:35:17

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索