一、问题背景:一个“看起来很简单”的查询接口

事情起因很普通。我们内部有一个商品聚合查询接口 /api/v1/products/search,逻辑就是:按关键词搜索商品,返回商品基本信息 + 所属类目 + 最近3条价格记录。业务方反馈“列表页转圈要两秒”,我一开始没太当回事,觉得加个索引就完事了。

结果上监控一看,确实不乐观:

  • P50:420ms
  • P95:1.8s
  • P99:2.6s
  • 平均QPS:42,压到100并发时错误率开始上升
  • 数据库CPU在高峰期打到70%

这个接口同时被FastAPI(新版网关层)和Flask(老后台管理端)调用,两边都走同一套SQLAlchemy模型和Service层。所以优化不能只改一边,得从共用的数据访问层下手。

我的目标很明确:P95压到200ms以内,QPS至少翻5倍,数据库压力明显下降。

二、环境与版本:先把基线固定住

压测和profiling最怕环境漂移,先把版本钉死:

  • Python 3.11.7
  • FastAPI 0.110.0 + Uvicorn 0.29.0(workers=4)
  • Flask 3.0.2 + Gunicorn 21.2.0(workers=4, worker_class=uvicorn.workers.UvicornWorker 用于对比)
  • SQLAlchemy 2.0.29 + asyncpg 0.29.0(FastAPI侧异步)/ psycopg2-binary 2.9.9(Flask侧同步)
  • PostgreSQL 15.6
  • Redis 7.2.4
  • 压测工具:wrk 4.2.0 + locust 2.24.0
  • Profiling:cProfile、py-spy 0.3.14、SQLAlchemy echo、pg_stat_statements

机器配置:4C8G 容器,数据库独立实例 8C16G。压测固定 100 并发、持续 60s,取稳定段数据。

三、方案设计:先测量,再动手

我给自己定了三步:

  1. 定位:用 py-spy 抓火焰图,cProfile 看函数级耗时,pg_stat_statements 看SQL。
  2. 拆分瓶颈:把总耗时拆成“框架开销 / 业务逻辑 / 数据库 / 序列化”四块。
  3. 逐项优化:数据库查询优化 → 缓存策略 → 序列化与框架层微调。

先说结论:这个接口1.8s里,数据库相关占了约1.1s,序列化占了约300ms,框架和业务逻辑其实没那么重。也就是说,大头在数据访问层,不在FastAPI本身。很多人一上来就换框架、加worker,方向就错了。

3.1 Profiling:py-spy + cProfile

py-spy 对运行中的进程几乎无侵入,适合先看整体:

# 找到 uvicorn worker pid
py-spy top --pid 12345 --duration 30
# 生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 60 --rate 100

火焰图里最显眼的是 sqlalchemy/orm/loading.pyasyncpg 相关调用反复出现,说明查询次数过多。再用 cProfile 在本地复现单次请求:

import cProfile
import pstats
from app.main import app
from fastapi.testclient import TestClient

client = TestClient(app)

def run():
    for _ in range(20):
        client.get("/api/v1/products/search", params={"q": "phone", "page": 1, "size": 20})

profiler = cProfile.Profile()
profiler.enable()
run()
profiler.disable()

stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(30)

输出里 load_scalar_attributes_load_for_state 调用次数高得离谱。再看 pg_stat_statements:

SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

发现同一个 SELECT ... FROM categories WHERE id = $1 被调用了 20 次/请求,价格记录查询也是每条商品一次。典型 N+1。

四、核心实现:三处改动,收益最大

4.1 数据库查询优化:selectinload + 索引

原来的代码大概是这样(简化):

# 优化前:N+1
products = session.execute(
    select(Product).where(Product.name.ilike(f"%{q}%")).limit(20)
).scalars().all()

result = []
for p in products:
    category = session.get(Category, p.category_id)          # 每次一条SQL
    prices = session.execute(
        select(Price).where(Price.product_id == p.id)
        .order_by(Price.created_at.desc()).limit(3)
    ).scalars().all()                                        # 每条商品一次
    result.append({"product": p, "category": category, "prices": prices})

改成 SQLAlchemy 2.0 的 selectinload,把 category 和 prices 一次性加载:

from sqlalchemy.orm import selectinload
from sqlalchemy import select

stmt = (
    select(Product)
    .where(Product.name.ilike(f"%{q}%"))
    .options(
        selectinload(Product.category),
        selectinload(Product.prices),   # 关系上配合 order_by 和 limit 需用窗口或子查询
    )
    .limit(20)
)
products = session.execute(stmt).scalars().all()

注意:selectinload 对“每个商品只取最近3条价格”这种带 limit 的场景不能直接生效。我的做法是把价格查询单独抽出来,用 IN + 窗口函数 一次拿回:

SELECT * FROM (
  SELECT p.*, ROW_NUMBER() OVER (
    PARTITION BY product_id ORDER BY created_at DESC
  ) AS rn
  FROM prices p
  WHERE p.product_id = ANY(:ids)
) t WHERE t.rn  str:
    raw = f"search:{q.lower().strip()}:{page}:{size}"
    return "api:" + hashlib.md5(raw.encode()).hexdigest()

def get_cached(key: str):
    data = r.get(key)
    return json.loads(data) if data else None

def set_cached(key: str, value, ttl: int = 60):
    # 抖动 ±10s,避免同一时刻大量key同时过期
    jitter = random.randint(-10, 10)
    r.setex(key, ttl + jitter, json.dumps(value, ensure_ascii=False))

在路由里:

@app.get("/api/v1/products/search")
async def search(q: str, page: int = 1, size: int = 20):
    key = make_cache_key(q, page, size)
    cached = await run_in_threadpool(get_cached, key)  # 或异步redis客户端
    if cached:
        return cached

    data = await query_products(q, page, size)
    await run_in_threadpool(set_cached, key, data, 60)
    return data

这里有个坑:一开始我用同步 redis 客户端直接在 async 路由里调用,把事件循环堵住了,QPS 反而下降。后来改成 redis.asyncio 或者丢到线程池,问题解决。

另外,缓存 key 一定要包含分页参数和排序参数,否则会串数据。我们上线前就遇到过 page=2 返回了 page=1 的结果,原因就是 key 少拼了 page。

4.3 序列化优化:orjson 替换默认 JSON

FastAPI 默认用 json 序列化,大列表下开销不小。换成 orjson

from fastapi.responses import ORJSONResponse
from fastapi import FastAPI

app = FastAPI(default_response_class=ORJSONResponse)

Flask 侧用 flask-orjson 或手动 orjson.dumps。这一项单独测下来,20条商品的响应序列化从约 300ms 降到约 90ms。别小看序列化,数据量大时它很吃CPU。

五、踩坑与优化:那些让我加班到十点的细节

坑1:selectinload + limit 不生效。 前面说了,关系上带 limit 会退化成全量加载再截断。必须用窗口函数或单独查询。

坑2:缓存穿透。 有人用不存在的关键词疯狂刷接口,每次都打到数据库。加了空结果缓存(TTL 10s)和布隆过滤器后缓解。

坑3:Gunicorn worker 数量。 Flask 侧原来配了 8 个 worker,但容器只有 4C,上下文切换严重。改成 workers=2*CPU+1=9 是理论值,实际压测 4~5 个最稳。FastAPI 侧 Uvicorn workers=4 比较合适。参数不是越多越好。

坑4:连接池。 SQLAlchemy 默认 pool_size=5,max_overflow=10。高并发下连接等待明显。调整:

engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,
    max_overflow=10,
    pool_pre_ping=True,
    pool_recycle=1800,
)

PostgreSQL 侧 max_connections=200,配合 pgBouncer 更稳。

坑5:压测工具本身。 wrk 单机压的时候,客户端先到瓶颈了。后来用 locust 分布式压,数据才可信。别拿压测客户端的极限当服务端极限。

六、效果数据:优化前后对比

同一套压测脚本、同一台机器、100并发、60s:

指标 优化前 优化后 变化
P50 420ms 45ms ↓89%
P95 1800ms 120ms ↓93%
P99 2600ms 210ms ↓92%
QPS 42 680 ↑16倍
数据库QPS ~1200 ~200 ↓83%
错误率 1.2% 0% -
数据库CPU峰值 70% 18% ↓74%

分项耗时(单请求,20条商品):

  • 数据库查询:1100ms → 210ms
  • 序列化:300ms → 90ms
  • 业务逻辑+框架:约400ms → 约120ms(含缓存命中时更低)

缓存命中率稳定在 78% 左右,热门关键词基本都命中。Redis 内存占用约 180MB,可接受。

Flask 老后台侧因为共用 Service 层,QPS 从 35 提升到 410,效果同样明显。

七、总结

这次调优最大的感受是:不要猜,要测。我一开始也以为是FastAPI慢,结果火焰图直接打脸——瓶颈在N+1查询和序列化。顺序应该是:profiling定位 → 数据库查询优化 → 缓存 → 序列化/框架微调。

几个可复用的经验:

  1. py-spy + pg_stat_statements 是定位API性能问题的黄金组合,成本低、信息量大。
  2. SQLAlchemy 2.0 的 selectinload 能解决大部分N+1,但带 limit 的关系要特殊处理。
  3. 缓存 key 必须包含所有影响结果的参数,TTL 加随机抖动。
  4. 异步框架里千万别混用同步IO,否则事件循环被堵,性能不升反降。
  5. 连接池、worker 数量这些参数要压测调,别照搬公式。

优化不是一劳永逸的。上线两周后我又加了一次监控告警,当 P95 超过 300ms 就触发,防止数据量增长后性能悄悄退化。性能这件事,得持续盯着。