一、问题背景

事情是这样的。我们有个商品详情接口 GET /api/v1/products/{id},用 FastAPI 写的,平时 QPS 也就几十,一直没出事。直到上周做了个活动,流量涨到 200 QPS 左右,监控上 P99 直接从 120ms 飙到 1.8s,P95 也有 900ms+。用户端表现就是商品页转圈圈。

第一反应是看数据库。结果很反直觉:MySQL 的 CPU 只有 20%,慢查询日志里也没几条。反倒是应用容器的 CPU 打到了 95%,uvicorn 的 worker 进程基本满载。

这就说明瓶颈大概率不在数据库本身,而在应用层——要么是 Python 里干了重活,要么是同步阻塞把事件循环卡住了。下面就是我一步步排查和优化的过程,中间走了些弯路,都如实写出来。

二、环境与版本

先把环境交代清楚,不然很多结论没法复现:

  • Python 3.11.6
  • FastAPI 0.109.2,uvicorn 0.27.1(4 worker,--workers 4)
  • SQLAlchemy 2.0.25(async 模式,asyncmy 驱动)
  • MySQL 8.0.35,Redis 7.2.4
  • 部署:Docker,单容器 4C8G,前面挂 Nginx
  • 压测工具:wrk 4.2.0,wrk -t4 -c100 -d60s

接口逻辑本身不复杂:查商品主表、查规格 SKU 列表、查库存、查促销标签、查用户是否收藏,最后拼成一个响应。听起来就是几个查询的事,但问题恰恰藏在这些"几个查询"里。

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

我的原则是不 profiling 不优化。凭感觉改代码,十有八九改错地方。

3.1 用 py-spy 抓火焰图

线上容器里直接装 py-spy 有风险,我是在预发环境复现的。py-spy 的好处是 attach 到运行中的进程,不用改代码、不用重启:

# 安装
pip install py-spy==0.3.14

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

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

火焰图一出来,问题就很明显了:超过 60% 的采样落在 requests.get 上。对,你没看错,这个接口里有一段我们早先写的"同步 HTTP 调用"——去调一个内部的价格服务拿实时价格,用的还是 requests 同步库。在 async 的 FastAPI 里,这种同步阻塞调用会直接卡死整个事件循环,一个 worker 同一时刻只能处理一个请求,QPS 上不去、延迟暴涨,全解释得通。

3.2 用 cProfile 看函数级耗时

火焰图看宏观,具体到函数耗时我用 cProfile 补一刀。写了个脚本直接调接口的处理函数:

import cProfile
import pstats
import asyncio
from app.api.products import get_product_detail

async def main():
    profiler = cProfile.Profile()
    profiler.enable()
    for _ in range(50):
        await get_product_detail(product_id=10086)
    profiler.disable()
    stats = pstats.Stats(profiler).sort_stats("cumulative")
    stats.print_stats(20)

asyncio.run(main())

输出里排名靠前的:requests.api.get 累计耗时占比 58%,剩下的主要是 SQLAlchemy 的多次单条查询(典型的 N+1)。到这里两个问题都坐实了:

  1. 同步 HTTP 调用阻塞事件循环
  2. 关联数据逐条查询,N+1

四、核心实现

4.1 同步调用改异步

价格服务支持并发,直接换成 httpx.AsyncClient,并且复用连接池(这点很关键,别每次请求新建 client):

# app/clients/price_client.py
import httpx

# 全局复用,连接池上限按 worker 并发调
_price_client: httpx.AsyncClient | None = None

def get_price_client() -> httpx.AsyncClient:
    global _price_client
    if _price_client is None:
        _price_client = httpx.AsyncClient(
            base_url="http://price-service.internal",
            timeout=httpx.Timeout(0.5, connect=0.2),
            limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
        )
    return _price_client

async def fetch_price(product_id: int) -> float:
    client = get_price_client()
    resp = await client.get(f"/v1/prices/{product_id}")
    resp.raise_for_status()
    return resp.json()["price"]

超时设 0.5s 是故意的:价格服务偶尔抖动,宁可降级用缓存价也不能拖垮主接口。后面加缓存后,这个调用大部分时候根本不会发生。

4.2 干掉 N+1:selectinload 预加载

原来的写法是查完商品,再循环查 SKU、库存、标签。改成一次性预加载:

# app/repositories/product_repo.py
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.models import Product

async def get_product_with_relations(session, product_id: int) -> Product | None:
    stmt = (
        select(Product)
        .options(
            selectinload(Product.skus),
            selectinload(Product.tags),
        )
        .where(Product.id == product_id)
        .execution_options(populate_existing=True)
    )
    result = await session.execute(stmt)
    return result.scalar_one_or_none()

这里选 selectinload 而不是 joinedload,是因为 SKU 是一对多,joined 会产生笛卡尔积放大行数;selectinload 走的是 IN (...) 二次查询,对一对多更友好。改完后 SQL 从 1+1+N 条降到固定 3 条。

4.3 Redis 两级缓存

商品详情读多写少,天然适合缓存。我做了两级:进程内 LRU(挡超热 key)+ Redis(挡大部分请求)。

# app/services/product_cache.py
import json
from cachetools import TTLCache
from app.clients.redis_client import redis_client

_local_cache: TTLCache = TTLCache(maxsize=2000, ttl=5)  # 本地 5s,防热点击穿
REDIS_TTL = 60

async def get_product_cache(product_id: int) -> dict | None:
    key = f"product:detail:{product_id}"
    if key in _local_cache:
        return _local_cache[key]
    raw = await redis_client.get(key)
    if raw:
        data = json.loads(raw)
        _local_cache[key] = data
        return data
    return None

async def set_product_cache(product_id: int, data: dict) -> None:
    key = f"product:detail:{product_id}"
    _local_cache[key] = data
    await redis_client.set(key, json.dumps(data), ex=REDIS_TTL)

本地 TTL 5s、Redis 60s 是权衡的结果:本地太长会导致多 worker 之间数据不一致明显,太短又起不到挡热点的作用;Redis 60s 是因为商品信息变更走的是主动删缓存,兜底过期时间不用太短。

缓存删除走"更新数据库后删 key",并且加了个简单的延迟双删,防止并发读写把旧值塞回去。

五、踩坑与优化

几个真实的坑,值得单独说:

坑 1:httpx client 放模块级变量,多 worker 下没问题,但单元测试里会报 event loop closed。 后来改成懒加载 + 提供 close(),测试里显式关闭。

坑 2:selectinload 配合 expire_on_commit=True 会出现额外查询。 我们 session 配置是默认的,commit 之后对象过期,再访问关系属性又触发 lazy load,等于白预加载。解决办法是查询时加 .execution_options(populate_existing=True),或者干脆把读路径的 session 设成 expire_on_commit=False。

坑 3:本地缓存和 Redis 双删顺序。 一开始先删 Redis 再删本地,结果多 worker 下别的 worker 本地还有旧值。后来统一成"先删 Redis,再通过一个轻量的 pub/sub 通知各 worker 清本地",或者干脆接受 5s 的本地不一致窗口——商品价格这种强一致的字段我们没放本地缓存,只放 Redis。

坑 4:压测时发现连接池不够。 SQLAlchemy 默认 pool_size=5,200 QPS 下等连接的时间很明显。调到 pool_size=20, max_overflow=10, pool_pre_ping=True 后排队消失。

六、效果数据

同一台机器、同样的 wrk 参数(-t4 -c100 -d60s),优化前后对比:

指标 优化前 优化后 变化
QPS 186 812 +336%
P50 420ms 31ms -93%
P95 920ms 68ms -93%
P99 1830ms 86ms -95%
应用 CPU 95% 48% 明显下降
SQL 次数/请求 平均 9 条 固定 3 条(命中缓存时 0 条) -67%

缓存命中率稳定在 91% 左右(Redis + 本地合计),数据库 QPS 从峰值 1600 降到 300 出头。

七、总结

这次优化最大的教训不是"要用缓存",而是一定要先 profiling。如果凭直觉,我大概率会去加索引、调 MySQL 参数,但真正的瓶颈是一个早就被遗忘的同步 requests 调用——它在低流量时完全无害,流量一上来就把事件循环锁死。

几个可以直接抄走的结论:

  1. FastAPI/async 项目里,任何同步 IO(requests、同步 DB 驱动、同步文件读写)都是定时炸弹,py-spy 一抓一个准。
  2. 一对多关系预加载优先 selectinload,注意 expire_on_commit 的坑。
  3. 缓存做两级,本地短 TTL 挡热点,Redis 长 TTL 挡大部分流量,强一致字段别放本地。
  4. 连接池参数(pool_size)在压测下才会暴露,别用默认值上生产。

优化完这版上线后,活动当天 P99 稳定在 100ms 以内,没再出过事。代码改动其实不大,难的是找到那几行该改的地方——这就是 profiling 的价值。