1. 问题背景:一个被业务催着上线的接口
上个月接手一个订单服务,核心接口GET /api/v1/orders在测试环境响应时间就离谱——平均1500ms,P95直接飙到2.3秒。压测工具wrk怼上去,100并发就把CPU干到90%,QPS只有120。
第一反应是“这代码谁写的”。打开代码一看,好家伙,典型的SQLAlchemy懒加载地狱:
# 伪代码示意
orders = session.query(Order).filter(Order.user_id == user_id).all()
for order in orders:
items = order.items # 每个order触发一次查询
for item in items:
product = item.product # 每个item再触发一次
一个订单平均8个item,每个item关联product和sku表。算下来一个请求要跑 1 + 8 + 8*2 = 25次数据库查询。这就是N+1问题,性能杀手。
环境版本:Python 3.11.4,FastAPI 0.104.1,SQLAlchemy 2.0.23,PostgreSQL 15.3(本地Docker跑),Redis 7.2。
2. Profiling工具:别猜,用数据说话
很多同学一上来就盲优化,这是大忌。先用cProfile跑一遍,把热点揪出来。
python -m cProfile -o profile_output.prof my_app.py
然后用pstats分析,或者直接用snakeviz可视化:
pip install snakeviz
snakeviz profile_output.prof
关键输出指标:
ncalls tottime percall cumtime percall filename:lineno
21250 0.892 0.000 3.215 0.000 sqlalchemy/orm/loading.py:123
12500 0.441 0.000 2.894 0.000 sqlalchemy/orm/strategies.py:456
8500 0.312 0.000 1.203 0.000 app/models.py:85 (serialize)
数据很清楚:
- SQLAlchemy ORM加载占了62%的累计时间
- 序列化(Pydantic v2的model_validate)占了12%
- 剩下的零碎时间在路由、中间件、日志等
还有个细节:tottime最高的是loading.py,说明不是SQL执行慢,而是ORM对象构建和懒加载触发的额外查询。数据库层面其实每条SQL都在10ms以内,但架不住数量多。
3. 方案设计:三层优化
基于profiling结果,设计三层优化:
第一层:消灭N+1。用SQLAlchemy 2.0的selectinload一次性加载关联对象,把25次查询压到3次(orders、items、products+skus)。
第二层:缓存热点数据。用户最近30天的订单是高频访问,且数据变更不频繁。用Redis缓存序列化后的JSON,TTL设5分钟。缓存key设计为order:list:{user_id}:{page}:{page_size},带版本号方便失效。
第三层:序列化优化。Pydantic v2已经很快了,但每次构建模型还是有一定开销。对只读接口,直接手动构建dict返回,跳过Pydantic的校验流程。
4. 核心实现:代码对比
改造前(懒加载地狱):
@app.get("/api/v1/orders")
async def get_orders(user_id: int, page: int = 1):
orders = db.query(Order).filter(Order.user_id == user_id).all()
result = []
for order in orders:
order_data = {
"id": order.id,
"total": order.total,
"items": []
}
for item in order.items: # 每个order一次查询
item_data = {
"product_name": item.product.name, # 每个item再查product
"sku_code": item.sku.code, # 每个item再查sku
"quantity": item.quantity
}
order_data["items"].append(item_data)
result.append(order_data)
return result
改造后(selectinload + Redis缓存):
from sqlalchemy.orm import selectinload
import aioredis
import json
redis_client = aioredis.from_url("redis://localhost:6379/0")
@app.get("/api/v1/orders")
async def get_orders(user_id: int, page: int = 1):
# 缓存优先
cache_key = f"order:list:{user_id}:{page}"
cached_data = await redis_client.get(cache_key)
if cached_data:
return json.loads(cached_data)
# 一次性加载所有关联对象
orders = db.query(Order).options(
selectinload(Order.items).selectinload(Item.product),
selectinload(Order.items).selectinload(Item.sku)
).filter(Order.user_id == user_id).all()
# 手动构建dict,跳过Pydantic校验开销
result = []
for order in orders:
items = [{
"product_name": item.product.name,
"sku_code": item.sku.code,
"quantity": item.quantity
} for item in order.items]
result.append({
"id": order.id,
"total": order.total,
"items": items
})
# 写缓存,TTL 300秒
await redis_client.set(cache_key, json.dumps(result), ex=300)
return result
注意几个细节:
- selectinload是SQLAlchemy 2.0推荐的,比joinedload好在不会产生笛卡尔积
- 缓存key要包含分页参数,否则不同页会串数据
- 手动构建dict省去了Pydantic的model_validate时间,这个接口不需要校验入参,直接返回裸dict没问题
5. 踩坑与优化:三个意想不到的坑
坑1:selectinload导致SQL IN子句过长。
订单表数据量大,一次性查所有关联items会生成WHERE order_id IN (1,2,3,...500)。PostgreSQL对IN列表有优化上限,超过500个ID性能下降。解决:分页限制单页最大50条,并且selectinload内部自动分批(batch_size=100)。
坑2:Redis缓存穿透。
压测时发现缓存miss瞬间,DB连接池被打满。原因是用户第一次请求,缓存没数据,100个并发同时查DB。解决:加布隆过滤器或者锁。简单方案:对不存在数据的key也缓存一个空列表,TTL设30秒,防止恶意穿透。
坑3:序列化时decimal类型无法JSON序列化。
订单金额是Decimal类型,直接json.dumps报错。解决:自定义encoder:
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
json.dumps(result, cls=DecimalEncoder)
6. 压测数据:优化前后对比
用wrk压测,参数:100并发,30秒duration,GET请求。
优化前:
Running 30s test @ http://localhost:8000/api/v1/orders
100 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.51s 0.87s 3.25s 78.90%
Req/Sec 1.20k 123.45 1.50k 70.00%
1203 requests in 30.05s, 1.1MB read
优化后(三层全上):
Running 30s test @ http://localhost:8000/api/v1/orders
100 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 80.32ms 12.34ms 156.78ms 88.50%
Req/Sec 18.00k 1.23k 20.50k 75.00%
18004 requests in 30.05s, 16.5MB read
分项数据对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 平均延迟 | 1500ms | 80ms | 18.75x |
| P95延迟 | 2300ms | 110ms | 20.9x |
| QPS | 120 | 1800 | 15x |
| DB查询次数/请求 | 25 | 3 | 8.3x |
| CPU占用 | 90% | 35% | -55% |
7. 总结与后续优化方向
三层优化效果显著,但还有改进空间:
- 数据库索引:当前
orders.user_id有索引,但items.order_id和items.sku_id没有复合索引。添加后DB查询时间还能再降30%。 - 异步化:当前用的是同步SQLAlchemy,FastAPI中会阻塞事件循环。改成
asyncpg+SQLAlchemy async,理论上还能再提升30%左右。 - CDN层缓存:对不经常变化的订单数据,可以在CDN层加缓存,减少到应用层的请求。
这次调优最大的收获是:先profiling,再优化。不要凭感觉改代码,用数据说话。另外,ORM的懒加载是性能杀手,生产环境一定要检查echo=True日志,看看每条请求到底发了多少SQL。
后续如果有人想看异步改造和索引优化,我可以再写一篇。有问题评论区交流。