FastAPI 接口压测全记录:从同步阻塞到 async SQLAlchemy,吞吐量提升 3 倍

一次把猜测变成数据的性能优化实战

February 22, 2026·4 min read·Yimin
#FastAPI#异步编程#asyncio#SQLAlchemy#性能优化#压测

性能优化最忌凭感觉。本文记录一次完整的压测实战:从基线数据出发,逐步定位瓶颈,量化每一步改造效果,最终找到这个接口真正的天花板。


🎯 背景:这个接口有多重?

被测接口是一个典型的批量写入场景:每次调用需要为一个用户同步约 200 条记录,底层操作分三步——先查询现有数据(SELECT ~200 行),再删除旧数据(DELETE),最后批量插入新数据(INSERT ~200 行)。

一次请求约涉及 400 行数据库 I/O,不算夸张,但也绝对不轻。

问题的起点很简单:这个接口到底能扛多少并发? 没人说得清楚,于是决定压一下。


🧠 先搞清楚:同步代码为什么会阻塞 FastAPI?

FastAPI 本质上跑在 asyncio 的单线程事件循环上。它高并发的秘密在于:当一个请求在等待 I/O 时,事件循环会切换去处理其他请求,所有人共享同一个线程。

【正常 async 模式】

请求 A ──► await db_op() ·········► 继续 A ──► 返回
请求 B ──►          await db_op() ·····► 继续 B ──► 返回
请求 C ──►                  await db_op() ···► 继续 C ──► 返回
           ↑ await 时主动让出,事件循环切换到下一个请求
【同步阻塞模式】

请求 A ──► db.query() [阻塞 800ms ████████████] ──► 返回
请求 B ──►                                          db.query() [阻塞 800ms] ──► 返回
请求 C ──►                                                                     ...排队...
           ↑ 同步调用不会让出,事件循环被卡住,后续请求只能串行等待

当你在 FastAPI 的 async def 路由里调用同步的 SQLAlchemy Session,就是第二种情况。哪怕你写了 async def,里面的同步 DB 调用一样会卡住整个事件循环。

一个常见的临时绕法是 asyncio.to_thread():把同步调用扔到线程池里跑,事件循环不再被卡住。但这只是治标——线程池本身有大小上限(默认 32 个),并发一高,请求就在线程池外面排队,换了个地方堵。


📊 压测方法论:先把指标搞懂

压测工具选 k6,它支持大量并发 VU(虚拟用户)、内置指标聚合、阶梯式负载,适合这类场景。

压测前,先把这几个关键指标搞清楚,否则看到数字不知道说明什么:

指标含义高了意味着什么
吞吐量 (req/s)每秒成功完成的请求数系统处理能力上限
P50 / P95 / P99响应时间百分位。P95=21s 表示 95% 的请求在 21s 内完成长尾延迟,用户体感
waiting(服务端排队)请求到达服务器到开始被处理的等待时间服务端队列积压,处理能力跟不上
blocked(连接等待)客户端等待建立 TCP/HTTP 连接的时间HTTP 连接池或 DB 连接池被耗尽
失败率 (fail rate)超时或报错的请求占比系统是否已达极限
VU(虚拟用户)同时在跑的并发请求数模拟的并发量

P95 比 P99 更常用:它过滤掉极端异常值,同时能反映绝大多数真实用户的体验。如果 P95 超过 5 秒,说明有相当一部分用户在忍受明显的卡顿。

测试采用阶梯式加压:100 VU → 500 VU → 1000 VU → 5000 VU,每阶段稳定跑足够久再看结果。每个 VU 跑完整的三步 DB 操作后统计指标。

为了绕过 Auth 鉴权不污染正式数据,在非生产环境下单独开了一个无鉴权的测试端点,并在测试结束后自动清理测试数据。


🔍 第一轮:基线数据(同步 + to_thread)

先不做任何优化,原始代码是同步 SQLAlchemy + asyncio.to_thread() 的组合。

100 VU 压测结果:

  • 吞吐量:6.2 req/s
  • P95:15,356 ms(15 秒!)
  • 失败率:0%

15 秒的 P95 说明大量请求在排队。观察服务端,线程池(32 个 worker)被打满,请求在线程池外等待分配——这就是 asyncio.to_thread() 的天花板。


🚀 改造:迁移到 async SQLAlchemy

真正的解法是把 DB 调用改成非阻塞的。SQLAlchemy 1.4+ 支持 AsyncSession,配合 create_async_engine,DB 的 I/O 等待期间会主动让出事件循环,其他请求可以同时被处理。

改造涉及整个调用链:数据库客户端 → Repository 层 → Manager 层 → Router 层,每一层都需要改成 async def + await。这是一次全链路的异步化,不能只改一半。

改完之后,同样的 100 VU 再压一次:

  • 吞吐量:18.2 req/s(+194%)
  • P95:6,023 ms(-61%)
  • 失败率:0%

吞吐量翻了 3 倍,P95 从 15 秒降到 6 秒。事件循环不再被阻塞,并发能力立刻释放出来。


📈 数据说话:五轮完整对比

加大压力,看系统在不同并发下的表现:

轮次并发 VU吞吐量P95 延迟blocked P95失败率瓶颈
同步 + to_thread1006.2 req/s15,356 ms0%线程池(32 worker)
async SQLAlchemy10018.2 req/s6,023 ms0 ms0%— ✅
async SQLAlchemy50028.7 req/s15,000 ms有压力0%DB 连接池开始受压
async SQLAlchemy100031.7 req/s28,000 ms有压力0.79%DB 连接池(上限 150)
async + 连接池×2100042.8 req/s21,395 ms0 ms1.20%连接池瓶颈消除 ✅
async + 连接池×25000101.6 req/s30,003 ms343 ms75.13%DB 写入吞吐天花板

有几个细节值得注意:

  • 500 VU 时失败率仍为 0%:系统在撑,只是 P95 开始变长——请求都能跑完,但要等
  • blocked 从"有压力"变成 0ms 是连接池调优的直接信号,说明连接争抢问题消失了
  • 5000 VU 失败率 75%:不是服务崩溃,而是请求超时——吞吐量已经到顶(101 req/s),新来的请求等不到处理机会

⚙️ 连接池调优:一个配置把 1000 VU 吞吐量再提 35%

在 1000 VU 测试中发现 blocked P95 有明显压力,这是 DB 连接池被耗尽的信号:请求在等待拿到一个数据库连接。

SQLAlchemy 连接池默认配置:pool_size=50(常驻连接),max_overflow=100(峰值扩展),合计最多 150 个并发连接。1000 个 VU 同时打过来,连接明显不够分。

将连接池调整为 pool_size=100, max_overflow=200(最多 300 个连接)之后:

  • 吞吐量:31.7 → 42.8 req/s(+35%)
  • P95:28,000 → 21,395 ms(-24%)
  • blocked P95 直接降到 0 ms ← 连接池瓶颈彻底消除

⚠️ 5000 VU:碰到真正的天花板

连接池扩容之后,5000 VU 的吞吐量飙到了 101.6 req/s——但失败率高达 75%。

这和之前的瓶颈性质不同:

1000 VU 瓶颈(连接池):
  请求 A ──► 等待 DB 连接 ·····► 拿到连接 ──► 执行 ──► 返回   ← 最终能完成

5000 VU 瓶颈(DB 写入吞吐):
  请求 A ──► 拿到连接 ──► 执行中...
  请求 B ──► 拿到连接 ──► 执行中...
  ... (300 个连接全满)
  请求 X ──► 等待连接 ·····················► 超时 ❌      ← 直接失败

blocked P95 = 343 ms 说明连接池仍有一点压力,但不是主因。主因是:DB 每秒能处理的写入行数就这么多。每个请求需要写约 400 行,101 req/s × 400 行 = 每秒 4 万行写入,数据库服务端已经全力在跑了。

这是一个关键的认知:当吞吐量到顶但连接池还没打满时,瓶颈不在 Python、不在连接数,而在数据库自身的写入速度


📝 总结

三条核心 takeaway:

  1. async def ≠ 异步:函数签名写了 async,里面调同步 DB 操作,一样会卡事件循环。FastAPI 的高并发能力需要整条调用链都是真正的 async。

  2. 瓶颈会迁移:解决线程池瓶颈之后,下一个瓶颈是 DB 连接池;连接池扩容之后,下一个瓶颈是 DB 写入吞吐。每次优化都要重新压测,否则以为解决了,其实只是换了个地方堵。

  3. 指标要组合看:P95 高不代表服务坏了,可能只是在排队;blocked=0 但 waiting 高,说明连接拿到了但 DB 在处理;失败率暴涨但吞吐量稳定,说明已到极限而非崩溃。


如果要继续往上推,下一步可以做什么?

  • 批量 upsert:把 SELECT + DELETE + INSERT 三步合并成一条 INSERT ... ON CONFLICT DO UPDATE,DB 操作量从 ~400 行降到 ~200 行,理论上吞吐量翻倍
  • 写入异步化:把实时同步改成消息队列 + 后台 worker,接口只负责收数据并返回 202 Accepted,DB 写入压力从请求链路上彻底解耦
  • 读写分离:SELECT 走只读副本,减少主库压力,让主库专注写入

性能优化没有终点,只有当前场景下够用的不够用的。找到真正的瓶颈,才知道优化方向在哪。