1. 问题背景:一个“还行”的接口,直到被压测打脸
事情是这样的:我们有个订单列表接口GET /api/v1/orders?user_id=xxx,平时用着感觉还行,单次请求大概800ms左右。直到某次大促前压测,发现QPS到50就扛不住了,CPU直接飙到90%,大量请求超时。
我第一反应是“代码写得没问题啊”,结果用time命令一看:
$ curl -w "time_total: %{time_total}s\n" "http://localhost:8000/api/v1/orders?user_id=123"
time_total: 0.832s
850ms,这绝不是“还行”。作为后端,这属于“能跑但绝不能上线”的水平。
2. 环境与版本:别问,问就是最新的
先交代一下环境,方便你复现:
- Python 3.11.5
- FastAPI 0.104.1
- Uvicorn 0.24.0 (单worker,方便排查)
- SQLAlchemy 2.0.23(用async模式)
- PostgreSQL 15.2(数据量:orders表50万行,order_items表200万行)
- Redis 7.2.3(后面优化用)
- 压测工具:wrk 4.2.0
代码结构很常规:main.py里定义路由,models.py定义ORM模型,schemas.py是Pydantic模型。核心查询逻辑大概长这样:
# main.py 片段
@app.get("/api/v1/orders")
async def get_orders(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Order).where(Order.user_id == user_id).order_by(Order.created_at.desc()).limit(20)
)
orders = result.scalars().all()
# 这里有个数据组装,后面说
return [order_to_dict(o) for o in orders]
3. 第一步:别猜,用Profiling说话
我见过太多人一上来就“我觉得是慢查询”,然后瞎优化。我的习惯是先量化,再动手。
工具选择: 我用的是py-spy(一个采样profiler,不需要改代码),配合cProfile做函数级分析。
# 安装
pip install py-spy
# 先跑一个压测,同时采样
wrk -t4 -c100 -d30s "http://localhost:8000/api/v1/orders?user_id=123" &
sleep 5
sudo py-spy record -o profile.svg --pid $(pgrep -f uvicorn) --duration 20
生成的火焰图让我一眼看出问题:82%的时间耗在sqlalchemy.orm.session的_load_persistent_attributes上,再往下钻,是order_items表的查询。
原因很清楚:order_to_dict函数里,对每个Order对象都访问了o.items属性,这触发了N+1查询。20个订单,每个订单又查一次order_items表,一共21次数据库往返。
验证: 打开SQLAlchemy的echo=True,看到日志里一堆重复的SELECT * FROM order_items WHERE order_id = ?。
4. 核心优化一:干掉N+1,用selectinload
SQLAlchemy 2.0的推荐做法是用selectinload一次性加载关联对象。修改如下:
# models.py 片段
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, index=True)
items = relationship("OrderItem", back_populates="order", lazy="selectin")
# 或者查询时显式指定
from sqlalchemy.orm import selectinload
stmt = (
select(Order)
.where(Order.user_id == user_id)
.options(selectinload(Order.items))
.order_by(Order.created_at.desc())
.limit(20)
)
踩坑记录: 一开始我用的是joinedload,结果发现因为是async模式,SQLAlchemy 2.0的joinedload在PostgreSQL上会产生一个巨大的JOIN,数据量大了之后反而更慢(内存占用飙升)。selectinload是发第二条IN查询,对数据库更友好,实测效果好得多。
改完之后,数据库日志里只剩2条SQL(1条orders查询 + 1条IN查询)。
5. 核心优化二:Redis做热点缓存
N+1解决后,单次请求降到了约120ms。但我还觉得不够——同一个用户的订单数据其实变化不频繁,完全可以用Redis缓存。
方案设计: 缓存key为orders:{user_id}:page:1,过期时间5分钟。查询时先查Redis,命中直接返回;没命中再查数据库,然后写入Redis。
import json
import redis.asyncio as aioredis
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
@app.get("/api/v1/orders")
async def get_orders_cached(user_id: int, db: AsyncSession = Depends(get_db)):
cache_key = f"orders:{user_id}:page:1"
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached) # 直接返回,不碰数据库
# 数据库查询(同上,略)
orders = ...
result = [order_to_dict(o) for o in orders]
# 写入缓存,TTL 300秒
await redis_client.setex(cache_key, 300, json.dumps(result))
return result
踩坑提醒:
1. json.dumps不能序列化datetime,我写了个自定义encoder,把datetime转成ISO字符串。
2. 缓存穿透问题:如果用户ID不存在,会每次查库。我加了空值缓存(缓存一个[])。
3. 缓存雪崩:我加了随机过期时间(300 + random.randint(0, 60)),避免大量key同时失效。
6. 压测数据对比:从850ms到47ms
最后用wrk做了三轮压测,每轮30秒,并发100,4个线程:
| 阶段 | 平均延迟(ms) | P99(ms) | QPS | CPU占用 |
|---|---|---|---|---|
| 优化前 | 852 | 1200 | 48 | 90% |
| 去掉N+1后 | 123 | 180 | 210 | 45% |
| 加Redis缓存后 | 47 | 85 | 530 | 18% |
QPS从48提升到530,提升11倍。 P99从1.2秒降到了85ms,用户感知就是“秒开”。
7. 总结与反思
这次调优的启示:
1. 永远先profiling再优化。我见过有人花了3天优化代码,结果瓶颈在数据库索引上,纯属浪费时间。
2. N+1是最常见的性能杀手。ORM虽好,但一定要检查生成的SQL。建议开发环境开echo=True,或者用sqlparse格式化日志。
3. 缓存是最后的手段,不是第一手段。先确保数据库查询本身是高效的,再上缓存,否则只是掩盖问题。
现在这个接口线上跑得很好,但每次改代码我都会重新跑一遍压测。性能优化是个持续的过程,不是一次性的。
如果你们的接口也有类似问题,建议先按这个思路排查一遍。有疑问欢迎评论区交流。