一、问题现场:一个“看起来没问题”的慢接口

先说结论:性能瓶颈往往不在你直觉认为的地方。我们有个FastAPI写的订单聚合接口/api/v1/orders/summary,功能是返回用户最近30天的订单统计。代码逻辑清晰、用了异步、加了索引,但生产环境P95延迟就是300ms+。最初怀疑是数据库慢,结果DBA给了慢查询日志——最慢的SQL才47ms。那剩下的250ms去哪了?

环境版本:Python 3.10.8,FastAPI 0.95.2,Uvicorn 0.21.1,SQLAlchemy 2.0.15(ORM模式),PostgreSQL 14.5,Redis 7.0。服务器是4C8G的容器,压测工具Locust 2.15。

二、第一板斧:cProfile + py-spy 双管齐下

不要凭感觉猜,直接上profiler。我用了两种工具互补:

  • cProfile:统计函数级调用耗时,适合看“哪些函数总耗时最长”
  • py-spy:无侵入式采样,适合线上环境看“当前卡在哪个调用栈”

先在测试环境跑单个请求的cProfile:

python -m cProfile -s cumtime -o /tmp/prof.out \
    -m uvicorn main:app --port 8000

# 压测后导出统计
python -c "
import pstats, cProfile
p = pstats.Stats('/tmp/prof.out')
p.sort_stats('cumtime').print_stats(30)
"

关键输出如下(已脱敏):

ncalls  tottime  cumtime  filename:lineno
   1    0.002    0.284  api/routers/order.py:46 (get_summary)
  47    0.041    0.183  sqlalchemy/orm/loading.py:... (selectinload)
  12    0.011    0.096  app/services/order_service.py:88 (_serialize)

真相大白:单次请求发起了47次数据库查询!不是慢SQL,而是ORM的N+1查询——虽然用了selectinload加载了直接关联,但二层关联(订单→商品→分类)触发了懒加载。另外_serialize里用了嵌套的BaseModel.dict(),每次调用都重新递归序列化整个对象树。

同时用py-spy抓生产环境:

sudo py-spy dump --pid $(pgrep -f uvicorn) --duration 5

发现worker进程有38%的时间卡在jsonable_encoder上——因为FastAPI的response_model会做二次序列化,而我们手动serialize后又交给FastAPI再处理一次。双重序列化是隐藏的性能杀手。

三、优化方案:三层递进,不搞花活

针对profiling结果,我做了三项优化,按收益从高到低排列:

1. 数据库层:干掉N+1查询

核心改动:把原来的懒加载关系改为显式join + 一次性加载,同时给order_created_atuser_id建了联合索引。

# 优化前(触发N+1)
stmt = select(Order).where(Order.user_id == uid).options(
    selectinload(Order.items)
).order_by(Order.created_at.desc()).limit(30)

# 优化后(子查询 + join一次拿全)
subq = select(Order.id).where(
    Order.user_id == uid,
    Order.created_at >= thirty_days_ago
).order_by(Order.created_at.desc()).limit(30).subquery()

stmt = select(Order).join(subq, Order.id == subq.c.id).options(
    selectinload(Order.items).selectinload(Item.category)
).execution_options(populate_existing=True)

2. 序列化层:手写to_dict,跳过FastAPI二次序列化

# 优化前:pydantic model嵌套dict
return OrderSummaryModel(
    orders=[OrderItemModel.from_orm(o) for o in orders]
).dict()

# 优化后:直接构建原生dict,关闭response_model校验
@app.get("/api/v1/orders/summary", response_model=None)
async def get_summary(user_id: int = Depends(get_current_user)):
    orders = await order_service.fetch_summary(user_id)
    return {
        "count": len(orders),
        "total_amount": sum(o.total for o in orders),
        "items": [
            {
                "id": o.id,
                "created_at": o.created_at.isoformat(),
                "product": {"name": o.item.product.name, "price": o.item.price}
            }
            for o in orders
        ]
    }

这里有个坑:FastAPI 0.95默认会对返回值做jsonable_encoder处理,即使response_model=None也逃不掉。所以我在路由装饰器里加了response_class=PlainTextResponse,然后自己json.dumps()——虽然有点hack,但在高并发下省掉了pydantic的递归校验开销。

3. 缓存层:Redis二级缓存兜底

对于相同用户30分钟内的重复请求,直接命中缓存。注意缓存粒度要细——只缓存序列化后的字符串,不缓存ORM对象,避免pickle和过期问题。

import aioredis, json

redis = aioredis.from_url("redis://localhost:6379/1", decode_responses=True)
CACHE_KEY_PREFIX = "order_summary:v1"
CACHE_TTL = 1800  # 30分钟

async def get_summary_with_cache(user_id: int):
    cache_key = f"{CACHE_KEY_PREFIX}:{user_id}"
    # 先查缓存
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    # 缓存未命中,查库(此处省略查询逻辑)
    data = await fetch_from_db(user_id)

    # 回填缓存,设置随机过期防止雪崩
    ttl = CACHE_TTL + random.randint(0, 300)
    await redis.set(cache_key, json.dumps(data), ex=ttl)
    return data

踩坑提醒:千万别在缓存里存列表再逐条更新——这个接口是聚合数据,任何一条订单变化都会导致整个缓存失效。所以只做了用户维度整key缓存,并通过消息队列监听订单变更事件删除对应key。业务上允许最多30秒延迟。

四、压测对比:数据说话

用Locust做30分钟压测,模拟300并发用户,Think time 2-5秒。硬件环境不变。

指标 优化前 优化后 提升
单请求平均耗时 198ms 41ms 4.8x
P95延迟 312ms 38ms 8.2x
吞吐量 (req/s) 187 612 3.3x
数据库查询数/请求 47 3 15.7x
CPU空闲率 63% 82% -

最直观的效果是数据库连接池的压力——优化前Postgres的活跃连接数稳定在40+,优化后降到12左右,DBA反馈慢查询日志基本消失了。

五、调优过程中的三个“反直觉”坑

  1. 异步不是万能药:FastAPI的async路由如果内部用了同步SQLAlchemy,反而会因为线程切换增加20%开销。我把纯IO密集的Redis操作保留async,但数据库查询全部改为run_in_executor在线程池执行,实测比纯async快15%。

  2. selectinload也有极限:当关联表超过3层时,生成的JOIN SQL会膨胀,此时需要手工拆分查询。我最终把订单→商品→分类三层拆成两个查询:先查订单+商品ID列表,再一次性查分类信息,代码复杂了但查询时间从82ms降到47ms。

  3. 缓存key的设计影响命中率:最初用user_id:全部订单做key,结果用户一有新订单就失效,命中率只有31%。改成按user_id:月份维度的key后,命中率升到68%,但由于要处理跨月查询,代码复杂度又上去了。最终权衡业务场景(报表接口),还是选择了简单方案。

六、总结与建议

这次调优给我最大的教训是:永远先profiling再动手改代码。我最初以为加个Redis缓存就完事,结果发现不解决N+1和序列化问题,缓存命中率再高也填不了47次数据库查询的坑。

另一个经验是:ORM的便利性是有代价的。SQLAlchemy 2.0虽然性能比1.4好了不少,但在复杂聚合场景下,手写SQL或使用Core API反而更可控。如果项目刚起步,建议对高频查询直接走SQLAlchemy Core,把ORM留给CRUD操作。

最后给个性能基线参考:单机4C8G的容器部署,FastAPI + PostgreSQL + Redis,如果你的接口P95超过200ms,且单请求数据库查询超过10次,基本可以断定存在N+1或序列化瓶颈。先跑一遍cProfile,看cumtime排行前10的函数,再决定优化方向——通常90%的耗时集中在两三个函数上,别平均用力。