一、问题背景:生产环境告警
那是个周五晚上,监控面板突然跳出红色告警:/api/v1/orders 接口P95延迟从正常的180ms飙升至2340ms,错误率1.2%,数据库连接数打满。查看慢查询日志,发现一条关联了5张表的SQL执行了1.8秒,而这条SQL在ORM层被触发了187次(N+1问题)。更离谱的是,这个接口的QPS只有40,意味着每次请求触发了将近5次数据库往返。
我们当时的架构是:Nginx → Uvicorn(4 workers) → FastAPI(0.95.0) → PostgreSQL 15,ORM使用SQLAlchemy 2.0.19,缓存层是Redis 7.0。老板给的压力很大:“明天早上客户要演示,必须修复。”
二、环境与版本
调优前的基础环境:
- Python 3.11.4
- FastAPI 0.95.0 + Uvicorn 0.23.2 (workers=4, loop=uvloop)
- SQLAlchemy 2.0.19 + asyncpg 0.27.0
- PostgreSQL 15.3 (shared_buffers=4GB, effective_cache_size=12GB)
- Redis 7.0.11 (maxmemory=2GB, maxmemory-policy=allkeys-lru)
- 压测工具:wrk 4.2.0 + Locust 2.15.1
- Profiling工具:PySpy 0.3.14 + cProfile + pg_stat_statements
三、第一步:Profiling定位瓶颈
3.1 用PySpy抓取在线调用栈
Uvicorn的worker进程是异步的,直接attach PySpy可能看不到Python层调用。先安装:
pip install pyspy
# 找到worker进程PID
ps aux | grep uvicorn
# attach到CPU使用率最高的那个worker
sudo pyspy dump --pid 12345 --duration 30
输出片段(关键部分):
Thread 0x7f8d2c1b6700 (idle):
- task: Task was created at:
- FastAPI route /api/v1/orders (order_router.py:42)
- ...
- Current frame:
- asyncpg.protocol.protocol: _process_message (asyncpg/protocol/protocol.pyx:590)
- sqlalchemy.dialects.postgresql.asyncpg: _executemany (dialects/postgresql/asyncpg.py:580)
- sqlalchemy.orm.session: flush (session.py:2800)
- app.services.order_service: get_orders (order_service.py:88)
看到_executemany和flush,立刻怀疑是ORM的懒加载导致批量写入或查询。再用cProfile做一次离线复现验证。
3.2 cProfile离线分析
写一个复现脚本,模拟真实请求逻辑:
import cProfile, pstats, asyncio
from app.services.order_service import get_orders
async def main():
# 模拟真实参数
await get_orders(user_id=1024, page=1, page_size=20)
if __name__ == "__main__":
profiler = cProfile.Profile()
profiler.enable()
asyncio.run(main())
profiler.disable()
stats = pstats.Stats(profiler).sort_stats('cumulative')
stats.print_stats(30)
输出统计(截取前几行):
ncalls tottime percall cumtime percall filename:lineno(function)
187 0.012 0.000 1.723 0.009 sqlalchemy/orm/loading.py:180(load_on_ident)
187 1.412 0.008 1.412 0.008 asyncpg/protocol/protocol.pyx:590(_process_message)
1 0.003 0.003 1.731 1.731 order_service.py:88(get_orders)
1 0.001 0.001 0.521 0.521 jsonable_encoder.py:45(jsonable_encoder)
实锤:187次load_on_ident(懒加载),累计1.723秒,占整个请求的80%。还有0.5秒在JSON序列化。两个瓶颈:N+1查询 + 序列化开销。
四、方案设计:三管齐下
定位到两个瓶颈后,设计如下优化方案:
- 消除N+1:SQLAlchemy 2.0的
selectinload批量加载关联表,把187次查询压到3次(主查询 + 2个关联查询)。 - 增加Redis缓存:对热点数据(用户最近订单)做缓存,TTL=60秒,key设计为
orders:{user_id}:{page}:{page_size},使用JSON序列化后存储。 - 优化序列化:用
msgspec替代Pydantic的jsonable_encoder(实测快3.2倍)。
五、核心实现:代码修改
5.1 SQLAlchemy查询重构(关键代码)
重构前的代码(有问题的版本):
# order_service.py (优化前)
async def get_orders(user_id: int, page: int, page_size: int):
async with async_session() as session:
# 这里只查了orders表,items和products是懒加载
result = await session.execute(
select(Order).where(Order.user_id == user_id)
.offset((page-1)*page_size).limit(page_size)
)
orders = result.scalars().all()
# 访问order.items时触发N+1查询
return [
{
"id": o.id,
"items": [
{"id": item.id, "product": item.product.name}
for item in o.items # 每次访问触发一次查询
]
} for o in orders
]
重构后使用selectinload + joinedload组合:
# order_service.py (优化后)
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import select
SELECTIN_OPTIONS = (
selectinload(Order.items).selectinload(OrderItem.product),
)
async def get_orders(user_id: int, page: int, page_size: int):
async with async_session() as session:
# 一次性加载所有关联,查询次数 = 1(主) + 1(items) + 1(products) = 3次
result = await session.execute(
select(Order)
.options(*SELECTIN_OPTIONS)
.where(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.offset((page-1)*page_size)
.limit(page_size)
)
orders = result.scalars().unique().all()
return [
{
"id": o.id,
"created_at": o.created_at.isoformat(),
"items": [
{"id": item.id, "product_name": item.product.name}
for item in o.items # 此时已经是内存数据
]
} for o in orders
]
5.2 Redis缓存层(防御性缓存)
# cache.py
import msgspec
import redis.asyncio as aioredis
from fastapi import HTTPException
# 使用msgspec的JSON解码器,比json.loads快3倍
json_decoder = msgspec.json.Decoder(type=list[dict])
json_encoder = msgspec.json.Encoder()
redis_client = aioredis.from_url(
"redis://localhost:6379/0",
max_connections=50,
decode_responses=False # 保持bytes模式,msgspec直接处理
)
CACHE_TTL = 60 # 秒
CACHE_PREFIX = "orders:v2"
async def get_cached_orders(user_id: int, page: int, page_size: int):
cache_key = f"{CACHE_PREFIX}:{user_id}:{page}:{page_size}"
cached = await redis_client.get(cache_key)
if cached:
return json_decoder.decode(cached)
return None
async def set_cached_orders(user_id: int, page: int, page_size: int, data: list[dict]):
cache_key = f"{CACHE_PREFIX}:{user_id}:{page}:{page_size}"
encoded = json_encoder.encode(data)
await redis_client.setex(cache_key, CACHE_TTL, encoded)
在FastAPI路由中接入缓存(注意处理缓存穿透):
# order_router.py
from fastapi import APIRouter, Query, Depends
from .cache import get_cached_orders, set_cached_orders
router = APIRouter(prefix="/api/v1")
@router.get("/orders")
async def get_orders_endpoint(
user_id: int = Query(..., gt=0),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100)
):
# 先查缓存
cached = await get_cached_orders(user_id, page, page_size)
if cached is not None:
return {"data": cached, "source": "cache"}
# 缓存未命中,查数据库
data = await get_orders(user_id, page, page_size)
if not data:
# 防止缓存穿透:空结果也缓存10秒
await set_cached_orders(user_id, page, page_size, data, ttl=10)
return {"data": data, "source": "db"}
await set_cached_orders(user_id, page, page_size, data)
return {"data": data, "source": "db"}
5.3 Flask版本对比(同样逻辑)
为了对比,我也用Flask 3.0写了一个等价接口。Flask本身是同步WSGI,用gunicorn+gevent跑。查询逻辑用同样的SQLAlchemy 2.0(同步版),缓存逻辑一样。核心区别:FastAPI走asyncpg原生异步驱动,Flask走psycopg2同步驱动。
六、踩坑与优化记录
坑1:selectinload 导致笛卡尔积
一开始我用了joinedload加载items和product,结果返回行数爆炸(20个订单×每个订单5个item×每个item1个product = 100行),数据量翻倍。后来改成selectinload,它先查主表,再WHERE id IN (...)查关联表,避免笛卡尔积。
坑2:缓存击穿
上线后第一波流量直接打崩Redis——所有用户同时过期,全部穿透到数据库。加了随机过期时间修复:
import random
# 在setex时加抖动
await redis_client.setex(cache_key, CACHE_TTL + random.randint(0, 15), encoded)
坑3:Pydantic v2的序列化陷阱
FastAPI默认用jsonable_encoder序列化ORM对象,它会把datetime转成字符串,但这个过程很慢。改用msgspec后,直接在ORM层把数据整理成dict再编码,省去了中间转换。
七、压测数据对比
使用wrk压测,参数:wrk -t4 -c100 -d30s --latency http://localhost:8000/api/v1/orders?user_id=1024&page=1&page_size=20
| 配置 | P50 (ms) | P95 (ms) | P99 (ms) | QPS | 错误率 |
|---|---|---|---|---|---|
| 优化前(FastAPI+懒加载) | 892 | 2314 | 3890 | 41 | 0.8% |
| 优化后(FastAPI+selectinload) | 305 | 782 | 1201 | 156 | 0.0% |
| 优化后+Redis缓存 | 87 | 287 | 512 | 237 | 0.0% |
| Flask(同步+selectinload) | 420 | 1103 | 1870 | 98 | 0.2% |
| Flask+Redis缓存 | 205 | 612 | 940 | 132 | 0.1% |
结论:FastAPI + selectinload + Redis 组合拳,P95从2314ms降到287ms(降幅87.6%),QPS从41涨到237(提升5.8倍)。Flask方案虽然也有优化,但受限于同步驱动,P95始终比FastAPI慢2倍左右。
补充一个数据点:使用pg_stat_statements查看,优化后单接口的SQL执行次数从187次/请求降到3次/请求,数据库CPU使用率从95%降到22%。
八、总结与心得
这次调优的核心收获:
- 先测量再动手:用PySpy+cProfile 5分钟定位瓶颈,比瞎猜快100倍。不要一上来就加索引、换框架。
- ORM懒加载是性能杀手:任何ORM框架(SQLAlchemy、Django ORM、Hibernate)都有N+1问题,必须显式使用
selectinload或joinedload。 - 缓存是银弹但不是万能的:Redis缓存解决读多写少场景,但要注意穿透、击穿、雪崩。实测缓存命中率91%时,效果最好。
- FastAPI vs Flask:如果追求极致性能且团队熟悉异步,选FastAPI。如果是内部管理系统、QPS<100,Flask完全够用且调试更简单。
- 序列化开销被低估:如果响应体超过100KB,序列化可能占CPU的20%。用
msgspec或orjson替代默认编码器。
最后,把压测脚本和监控告警都接入了CI/CD,以后每次改动API都会自动跑性能回归,再也没出现过半夜被叫醒的情况。
参考链接:
- SQLAlchemy 2.0 relationship loading: https://docs.sqlalchemy.org/en/20/orm/loading_relationships.html
- PySpy GitHub: https://github.com/benfred/py-spy
- msgspec文档: https://jcristharif.com/msgspec/
- FastAPI性能优化官方文档: https://fastapi.tiangolo.com/advanced/performance/