一、问题背景
先说结论:一个看起来很普通的商品详情接口,在FastAPI + SQLAlchemy + PostgreSQL的技术栈上,P99延迟1.8s,QPS压到120就开始雪崩。
这个接口逻辑非常简单:根据商品ID返回商品基本信息、SKU列表、库存、店铺信息、最近10条评价。上线初期数据量小,没人关注。等商品表到了80万行、SKU表300万行、评价表1200万行之后,监控开始报警。
典型的症状是:
- P50延迟还算正常(200ms左右),P99直接飞到1.8s
- CPU使用率在高峰期打到95%以上
- 数据库连接池频繁耗尽,报TimeoutError: QueuePool limit of size 20 overflow 10 reached
- 用户侧表现为"页面转圈,偶尔白屏"
一开始团队怀疑是网络问题、怀疑是K8s调度,折腾了两天没结果。后来老老实实上profiling工具,才发现问题全在自己代码里。
二、环境与版本
先把环境交代清楚,避免版本差异导致结论不可复现:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.11.6 | |
| FastAPI | 0.104.1 | |
| Uvicorn | 0.24.0 | workers=4,单机 |
| SQLAlchemy | 2.0.23 | 使用2.0风格查询 |
| asyncpg | 0.29.0 | 异步驱动 |
| PostgreSQL | 15.4 | 8C16G,shared_buffers=4GB |
| Redis | 7.2.3 | 单机,maxmemory 4GB,allkeys-lru |
| py-spy | 0.3.14 | profiling工具 |
| locust | 2.19.0 | 压测工具 |
| 机器 | 8C16G × 2 台 | 应用层 |
压测基线:locust 200并发,持续5分钟,请求商品ID随机分布在1~800000之间。
改造前基线数据:
Type Name # reqs # fails Median p95 p99 req/s
GET /api/v1/products 58213 0.31% 210ms 890ms 1800ms 118.6
三、方案设计
定位思路分三步走,每一步都拿数据说话,不猜:
第一步:定位热点。 用py-spy对运行中的worker做采样,生成火焰图,看时间到底花在CPU计算、IO等待还是锁竞争上。同时开SQLAlchemy的慢查询日志(echo_pool + 自定义event listener,超过100ms的SQL打日志)。
第二步:拆解瓶颈。 根据profiling结果分类处理:
- 如果是N+1查询 → 用selectinload / joinedload做预加载
- 如果是索引缺失 → 分析EXPLAIN ANALYZE,补复合索引
- 如果是重复查询 → 上缓存
- 如果是同步阻塞 → 改成异步或丢到线程池
第三步:缓存分层。 不是所有数据都值得缓存。设计上分两层:
- L1:进程内缓存(cachetools.TTLCache),存极热且体积极小的数据,比如店铺基础信息
- L2:Redis,存完整的商品详情聚合结果,TTL 60s + 主动失效
缓存key设计成product:detail:{id}:v{schema_version},schema_version在商品结构变更时手动+1,避免脏数据。
四、核心实现
4.1 Profiling:先看清楚再动手
py-spy的用法很简单,但对生产环境很友好(attach到运行中的进程,不需要重启):
# 找到worker进程
ps -ef | grep uvicorn
# 采样30秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 100
火焰图上两个明显的"平顶山":
1. asyncpg的fetch调用占了约42%的采样,且调用栈里反复出现load_sku → load_stock → load_shop,典型的N+1
2. 剩下约30%卡在jsonable_encoder上——因为返回的ORM对象太大,Pydantic序列化耗时被低估了
慢查询日志同时印证了这一点:单次请求触发了 47条SQL,其中SKU查询循环了32次。
4.2 数据库查询优化:干掉N+1
改造前的代码(简化版):
# ❌ 改造前:典型N+1
@app.get("/api/v1/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
product = await db.get(Product, product_id)
skus = await db.execute(select(SKU).where(SKU.product_id == product_id))
sku_list = skus.scalars().all()
for sku in sku_list: # 每个SKU查一次库存
sku.stock = await db.scalar(
select(Stock.quantity).where(Stock.sku_id == sku.id)
)
shop = await db.get(Shop, product.shop_id) # 单独查店铺
reviews = await db.execute( # 单独查评价
select(Review).where(Review.product_id == product_id)
.order_by(Review.created_at.desc()).limit(10)
)
return build_response(product, sku_list, shop, reviews.scalars().all())
改造后,用selectinload一次性预加载,配合lazy="raise"防止意外懒加载:
# ✅ 改造后:预加载 + 复合索引 + 只取需要的列
from sqlalchemy.orm import selectinload
from sqlalchemy import select
@app.get("/api/v1/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
stmt = (
select(Product)
.options(
selectinload(Product.skus).selectinload(SKU.stock),
selectinload(Product.shop),
selectinload(Product.reviews.and_(
Review.is_deleted == False
)),
)
.where(Product.id == product_id, Product.is_deleted == False)
)
product = (await db.execute(stmt)).scalar_one_or_none()
if product is None:
raise HTTPException(status_code=404, detail="product not found")
# 评价只取最近10条,避免把1200万行的表整个拉进内存
reviews = sorted(product.reviews, key=lambda r: r.created_at, reverse=True)[:10]
return build_response(product, reviews)
配套的索引(这一步收益巨大,别省):
-- SKU按商品查
CREATE INDEX CONCURRENTLY idx_sku_product_id ON sku (product_id) WHERE is_deleted = false;
-- 库存按SKU查(唯一索引,顺便保证一致性)
CREATE UNIQUE INDEX CONCURRENTLY idx_stock_sku_id ON stock (sku_id);
-- 评价按商品+时间倒序(覆盖排序,避免filesort)
CREATE INDEX CONCURRENTLY idx_review_product_time
ON review (product_id, created_at DESC) WHERE is_deleted = false;
EXPLAIN ANALYZE验证:评价查询从Seq Scan(1200万行,耗时680ms)变成Index Scan(耗时2.1ms)。
这里有个坑:selectinload会发一条IN (...)查询,如果SKU数量超过几千,IN列表会非常长。我们的商品最多32个SKU所以没问题,但如果你的场景SKU上千,要改成分批加载(selectinload本身不支持分批,得手动拆)。
4.3 缓存策略:L1进程内 + L2 Redis
import json
from cachetools import TTLCache
from redis.asyncio import Redis
# L1:进程内,只存店铺这种小对象,1000条 × 60s
_shop_cache: TTLCache = TTLCache(maxsize=1000, ttl=60)
async def get_shop_cached(shop_id: int, db, redis: Redis) -> dict:
if (cached := _shop_cache.get(shop_id)) is not None:
return cached
key = f"shop:base:{shop_id}:v1"
if (raw := await redis.get(key)) is not None:
data = json.loads(raw)
_shop_cache[shop_id] = data
return data
shop = await db.get(Shop, shop_id)
data = {"id": shop.id, "name": shop.name, "logo": shop.logo}
await redis.setex(key, 300, json.dumps(data))
_shop_cache[shop_id] = data
return data
商品详情聚合结果的缓存,用singleflight模式防止缓存击穿(同一时刻大量请求打到同一个key):
import asyncio
from collections import defaultdict
_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
async def get_product_detail_cached(product_id: int, db, redis: Redis) -> dict:
key = f"product:detail:{product_id}:v3"
if (raw := await redis.get(key)) is not None:
return json.loads(raw)
# 同一进程内同key串行,避免击穿
async with _locks[key]:
if (raw := await redis.get(key)) is not None: # double check
return json.loads(raw)
data = await build_product_detail(product_id, db) # 真实查询
await redis.setex(key, 60, json.dumps(data, ensure_ascii=False))
return data
注意_locks是进程内的,多worker之间不共享。我们的QPS下这已经够用;如果要求严格,得上Redis分布式锁,但会引入额外RT,权衡后没做。
缓存失效:商品更新时主动删key,同时依赖60s TTL兜底。写操作走await redis.delete(key),失败也不阻塞主流程(记日志+告警)。
五、踩坑与优化
几个真实踩过的坑,值得单独说:
坑1:jsonable_encoder成为新瓶颈。 干掉N+1之后,profiling显示序列化占了35%。原因是返回的是ORM对象,FastAPI内部还要做一次完整编码。解决办法是查询时只select需要的列,直接构造Pydantic model(model_validate with from_attributes=False),序列化耗时从180ms降到22ms。
坑2:连接池配置太保守。 默认pool_size=5, max_overflow=10,4个worker一共才60个连接,高峰期直接排队。调到pool_size=20, max_overflow=10, pool_timeout=5, pool_recycle=1800,配合PostgreSQL的max_connections=200。注意pool_recycle要小于数据库或中间件的空闲超时,否则会遇到"server closed the connection unexpectedly"。
坑3:Redis大key。 一开始把整个商品详情(含32个SKU+库存)序列化成JSON存进去,单key约40KB,GET的RT从0.3ms涨到4ms。后来拆分:主详情key保留,SKU库存用Hash单独存(HSET product:stock:{id}),按需HMGET。单key降到8KB。
坑4:asyncio.Lock在defaultdict里的并发安全问题。 严格说defaultdict的__getitem__在CPython下是原子的,但为了消除歧义,改成了显式的setdefault写法,并且定期清理空锁(我们的实现里锁对象长期存在,量级可控,没做清理)。
坑5:压测环境的假象。 第一次locust压测显示QPS 1200很漂亮,结果是因为压测机到服务的网络走了内网直连,且Redis是空库(全命中L1)。把locust放到独立机器、预热缓存、混入10%的缓存miss请求之后,真实QPS是1400但P99会抖到120ms。压测一定要模拟真实命中率。
六、效果数据
每一轮改造后的locust数据(200并发,5分钟,随机商品ID,缓存命中率约92%):
| 阶段 | QPS | P50 | P95 | P99 | 单请求SQL数 |
|---|---|---|---|---|---|
| 改造前 | 118.6 | 210ms | 890ms | 1800ms | 47 |
| + 预加载(selectinload) | 460 | 68ms | 210ms | 420ms | 4 |
| + 复合索引 | 620 | 45ms | 140ms | 260ms | 4 |
| + 列裁剪/序列化优化 | 780 | 32ms | 95ms | 180ms | 4 |
| + Redis L2缓存 | 1350 | 12ms | 48ms | 92ms | 0.3(均值) |
| + 连接池调优(最终) | 1400 | 11ms | 45ms | 86ms | 0.3 |
线上真实流量(日均PV 240万)观察一周:P99从1.8s稳定在90ms以内,数据库CPU从85%降到22%,连接池等待告警归零。改造总工时约3人日,其中profiling和验证占了1.5天——定位比优化更花时间,但也更值钱。
七、总结
回头看,这次优化没有用到任何"高级技巧",全是基本功:
- 先profiling再动手。火焰图+慢查询日志,半小时就能把"感觉慢"变成"这里慢、慢多少"。没有数据的优化都是赌博。
- N+1是API性能的头号杀手。SQLAlchemy 2.0的
selectinload配合lazy="raise",能在开发期就把问题暴露出来,强烈建议默认开启。 - 索引要按查询模式建,不是按字段建。
(product_id, created_at DESC)这种复合索引配合WHERE is_deleted = false的部分索引,收益远超想象。 - 缓存是放大器不是救命稻草。查询本身从1.8s优化到180ms之后,缓存才把P99压到86ms;如果查询还是1.8s,缓存击穿时依然会雪崩。
- 压测要模拟真实命中率和真实网络,否则数据会骗你。
最后说句实话:这套东西写出来看着挺顺,实际过程是反复试错、改配置、重跑压测,中间还有一次因为索引建错方向导致写入变慢被回滚。性能优化没有银弹,只有"测量—假设—验证"的循环。希望这篇记录能帮你少走两天弯路。