一、问题背景
事情是这样的:我们给内部运营团队做了个订单查询接口 /api/orders,用 FastAPI + SQLAlchemy + PostgreSQL。上线头两周没什么人用,一切正常。后来运营那边接了个后台看板,每30秒轮询一次这个接口,用户量一上来,问题就暴露了。
监控面板上 P99 直接冲到 1200ms,QPS 到 50 左右时,接口开始大面积超时(我们设的 5s timeout)。更离谱的是,CPU 才用了 30%,数据库连接数却打满了。第一反应是"数据库慢查询",但看完慢日志发现单条 SQL 都是毫秒级的——这就说明问题不在单条 SQL,而在调用次数或者Python 侧的阻塞。
这篇文章就把整个排查和优化的过程完整记下来,给遇到类似问题的同学一个参考。
二、环境与版本
先把环境列清楚,不同版本行为差异挺大的:
- Python 3.11.6
- FastAPI 0.110.0
- Uvicorn 0.27.1(
--workers 4,单机 4 核 8G) - SQLAlchemy 2.0.28(同步 ORM,不是 async)
- psycopg2-binary 2.9.9
- PostgreSQL 14.11
- redis-py 5.0.1 / Redis 7.2
- py-spy 0.3.14,cProfile(标准库)
注意:我们当时没有上 async SQLAlchemy,这是后面踩坑的一个关键点。
接口大致长这样(简化版):
@app.get("/api/orders")
def list_orders(user_id: int, page: int = 1, size: int = 20):
offset = (page - 1) * size
orders = db.query(Order).filter(Order.user_id == user_id)\
.order_by(Order.created_at.desc())\
.offset(offset).limit(size).all()
result = []
for o in orders:
result.append({
"id": o.id,
"amount": o.amount,
"status": o.status,
"user_name": o.user.name, # 触发懒加载
"items_count": len(o.items), # 又触发懒加载
})
return {"total": db.query(Order).filter(Order.user_id == user_id).count(),
"data": result}
看着挺正常的对吧?问题就藏在这里面。
三、方案设计:先定位,再动手
我的原则是:没有 profiling 数据就不要瞎优化。凭直觉改代码,往往改了个寂寞,还引入新 bug。
排查分三步走:
- 火焰图定位热点:用 py-spy 抓生产环境(或压测环境)的实时火焰图,看时间花在哪。
- 单请求粒度分析:用 cProfile 跑单次请求,看函数调用次数和累计耗时。
- 数据库侧验证:开
echo=True或 pg_stat_statements,数一数一次请求到底发了几条 SQL。
定位结果
先说结论,避免大家看得云里雾里:
- N+1 查询:一页 20 条订单,每条触发
o.user和o.items各一次查询,一次请求 = 1(列表)+ 1(count)+ 20×2 = 42 条 SQL。 - 同步阻塞:FastAPI 的
def路由跑在线程池里,但 SQLAlchemy 同步查询会占满线程,4 workers × 默认线程数不够扛。 - 无缓存:count 查询每次都全表扫,翻页时尤其慢。
压测脚本我用的是 locust,也贴一下,方便复现:
# locustfile.py
from locust import HttpUser, task, between
class OrderUser(HttpUser):
wait_time = between(0.1, 0.3)
host = "http://127.0.0.1:8000"
@task
def query_orders(self):
self.client.get("/api/orders?user_id=10086&page=1&size=20")
启动:locust -f locustfile.py --headless -u 100 -r 10 -t 60s
优化前数据:P50=320ms,P99=1200ms,RPS=48,失败率 6%。
四、核心实现:三刀下去
第一刀:干掉 N+1,用 selectinload
SQLAlchemy 2.0 的 selectinload 是解决 N+1 最省事的方案,它用一条 IN 查询批量捞关联数据,而不是逐条查。
from sqlalchemy.orm import selectinload
from sqlalchemy import func, select
@app.get("/api/orders")
def list_orders(user_id: int, page: int = 1, size: int = 20):
offset = (page - 1) * size
stmt = (
select(Order)
.options(
selectinload(Order.user),
selectinload(Order.items),
)
.where(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.offset(offset).limit(size)
)
orders = db.execute(stmt).scalars().all()
# count 单独走缓存,见第二刀
total = get_order_count(user_id)
return {
"total": total,
"data": [
{
"id": o.id,
"amount": o.amount,
"status": o.status,
"user_name": o.user.name,
"items_count": len(o.items),
}
for o in orders
],
}
这一刀下去,SQL 从 42 条降到 3 条(列表 + user + items)加 count。单请求耗时从 320ms 掉到 90ms 左右。
第二刀:缓存 count 和首页数据
count 查询其实是全表扫,翻页时数据没变的话没必要每次算。用 Redis 缓存 30 秒,key 带上 user_id。
import json
import redis
from fastapi import FastAPI
r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True)
COUNT_TTL = 30
def get_order_count(user_id: int) -> int:
key = f"order:count:{user_id}"
cached = r.get(key)
if cached is not None:
return int(cached)
total = db.execute(
select(func.count()).select_from(Order).where(Order.user_id == user_id)
).scalar_one()
r.setex(key, COUNT_TTL, total)
return total
首页缓存更激进一点:page=1&size=20 的完整响应缓存 10 秒,因为看板轮询基本都打在这个页面上。key 用 order:list:{user_id}:{page}:{size},值直接存 JSON 字符串。
有个坑:缓存和数据库一致性。我们订单写入频率不高(每分钟几条),所以 TTL 30s 完全能接受。如果写入频繁,就得在写路径上主动 delete key,别偷懒。
第三刀:连接池 + 线程池调优
SQLAlchemy 默认连接池 pool_size=5,max_overflow=10。4 workers 下总共就 20 个连接,QPS 一高就排队。调成:
engine = create_engine(
DATABASE_URL,
pool_size=20,
max_overflow=40,
pool_pre_ping=True,
pool_recycle=1800,
future=True,
)
同时启动命令改成 uvicorn main:app --workers 4 --limit-concurrency 200 --backlog 2048。limit-concurrency 限制单 worker 并发,避免线程池被打爆后请求堆积。
五、踩坑与优化
坑 1:selectinload 和 limit 一起用。如果对 items 用 joinedload(JOIN 方式),再配合 limit,SQLAlchemy 会先生成一个大 JOIN 再截断,导致返回条数不对。所以关联集合一定用 selectinload,别用 joinedload。
坑 2:Redis 序列化开销。一开始我用 json.dumps 存整个响应,20 条数据大概 8KB,读的时候 json.loads 也要 1-2ms。后来改成只缓存 count,列表部分靠查询本身快,反而整体更稳。
坑 3:py-spy 抓不到热点。第一次抓火焰图只看到 psycopg2 在等网络,看不出具体是哪条 SQL。后来配合 pg_stat_statements 才确认是 N+1。
坑 4:count 缓存穿透。用户第一次请求某个 user_id 时缓存为空,并发打进来会同时查库。加了个简单的互斥锁(r.set(key, ..., nx=True) 做短锁),或者干脆用 cachetools 本地兜底。
六、效果数据
压测条件统一:locust 100 并发,持续 60s,同一台机器。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P50 | 320ms | 42ms |
| P95 | 780ms | 70ms |
| P99 | 1200ms | 85ms |
| RPS | 48 | 620 |
| 失败率 | 6% | 0% |
| 单请求 SQL 数 | 42 | 3(命中缓存时 1) |
生产环境上线后观察一周,P99 稳定在 85-110ms 之间,Redis 命中率约 92%,数据库连接数从峰值 40 降到 8。
七、总结
回头看这次优化,真正有效的是三件事,按收益排序:
- selectinload 干掉 N+1:贡献了大约 70% 的收益,也是最容易踩的坑。
- 连接池调优:解决了高并发下的排队问题,让 QPS 能真正上去。
- 缓存 count 和热点页:锦上添花,但要注意一致性。
如果你们也在用 Flask,思路完全一样:joinedload/selectinload、连接池参数、Flask-Caching 或直接上 Redis,profiling 工具换成 Flask-DebugToolbar 或 py-spy 即可。核心心法就一句:先测量,再优化,别猜。