一、问题背景:一个“看起来没毛病”的接口
事情起因很简单,我们的订单查询接口 /api/v1/orders 被前端投诉“转圈圈”。这个接口同时被 FastAPI 网关和 Flask 内部管理后台调用,逻辑几乎一样:根据用户ID查订单列表,附带商品信息和状态。
上线初期 QPS 不到 20,没人注意。直到一次运营活动,QPS 冲到 50 左右,监控直接报警:P95 响应时间 2.3s,错误率飙升到 8%。更离谱的是,CPU 只用了 30%,数据库连接数却打满了。
这是一个非常典型的“资源没吃满但就是慢”的场景。经验告诉我,大概率是同步阻塞 + N+1 查询。但具体是哪个,得靠数据说话。
二、环境与版本
先把环境交代清楚,避免版本差异导致结论不通用:
- Python 3.11.6
- FastAPI 0.110.0 + Uvicorn 0.27.1(4 workers)
- Flask 3.0.2 + Gunicorn 21.2.0(4 workers,gevent worker)
- SQLAlchemy 2.0.28 + asyncpg 0.29.0(FastAPI侧)
- SQLAlchemy 2.0.28 + PyMySQL 1.1.0(Flask侧,历史遗留同步)
- PostgreSQL 15.4,Redis 7.2.4
- 压测工具:Locust 2.24.0,4台压测机
三、方案设计:先测量,再动手
调优最忌讳“我觉得”。我的原则是:没有 profiling 数据的优化都是赌博。
整体思路分三步:
- 定位:用 cProfile + py-spy 找出热点函数,用 SQLAlchemy 的 echo 和 pg_stat_statements 找出慢查询。
- 拆解:把瓶颈分类——是 CPU 计算、IO 等待,还是锁竞争。
- 优化:按投入产出比排序,先干掉 N+1 和同步阻塞,再上缓存。
四、核心实现:定位与优化
4.1 Profiling:py-spy 一眼看出问题
线上环境不敢随便重启,我直接用 py-spy 对运行中的 Uvicorn worker 做火焰图:
pip install py-spy==0.3.14
py-spy top --pid 12345 --rate 100
结果非常直观:get_order_items 这个函数占了 68% 的采样,而且大部分时间在 socket.recv——典型的同步等待数据库返回。
再用 cProfile 跑一遍本地复现:
import cProfile, pstats, io
from app.api import get_orders
pr = cProfile.Profile()
pr.enable()
for _ in range(100):
get_orders(user_id=1)
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())
输出里 sqlalchemy/engine/base.py 的 _execute_context 调用次数是 100 次请求 × 21 次查询 = 2100 次。N+1 实锤。
4.2 数据库查询优化:从 N+1 到一次查询
原始代码(简化版):
# 问题代码:典型 N+1
orders = session.query(Order).filter(Order.user_id == user_id).all()
result = []
for o in orders:
items = session.query(OrderItem).filter(OrderItem.order_id == o.id).all()
result.append({"order": o, "items": items})
100 个订单就是 1 + 100 次查询。改成 SQLAlchemy 2.0 的 selectinload:
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Order)
.where(Order.user_id == user_id)
.options(selectinload(Order.items))
.order_by(Order.created_at.desc())
.limit(50)
)
orders = session.execute(stmt).scalars().all()
selectinload 会发一条 IN 查询,总共 2 次 SQL。如果是一对一关系,用 joinedload 更合适。
同时给 orders(user_id, created_at) 加了联合索引:
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders (user_id, created_at DESC);
CONCURRENTLY 避免锁表,线上执行更安全。
4.3 缓存策略:Redis 二级缓存 + 本地缓存
订单数据读多写少,非常适合缓存。我设计了三级策略:
- L1:进程内
cachetools.TTLCache,TTL 5s,抗热点 - L2:Redis 7.2,TTL 60s,跨进程共享
- L3:PostgreSQL,兜底
核心代码:
import json
from cachetools import TTLCache
from redis.asyncio import Redis
local_cache = TTLCache(maxsize=1000, ttl=5)
redis = Redis(host="redis", port=6379, decode_responses=True)
async def get_orders_cached(user_id: int):
key = f"orders:{user_id}"
if key in local_cache:
return local_cache[key]
cached = await redis.get(key)
if cached:
data = json.loads(cached)
local_cache[key] = data
return data
data = await fetch_from_db(user_id)
await redis.setex(key, 60, json.dumps(data))
local_cache[key] = data
return data
注意:缓存 key 一定要带版本号,比如 orders:v2:{user_id},避免数据结构变更后脏读。
4.4 连接池与异步化
Flask 侧还是同步的,我把 Gunicorn worker 从 sync 换成 gevent,并把 SQLAlchemy 连接池参数调整:
engine = create_engine(
DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=3600,
)
FastAPI 侧用 asyncpg,连接池 min_size=10, max_size=50,配合 Uvicorn 4 workers,理论并发 200。
五、踩坑与优化
坑1:缓存击穿。活动开始瞬间,某个热门用户缓存过期,大量请求打到 DB。解决方案:用 Redis 的 SETNX 做互斥锁,或者对热点 key 设置随机 TTL(60±10s)。
坑2:selectinload 分页问题。如果先 limit(50) 再 selectinload,SQLAlchemy 会先查主表再查关联,没问题;但如果用 joinedload + limit,会导致结果集膨胀。这里必须用 selectinload。
坑3:gevent + PyMySQL 的兼容性。必须用 pymysql.install_as_MySQLdb() 并确保 gevent.monkey.patch_all() 在 import 之前执行,否则连接池会阻塞。
坑4:Redis 大 key。一开始把整个订单列表塞一个 key,单个 value 到 200KB。改成按用户维度拆分,单 key 控制在 10KB 以内。
六、效果数据
压测环境:4 台 Locust 压测机,每台 50 并发,持续 5 分钟。
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P50 | 820ms | 65ms | 12.6x |
| P95 | 2300ms | 180ms | 12.8x |
| P99 | 3800ms | 320ms | 11.9x |
| QPS | 47 | 620 | 13.2x |
| DB QPS | 4700 | 380 | 92% 下降 |
| CPU 使用率 | 30% | 55% | 更充分利用 |
服务器从 8 台降到 3 台,月度成本下降约 60%。
七、总结
这次调优最大的感受是:不要猜,要测。py-spy 和 cProfile 花了不到半小时,就锁定了 80% 的问题。剩下的就是按部就班地改查询、加缓存、调连接池。
几个可以直接抄的经验:
- 看到“CPU 不高但慢”,先查 IO 和 N+1。
- SQLAlchemy 2.0 的
selectinload是解决 N+1 的利器,但要注意和limit的配合。 - 缓存一定要做分层和防击穿,别指望一把梭。
- 连接池参数要结合 worker 数量和 DB 最大连接数算,别拍脑袋。
最后,性能优化没有银弹,只有不断测量、假设、验证。希望这篇博客能帮你少踩几个坑。