一、问题背景

事情是这样的,我们有个内部商品查询接口 GET /api/v1/products/search,给运营后台和几个内部服务用。功能很简单:按关键词、类目、价格区间查商品列表,带分页,返回商品基础信息加库存和价格。

上线初期数据量小,没人管。后来商品表到了 320 万行,这个接口就开始不对劲了:

  • P95 响应时间 1.8s,P99 到 3.2s
  • 单机 QPS 超过 200 就开始出现 5s 超时
  • 数据库 CPU 常年在 80% 以上,高峰期直接打满

运营那边天天投诉"搜个商品要等两秒",我作为接口的负责人,只能硬着头皮上。这篇文章就是这次调优的完整记录,包括怎么定位、怎么改、改完到底快了多少。

先说结论:瓶颈不在 FastAPI 本身,也不在 Python 的 GIL,而在数据库——具体是 N+1 查询加上缺失的复合索引,再叠加一个没有任何缓存的同步查询链路。

二、环境与版本

先交代下环境,不然数据没法复现:

组件 版本/配置
Python 3.11.6
FastAPI 0.109.2
Uvicorn 0.27.1,4 workers
SQLAlchemy 2.0.25(async + asyncpg 0.29.0)
PostgreSQL 15.4,4C8G,shared_buffers=2GB
Redis 7.2.4,单节点
py-spy 0.3.14
Locust 2.20.1

机器是 4 核 8G 的云主机,接口部署在 Uvicorn 4 workers 下。压测机和被测机分离,避免互相干扰。

三、方案设计:先定位,再动手

我的原则是:没有 profiling 数据之前,一行优化代码都不写。 凭直觉优化,十有八九是在优化一个不存在的瓶颈。

整体分三步:

  1. 定位:用 py-spy 做火焰图采样 + 自建中间件记录慢请求的分段耗时,确认时间到底花在哪。
  2. 优化数据库:根据慢查询日志和 EXPLAIN ANALYZE,处理 N+1 查询、补索引、改分页方式。
  3. 加缓存:对热点查询做 Redis 缓存,设计好 key 和失效策略。

先上定位工具。

3.1 py-spy 火焰图

py-spy 的好处是不用改代码、不重启进程,直接 attach 到运行中的 worker 上采样:

# 找到 uvicorn worker 的 pid
ps -ef | grep uvicorn

# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200

同时在压测端用 Locust 打流量,保证采样期间进程是忙的。火焰图出来后一眼就看到问题:超过 60% 的采样落在 asyncpg 的 fetch 等待上,也就是在等数据库返回。Python 层本身的 CPU 占用很低,说明不是代码逻辑慢,是 IO 等待。

这个结论很关键——它直接排除了"要不要换 Go/Rust 重写"这种错误方向。

3.2 自建分段计时中间件

火焰图只能告诉你"在等数据库",但一个请求里可能有十几次查询,得知道是哪几次。我加了个轻量中间件,把请求内的 SQL 数量和总耗时打进日志:

# middleware.py
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy import event
from app.db import engine

logger = logging.getLogger("slowapi")

class ProfilingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        sql_count = {"n": 0, "t": 0.0}

        def before_cursor_execute(conn, cursor, statement, params, context, executemany):
            context._query_start = time.perf_counter()

        def after_cursor_execute(conn, cursor, statement, params, context, executemany):
            cost = time.perf_counter() - context._query_start
            sql_count["n"] += 1
            sql_count["t"] += cost

        event.listen(engine.sync_engine, "before_cursor_execute", before_cursor_execute)
        event.listen(engine.sync_engine, "after_cursor_execute", after_cursor_execute)

        start = time.perf_counter()
        try:
            response = await call_next(request)
        finally:
            event.remove(engine.sync_engine, "before_cursor_execute", before_cursor_execute)
            event.remove(engine.sync_engine, "after_cursor_execute", after_cursor_execute)

        total = (time.perf_counter() - start) * 1000
        if total > 300:  # 只记录慢请求
            logger.warning(
                "SLOW %s %s total=%.1fms sql_count=%d sql_time=%.1fms",
                request.method, request.url.path,
                total, sql_count["n"], sql_count["t"] * 1000,
            )
        return response

挂上去跑了一会儿,日志直接把我干沉默了:

SLOW GET /api/v1/products/search total=1842.3ms sql_count=127 sql_time=1710.5ms
SLOW GET /api/v1/products/search total=1903.7ms sql_count=131 sql_time=1755.2ms

一个请求发了 127 次 SQL,SQL 总耗时 1.7 秒,占了总耗时的 93%。 这就是典型的 N+1 查询:先查商品列表(1 次),然后对每个商品循环查库存、查价格、查类目名(每个商品 3 次,一页 40 条就是 120 次)。

到这里,问题定位完成,可以动手了。

四、核心实现:三步优化

4.1 干掉 N+1:改成批量查询 + join

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

# 优化前
products = await session.execute(select(Product).where(...).limit(40))
result = []
for p in products.scalars():
    stock = await session.execute(select(Stock).where(Stock.product_id == p.id))
    price = await session.execute(select(Price).where(Price.product_id == p.id))
    category = await session.execute(select(Category).where(Category.id == p.category_id))
    result.append({...})

改法很直接:库存和价格用 IN 批量查一次,类目用 join 一次带出来。如果关系定义得好,直接用 SQLAlchemy 2.0 的 selectinload 最省事:

# 优化后
from sqlalchemy.orm import selectinload

stmt = (
    select(Product)
    .options(
        selectinload(Product.stock),
        selectinload(Product.price),
        joinedload(Product.category),
    )
    .where(Product.status == 1)
    .order_by(Product.updated_at.desc())
    .limit(40)
)
products = (await session.execute(stmt)).scalars().all()

selectinload 会额外发一条 WHERE product_id IN (...) 的查询,而不是 N 条;joinedload 直接 join。改完后 SQL 数量从 127 降到 4 条(count + 主查询 + stock + price)。

4.2 补索引与改分页

SQL 数量降下来了,但单条还是慢。跑 EXPLAIN ANALYZE 看主查询:

Seq Scan on products  (cost=0.00..89234.00 rows=1200000 width=...)
  Filter: ((status = 1) AND (category_id = 12) AND (price >= 100))
  Rows Removed by Filter: 2980000

全表扫描,320 万行扫一遍。而且分页用的是 OFFSET,翻到第 50 页时要先跳过 2000 行再取 40 行,越翻越慢。

两个改动:

(1)加复合索引。 根据查询条件,建了 (status, category_id, updated_at DESC) 的复合索引,价格区间过滤放到索引后面再筛:

CREATE INDEX CONCURRENTLY idx_products_status_cat_updated
ON products (status, category_id, updated_at DESC)
WHERE status = 1;

用了部分索引(WHERE status = 1),因为线上 95% 的查询都只查上架商品,索引体积小了一半。

(2)把 OFFSET 分页改成游标分页。 用 updated_at + id 做游标:

if cursor:
    stmt = stmt.where(
        tuple_(Product.updated_at, Product.id)  str:
    raw = json.dumps(params, sort_keys=True, ensure_ascii=False)
    return f"{prefix}:{hashlib.md5(raw.encode()).hexdigest()}"

async def cached_search(params: dict, loader):
    key = make_key("prodsearch", params)
    hit = await redis.get(key)
    if hit:
        return json.loads(hit)

    data = await loader(params)
    ttl = 60 + random.randint(0, 15)  # 抖动,防雪崩
    await redis.set(key, json.dumps(data, ensure_ascii=False), ex=ttl)
    return data

另外做了个小的防护:缓存空结果也存(TTL 短一些,10 秒),防止"查不到的关键词"每次都穿透到数据库。

五、踩坑与优化

这部分是我踩过的坑,写出来能帮一个是一个。

坑 1:selectinload 和 limit 的顺序问题。 一开始我在 selectinload 之后加 limit,结果 SQLAlchemy 生成了子查询套 join 的语句,索引失效。后来改成先 limit 主查询、再 selectin,才走对索引。这个要看具体版本行为,2.0.25 上我实测是这样。

坑 2:连接池打满。 优化后单请求 SQL 少了,但并发上来了,4 个 worker 默认连接池(pool_size=5)很快不够用,出现 QueuePool limit overflow。改成 pool_size=20, max_overflow=10, pool_pre_ping=True 后稳定。

坑 3:缓存 key 没考虑分页游标。 第一版 key 只哈希了关键词和类目,忘了游标,导致翻页拿到的是第一页的缓存。后来把 cursor 也纳入 key,并且对第一页单独用长 TTL、后续页用短 TTL。

坑 4:CREATE INDEX 锁表。 直接在业务高峰期跑普通 CREATE INDEX 会锁写,必须用 CONCURRENTLY,虽然慢一点但不阻塞写入。这个一定要在低峰期做,并且监控好。

坑 5:序列化开销。 一开始缓存里存 ORM 对象 pickl​e,反序列化比查库还慢。改成存纯 dict 的 JSON 后正常了。教训是:缓存里只存 DTO,不存 ORM 实体。

六、效果数据

压测用 Locust,脚本模拟运营后台的真实查询分布(60% 带关键词、30% 带类目、10% 组合条件),持续压 5 分钟:

# locustfile.py
from locust import HttpUser, task, between
import random

KEYWORDS = ["手机", "耳机", "充电器", "数据线", "壳"]

class SearchUser(HttpUser):
    wait_time = between(0.1, 0.5)

    @task(6)
    def search_by_keyword(self):
        self.client.get("/api/v1/products/search",
                        params={"q": random.choice(KEYWORDS), "page_size": 40})

    @task(3)
    def search_by_category(self):
        self.client.get("/api/v1/products/search",
                        params={"category_id": random.randint(1, 50), "page_size": 40})

    @task(1)
    def search_combo(self):
        self.client.get("/api/v1/products/search",
                        params={"q": random.choice(KEYWORDS),
                                "category_id": random.randint(1, 50),
                                "price_min": 100, "page_size": 40})

前后对比(同样的 4C8G 环境,4 workers):

指标 优化前 优化后 变化
P50 820ms 45ms -94.5%
P95 1840ms 120ms -93.5%
P99 3200ms 260ms -91.9%
单机 QPS(P95<500ms) 210 1420 +576%
单请求 SQL 数 127 4 -96.9%
数据库 CPU 峰值 85% 22% -63pp
缓存命中率 - 78% -

几个观察:

  • QPS 提升主要来自 SQL 数量下降和缓存命中,两者叠加。
  • 缓存命中率 78%,比我预期低一点,因为压测脚本里关键词是随机的,真实运营场景重复度更高,线上实测在 85% 左右。
  • 数据库 CPU 降到 22%,终于不用天天盯着慢查询告警了。

七、总结

这次调优下来,几点体会:

  1. 先 profiling 再动手。 py-spy 五分钟就能告诉你瓶颈在哪,比拍脑袋猜一小时靠谱。我见过太多人一上来就"上缓存""换框架",结果瓶颈在索引上。
  2. N+1 是 API 性能的头号杀手。 一个请求 127 条 SQL,再怎么优化 Python 代码都没用。ORM 用起来爽,但一定要盯住实际发出的 SQL。
  3. 索引要按查询模式建。 复合索引的字段顺序、部分索引的条件,都得对着真实查询来,不是越多越好。
  4. 缓存是放大器不是救命稻草。 数据库没优化好就上缓存,只会把问题延后。我们是先把 SQL 从 127 降到 4、把全表扫描干掉,再加缓存,收益才叠加得起来。
  5. 压测要贴近真实分布。 如果只压单一关键词,缓存命中率虚高,数据会骗人。

后续还可以做的:把搜索结果预热到本地 LRU、对 count 查询做近似估算、考虑读库分离。但就目前 1400 QPS 的量级,这套方案够用一阵了,不为了优化而优化。

代码都在上面了,profiling 中间件和缓存装饰器可以直接拿去改改用。如果你的接口也在 1 秒以上,先把那个中间件挂上,看看日志里 sql_count 是多少——大概率你会和我一样被吓一跳。