一、问题背景:一个“还能用”的接口背后的隐忧
事情始于一次线上告警:订单查询接口在促销活动期间平均响应时间飙升至2.3秒,数据库CPU打满。这个接口是基于Flask 2.2 + SQLAlchemy 1.4 + PostgreSQL 13构建的,逻辑很简单——根据用户ID查订单列表,关联商品和店铺信息。
最初代码写得很“直观”:
# app.py (Flask版本)
@app.route('/api/orders/')
def get_orders(user_id):
orders = db.session.query(Order).filter(Order.user_id == user_id).all()
result = []
for order in orders:
# 每个订单都触发一次商品查询
product = db.session.query(Product).get(order.product_id)
# 再触发一次店铺查询
shop = db.session.query(Shop).get(order.shop_id)
result.append({
'order_id': order.id,
'product_name': product.name,
'shop_name': shop.name,
'amount': order.amount
})
return jsonify(result)
这段代码有个经典问题:N+1查询。每个订单额外执行2条SQL,如果用户有50个订单,就要执行101条查询。在200并发下,数据库连接池直接被打爆。
二、环境与版本:调优前的基线
- 应用框架:Flask 2.2.5 / FastAPI 0.104.1
- ORM:SQLAlchemy 1.4.50 → 2.0.23(FastAPI版本)
- 数据库:PostgreSQL 13.4,配置shared_buffers=1GB,work_mem=64MB
- 缓存:Redis 6.2,maxmemory 512MB,allkeys-lru策略
- 压测工具:wrk 4.2.0,单机8核8G
- 部署:Gunicorn + gevent(Flask),Uvicorn(FastAPI)
基线压测命令:wrk -t8 -c200 -d60s http://localhost:8000/api/orders/12345
基线结果:P95=1843ms,P99=2.2s,吞吐率=318 req/s
三、方案设计:三层递进优化
不搞花活,直接按收益排序来:
- 第一层:消除N+1 —— 用SQLAlchemy的
selectinload一次性加载关联对象,减少SQL执行次数。 - 第二层:序列化优化 —— 放弃Flask的
jsonify,改用FastAPI + Pydantic v2的序列化,减少JSON处理时间。 - 第三层:Redis缓存 —— 对热点用户订单缓存5分钟,减少数据库压力。
四、核心实现:FastAPI重写 + SQLAlchemy 2.0优化
4.1 消除N+1查询
# main.py (FastAPI版本)
from fastapi import FastAPI, Depends
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import List
app = FastAPI()
class OrderOut(BaseModel):
order_id: int
product_name: str
shop_name: str
amount: float
class Config:
from_attributes = True
@app.get('/api/orders/{user_id}', response_model=List[OrderOut])
async def get_orders(user_id: int, session: AsyncSession = Depends(get_db)):
# 关键:selectinload一次性加载关联对象
result = await session.execute(
select(Order)
.options(
selectinload(Order.product),
selectinload(Order.shop)
)
.where(Order.user_id == user_id)
)
orders = result.scalars().all()
# 直接返回ORM对象,Pydantic负责序列化
return orders
这里有两个关键点:
- selectinload会生成WHERE id IN (...)的批量查询,将101条SQL降为3条。
- FastAPI的response_model使用Pydantic v2(基于Rust的pydantic-core)序列化,比Flask的jsonify快约5倍。
4.2 Redis缓存策略
# cache.py
import json
import redis
from fastapi import HTTPException
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
CACHE_TTL = 300 # 5分钟
@app.get('/api/orders/{user_id}', response_model=List[OrderOut])
async def get_orders_cached(user_id: int, session: AsyncSession = Depends(get_db)):
# 先查缓存
cache_key = f"user_orders:{user_id}"
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 缓存未命中,查数据库
result = await session.execute(
select(Order)
.options(selectinload(Order.product), selectinload(Order.shop))
.where(Order.user_id == user_id)
)
orders = result.scalars().all()
# 序列化并写入缓存
response_data = [OrderOut.model_validate(o).model_dump() for o in orders]
redis_client.setex(cache_key, CACHE_TTL, json.dumps(response_data))
return response_data
缓存策略选择:只缓存订单列表,不缓存单个商品(因为商品信息可能频繁变更)。TTL设300秒,配合Redis的allkeys-lru策略,避免内存溢出。
五、踩坑与优化:四个真实遇到的问题
踩坑1:异步SQLAlchemy的坑
FastAPI用async,但SQLAlchemy 1.4的异步支持不完整。必须升级到2.0+,并且注意session.execute()返回的是Result对象,要用.scalars().all()而不是.all()。
踩坑2:selectinload的陷阱
selectinload会产生额外的IN查询,如果关联表数据量巨大(比如商品表100万行),首次加载会慢。解决方案:对商品表加(user_id, status)复合索引,实测索引后查询时间从120ms降到8ms。
踩坑3:Pydantic v2的field别名问题
from_attributes = True必须显式声明,否则ORM对象无法直接传给Pydantic。另外,model_validate()代替了v1的parse_obj()。
踩坑4:Gunicorn worker类型
FastAPI必须用uvicorn worker,不能用gevent。部署命令改为:
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
六、效果数据:优化前后对比
使用wrk压测,参数与基线完全一致,结果如下:
| 指标 | Flask基线 | FastAPI+SQL优化 | +Redis缓存 |
|---|---|---|---|
| 吞吐率 | 318 req/s | 1,450 req/s | 2,100 req/s |
| P50 | 620ms | 38ms | 12ms |
| P95 | 1,843ms | 89ms | 42ms |
| P99 | 2,200ms | 210ms | 95ms |
| 数据库QPS | 32,000 | 4,500 | 1,200 |
数据库QPS从32000降到1200,意味着Redis缓存了约96%的请求。这彻底缓解了数据库压力,促销活动期间不再告警。
七、总结与方法论
这次调优的核心收获:
- 先profile再优化:用
py-spy dump --pid看线程栈,用cProfile统计函数耗时,而不是靠猜。 - N+1是原罪:ORM框架都提供预加载机制(selectinload/joinedload),必须用。
- 序列化常被忽视:Python的json.dumps在大量数据下非常慢,Pydantic v2的Rust核心是重大改进。
- 缓存要分层:Redis缓存适合读多写少的热点数据,但注意缓存穿透和雪崩,后续可以考虑加布隆过滤器。
如果你也遇到类似性能问题,建议按此顺序排查:SQL查询数 → 序列化耗时 → 缓存命中率。不要一开始就上消息队列或分库分表,先把这三板斧用到位。
最后留个思考题:如果订单量过亿,这个方案还能撑住吗?欢迎评论区讨论。