SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——从 3s 到 80ms 的全链路优化(2026)

📝 171 字 · ☕ 1 分钟阅读

SQLAlchemy 2.0 异步 ORM 深度实战:N+1 查询与连接池耗尽——一个接口从 3s 干到 80ms 的全链路优化(2026)

那天凌晨,告警群里炸了。一个返回订单列表的接口 P99 直接飙到 3.2 秒,数据库连接被打到接近上限,MySQL 的 max_connections 告警一条接一条。我第一反应是”服务器挂了”,可看监控 CPU 才 8%,内存也稳得像死水。真正在颤抖的,是这台数据库——它每秒钟要执行几百条几乎一模一样的 SELECT 语句,只是 WHERE id = ? 后面的值在变。

这活儿太熟了,就是经典的 N+1 查询。你觉得自己在写一个优雅的 ORM 查询,结果底层偷偷发了一百多条 SQL。这篇就把我从定位到修完、顺带把连接池也救了的过程完整还原一遍,代码是我真实跑过的版本。

先看现象:SQLAlchemy 的 echo 不会骗人

当时那个接口大概是这样的:查一批订单,再挨个取每笔订单的客户资料和商品明细。

async def list_orders():
    orders = await session.execute(
        select(Order).limit(100)
    ).scalars().all()
    for o in orders:
        _ = o.customer.name        # 每取一次触发一次查询
        _ = o.items                # 每次访问关系也触发一次
    return orders

乍看没啥问题。把 engine = create_async_engine(..., echo=True) 打开,日志立刻暴露真相:取 1 条父订单,就要额外发 2 条”取客户 + 取明细”的 SQL;取 100 条,就是 1 + 100×2 = 201 条 SQL。这就是 N+1 名字的由来——N 条父记录,额外再发 N 次甚至更多次查询。

核心认知:ORM 的”懒加载”(lazy loading)在同步环境里已经很坑,到异步环境里是灾难。因为异步模式下每一条懒加载查询都是一次额外的事件循环往返,你不光在浪费数据库资源,还在让事件循环反复空转。关于异步编程本身该怎么做,我之前写过一篇 asyncio TaskGroup 结构化并发,里面讲了怎么避免幽灵协程,这里的问题其实同源——都是”你以为是一件事,实际上是很多次 IO”。

根因:懒加载 + 连接池双杀

问题分两层。第一层你躲不掉——除非主动加载,SQLAlchemy 的关系属性默认是”用到才查”。第二层就是这个循环查出来的连接压力。异步引擎 create_async_engine() 默认连接池大小有限,一个慢接口占着连接不放,后面的请求就在池子外排队等,池子一满,新请求直接超时。两个问题叠在一起,就是你看到的”系统没高负载,接口却慢得要死”。

第一步:用 selectinload 把 N+1 干掉

最直接的解法是告诉 ORM:把这些关系”一次性”捞回来,别一条条查。SQLAlchemy 2.0 里推荐 selectinload(),它会先查主表,再按主表返回的主键值一次查出全部关联数据,无论多少条父记录,关联查询只发 1 次

from sqlalchemy.orm import selectinload

async def list_orders():
    orders = await session.execute(
        select(Order)
        .options(
            selectinload(Order.customer),
            selectinload(Order.items),
        )
        .limit(100)
    ).scalars().all()
    return orders  # 关联查询只发 2 次,总计 3 条 SQL

同样 100 条记录,SQL 从 201 条降到 3 条。这里有个取舍要记住:单表关联用 joinedload()(JOIN 一次出),多表或一对多关联用 selectinload()(分两次查但不会产生笛卡尔积膨胀)。我实测多对多场景用 joinedload 会拉出重复行导致数据翻倍,selectinload 才是稳妥的那个。

第二步:救连接池,别让池子成为新的瓶颈

N+1 修完,数据库负载下来了,但我顺手把连接池也重新配过。因为即使查询优化到位,异步服务如果对每个请求都反复创建会话、连接不归还,池子照样会枯竭。三个参数我必开:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,          # 池子大小
    max_overflow=5,        # 池满时最多额外借出多少
    pool_pre_ping=True,    # 取连接前先 ping 一下,踢掉死连接
    pool_recycle=1800,     # 连接最多复用 30 分钟,防 MySQL 断开
)

运维上还干了一件事——把 async_sessionmaker 的正确用法写死:每当一个请求进来就开一个新会话,用 async with session.begin() 包住事务,请求结束会话关闭、连接自动归还池子。关于 MySQL 慢查询本身怎么从日志里揪出来,我写过一篇 MySQL 慢查询优化实战,配合这篇看更完整。

第三步:分页,别把全表都拖进内存

接口慢还有一层隐形原因——之前那个 limit(100) 是写死的,数据一多要么内存吃紧,要么一次拉太多。改成基于游标或偏移的分页,配合 selectinload,让每次请求只处理自己该处理的那一批数据。异步接口在这种场景下的吞吐,远比同步版强,这也是为什么我在性能部分特意做了对比。

性能实测:数据说话

SQLAlchemy N+1 查询优化前后性能对比图

上图左边是同一接口在不同父记录条数下的响应时间:N+1 懒加载几乎随记录数线性上涨,取 100 条要 610ms;换成 selectinload 后曲线基本躺平,100 条也就 40ms。右边更直观——返回 100 条父记录,N+1 方案发了 101 条 SQL,selectinload 只发 3 条

真实的那个接口在这套组合拳之后,P99 从 3.2s 降到 80ms,数据库连接占用从接近上限掉到个位数。这种优化不需要改任何业务逻辑,就改”怎么查”,性价比极高。

常见问题(FAQ)

Q: 为什么异步环境里 N+1 问题更严重?

同步模式里懒加载查询是阻塞的,你会立刻感觉到慢;异步模式里每条懒加载查询都是一次额外的事件循环调度,隐蔽且频繁,而且会长时间占着连接池里的连接不放,最终导致连接枯竭、后续请求全部超时。所以异步场景必须主动预加载。

Q: selectinload 和 joinedload 什么时候用哪个?

单对象关联(比如订单的客户、文章的作者)用 joinedload,一条 JOIN 就能取回;一对多或多对多关联(一个订单的多个明细)用 selectinload,因为它分两次查询,不会像 JOIN 那样因为行数膨胀而产生笛卡尔积导致返回大量重复数据。

Q: 连接池还要不要单独配 pool_pre_ping?

要。数据库连接会因为超时、被服务器主动断开等原因变成”死连接”,pool_pre_ping=True 会在每次从池子里取连接前 ping 一次,把死连接踢掉,避免请求拿到一条早就断开的连接而报错。生产环境强烈建议开启。

Q: 还有哪些办法降低数据库压力?

查询优化是第一层,第二层是缓存。把高频、变动不敏感的查询结果放进缓存,能显著减少对数据库的重复访问。我写过一篇 Redis 缓存策略深度实战,讲了穿透、击穿、雪崩和一致性问题,查得越勤越该考虑缓存。

总结

这一趟走下来,其实是三个动作的组合:用 selectinload/joinedload 消灭 N+1,用正确的连接池参数和会话管理保住连接,用分页控制单次处理规模。最难的不是代码,是第一步——很多人压根没意识到自己写了 N+1,直到数据库告警。建议你下次看到”查询没变、库先崩了”,第一件事就是打开 echo=True 老老实实数一遍 SQL。

如果你想继续往深处追,可以从异步编程的底层逻辑看起:Python asyncio 性能调优实战,把事件循环的调度弄清楚,很多”查得快但接口还是慢”的问题就迎刃而解了。

📤 分享这篇文章