FastAPI 接口压测全记录:从同步阻塞到 async SQLAlchemy,吞吐量提升 3 倍
一次把猜测变成数据的性能优化实战
性能优化最忌凭感觉。本文记录一次完整的压测实战:从基线数据出发,逐步定位瓶颈,量化每一步改造效果,最终找到这个接口真正的天花板。
🎯 背景:这个接口有多重?
被测接口是一个典型的批量写入场景:每次调用需要为一个用户同步约 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_thread | 100 | 6.2 req/s | 15,356 ms | — | 0% | 线程池(32 worker) |
| async SQLAlchemy | 100 | 18.2 req/s | 6,023 ms | 0 ms | 0% | — ✅ |
| async SQLAlchemy | 500 | 28.7 req/s | 15,000 ms | 有压力 | 0% | DB 连接池开始受压 |
| async SQLAlchemy | 1000 | 31.7 req/s | 28,000 ms | 有压力 | 0.79% | DB 连接池(上限 150) |
| async + 连接池×2 | 1000 | 42.8 req/s | 21,395 ms | 0 ms | 1.20% | 连接池瓶颈消除 ✅ |
| async + 连接池×2 | 5000 | 101.6 req/s | 30,003 ms | 343 ms | 75.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:
-
async def≠ 异步:函数签名写了async,里面调同步 DB 操作,一样会卡事件循环。FastAPI 的高并发能力需要整条调用链都是真正的 async。 -
瓶颈会迁移:解决线程池瓶颈之后,下一个瓶颈是 DB 连接池;连接池扩容之后,下一个瓶颈是 DB 写入吞吐。每次优化都要重新压测,否则以为解决了,其实只是换了个地方堵。
-
指标要组合看:P95 高不代表服务坏了,可能只是在排队;blocked=0 但 waiting 高,说明连接拿到了但 DB 在处理;失败率暴涨但吞吐量稳定,说明已到极限而非崩溃。
如果要继续往上推,下一步可以做什么?
- 批量 upsert:把 SELECT + DELETE + INSERT 三步合并成一条
INSERT ... ON CONFLICT DO UPDATE,DB 操作量从 ~400 行降到 ~200 行,理论上吞吐量翻倍 - 写入异步化:把实时同步改成消息队列 + 后台 worker,接口只负责收数据并返回 202 Accepted,DB 写入压力从请求链路上彻底解耦
- 读写分离:SELECT 走只读副本,减少主库压力,让主库专注写入
性能优化没有终点,只有当前场景下够用的 和不够用的。找到真正的瓶颈,才知道优化方向在哪。