一、问题现场:一个“看起来没问题”的慢接口
先说结论:性能瓶颈往往不在你直觉认为的地方。我们有个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_at和user_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反馈慢查询日志基本消失了。
五、调优过程中的三个“反直觉”坑
-
异步不是万能药:FastAPI的async路由如果内部用了同步SQLAlchemy,反而会因为线程切换增加20%开销。我把纯IO密集的Redis操作保留async,但数据库查询全部改为
run_in_executor在线程池执行,实测比纯async快15%。 -
selectinload也有极限:当关联表超过3层时,生成的JOIN SQL会膨胀,此时需要手工拆分查询。我最终把订单→商品→分类三层拆成两个查询:先查订单+商品ID列表,再一次性查分类信息,代码复杂了但查询时间从82ms降到47ms。
-
缓存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%的耗时集中在两三个函数上,别平均用力。