一、问题背景:一个“跑得动”但“扛不住”的API
上周上线了一个订单聚合接口,功能很简单:根据用户ID返回近30天订单统计。上线前手测单次响应120ms,感觉还行。结果压测工具(wrk)一上200并发,P95直接飙到380ms,数据库CPU瞬间打到95%。更诡异的是,数据库连接池疯狂报错,但慢查询日志里却看不到特别慢的SQL。
直觉告诉我,问题不在SQL本身,而在“SQL执行次数”和“数据序列化”上。用两个工具验证了猜想:cProfile(函数级CPU分析)和py-spy(生产环境采样),最终定位到三个瓶颈:N+1查询、重复计算、JSON序列化开销。
二、环境与版本:别用老版本踩坑
- Python 3.11.9(3.10以下性能差异明显)
- FastAPI 0.115.2 + Uvicorn 0.30.6(worker=4,loop=uvloop)
- SQLAlchemy 2.0.35 + asyncmy 0.2.9(异步MySQL驱动)
- Redis 7.2(单机,用于二级缓存)
- 压测工具:wrk 4.2.0(线程4,连接200,时长30s)
注意:FastAPI 0.100之前和之后的异步性能差异很大。0.100+对异步路由支持更彻底,建议至少用0.100+。另外,SQLAlchemy 1.4和2.0的查询写法不兼容,2.0的
select()语法性能更好。
三、第一步:Profiling——先定位,再动手
3.1 用cProfile抓函数级瓶颈
import cProfile
import pstats
from io import StringIO
# 假设这是你的FastAPI应用
from main import app
from fastapi.testclient import TestClient
client = TestClient(app)
# 模拟一次真实请求,捕获profile数据
profiler = cProfile.Profile()
profiler.enable()
client.get("/api/v1/users/12345/order-stats")
profiler.disable()
# 输出耗时Top 10
s = StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats("cumulative")
ps.print_stats(10)
print(s.getvalue())
输出关键行摘录:
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.002 0.002 0.381 0.381 fastapi/routing.py:... (get_route_handler)
83 0.008 0.000 0.247 0.003 sqlalchemy/orm/loading.py:... (_load_scalar_from_orm)
83 0.011 0.000 0.225 0.003 sqlalchemy/dialects/mysql/asyncai.py:... (_execute_context)
1 0.067 0.067 0.067 0.067 json/encoder.py:... (default)
结论:83次ORM加载调用,累计占了0.247秒——典型的N+1。另外JSON序列化用了0.067秒,说明数据量不小。
3.2 用py-spy确认生产环境瓶颈
cProfile会显著拖慢速度(30%+),生产环境用py-spy采样更安全:
# 安装并获取进程PID
pip install py-spy
pgrep -f "uvicorn main:app"
# 采样10秒,生成火焰图SVG
py-spy record --pid --duration 10 -o /tmp/perf.svg
py-spy top --pid --duration 5
火焰图显示build_order_stats函数占用了58%的CPU,其中大部分时间消耗在OrderItem的__repr__和关联的Product查询上。埋点确认:一次请求内部执行了83条SQL,其中80条是查询同一个商品表——典型的懒加载。
四、数据库查询优化:消灭N+1与重复查询
4.1 方案:用selectinload替代懒加载
原代码用的是relationship懒加载,每次访问order.items都会发一条SQL。改为显式预加载:
# 优化前(懒加载,N+1)
async def get_order_stats(user_id: int):
orders = await session.execute(
select(Order).where(Order.user_id == user_id)
)
orders = orders.scalars().all()
total_amount = 0.0
item_count = 0
for order in orders:
for item in order.items: # 这里每访问一次发一条SQL!
total_amount += item.price
item_count += 1
return {"total": total_amount, "count": item_count}
# 优化后(selectinload,2条SQL)
from sqlalchemy.orm import selectinload
async def get_order_stats(user_id: int):
orders = await session.execute(
select(Order)
.options(selectinload(Order.items)) # 一次性预加载所有items
.where(Order.user_id == user_id)
)
orders = orders.scalars().all()
total_amount = sum(item.price for order in orders for item in order.items)
item_count = sum(len(order.items) for order in orders)
return {"total": total_amount, "count": item_count}
效果:SQL执行次数从83次降到2次。这一步直接让响应时间从380ms降到约150ms(压测P95)。
4.2 数据库端聚合:连Python计算都省了
既然只是求和和计数,直接在SQL里完成:
from sqlalchemy import func, select, and_
async def get_order_stats_fast(user_id: int):
# 单条SQL聚合查询,数据库端完成计算
result = await session.execute(
select(
func.coalesce(func.sum(OrderItem.price), 0.0).label("total"),
func.count(OrderItem.id).label("count")
)
.join(Order, Order.id == OrderItem.order_id)
.where(and_(Order.user_id == user_id,
Order.created_at >= datetime.now() - timedelta(days=30)))
)
row = result.one()
return {"total": row.total, "count": row.count}
这一版SQL从2条降到1条,而且不再需要Python遍历计算。P95降到约95ms。但压测200并发时,数据库CPU仍然在60%左右——查询本身不慢了,但每次都查同一个用户的数据,太浪费。
五、缓存策略:Redis+本地二级缓存,命中率94%
5.1 为什么需要两级缓存?
- Redis缓存:跨进程共享,适合多worker部署,但网络I/O有开销(~1ms)
- 本地缓存(字典+TTL):零网络开销,适合高频重复访问。但多worker下数据可能不一致,需要设置较短TTL(30-60秒)
对于这个接口,用户数据更新不频繁(订单T+1统计),完全可以接受60秒的缓存延迟。
5.2 核心实现代码
import json
import time
from functools import lru_cache
from fastapi import APIRouter, Depends
from redis.asyncio import Redis
router = APIRouter()
# 本地缓存:简单字典 + 过期时间
_local_cache = {}
_LOCAL_TTL = 30 # 本地缓存30秒
# Redis连接(全局复用)
redis_client = Redis(host="localhost", port=6379, decode_responses=True)
_REDIS_TTL = 60 # Redis缓存60秒
async def get_order_stats_cached(user_id: int):
# 1. 查本地缓存(最快)
cached = _local_cache.get(user_id)
if cached and time.time() - cached[1] < _LOCAL_TTL:
return cached[0]
# 2. 查Redis(跨进程共享)
redis_key = f"order_stats:{user_id}"
redis_val = await redis_client.get(redis_key)
if redis_val:
data = json.loads(redis_val)
_local_cache[user_id] = (data, time.time())
return data
# 3. 查数据库(唯一慢路径)
data = await query_from_database(user_id) # 即上一节的聚合SQL
# 4. 回填缓存
await redis_client.setex(redis_key, _REDIS_TTL, json.dumps(data))
_local_cache[user_id] = (data, time.time())
return data
@router.get("/api/v1/users/{user_id}/order-stats")
async def get_stats(user_id: int):
return await get_order_stats_cached(user_id)
5.3 踩坑:缓存穿透与更新策略
- 穿透:如果user_id不存在,会每次都查数据库。解决方案是缓存空结果(
{"total":0,"count":0})并设置较短TTL(如5秒)。 - 更新:订单状态变更时,手动删除对应Redis key。我写了一个后台任务,在订单写入后
await redis_client.delete(f"order_stats:{user_id}")。 - 本地缓存一致性:多worker下,本地缓存可能不一致。我接受30秒内不一致,因为这是报表数据,不是实时库存。
六、最终压测数据与总结
使用wrk压测30秒,200并发,结果对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| P50延迟 | 120ms | 22ms | 5.4x |
| P95延迟 | 380ms | 48ms | 7.9x |
| P99延迟 | 620ms | 95ms | 6.5x |
| 吞吐量(QPS) | 610 | 2150 | 3.5x |
| 数据库CPU | 95% | 27% | -72% |
| SQL执行次数/请求 | 83 | 1 | 98.8% |
关键结论:
1. 先Profiling再优化,不要凭感觉。cProfile+py-spy帮我们省了至少2小时瞎猜时间。
2. N+1问题在高并发下是致命的,ORM的懒加载是性能杀手。SQLAlchemy 2.0的selectinload是标准解法。
3. 缓存一定要分级。本地缓存(30秒)承担了90%的请求,Redis只做跨进程兜底。注意设置合理的TTL,避免一致性问题。
4. 数据库聚合优于Python计算。能把计算下推到数据库就下推,减少网络传输和Python开销。
现在这个接口在生产环境稳定运行了两周,P95一直维持在45-55ms之间。如果你也遇到类似的“单次请求快,并发一高就崩”的问题,建议按这个顺序排查:先看ORM查询次数,再看是否过度序列化,最后上缓存。
踩坑补充:不要用asyncio.gather去并发查询N个小SQL,那只是把N+1从串行变并行,数据库压力没减。正确做法永远是减少查询次数,而不是加速查询。