1. 问题背景:一个“简单”的列表接口为何扛不住?
上个月负责一个内部数据平台的API重构。业务逻辑很简单:查询某时间范围内的订单列表,每页20条,返回订单号、金额、用户名称。旧系统用Flask 1.1.2 + SQLAlchemy 1.3.23 + MySQL 5.7,接口响应时间在低并发时约500ms,但生产环境一旦超过50并发,平均响应时间飙升到10秒以上,频繁超时。接手后决定用FastAPI 0.78.0 + Uvicorn 0.17.6重写,但第一版压测结果依然惨不忍睹——必须拆解到底层。
2. 环境与版本
- 压测工具:wrk 4.2.0
- Python版本:3.9.13
- Web框架:FastAPI 0.78.0 / Flask 1.1.2
- ORM:SQLAlchemy 1.4.40(FastAPI版)/ 1.3.23(Flask版)
- 数据库:MySQL 5.7.38(事务隔离级别 RR,连接池大小10)
- 缓存:Redis 6.2.6(单机,无集群)
- 部署:单台4C8G阿里云ECS,Ubuntu 20.04,容器化运行
3. 定位瓶颈:别猜,用数据说话
先写一个简易的profiling中间件,对每个请求进行cProfile采样,生成火焰图。这里给一个可直接运行的profiling装饰器(FastAPI版本):
# profiling_middleware.py
import cProfile
import io
import pstats
from functools import wraps
from fastapi import Request, Response
import time
def profile_endpoint(func):
@wraps(func)
async def wrapper(request: Request, *args, **kwargs):
pr = cProfile.Profile()
pr.enable()
start = time.perf_counter()
response = await func(request, *args, **kwargs)
elapsed = time.perf_counter() - start
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumtime')
ps.print_stats(20) # 只打印前20耗时
# 写入日志或返回header
print(f"Endpoint {request.url.path} took {elapsed:.3f}s")
print(s.getvalue()[:2000]) # 截断输出
return response
return wrapper
# 使用方式:在路由上 @router.get("/orders")(profile_endpoint)
第一次压测火焰图显示:__json_serialize 占39%,User.query.get 占32%,Order.query.filter 占18%。问题一目了然:
- 序列化开销:FastAPI默认用Pydantic的
json.dumps,每行数据都重新序列化,20条订单重复生成200次时间戳对象。 - ORM懒加载:
User.query.get在循环中执行,每次查询都新建数据库连接——N+1查询。 - 无连接池复用:SQLAlchemy默认没有开启连接池回收,高并发下频繁建连。
4. 方案一:ORM查询优化——干掉N+1与连接池配置
第一个优化点:将循环中的单条用户查询改为预加载(eager loading),同时配置连接池。
核心代码改动(基于FastAPI + SQLAlchemy 1.4):
# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from sqlalchemy.pool import QueuePool
# 关键配置:连接池大小20,溢出10,回收300秒
engine = create_engine(
"mysql+pymysql://user:pass@host:3306/orders",
poolclass=QueuePool,
pool_size=20,
max_overflow=10,
pool_recycle=300,
pool_pre_ping=True
)
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))
# order_service.py
from sqlalchemy.orm import joinedload
from models import Order, User
def get_orders_with_user(db, limit=20, offset=0):
# 之前写法:先查订单,再循环查用户
# orders = db.query(Order).offset(offset).limit(limit).all()
# 优化后:一次性join加载user
orders = db.query(Order).options(
joinedload(Order.user) # 假设Order模型有user关系
).offset(offset).limit(limit).all()
# 直接访问Order.user不再触发额外查询
result = []
for order in orders:
result.append({
"order_id": order.id,
"amount": order.amount,
"user_name": order.user.name # 懒加载已预填充
})
return result
踩坑:joinedload 会默认内连接,如果订单没有对应用户则结果集减少。改用 contains_eager + 显式join可保留左外连接。另外注意SQLAlchemy 1.4中 joinedload 默认惰性加载策略改为selectinload,需要显式指定。
效果:单次接口响应从500ms降至220ms(数据库查询从35次减少到1次),但序列化仍占大头。
5. 方案二:序列化与响应体优化——使用ujson与StreamingResponse
Pydantic的默认JSON序列化器是标准库json,处理大量datetime对象时性能差。替换为ujson,同时对于大列表返回使用StreamingResponse逐块生成。
# main.py
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import ujson
import datetime
app = FastAPI()
def custom_serializer(obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
raise TypeError(f"Type {type(obj)} not serializable")
def json_streamer(data_generator):
"""逐块生成JSON数组,避免一次性构建大字符串"""
yield "["
first = True
for item in data_generator:
if not first:
yield ","
first = False
# 使用ujson.dumps,速度比标准json快3-5倍
yield ujson.dumps(item, default=custom_serializer)
yield "]"
@app.get("/orders/stream")
def get_orders_stream(page: int = 1, page_size: int = 20):
db = SessionLocal()
try:
orders = get_orders_optimized(db, page, page_size)
# 生成器逐条返回
def gen():
for o in orders:
yield {
"order_id": o.id,
"amount": float(o.amount),
"user_name": o.user.name,
"created_at": o.created_at
}
return StreamingResponse(
json_streamer(gen()),
media_type="application/json"
)
finally:
db.close()
踩坑:StreamingResponse 虽然减少了内存,但压测时发现wrk无法正确处理分块传输编码,需要设置 Transfer-Encoding: chunked,且前端需要支持流式解析。如果客户端不支持,建议用普通Response + ujson一次性序列化。
效果:序列化耗时从120ms降至25ms,整体接口响应降至180ms。
6. 方案三:缓存策略——一级缓存+Redis二级缓存
数据库查询优化后,QPS从150提升到800,但距离目标3000 QPS还有差距。引入两级缓存:
- 一级缓存:SQLAlchemy的cache选项(需要安装sqlalchemy-caching),或简单的lru_cache装饰器对耗时计算函数缓存。
- 二级缓存:Redis缓存序列化后的响应体,TTL设为60秒。
核心实现(FastAPI中间件+Redis):
# cache_middleware.py
import hashlib
import json
import redis.asyncio as aioredis
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)
class RedisCacheMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# 只缓存GET请求
if request.method != "GET":
return await call_next(request)
# 生成缓存key(基于路径+查询参数)
cache_key = f"api_cache:{request.url.path}:{hashlib.md5(json.dumps(dict(request.query_params)).encode()).hexdigest()}"
# 尝试从Redis获取
cached = await redis_client.get(cache_key)
if cached:
return Response(content=cached, media_type="application/json")
# 执行真实请求
response = await call_next(request)
# 写入Redis,TTL 60秒
if response.status_code == 200:
body = b""
async for chunk in response.body_iterator:
body += chunk
await redis_client.setex(cache_key, 60, body.decode())
# 重新构造Response
return Response(content=body, media_type=response.media_type, status_code=response.status_code)
return response
效果数据(wrk压测,30秒,10线程,100连接):
| 优化阶段 | 平均延迟 | P90延迟 | QPS | CPU利用率 |
|---|---|---|---|---|
| 原始Flask | 9.8s | 15.2s | 52 | 35% |
| FastAPI + ORM优化 | 220ms | 380ms | 812 | 55% |
| + ujson序列化 | 180ms | 290ms | 1050 | 60% |
| + 连接池/预加载 | 140ms | 210ms | 1580 | 72% |
| + Redis缓存 | 18ms | 35ms | 3210 | 45% |
缓存命中率约85%时,QPS从1580飙升至3210,CPU反而下降,因为大量请求直接返回缓存。
7. 总结与反思
这次优化让我确认了几件事:
1. 永远先profiling:不要凭感觉优化。cProfile火焰图直接告诉我序列化和N+1是元凶,而不是数据库索引。
2. ORM不是恶魔,懒加载才是:正确使用joinedload和连接池,SQLAlchemy 1.4的性能足以媲美原生SQL。
3. 缓存要分层次:一级缓存(进程内)扛住瞬时高并发,二级缓存(Redis)做跨实例共享。但注意缓存失效风暴——我设置了TTL抖动(±10秒)防止雪崩。
4. FastAPI的StreamingResponse是双刃剑:适合大结果集,但兼容性不如标准Response。
最终这个接口从用户反馈“慢到想砸键盘”变成了“秒开”。如果你也遇到类似的性能问题,建议先跑一遍profiling,大概率会发现80%的瓶颈在20%的代码里。