一、问题背景:一个“看起来没毛病”的接口

事情起因很简单,我们的订单查询接口 /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 数据的优化都是赌博

整体思路分三步:

  1. 定位:用 cProfile + py-spy 找出热点函数,用 SQLAlchemy 的 echo 和 pg_stat_statements 找出慢查询。
  2. 拆解:把瓶颈分类——是 CPU 计算、IO 等待,还是锁竞争。
  3. 优化:按投入产出比排序,先干掉 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% 的问题。剩下的就是按部就班地改查询、加缓存、调连接池。

几个可以直接抄的经验:

  1. 看到“CPU 不高但慢”,先查 IO 和 N+1。
  2. SQLAlchemy 2.0 的 selectinload 是解决 N+1 的利器,但要注意和 limit 的配合。
  3. 缓存一定要做分层和防击穿,别指望一把梭。
  4. 连接池参数要结合 worker 数量和 DB 最大连接数算,别拍脑袋。

最后,性能优化没有银弹,只有不断测量、假设、验证。希望这篇博客能帮你少踩几个坑。