← 全部文章

技术笔记 · 2026-09-14

接口没有慢 SQL,为什么数据库连接池仍然耗尽

只讨论一个问题:异步请求为何在等待外部服务时仍持有数据库连接,以及怎样通过独立短会话缩短占用,而不破坏业务事务。

  • Python
  • SQLAlchemy
  • 异步请求
  • 连接池

关联案例:智能台账与订单管理。文章依据本地鉴权只读会话调整,示例隐去了身份字段与外部接口。本文讨论资源生命周期,不把本地实现等同于生产恢复。

慢的可能是“拿着连接等”

一个接口可能只执行一次很快的身份查询,随后等待外部业务服务几秒钟。如果数据库会话仍维持查询开启的事务,连接可能在这段等待中一直不能归还。

查询身份 ─→ 等待外部服务 ─→ 整理响应 ─→ 请求结束
└──────── 数据库连接占用可能贯穿整段 ────────┘

async 让协程可以在等待时交出执行权,但不会自动释放数据库连接、事务或已经获得的锁。进程可以继续处理其他请求,不代表它持有的资源已经空闲。

不要把 Session 对象等同于已借出的连接

创建一个 Session 对象通常并不立即借用数据库连接;执行数据库操作、开启事务后,连接的持有和归还才成为关键。事务结束、会话关闭与连接池回收的关系,需要根据实际使用方式观察。SQLAlchemy 的连接池文档说明了连接借还与归还时的事务重置机制。

因此,应记录连接的 checkout/checkin、事务起止和外部调用区间,而不是只看“创建了几个 session”或者 SQL 耗时。

为独立只读步骤建立短生命周期

本地项目中的身份解析逻辑使用独立只读会话:查询后取出普通字段,离开会话,再等待外部令牌或业务数据。

下面是控制流程示意。read_identityfetch_resource 是注入的应用函数,不代表一套新的接口;前者必须把结果转换成普通字段快照。

async def resolve_resource(
    session_factory, read_identity, fetch_resource
):
    # read_identity 必须返回仅含普通字段的快照。
    async with session_factory() as identity_session:
        identity = await read_identity(identity_session)

    # 等待外部服务时,独立只读会话已经关闭。
    return await fetch_resource(identity)

如果读取结果仍是会触发懒加载的 ORM 对象,关闭会话后访问它可能失败。更清楚的边界是返回标量、不可变数据对象或明确的 DTO。

独立会话仍可以复用同一个连接池。它的价值在于生命周期更短、事务责任清楚,不在于创建了一个“额外的池”。

为什么不能直接 commit 调用方的会话

调用方的会话可能已经包含订单修改或其他尚未提交的业务写入。如果工具函数为了尽快释放连接而随意 commit()rollback(),就会替上层提前决定事务结果。

只有真正可独立完成、允许独立快照的只读步骤,才适合这样拆分。若后续写操作依赖读取时的条件仍然成立,应在自己的写事务中重新检查必要条件;不能把早先身份或权限快照当成永远有效。

同样,不要把同一个 AsyncSession 交给多个并行任务共同使用。SQLAlchemy 明确要求并发任务使用独立会话,见 AsyncSession 并发说明

用等待场景验证,而不是只做快速请求

可以把外部服务替换为一个被测试控制的等待点:身份查询结束后让它保持等待,观察连接是否已经归还。随后分别验证成功、外部失败和请求取消。

还要覆盖调用方已有未提交写入的情况,确认辅助读取没有替调用方提交或回滚。真实数据库集成测试则应观察连接池计数与事务状态,不能只用 mock 得出资源已经释放的结论。

一个量级示例是:每秒 20 个请求,如果各占用连接 2 秒,稳定状态下的平均连接需求可能接近 40;若外部等待移出会话,占用时间就不必包含那 2 秒。这只是排查用的估算,不是项目压测数据,也没有涵盖突发和长尾。

扩池之前先画出占用区间

连接池容量决定最多能同时服务多少数据库使用者,不能消除不必要的持有。先定位连接是在执行 SQL、等锁还是等外部服务,再决定缩短事务、调整并发入口或评估池容量。

本文的改动边界很明确:将不需要数据库连接的等待,移出独立只读会话的生命周期。