1. 问题背景:一个“看起来没问题”的API
事情是这样的,我们有个订单查询接口,逻辑不复杂:根据用户ID查订单列表,关联商品信息、店铺信息、物流状态。开发环境跑得欢,部署到生产后,一到下午高峰期,前端反馈“转圈圈”,监控面板上P99延迟直接飙到2170ms,数据库连接池频繁报TimeoutError。
第一反应是加机器,但看了下监控,四台4C8G的Pod CPU平均才35%,内存也没压力。典型的“假负载”——服务没吃饱,但活干不完。查了慢日志,发现单条SQL执行都在200ms以内,但接口整体要2秒多,直觉告诉我:问题不在数据库本身,而在代码怎么“用”数据库。
2. 环境与版本:别在错误版本上浪费时间
先交代下现场,方便你复现:
Python: 3.10.12
FastAPI: 0.104.1
uvicorn: 0.24.0 (worker_class="uvicorn.workers.UvicornWorker")
SQLAlchemy: 2.0.23
asyncpg: 0.28.0
Redis: 7.0 (redis-py 5.0.1)
压测工具: wrk 4.2.0 / locust 2.16.1
部署: Gunicorn 21.2.0 + UvicornWorker,4 workers,每worker 1个event loop
特别提醒:如果你还在用SQLAlchemy 1.4的Query API,建议直接升2.0,selectinload的性能提升不是一点半点。
3. 方案设计:先Profile,别猜
很多人一上来就加缓存、加索引,结果往往事倍功半。我的流程是固定三步:
- 用cProfile抓函数级耗时,找出真正的时间黑洞
- 用SQLAlchemy的
echo=True和数据库EXPLAIN看执行计划,确认查询是否是“批处理” - 针对热点路径设计缓存,缓存粒度要细,别一把梭全量缓存
4. 核心实现:从2170ms到300ms的第一次优化
4.1 定位瓶颈:cProfile + snakeviz
在路由函数里临时加个装饰器,跑一次单请求profile:
import cProfile
import pstats
import io
from functools import wraps
def profile_endpoint(func):
@wraps(func)
async def wrapper(*args, **kwargs):
profiler = cProfile.Profile()
profiler.enable()
result = await func(*args, **kwargs)
profiler.disable()
s = io.StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
ps.print_stats(30)
print(s.getvalue())
return result
return wrapper
输出结果一目了然,to_async(asyncpg的驱动方法)累计耗时1.9秒,其中有492次独立的SELECT调用——典型的N+1查询。原因是SQLAlchemy ORM的懒加载(lazy='select'),每次访问order.items或order.shop都会发一条新SQL。
4.2 修复N+1:selectinload批量加载
把查询从懒加载改成显式批量加载,这是SQLAlchemy 2.0的官方推荐做法:
# 优化前:懒加载,产生N+1
stmt = (
select(Order)
.where(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.limit(20)
)
# 优化后:selectinload,一次性IN查询加载关联
from sqlalchemy.orm import selectinload
stmt = (
select(Order)
.options(
selectinload(Order.items).selectinload(Item.sku),
selectinload(Order.shop),
selectinload(Order.logistics),
)
.where(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.limit(20)
)
selectinload会生成WHERE id IN (..., ...)语句,把20笔订单的关联数据一次性拉回来。数据库查询次数从492次降到5次。改完本地压测,延迟已经从2170ms降至310ms左右,效果立竿见影。
4.3 数据库查询优化:索引和覆盖索引
看执行计划,orders表按user_id过滤后排序,走了全表扫描。加了个联合索引:
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
这个索引同时覆盖了WHERE和ORDER BY,避免filesort。注意别建(created_at, user_id),顺序错了索引就废了。另外把items表的外键order_id加上普通索引,因为selectinload依赖这个列的IN查询。
5. 踩坑与优化:缓存策略和隐藏的坑
5.1 缓存策略:Redis只缓存热数据
优化后接口延迟310ms,但QPS一高,数据库压力还是大。我们统计了用户行为,80%的请求集中在最近10%的活跃用户上。所以缓存策略定为:
- 粒度:
用户ID + 页码 + 时间戳(分钟级)作为key,缓存整个列表的JSON序列化结果 - 过期时间:60秒,避免数据太旧
- 缓存穿透:布隆过滤器拦截不存在的用户ID(不过我们用户量小,直接查库兜底)
import json
import aioredis
from fastapi import APIRouter, Depends
router = APIRouter()
redis = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
@router.get("/api/orders")
async def get_orders(user_id: int, page: int = 1):
cache_key = f"orders:{user_id}:{page}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
# 数据库查询逻辑(见上文selectinload部分)
orders = await fetch_orders(user_id, page)
# 序列化后写入缓存,60秒过期
result = [order_to_dict(o) for o in orders]
await redis.set(cache_key, json.dumps(result), ex=60)
return result
5.2 踩坑记录:三个真实的坑
- 坑一:
selectinload配合limit时,如果关联表数据量大,IN子句可能超过PostgreSQL的参数上限(65535)。我们的items表单订单最多50条,20个订单也就1000个参数,安全。但如果你的业务关联数据量大,需要手动分批。 - 坑二:Gunicorn + Uvicorn的worker数不能等于CPU核数,因为异步worker是单进程单线程,建议
2*CPU核数+1。我们4核机器开了9个worker,QPS从180提到320。 - 坑三:Redis缓存序列化用了
json.dumps,但datetime对象不能直接序列化。需要自定义default=str参数,否则会抛TypeError。这个坑debug了我半小时。
6. 效果数据:最终压测结果对比
用wrk压测,命令如下:
wrk -t8 -c200 -d60s --script=post.lua http://localhost:8000/api/orders
| 指标 | 优化前 | 第一次优化(去N+1) | 最终(+索引+缓存+多worker) |
|---|---|---|---|
| P50延迟 | 480ms | 145ms | 62ms |
| P99延迟 | 2170ms | 310ms | 89ms |
| QPS | 15 | 87 | 320 |
| 数据库连接数 | 峰值45 | 峰值22 | 峰值8(缓存命中率78%) |
内存占用从每worker 680MB降到420MB,因为ORM不再持有大量懒加载对象。数据库CPU使用率也从70%降到15%。
7. 总结:给后来者的三个建议
- Profile先行,性能优化不是玄学——
cProfile+snakeviz五分钟就能定位90%的问题,别靠猜。 - ORM懒加载是生产事故的温床——任何
async接口里出现同步懒加载,都会阻塞事件循环。要么全部用selectinload,要么干脆用CoreAPI写SQL。 - 缓存是最后的手段,不是第一选择——先保证查询本身是高效的,再加缓存才不会掩盖问题。我们加了Redis后,数据库压力大减,但即使缓存全部失效,接口也能扛住(P99 < 200ms)。
这次优化总共花了一个下午,收益是接口快了24倍,数据库压力降了5倍。如果你也遇到类似情况,建议按这个顺序排查:N+1 → 缺失索引 → 缓存 → 部署配置。有问题欢迎评论区交流。