
本文摘要流量不大、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 真异步驱动 AsyncSessioncreate_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 减少往返次数修 N1、批量插入、只取所需列任何方案之前都该做成本低收益常比换 API 大不该用 A 的情况QPS 低、瓶颈只在一两个重查询改造面远大于收益改了异步驱动但 N1 不修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_commitFalse避免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 RT0.30# 模拟一次 DB 往返的阻塞等待N20LAG[]defblocking_db()-int:time.sleep(RT)# 等价于 DBAPI 的 socket.recv 阻塞return1asyncdefprobe():# 事件循环延迟探针5ms sleep 的实际耗时减 5ms 即循环被冻结的时长whileTrue:ttime.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()pasyncio.create_task(probe())lat[]asyncdefone(i):ttime.perf_counter()awaitfn(i)lat.append(time.perf_counter()-t)t0time.perf_counter()awaitasyncio.gather(*[one(i)foriinrange(N)])walltime.perf_counter()-t0 p.cancel()try:awaitpexceptasyncio.CancelledError:passlat.sort()p50lat[N//2]p99lat[min(N-1,int(N*0.99)-1)]lagmax(LAG)ifLAGelse0.0print(f{name}: 墙钟{wall:.2f}s P50{p50*1000:.0f}ms fP99{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 P503300ms P995700ms 循环最大停顿302ms B 线程卸载: 墙钟0.31s P50301ms P99302ms 循环最大停顿3ms实际输出以本机运行结果为准核对两点——A 组墙钟接近 20 × 300ms循环最大停顿与单次往返同量级B 组墙钟接近单次往返循环停顿回落到毫秒级。若 B 组墙钟约为300ms × ceil(20 / 线程数)是默认线程池容量不足在分批执行若 A 组墙钟远小于 6s检查RT是否被改动或协程是否真的并发提交。常见失败一改成AsyncSession后漏awaitsession.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 事件循环文档