一、问题背景
事情是这样的。我们有个商品详情接口 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)。到这里两个问题都坐实了:
- 同步 HTTP 调用阻塞事件循环
- 关联数据逐条查询,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 调用——它在低流量时完全无害,流量一上来就把事件循环锁死。
几个可以直接抄走的结论:
- FastAPI/async 项目里,任何同步 IO(requests、同步 DB 驱动、同步文件读写)都是定时炸弹,py-spy 一抓一个准。
- 一对多关系预加载优先
selectinload,注意expire_on_commit的坑。 - 缓存做两级,本地短 TTL 挡热点,Redis 长 TTL 挡大部分流量,强一致字段别放本地。
- 连接池参数(
pool_size)在压测下才会暴露,别用默认值上生产。
优化完这版上线后,活动当天 P99 稳定在 100ms 以内,没再出过事。代码改动其实不大,难的是找到那几行该改的地方——这就是 profiling 的价值。