一、问题背景

先说结论:一个用 FastAPI 写的订单查询接口 /api/v1/orders/{user_id},在线上环境 P95 响应时间 1200ms,QPS 只有 45 左右,一到晚高峰就开始大量超时告警。

这个接口逻辑本身不复杂:根据 user_id 查最近 50 条订单,每条订单再带上商品信息、物流状态。之前一直是能跑的,但随着单用户订单量增长(有些老用户订单上千条),接口越来越慢。

我接手的时候,运维给的监控数据是:

  • 平均响应时间:680ms
  • P95:1200ms
  • P99:2400ms
  • 单实例 QPS:45
  • CPU 使用率:35%(不高,说明在等 IO)
  • 数据库连接数:经常打满到 100

CPU 不高但响应慢,典型的 IO 等待型瓶颈。下面记录完整的排查和优化过程。

二、环境与版本

先把环境列清楚,避免版本差异导致的结论不一致:

  • Python 3.11.6
  • FastAPI 0.109.2
  • Uvicorn 0.27.0(workers=4,单机 4 核 8G)
  • SQLAlchemy 2.0.25(async 模式,asyncpg 0.29.0)
  • PostgreSQL 14.10
  • Redis 7.2.3
  • 压测工具:wrk 4.2.0 + locust 2.20.0

顺便说一句,项目里还有几个老的 Flask 接口(Flask 2.3.3 + Gunicorn),这次也一并做了对比优化,后面会提到。

三、方案设计:先定位,再动手

我的原则是:没有 profiling 数据的优化都是瞎猜。所以第一步不是改代码,而是搞清楚时间花在哪。

排查分三步:

  1. 用 py-spy 抓火焰图,看 Python 层面哪里卡住
  2. 用 cProfile 做单请求级别的函数耗时分析,定位到具体函数
  3. 打开 SQLAlchemy 的 echo,看实际执行了多少条 SQL

定位手段确定后,优化方向大致是三个:

  • 数据库查询:N+1 问题、缺索引、一次性拉取过多字段
  • 缓存:热点数据用 Redis 缓存,减少重复查询
  • 并发模型:把能并行的 IO 并行化,调整连接池

四、核心实现

4.1 用 py-spy 抓火焰图

线上环境不方便改代码,py-spy 可以直接 attach 到进程,零侵入:

# 安装
pip install py-spy==0.3.14

# 抓 30 秒火焰图
py-spy record -o profile.svg --pid  --duration 30 --rate 100

火焰图一出来就发现问题了:大量时间花在 asyncpgfetch 上,而且调用栈里同一个 get_product_by_id 函数被反复调用了几十次。这就是典型的 N+1。

4.2 cProfile 单请求分析

为了拿到精确数字,我写了个脚本单独跑一次请求:

import cProfile
import pstats
import asyncio
from app.api.orders import get_user_orders

async def main():
    profiler = cProfile.Profile()
    profiler.enable()
    # 模拟请求,user_id=12345 有 800 条订单
    await get_user_orders(user_id=12345, limit=50)
    profiler.disable()
    stats = pstats.Stats(profiler).sort_stats("cumulative")
    stats.print_stats(20)

asyncio.run(main())

输出里最扎眼的一条:

ncalls  tottime  cumtime  function
   51    0.002    1.180   get_product_by_id

50 条订单触发了 51 次商品查询,每次约 23ms,光这一项就 1.18 秒。问题确认。

4.3 优化一:消除 N+1

原始代码长这样(简化版):

# 优化前:N+1
@router.get("/orders/{user_id}")
async def get_user_orders(user_id: int, limit: int = 50, db: AsyncSession = Depends(get_db)):
    result = await db.execute(
        select(Order).where(Order.user_id == user_id).order_by(Order.created_at.desc()).limit(limit)
    )
    orders = result.scalars().all()

    items = []
    for order in orders:
        # 每条订单单独查商品,N+1 元凶
        product = await db.execute(select(Product).where(Product.id == order.product_id))
        product = product.scalar_one()
        items.append({"order_id": order.id, "product": product.name, "amount": order.amount})
    return {"items": items}

改成用 selectinload 一次性预加载关联对象:

# 优化后:预加载 + 只取需要的字段
from sqlalchemy.orm import selectinload

@router.get("/orders/{user_id}")
async def get_user_orders(user_id: int, limit: int = 50, db: AsyncSession = Depends(get_db)):
    stmt = (
        select(Order)
        .options(selectinload(Order.product))
        .where(Order.user_id == user_id)
        .order_by(Order.created_at.desc())
        .limit(limit)
    )
    result = await db.execute(stmt)
    orders = result.scalars().all()

    return {
        "items": [
            {"order_id": o.id, "product": o.product.name, "amount": o.amount}
            for o in orders
        ]
    }

这一改,SQL 从 51 条降到 2 条,接口耗时直接从 1180ms 掉到 210ms。

4.4 优化二:加索引

Order.user_id 上原本没有索引,800 条订单的全表扫描也要几十毫秒。加上复合索引:

CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);

注意用 CONCURRENTLY,线上大表加索引不锁表。

4.5 优化三:Redis 缓存

这个接口有个特点:订单列表在短时间内变化不大,但被高频访问(用户刷新页面、App 轮询)。所以对第一页做缓存很划算:

import json
import redis.asyncio as redis

redis_client = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)
CACHE_TTL = 60  # 秒

@router.get("/orders/{user_id}")
async def get_user_orders(user_id: int, limit: int = 50, page: int = 1, db: AsyncSession = Depends(get_db)):
    cache_key = f"orders:{user_id}:{limit}:{page}"

    # 只缓存第一页,命中率高
    if page == 1:
        cached = await redis_client.get(cache_key)
        if cached:
            return json.loads(cached)

    stmt = (
        select(Order)
        .options(selectinload(Order.product))
        .where(Order.user_id == user_id)
        .order_by(Order.created_at.desc())
        .limit(limit)
        .offset((page - 1) * limit)
    )
    result = await db.execute(stmt)
    orders = result.scalars().all()
    data = {
        "items": [
            {"order_id": o.id, "product": o.product.name, "amount": o.amount}
            for o in orders
        ]
    }

    if page == 1:
        await redis_client.setex(cache_key, CACHE_TTL, json.dumps(data))
    return data

这里有个坑:写订单时要主动删缓存,否则用户下单后刷不出来。用 redis_client.delete(f"orders:{user_id}:*") 配合 SCAN 删(KEYS 命令线上禁用)。

4.6 优化四:连接池调优

Uvicorn 4 workers,每个 worker 默认连接池 pool_size=5,总共 20 个连接,根本不够。调整 SQLAlchemy 的 pool:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost:5432/orders",
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=1800,
    echo=False,
)

20 * 4 = 80 个连接,配合 PostgreSQL 的 max_connections=200,充裕。

4.7 Flask 老接口的对比优化

项目里还有个 Flask 的 /api/v1/products/{id} 接口,用的是同步 SQLAlchemy + Gunicorn(4 workers,sync worker)。这个接口 QPS 只有 30,优化思路类似:

  • 换成 gevent worker:gunicorn -k gevent -w 4 --worker-connections 1000
  • 加 Flask-Caching + Redis:CACHE_TYPE=redisCACHE_DEFAULT_TIMEOUT=120
  • 商品查询用 joinedload 替代懒加载

改完后 QPS 从 30 提升到 180,提升幅度不如 FastAPI 那边,主要是 sync worker 的并发模型限制。

五、踩坑与优化

几个踩过的坑,记录一下:

坑 1:selectinload 不适用于多对一。 一开始对 Order.product 用了 selectinload,虽然能work,但实际上是发了两条 SQL。多对一关系用 joinedload 更合适,能合并成一条 JOIN。后来改成了 joinedload,又省了一次往返。

坑 2:JSON 序列化成了新瓶颈。 缓存里存的是 JSON 字符串,每次 json.loads 反序列化 50 条记录要 8ms 左右。数据量大时可以考虑用 orjson 替代标准库。

坑 3:缓存击穿。 热门用户缓存过期瞬间,大量请求同时打到数据库。加了简单的互斥锁(redis.setnx)做单飞,或者对热点 key 用随机 TTL 打散。

坑 4:连接池参数不是越大越好。 一开始 pool_size 调到 50,结果 PostgreSQL 连接开销反而拖慢了。最终 20 是压测出来的甜点值。

六、效果数据

用 wrk 在同样硬件上压测,对比优化前后(每个配置跑 3 次取中位数):

指标 优化前 优化后 提升
平均响应时间 680ms 95ms 7.2x
P95 1200ms 180ms 6.7x
P99 2400ms 320ms 7.5x
QPS(单实例) 45 320 7.1x
数据库 QPS 2300 180 下降 92%
缓存命中率 - 78% -

压测命令:

wrk -t8 -c100 -d30s --latency \
  "http://localhost:8000/api/v1/orders/12345?limit=50&page=1"

线上灰度一周后,接口超时告警从每天 200+ 降到 0,数据库连接数峰值从 100 降到 35。CPU 使用率反而升到 55%,因为处理能力上去了。

七、总结

这次优化下来,几点体会:

  1. profiling 优先于优化。 py-spy + cProfile 组合,10 分钟就能定位到瓶颈,比拍脑袋强一百倍。
  2. N+1 是老生常谈,但真的常见。 只要用了 ORM,就要警惕这个,selectinload/joinedload 是标配。
  3. 缓存不是银弹,要注意失效和击穿。 缓存 key 设计、TTL、主动失效三件事想清楚再上。
  4. 连接池参数要压测。 不同硬件、不同数据库配置下的最优值不一样,拍脑袋设定迟早出事。
  5. FastAPI 的异步优势要用起来。 Flask 那边即使换了 gevent,并发能力还是差一截,新项目建议直接上 FastAPI。

代码里的完整示例可以直接跑,但记得把数据库连接、Redis 地址换成你自己的。有问题的欢迎评论区讨论。