Python FastAPI 性能调优实战:从1000到15000 req/s——一个生产接口的优化全记录(2026)

📝 724 字 · ☕ 2 分钟阅读

Python FastAPI 性能调优实战:从1000到15000 req/s——一个生产接口的优化全记录

去年年底,我们有一个 FastAPI 服务突然上了监控的 “告警头条”:一个商品列表接口的 P99 延迟飙到了 850ms,QPS 只有 1200 出头。用户投诉说「你们这个页面打开怎么跟拨号上网似的」——你猜怎么着?CPU 和内存都没满,数据库也不慢,但整个链路就是卡。

排查下来发现,问题出在一个 “祖传写法” 上:用 FastAPI 框架但 同步阻塞式调用 MySQL。对,框架是异步的,但数据库操作是同步的——等于你买了个跑车,结果在乡间土路上开。

这篇文章是我从头到尾把这个接口从 1250 QPS 优化到 15000+ QPS 的全过程复盘。每一个优化阶段都有真实代码、实测数据和踩过的坑。

FastAPI性能优化各阶段QPS与P99延迟对比
▲ 五个优化阶段 QPS 与 P99 延迟变化曲线。QPS 提升 12.5 倍,延迟降低 17.7 倍。

基线:同步阻塞的灾难现场

先看看最初的代码长什么样:

# ❌ 问题代码: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 个字段。Decimaldatetime 的验证尤其慢。

优化方案:

# ✅ 用 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。

  • Python 结构化日志实战:从 print 到 structlog — 生产级日志方案,structlog + FastAPI 完整落地方案
  • 免责声明:本文所有性能数据基于本地测试环境(4核16G + MySQL 8.0 + Redis 7.0),实际生产环境受网络延迟、硬件配置等因素影响,数据可能有所不同。优化策略仅供参考,请根据自身业务场景评估适用性。

    📤 分享这篇文章