本文摘要:流量不大、SQL 也快,接口 P99 却从毫秒级涨到 3 秒,差额全耗在应用进程内。同步 Session 在
async def路由里独占事件循环,socket 等待把并发请求全部串行化。
一、问题与结论
pg_stat_statements显示平均执行 5ms、慢查询 0 条,容器 CPU 占用一成多,async def接口的 P99 却从 200ms 涨到 3s,日志里没有任何异常。
结论:SQL 不慢,是路由里直接用了 SQLAlchemy 同步Session。从session.execute()到 DBAPI 的socket.recv,整条链路没有一个await点,事件循环在一次网络往返期间整体冻结,其他请求一起排队。5ms 与 3000ms 之间的 2995ms 全在应用进程的阻塞等待里被串行化,N 个并发请求的尾延迟约等于 N × 单次往返。
这与「线程池槽位不够」是两层问题:def端点会整体进线程池,async def端点内部的阻塞库根本不进线程池,直接冻住单线程循环。
二、排查与选择依据
动手改之前,先自证「不是 SQL 慢」,用三个数据源对账:
| 数据源 | 观测到什么 | 排除掉什么 |
|---|---|---|
DB 侧pg_stat_statements | 平均 5ms,无慢查询 | SQL 慢 |
| 应用侧事件循环延迟探针 | DB 调用期间循环停顿与往返同量级 | 定位到客户端等待 |
进程侧py-spy dump | 线程停在socket.recv | 阻塞点在 DBAPI I/O |
三行对不上的差额就是问题本身。探针用一个asyncio.sleep(0.005)循环,实际耗时减去 5ms 即循环被冻结的时长,可长期挂着。
替代方案与取舍
| 方案 | 做法 | 选择条件 | 代价与边界 |
|---|---|---|---|
A 真异步驱动 +AsyncSession | create_async_engine配asyncpg/aiosqlite等,DAO 全链路await | 大量端点 DB 密集、需要长连接高并发 | 驱动替换、DSN 变更,异步驱动的 TLS、预处理语句、池化行为需重新评估,测试栈要能跑协程 |
| B 保留同步 Session,逐调用卸载 | 每个阻塞调用点await asyncio.to_thread(...) | 阻塞只在少数重查询,短期不想动 DAO | 每处都要包;asyncio.to_thread用默认线程池,max_workers约为min(32, os.cpu_count() + 4),容器 CPU 限额小时偏小,需与连接池联动 |
C 端点改回def | 让 FastAPI 整体走线程池 | 端点内没有真正的异步依赖 | 丧失该端点异步能力,线程池槽位受限 |
| D 减少往返次数 | 修 N+1、批量插入、只取所需列 | 任何方案之前都该做 | 成本低,收益常比换 API 大 |
不该用 A 的情况:QPS 低、瓶颈只在一两个重查询,改造面远大于收益;改了异步驱动但 N+1 不修,P99 不会好转。也不要用多 worker 掩盖单循环阻塞,那是多进程各跑各的循环,内存与冷启动代价照付。
三、关键原理
asyncio 是单线程事件循环,协程从一个await到下一个await之间的同步代码独占循环,其他协程只能等。SQLAlchemy 2.0 系列把 API 分成阻塞与非阻塞两套,同步Session不会自动卸载;FastAPI 也只对def端点整体放线程池。两者叠加的结果是:async def+ 同步Session语法合法、功能正常、性能塌方,且不抛异常。
异步扩展靠greenlet把 ORM 内部的同步调用桥接到异步驱动,所以同步引擎不能配AsyncSession,混用会在运行期报错;AsyncSession.sync_session是同步镜像,在事件循环线程里调用它仍走阻塞路径。Session 与连接是池化有状态资源,按「每个请求一个」使用,不能跨 task 共享。
连接池取舍:pool_size + max_overflow决定最大并发连接,线程卸载后并发同时压在线程数和连接数上,超过上限就从卡顿变成获取连接超时。异步模式下建议设expire_on_commit=False,避免commit()后属性过期触发隐式 IO。
四、可运行示例
环境:Python 3.9+,仅标准库,不需要数据库服务。输入:模拟一次 300ms 的 DB 往返,20 并发。
操作步骤:保存为demo_loop_block.py,执行python demo_loop_block.py。
# 运行: python demo_loop_block.py 依赖: Python 3.9+ 标准库importasyncioimporttime RT=0.30# 模拟一次 DB 往返的阻塞等待N=20LAG=[]defblocking_db()->int:time.sleep(RT)# 等价于 DBAPI 的 socket.recv 阻塞return1asyncdefprobe():# 事件循环延迟探针:5ms sleep 的实际耗时减 5ms 即循环被冻结的时长whileTrue:t=time.perf_counter()awaitasyncio.sleep(0.005)LAG.append(time.perf_counter()-t-0.005)asyncdefsync_route(i):blocking_db()asyncdefoffload_route(i):awaitasyncio.to_thread(blocking_db)asyncdefbench(fn,name):LAG.clear()p=asyncio.create_task(probe())lat=[]asyncdefone(i):t=time.perf_counter()awaitfn(i)lat.append(time.perf_counter()-t)t0=time.perf_counter()awaitasyncio.gather(*[one(i)foriinrange(N)])wall=time.perf_counter()-t0 p.cancel()try:awaitpexceptasyncio.CancelledError:passlat.sort()p50=lat[N//2]p99=lat[min(N-1,int(N*0.99)-1)]lag=max(LAG)ifLAGelse0.0print(f"{name}: 墙钟={wall:.2f}s P50={p50*1000:.0f}ms "f"P99={p99*1000:.0f}ms 循环最大停顿={lag*1000:.0f}ms")asyncdefmain():awaitbench(sync_route,"A 直调同步 Session")awaitbench(offload_route,"B 线程卸载")if__name__=="__main__":asyncio.run(main())预期输出(按上述参数推算的形态,数字随机器浮动,未在多机型实测验证):
A 直调同步 Session: 墙钟=6.00s P50=3300ms P99=5700ms 循环最大停顿=302ms B 线程卸载: 墙钟=0.31s P50=301ms P99=302ms 循环最大停顿=3ms实际输出:以本机运行结果为准,核对两点——A 组墙钟接近 20 × 300ms,循环最大停顿与单次往返同量级;B 组墙钟接近单次往返,循环停顿回落到毫秒级。若 B 组墙钟约为300ms × ceil(20 / 线程数),是默认线程池容量不足在分批执行;若 A 组墙钟远小于 6s,检查RT是否被改动,或协程是否真的并发提交。
常见失败一:改成AsyncSession后漏await,session.execute(stmt)返回的协程被丢弃,出现coroutine ... was never awaited,查询不执行、数据不写入,且不抛业务异常。修复:DAO 全链路改async def并逐处await。
常见失败二:卸载到线程后出现间歇性TimeoutError。原因:并发超过pool_size + max_overflow,线程在排队等连接。修复:联动上调连接池上限或压低并发,只加线程不加连接只会换一种报错。
五、验证结果与边界
上面的数字是示例形态,未经多环境验证;真实阻塞点是 socket 读,停顿时长随 DB 往返变化,time.sleep只用于复现形态。
边界:QPS 低、阻塞只出现在个别重查询时,线程卸载加减少往返次数通常就够,不必把数据访问层改成async;只有大量端点都是 DB 密集且需要长连接时,AsyncSession的改造成本才摊得平。若 DB 侧本来就有秒级慢查询,先优化 SQL,本方案无解。
参考资料
- SQLAlchemy 2.0 异步 ORM 扩展文档
- SQLAlchemy 2.0 连接池文档
- SQLAlchemy 2.0 连接与执行文档
- SQLAlchemy 引擎与依赖说明
- FastAPI 并发与 async 指南
- Python asyncio 事件循环文档