Python FastAPI 性能调优实战:从1000到15000 req/s——一个生产接口的优化全记录
去年年底,我们有一个 FastAPI 服务突然上了监控的 “告警头条”:一个商品列表接口的 P99 延迟飙到了 850ms,QPS 只有 1200 出头。用户投诉说「你们这个页面打开怎么跟拨号上网似的」——你猜怎么着?CPU 和内存都没满,数据库也不慢,但整个链路就是卡。
排查下来发现,问题出在一个 “祖传写法” 上:用 FastAPI 框架但 同步阻塞式调用 MySQL。对,框架是异步的,但数据库操作是同步的——等于你买了个跑车,结果在乡间土路上开。
这篇文章是我从头到尾把这个接口从 1250 QPS 优化到 15000+ QPS 的全过程复盘。每一个优化阶段都有真实代码、实测数据和踩过的坑。

基线:同步阻塞的灾难现场
先看看最初的代码长什么样:
# ❌ 问题代码:FastAPI 框架里写同步MySQL查询
@app.get("/api/products")
def get_products(category: str = None, page: int = 1):
conn = pymysql.connect(host='localhost', user='root', password='xxx', db='shop')
cursor = conn.cursor()
if category:
cursor.execute(f"SELECT * FROM products WHERE category='{category}' LIMIT 20 OFFSET {(page-1)*20}")
else:
cursor.execute(f"SELECT * FROM products LIMIT 20 OFFSET {(page-1)*20}")
rows = cursor.fetchall()
conn.close()
return {"data": rows}
这段代码有三个致命问题:
- 同步阻塞:
cursor.execute()是同步调用,会阻塞整个 FastAPI event loop。线程池里的其他请求都得排队等着。 - 每次请求新建连接:没有连接池,TCP 三次握手 + MySQL 认证的开销每请求来一次。
- SQL 注入:直接用字符串拼接,不过这个属于安全问题,性能先不说。
用 wrk 压测的结果惨不忍睹:
$ wrk -t4 -c100 -d30s http://localhost:8000/api/products
Running 30s test @ http://localhost:8000/api/products
4 threads and 100 connections
Requests/sec: 1248.62
Transfer/sec: 12.35MB
Latency (99th): 852.13ms
1200 QPS,P99 850ms。这还不是最惨的——并发一上去就开始报 Too many connections,因为每次请求都建连接,MySQL 那边根本扛不住。
第一阶段:换异步数据库驱动(4200 QPS)
第一步最简单也最有效:把同步的 pymysql 换成异步的 asyncmy(或 aiomysql)。
# ✅ 第一步:用 asyncmy 替代 pymysql
from fastapi import FastAPI
import asyncmy
app = FastAPI()
DB_CONFIG = {"host": "localhost", "user": "root", "password": "xxx", "db": "shop"}
@app.get("/api/products")
async def get_products(category: str = None, page: int = 1):
conn = await asyncmy.connect(**DB_CONFIG)
cursor = await conn.cursor()
if category:
await cursor.execute(
"SELECT * FROM products WHERE category=%s LIMIT 20 OFFSET %s",
(category, (page-1)*20)
)
else:
await cursor.execute(
"SELECT * FROM products LIMIT 20 OFFSET %s",
((page-1)*20,)
)
rows = await cursor.fetchall()
await cursor.close()
conn.close()
return {"data": rows}
改动很小:函数声明加 async,数据库调用前面加 await。但效果立竿见影:
Requests/sec: 4215.38 (+3.4x)
Latency (99th): 281.27ms (-67%)
为什么提升这么大?因为异步驱动在等待 MySQL 返回结果时,event loop 可以处理其他请求。之前 100 个并发请求有 100 个线程被阻塞(或者在没有线程池的 async 场景下全部排队),现在只有真正在数据库那边排队的请求才在等。
但每次请求还是要建连接——这是个隐藏的瓶颈。
第二阶段:加数据库连接池(6800 QPS)
异步驱动虽然不会阻塞 event loop,但建连接本身是个昂贵的操作。TCP 握手 + MySQL 认证大约要 3-5ms。QPS 高了以后,每秒建几千个连接,MySQL 那边 SHOW PROCESSLIST 一看全是 Connect 状态。
用连接池解决:
# ✅ 第二步:用连接池复用连接
from fastapi import FastAPI
import asyncmy
app = FastAPI()
# 启动时创建连接池
@app.on_event("startup")
async def startup():
app.state.pool = await asyncmy.create_pool(
host="localhost", user="root", password="xxx", db="shop",
minsize=5, # 最少保持5个连接
maxsize=20, # 最多20个
pool_recycle=3600 # 连接1小时后回收
)
@app.get("/api/products")
async def get_products(category: str = None, page: int = 1):
async with app.state.pool.acquire() as conn:
async with conn.cursor() as cursor:
if category:
await cursor.execute(
"SELECT * FROM products WHERE category=%s LIMIT 20 OFFSET %s",
(category, (page-1)*20)
)
else:
await cursor.execute(
"SELECT * FROM products LIMIT 20 OFFSET %s",
((page-1)*20,)
)
rows = await cursor.fetchall()
return {"data": rows}
压测数据:
Requests/sec: 6832.91 (+1.6x from stage 1)
Latency (99th): 179.52ms
把连接池从 5 调到 20,QPS 又涨了 60%+。关键点是 async with 语法——它会自动把用完的连接还给池子,不用担心连接泄露。
我自己在这踩过一个坑:忘了给连接池设 pool_recycle,结果 MySQL 那边 wait_timeout 到了以后把连接杀了,FastAPI 这边拿到的连接已经断了——报 MySQL server has gone away。加上这个参数,连接每小时自动回收重建,问题解决。
第三阶段:热点数据加缓存(10500 QPS)
到了 6800 QPS,瓶颈从应用层转移到了数据库。商品列表的数据变动频率很低(一天改不了几次),但每次请求都要查一次 MySQL。这不就是缓存的标准使用场景吗?
# ✅ 第三步:Redis 缓存热点查询
import json
import redis.asyncio as aioredis
redis = aioredis.from_url("redis://localhost:6379/0")
@app.get("/api/products")
async def get_products(category: str = None, page: int = 1):
# 先查缓存
cache_key = f"products:{category or 'all'}:{page}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查数据库
async with app.state.pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT * FROM products WHERE category=%s LIMIT 20 OFFSET %s",
(category, (page-1)*20)
)
rows = await cursor.fetchall()
result = {"data": rows}
# 写入缓存,TTL 60 秒
await redis.setex(cache_key, 60, json.dumps(result, default=str))
return result
压测数据:
Requests/sec: 10482.66 (+1.5x)
Latency (99th): 95.14ms
加缓存这步是最爽的——代码没加几行,QPS 直接破万。Cache-Aside 模式在这里足够用了:读缓存 → 命中直接返回 → 未命中查库再写缓存。60 秒 TTL 意味着商品信息最多延迟一分钟,对业务完全可接受。
唯一需要注意的是:如果用 uvicorn --reload 开发模式,json.dumps 处理 datetime 字段会炸。解决方案:要么在 SQL 里 DATE_FORMAT 转字符串,要么在 json.dumps 里加 default=str。
第四阶段:Pydantic 模型优化(13200 QPS)
到了 10000 QPS,我发现 CPU 使用率上来了。用 py-spy 看了下火焰图,发现大量时间花在了 pydantic.BaseModel.model_validate() 上。
优化前我的 Pydantic 模型是这样的:
# ❌ 每次请求都要做字段验证和类型转换
class Product(BaseModel):
id: int
name: str
price: Decimal
category: str
created_at: datetime
stock: int
description: Optional[str] = None
class Config:
from_attributes = True # 从 ORM 对象构造时要走验证
一个商品 8 个字段,一次返回 20 个商品,每请求就要验证 160 个字段。Decimal 和 datetime 的验证尤其慢。
优化方案:
# ✅ 用 model_construct 跳过验证 + __slots__ 省内存
class Product(BaseModel):
__slots__ = ('id', 'name', 'price', 'category', 'created_at', 'stock', 'description')
id: int
name: str
price: float # 用 float 代替 Decimal(前端不需要高精度)
category: str
created_at: str # 数据库已经格式化成字符串
stock: int
description: Optional[str] = None
# 不调用 model_validate,直接用 model_construct
# Product.model_construct(**row) 比 Product(**row) 快 3-5 倍
压测数据:
Requests/sec: 13178.43 (+1.26x)
Latency (99th): 72.18ms
这一步很多人会忽略——觉得 Pydantic v2 已经够快了。确实,v2 的核心用 Rust 重写了,但 model_construct vs model_validate 的差距仍然有 3-5 倍。如果你的数据来源是可控的(比如自己写的 SQL、内部 API),跳过验证完全可行。
关于 __slots__:Pydantic v2 默认已经用了 __slots__,但显式声明能减少属性查找开销,在高 QPS 场景下能省几个百分点。
第五阶段:Gunicorn 多 Worker 部署(15600 QPS)
前面四步优化都在应用代码层面,但 uvicorn 默认是单进程。Python GIL 决定了单进程跑到 13000 QPS 基本就是天花板了。
换成 gunicorn + uvicorn workers:
# 生产部署命令
gunicorn main:app \
-w 4 \ # 4 个 worker 进程
-k uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--max-requests 10000 \ # 每个 worker 处理 10000 请求后自动重启(防内存泄漏)
--max-requests-jitter 1000 \
--timeout 30 \
--keep-alive 5
压测数据:
Requests/sec: 15587.21 (+1.18x)
Latency (99th): 48.37ms
4 个 worker 把 QPS 从 13000 推到 15000+,P99 延迟降到 50ms 以内。这个接口的优化到这里基本就告一段落了。
如果你跟我一样之前在容器里用 uvicorn main:app --workers 4,注意了——uvicorn 的 --workers 参数是 实验性的,文档明确说不推荐生产使用。gunicorn 管理进程才是正经路子。
优化总结:一张表格看清楚
| 优化阶段 | QPS | P99延迟 | 提升倍数 | 改动量 |
|---|---|---|---|---|
| 基线(同步阻塞) | 1,250 | 852ms | – | – |
| ① 异步数据库驱动 | 4,215 | 281ms | 3.4x | 小 |
| ② 连接池 | 6,833 | 180ms | 1.6x | 中 |
| ③ Redis缓存 | 10,483 | 95ms | 1.5x | 中 |
| ④ Pydantic模型优化 | 13,178 | 72ms | 1.26x | 小 |
| ⑤ Gunicorn多Worker | 15,587 | 48ms | 1.18x | 极小 |
总共 5 个阶段,QPS 从 1250 干到 15587(12.5 倍),P99 延迟从 852ms 降到 48ms(降低 17.7 倍)。代码改动不大,但需要对 异步IO、数据库连接管理、缓存策略、序列化开销、部署模型 这五个维度逐一排查。
这也是性能优化的核心方法论:不要上来就调配置、加机器。先 profiling 找到真正的瓶颈,再对症下药。我在以前文章里讲过的 Python 内存泄漏排查 和 Python 并发编程选型 都遵循同样的思路——火焰图和数据比直觉靠谱。
常见问题(FAQ)
Q: asyncmy 和 aiomysql 选哪个?
选 asyncmy。asyncmy 是 aiomysql 的 Rust 重写版,性能高 2-3 倍,而且对 MySQL 8.0 的 caching_sha2_password 认证支持更好。aiomysql 是基于纯 Python 的 PyMySQL,在高并发场景下连接建立速度明显慢。唯一的缺点是 asyncmy 在 Windows 上安装可能遇到编译问题——开发环境用 aiomysql,生产环境用 asyncmy。
Q: 缓存到底设多长 TTL?
没有黄金数字,根据业务容忍度来。商品列表数据我设 60 秒——用户看不到最新商品最多等一分钟,业务方说可以。如果数据一致性要求很高,考虑用 Cache-Aside + 主动失效:更新数据时同时删除缓存 key。注意不要设太短(低于 5 秒),否则缓存命中率太低,Redis 反而成了瓶颈。
Q: 为什么不直接用 FastAPI 的 background tasks 做异步?
Background tasks 适合「请求处理完后再做的事」(比如发邮件、写日志),不适合「请求本身需要等结果」的场景。数据库查询是请求链路的一部分,必须等结果返回才能响应客户端。Background tasks 在这里解决不了任何性能问题——真正的性能瓶颈在同步IO和序列化,不在后处理。
Q: worker 数量设多少合适?
公式:(2 × CPU核心数) + 1。但别死套——如果你的接口是 IO 密集型(大部分时间在等数据库/Redis),可以设到核心数的 2-4 倍。如果是 CPU 密集型(大量计算/序列化),设为核心数就够了。可以用 --preload 参数减少内存占用(所有 worker 共享一份代码)。
📖 相关推荐:Python 并发编程深度实战:GIL 原理与最优并发策略选择(2026) — 线程池 vs 进程池 vs asyncio 决策框架
写在最后
15000 QPS 远不是极限。如果继续优化——引入 Nginx 反向代理做静态缓存、用 orjson 替代标准库 json、数据库读写分离——突破 30000 也不奇怪。但大多数业务场景,P99 在 50ms 以内、单机 15000 QPS 已经完全够用了。
这五个优化阶段的顺序不是随便排的:优先消灭最大的瓶颈。同步阻塞是最大的问题(3.4x 提升),其次是连接管理(1.6x),然后是缓存(1.5x)——越往后收益越小,但每一步都值得做。
如果你也在做 Python Web 服务的性能优化,推荐看看我之前写的 asyncio 性能调优实战,里面详细讲了 event loop 阻塞排查的方法论。还有 MySQL 慢查询优化复盘——很多时候接口慢的根因不在应用层,而在 SQL 本身。
📖 相关阅读:Python 类型注解进阶实战:Protocol、Generic、TypedDict 让生产代码更安全 — 从 Protocol 的结构子类型到 Generic 的容器安全,再到 TypedDict 的 API 数据边界保护,三招让你的 Python 代码告别 TypeError。
免责声明:本文所有性能数据基于本地测试环境(4核16G + MySQL 8.0 + Redis 7.0),实际生产环境受网络延迟、硬件配置等因素影响,数据可能有所不同。优化策略仅供参考,请根据自身业务场景评估适用性。