1. 背景:一个被投诉的报表接口
上周三下午,运营同学在群里@我:“那个销售汇总报表,每次打开要转圈5-6秒,还经常超时,能不能管管?” 我一看监控,/api/v1/sales/summary 这个接口,P95耗时560ms,单机QPS峰值12,数据库连接池经常打满。这个接口是FastAPI写的,部署在K8s里,2个Pod,每个Pod限制1核CPU。
第一反应是“加机器”。但看了下代码,发现这接口从数据库查了4张表,每张表还循环查询关联子表——典型的N+1问题。加机器治标不治本,数据库才是瓶颈。
2. 环境与版本:先把家底盘清楚
调优前,先确认所有依赖版本,避免玄学问题:
| 组件 | 版本 |
|---|---|
| Python | 3.11.4 |
| FastAPI | 0.100.0 |
| Uvicorn | 0.23.2 |
| SQLAlchemy | 2.0.19 |
| PostgreSQL | 14.5 |
| Redis | 7.0.12 |
| py-spy | 0.3.14 |
| cProfile | 内置 |
服务部署:Docker + K8s,资源限制requests: cpu=500m, memory=512Mi,limits: cpu=1, memory=1Gi。压测工具用wrk,单机300并发,持续60秒。
3. Profiling第一轮:cProfile找到“谁在慢”
先上cProfile,直接在FastAPI路由函数里加装饰器,只测单个请求:
import cProfile
import pstats
import io
from functools import wraps
def profile_endpoint(func):
@wraps(func)
async def wrapper(*args, **kwargs):
profiler = cProfile.Profile()
profiler.enable()
try:
result = await func(*args, **kwargs)
finally:
profiler.disable()
s = io.StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())
return result
return wrapper
# 在路由上使用
@app.get("/api/v1/sales/summary")
@profile_endpoint
async def get_sales_summary(date: str):
# ... 原有逻辑
pass
跑一次请求,输出关键部分:
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.002 0.002 0.512 0.512 routes/sales.py:42(get_sales_summary)
24 0.001 0.000 0.398 0.017 orm/loading.py:526(_load_collection_from_zipped)
128 0.015 0.000 0.245 0.002 orm/relationships.py:1554(_emit_load)
512 0.034 0.000 0.087 0.000 sqlalchemy/dialects/postgresql/psycopg2.py:200(do_execute)
破案了:_load_collection_from_zipped 累计耗时398ms,说明SQLAlchemy在加载关系对象时,对每个父对象都执行了一次子查询。这就是N+1:主查询返回24条销售记录,每条记录又查了5-6张关联表,总共执行了128条SQL。
验证一下数据库日志,果然,一个请求产生了129条SELECT语句。
4. 方案设计:N+1消除 + 缓存策略
针对定位到的问题,设计两个方案并行:
方案A:消除N+1。用SQLAlchemy 2.0的selectinload,一次性预加载所有关联表。核心改动是查询语句:
from sqlalchemy.orm import selectinload
# 改造前(产生N+1)
stmt = select(SalesOrder).where(SalesOrder.date == target_date)
# 改造后(一次性JOIN或IN查询)
stmt = select(SalesOrder).options(
selectinload(SalesOrder.customer),
selectinload(SalesOrder.items).selectinload(OrderItem.product),
selectinload(SalesOrder.region),
selectinload(SalesOrder.salesperson)
).where(SalesOrder.date == target_date)
async with async_session() as session:
result = await session.execute(stmt)
orders = result.scalars().unique().all()
selectinload会先查主表,然后对每个关系用WHERE id IN (...)批量加载,把128条SQL降为5条。
方案B:Redis缓存热点数据。这个报表按日期聚合,历史数据不会变(当天数据有延迟统计),所以适合缓存。TTL设为5分钟,key设计为sales:summary:{date}:{version},加个版本号方便强制刷新。
5. 核心实现:缓存层 + 兜底策略
缓存代码直接放在路由层,注意两点:一是序列化用orjson(比json快3-5倍),二是缓存穿透保护(如果数据库查出来是空,也缓存空结果,但TTL缩短到30秒):
import orjson
import redis.asyncio as aioredis
redis_client = aioredis.from_url(
"redis://redis-service:6379/0",
decode_responses=False # 保持bytes,orjson直接处理
)
async def get_sales_summary_cached(date: str) -> dict:
cache_key = f"sales:summary:{date}:v2"
# 1. 查缓存
cached = await redis_client.get(cache_key)
if cached:
return orjson.loads(cached)
# 2. 缓存未命中,查数据库(使用优化后的查询)
async with async_session() as session:
result = await session.execute(
stmt # 上面优化后的selectinload查询
)
orders = result.scalars().unique().all()
if not orders:
# 防止穿透:空结果也缓存,但TTL短
await redis_client.set(cache_key, b"[]", ex=30)
return []
# 3. 聚合计算(原本在循环里重复计算,现在提前聚合)
summary = aggregate_orders(orders)
# 4. 写入缓存,TTL 300秒
await redis_client.set(cache_key, orjson.dumps(summary), ex=300)
return summary
另外,注意一个坑:FastAPI的async路由里,如果用了同步的redis-py,会阻塞事件循环。必须用redis.asyncio,或者用run_in_executor。我第一次用同步客户端,压测时P99直接飙到1.2秒,后来排查发现是事件循环被Redis的IO阻塞了。
6. 踩坑与二次优化:序列化与连接复用
第一个坑:orjson.dumps返回bytes,redis_client.set需要bytes,这个没问题。但orjson.loads接受bytes和str,注意别传错类型。
第二个坑:SQLAlchemy对象直接序列化会报错。我一开始在aggregate_orders里返回ORM对象列表,然后orjson.dumps直接炸了——“TypeError: Object of type SalesOrder is not JSON serializable”。解法:在聚合函数里转成普通dict,只保留前端需要的字段。
第三个优化:数据库连接池调参。SQLAlchemy默认pool_size=5, max_overflow=10,在300并发压测下不够用。调成pool_size=20, max_overflow=20, pool_timeout=30,并在PostgreSQL侧限制max_connections为100,避免连接风暴。
7. 压测数据对比:560ms → 40ms
用wrk压测60秒,300并发,结果如下:
| 指标 | 优化前 | 优化后(N+1消除) | 优化后(+Redis缓存) |
|---|---|---|---|
| 平均延迟 | 512ms | 148ms | 38ms |
| P95延迟 | 560ms | 210ms | 42ms |
| P99延迟 | 1.2s | 480ms | 95ms |
| QPS | 12 | 45 | 480 |
| 每请求SQL数 | 129 | 5 | 0(缓存命中) |
| 数据库CPU | 85% | 30% | 8% |
缓存命中率:压测场景下,所有请求都打同一个日期,缓存命中率100%。真实业务中,历史日期查询命中率约80%,当天数据命中率较低(因为TTL只有5分钟)。整体P95维持在40ms左右。
一个意外的发现:优化前,Uvicorn单worker只能跑12 QPS,因为事件循环被阻塞(同步SQLAlchemy调用)。我之前用的是asyncio模式,但SQLAlchemy的session是同步的,等于假异步。后续干脆换成了async_sessionmaker + asyncpg驱动,彻底解决阻塞问题。
8. 总结与反思
这次调优的核心思路就三步:
- 先profiling,别猜。cProfile直接告诉我N+1是元凶,省了瞎折腾的时间。
- 数据库层面解决根本问题。
selectinload消除了99%的SQL语句,这是最大收益。 - 缓存是放大器。在数据库查询优化到150ms后,Redis把延迟进一步压到40ms,QPS从45跳到480。
几个经验教训:
- FastAPI+SQLAlchemy,务必用
async版本(asyncpg),否则高并发下事件循环被阻塞,性能惨不忍睹。 selectinload不是银弹。如果关联表数据量极大(单表百万行),IN查询也可能慢,这时候需要改用subqueryload或手动分页。- 缓存一定要考虑穿透和雪崩。我这里用了空结果缓存+短TTL,并且给缓存key加了版本号,方便业务上主动失效。
- 压测时注意预热。第一次请求会加载SQLAlchemy映射和连接池,建议先跑10次请求再开始正式压测。
调完这个接口,监控面板终于绿了。下一步计划优化另一个更慢的导出接口(目前耗时2.3秒),思路类似,但可能要引入任务队列异步生成文件。到时候再写一篇记录。