一、问题背景
先说结论:一个看起来只有几十行逻辑的 GET /products/{id} 接口,在压测下 P95 干到了 1.8 秒,QPS 只有 230 左右就开始大面积 504。这个接口本身逻辑很简单——查商品详情、查分类、查几个 SKU、再拼一下库存。但就是这种"看起来简单"的接口最容易埋雷。
我们当时的业务场景是:大促前压测,商品详情页是流量最大的入口,占比大概 60%。测试同学反馈"接口慢",开发第一反应是"加机器",但我在看监控时发现 CPU 只跑到 40%,DB 的 CPU 却接近 90%。这就说明问题不在应用层算力,而在数据访问路径。
所以这篇文章讲的不是"如何做一个高性能 API"这种空话,而是:怎么用 profiling 找到真正的瓶颈、怎么判断是 N+1 还是缓存缺失、怎么改、改完数据是多少。
二、环境与版本
先把环境交代清楚,不然数据没法复现:
- Python 3.11.6
- FastAPI 0.110.0
- Uvicorn 0.29.0(
--workers 4) - SQLAlchemy 2.0.29(async 模式,asyncpg 0.29.0)
- PostgreSQL 15.6
- Redis 7.2.4(redis-py 5.0.3)
- 压测工具:wrk 4.2.0 + locust 2.24.0(做阶梯压测)
- 机器:4C8G 容器,DB 单独一台 8C16G
接口原始实现大概是这样(简化版):
# app/api/product.py (优化前)
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.db import get_session
from app.models import Product, Category, SKU
router = APIRouter()
@router.get("/products/{pid}")
async def get_product(pid: int, db: AsyncSession = Depends(get_session)):
product = (await db.execute(
select(Product).where(Product.id == pid)
)).scalar_one_or_none()
if not product:
return {"code": 404, "msg": "not found"}
category = (await db.execute(
select(Category).where(Category.id == product.category_id)
)).scalar_one()
skus = (await db.execute(
select(SKU).where(SKU.product_id == pid)
)).scalars().all()
sku_list = []
for s in skus:
sku_list.append({
"id": s.id,
"name": s.name,
"price": float(s.price),
"stock": s.stock,
})
return {
"code": 0,
"data": {
"id": product.id,
"title": product.title,
"category": category.name,
"skus": sku_list,
}
}
这段代码一眼看过去没什么毛病,select 写得也挺规范。但它在压测下的问题会在后面暴露出来。
三、方案设计:先定位,再动手
我给自己定了个顺序,避免"凭感觉优化":
- 先用 py-spy + cProfile 定位热点函数,确认是 CPU 密集还是 IO 等待。
- 打开 SQLAlchemy 的 echo 或慢查询日志,看一次请求到底发了几条 SQL。
- 确认是 N+1、缺索引,还是缺缓存。
- 按"成本从低到高"改:先加索引 → 再改查询 → 再上缓存 → 最后调连接池。
3.1 用 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
# 或者直接 top 看实时热点
py-spy top --pid 12345
第一次看火焰图就很明显:大量时间卡在 asyncpg 的 _wait_for_response 上。也就是说,应用层没在算,全在等数据库。这就直接排除了"Python 代码写得慢"这个方向。
3.2 用 cProfile 看函数级耗时
对单个请求做精确统计,我用 cProfile 包了一层:
# scripts/prof_one_request.py
import asyncio
import cProfile
import pstats
import io
from app.api.product import get_product
from app.db import AsyncSessionLocal
async def run():
async with AsyncSessionLocal() as db:
await get_product(1, db)
def main():
pr = cProfile.Profile()
pr.enable()
asyncio.run(run())
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(20)
print(s.getvalue())
if __name__ == "__main__":
main()
输出里 asyncpg 相关调用累计占了 80% 以上,确认瓶颈在 DB。
3.3 看 SQL 数量
把 SQLAlchemy 日志级别调到 INFO,或者临时加 echo=True,一次请求打出来 4 条 SQL:
SELECT ... FROM products WHERE id = 1;
SELECT ... FROM categories WHERE id = 3;
SELECT ... FROM skus WHERE product_id = 1;
SELECT ... FROM skus WHERE product_id = 1; -- 又查了一次
前三条还算正常,第四条是 ORM 关系懒加载触发的。这就是典型的 N+1 的变种:因为 product.skus 在别处被访问了一次,SQLAlchemy 又发了一次查询。
四、核心实现:三步优化
4.1 第一步:消除重复查询 + 加索引
用 selectinload 一次性把关联对象拉出来,同时给 skus.product_id 加索引:
# app/api/product.py (优化后)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.db import get_session
from app.models import Product
from app.cache import redis_client
import json
router = APIRouter()
@router.get("/products/{pid}")
async def get_product(pid: int, db: AsyncSession = Depends(get_session)):
cache_key = f"product:detail:{pid}"
cached = await redis_client.get(cache_key)
if cached:
return {"code": 0, "data": json.loads(cached), "cached": True}
stmt = (
select(Product)
.options(selectinload(Product.category), selectinload(Product.skus))
.where(Product.id == pid)
)
product = (await db.execute(stmt)).scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="not found")
data = {
"id": product.id,
"title": product.title,
"category": product.category.name,
"skus": [
{
"id": s.id,
"name": s.name,
"price": float(s.price),
"stock": s.stock,
}
for s in product.skus
],
}
# 缓存 300 秒,带随机抖动防雪崩
import random
ttl = 300 + random.randint(0, 30)
await redis_client.setex(cache_key, ttl, json.dumps(data, ensure_ascii=False))
return {"code": 0, "data": data, "cached": False}
索引:
CREATE INDEX CONCURRENTLY idx_skus_product_id ON skus (product_id);
CREATE INDEX CONCURRENTLY idx_products_category_id ON products (category_id);
到这里,单请求 SQL 从 4 条降到 2 条(selectinload 会合并成两条:一条主表 + 一条 in 查询)。
4.2 第二步:Redis 缓存
上面代码里已经加了缓存。几个关键点:
- TTL 加随机抖动:防止大量 key 同时过期造成缓存雪崩。
- 缓存穿透:对不存在的 pid,可以缓存空值
__NULL__短 TTL,避免每次都打 DB。 - 序列化:用
json.dumps而不是 pickle,跨语言安全,也避免反序列化漏洞。
Redis 连接池配置(这个很关键,默认配置在并发高时会成为瓶颈):
# app/cache.py
import redis.asyncio as redis
redis_client = redis.Redis(
host="127.0.0.1",
port=6379,
db=0,
max_connections=200, # 默认是 2**31,但实际 socket 数会爆
socket_timeout=0.5, # 超时快速失败,避免拖垮请求
socket_connect_timeout=0.5,
retry_on_timeout=True,
health_check_interval=30,
decode_responses=True,
)
4.3 第三步:DB 连接池与 Uvicorn 参数
SQLAlchemy async 引擎配置:
# app/db.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(
"postgresql+asyncpg://user:pwd@db:5432/shop",
pool_size=20, # 每个 worker 的连接池大小
max_overflow=10,
pool_pre_ping=True, # 防止拿到死连接
pool_recycle=1800, # 30 分钟回收,配合 DB 的 idle timeout
echo=False,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
Uvicorn 启动:
uvicorn app.main:app \
--host 0.0.0.0 --port 8000 \
--workers 4 \
--loop uvloop \
--http httptools \
--backlog 2048 \
--limit-concurrency 1000
pool_size * workers 要小于 DB 的 max_connections,否则高并发下会报 too many clients。我们 DB 设的是 max_connections=200,4 个 worker × 20 = 80,留足余量。
五、踩坑与优化
坑 1:缓存和 DB 数据不一致。
一开始我们在写操作里直接 del cache,但并发下会出现"删缓存 → 读旧值回填"。后来改成先更新 DB 再删缓存,并且写操作加一个短锁(Redis SETNX,TTL 3 秒),保证同一商品的写串行。
坑 2:selectinload 拉太多数据。
selectinload(Product.skus) 会把该商品所有 SKU 都拉出来。如果某个商品有几百个 SKU,单次响应体就会很大。后来加了 .limit(50) 和分页参数。
坑 3:连接池耗尽导致请求堆积。
压测初期 P99 突然从 200ms 涨到 5s,看日志发现是 QueuePool limit of size 20 overflow 10 reached。原因是缓存命中率低的时候所有请求都打到 DB。解决办法是:提高缓存命中率 + 增加连接池 + 设置 socket_timeout 快速失败,不要让请求无限等待。
坑 4:py-spy 在容器里 attach 需要 --cap-add=SYS_PTRACE。
这个不踩一次真的会懵,报 Permission denied。K8s 里可以加:
securityContext:
capabilities:
add: ["SYS_PTRACE"]
六、效果数据
压测条件:4C8G 容器 × 1,wrk 并发 200,持续 3 分钟,数据集 10 万商品,每个商品平均 8 个 SKU。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P50 | 620 ms | 45 ms |
| P95 | 1800 ms | 120 ms |
| P99 | 3100 ms | 210 ms |
| QPS | 230 | 1420 |
| 单请求 SQL 数 | 4 | 0(命中缓存)/ 2(未命中) |
| 缓存命中率 | - | 96.3% |
| DB CPU | 90% | 25% |
| 错误率 | 4.7% | 0% |
locust 阶梯压测下,优化后 QPS 到 1400 时 P95 依然稳定在 130ms 以内,DB 连接池使用率最高 65%。
说句实话,这个提升里 缓存贡献了大概 70%,selectinload 和索引贡献了 20%,连接池和 uvicorn 参数贡献 10%。所以如果只让我做一件事,我会先看缓存命中率。
七、总结
这次调优的核心不是某个"银弹",而是一个可复用的排查链路:
- py-spy 火焰图 → 判断是 CPU 还是 IO 瓶颈;
- SQL 日志 / cProfile → 确认是 N+1 还是慢查询;
- 索引 + selectinload → 消除重复查询;
- Redis 缓存 → 挡住绝大部分读流量;
- 连接池 + uvicorn 参数 → 保证高并发下不雪崩。
如果你的接口也慢,别急着加机器,先跑一遍 py-spy。很多时候你会发现:不是机器不够,是 SQL 发太多了。
最后留个 checklist,方便你对着排查:
- [ ] 单请求 SQL 数量是否 > 3?
- [ ] 有没有 N+1(懒加载)?
- [ ] 关联字段有没有索引?
- [ ] 热点数据有没有缓存?TTL 有没有抖动?
- [ ] 缓存穿透、雪崩、击穿是否处理?
- [ ] DB 连接池和 worker 数是否匹配?
- [ ] 有没有慢查询日志?P95 是多少?
以上数据都是我们线上真实压测的结果,环境和参数都写在文里了,有兴趣的同学可以照着复现一遍。有更好的方案欢迎评论区交流。