一、问题背景
事情是这样的,我们有个商品详情聚合接口 /api/v1/products/{id}/detail,功能不复杂:返回商品基本信息、SKU列表、库存、促销标签、最近10条评价。上线初期没什么流量,跑得挺快。但最近做了一次运营活动,流量涨了大概10倍,这个接口直接成了重灾区。
监控上看,P99从最初的200ms涨到了1.2s,QPS卡在120左右上不去,CPU单核跑满。更诡异的是,数据库连接池经常被打满,报 QueuePool limit of size 20 overflow 10 reached。
这篇文章就把我从定位到优化的全过程记下来,包括踩的坑。
二、环境与版本
先把环境交代清楚,避免版本差异导致结论不适用:
- Python 3.11.6
- FastAPI 0.110.0
- Uvicorn 0.27.1,workers=4(8核机器)
- SQLAlchemy 2.0.27(同步ORM,没用async)
- PostgreSQL 15.4
- Redis 7.2.4,redis-py 5.0.1
- py-spy 0.3.14
- locust 2.20.0(压测)
部署是单机8C16G,接口走的是同步def,FastAPI用线程池跑。
三、方案设计:先定位,再动手
我见过太多人一上来就"加缓存",结果缓存加了个寂寞,因为瓶颈根本不在数据库。所以顺序一定是:Profiling → 定位 → 针对性优化 → 压测验证。
我的计划:
- 用 py-spy 采样火焰图,找到CPU时间花在哪
- 开 SQLAlchemy 的 echo 或慢查询日志,看SQL数量和耗时
- 根据结果决定:是加索引、改查询、还是加缓存
- 每步都用 locust 压测对比
定位:py-spy 抓火焰图
py-spy 的好处是不用改代码,直接attach到进程:
# 找到uvicorn worker的pid
ps aux | grep uvicorn
# 采样30秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200
# 或者直接top看实时热点
py-spy top --pid 12345
火焰图一出来就清楚了:sqlalchemy/orm/loading.py 占了将近60%的采样,psycopg2 的 execute 又占其中大头。也就是说,大量时间花在ORM加载和SQL执行上。
再看SQL日志,我把 echo=True 打开跑了一次请求,好家伙,一个请求打了 47条SQL。典型的N+1:
- 1条查商品
- 1条查SKU
- 每个SKU单独查一次库存(10个SKU就是10条)
- 每个SKU查促销标签(又是10条)
- 1条查评价
- 评价里的用户信息又每条查一次(10条)
47条SQL,每条哪怕只有5ms,光网络往返+解析就235ms,再加上ORM的对象构造开销,1.2s一点不冤。
四、核心实现:三步优化
第一步:消灭N+1,改批量查询
原来的代码大概长这样(简化版):
# 优化前:典型N+1
@app.get("/api/v1/products/{pid}/detail")
def get_product_detail(pid: int, db: Session = Depends(get_db)):
product = db.query(Product).filter(Product.id == pid).first()
if not product:
raise HTTPException(404)
skus = db.query(SKU).filter(SKU.product_id == pid).all()
sku_list = []
for sku in skus:
# 每个SKU单独查库存
stock = db.query(Stock).filter(Stock.sku_id == sku.id).first()
# 每个SKU单独查促销
promos = db.query(Promotion).filter(Promotion.sku_id == sku.id).all()
sku_list.append({
"id": sku.id,
"name": sku.name,
"stock": stock.quantity if stock else 0,
"promotions": [p.tag for p in promos],
})
reviews = db.query(Review).filter(Review.product_id == pid)\
.order_by(Review.created_at.desc()).limit(10).all()
review_list = []
for r in reviews:
# 每条评价单独查用户
user = db.query(User).filter(User.id == r.user_id).first()
review_list.append({"content": r.content, "user": user.nickname})
return {"product": product.name, "skus": sku_list, "reviews": review_list}
改成批量查询 + selectinload 预加载:
# 优化后:批量预加载,SQL从47条降到4条
from sqlalchemy.orm import selectinload
@app.get("/api/v1/products/{pid}/detail")
def get_product_detail(pid: int, db: Session = Depends(get_db)):
product = db.query(Product).filter(Product.id == pid).first()
if not product:
raise HTTPException(404)
# 一次查出所有SKU及其关联的库存、促销
skus = db.query(SKU)\
.options(
selectinload(SKU.stock),
selectinload(SKU.promotions),
)\
.filter(SKU.product_id == pid)\
.all()
sku_list = [{
"id": s.id,
"name": s.name,
"stock": s.stock.quantity if s.stock else 0,
"promotions": [p.tag for p in s.promotions],
} for s in skus]
# 评价和用户一次join查出来
reviews = db.query(Review, User)\
.join(User, User.id == Review.user_id)\
.filter(Review.product_id == pid)\
.order_by(Review.created_at.desc())\
.limit(10)\
.all()
review_list = [{"content": r.content, "user": u.nickname} for r, u in reviews]
return {"product": product.name, "skus": sku_list, "reviews": review_list}
这一步SQL从47条降到4条。
第二步:补索引
看慢查询日志发现两个问题:
review.product_id + created_at没有联合索引,ORDER BY ... LIMIT 10走了全表扫stock.sku_id有索引但没生效(因为类型不匹配,sku_id是bigint,传进去的是int,PG没做隐式转换)
补索引:
CREATE INDEX CONCURRENTLY idx_review_product_created
ON review (product_id, created_at DESC);
-- 确认类型匹配后重建
CREATE INDEX CONCURRENTLY idx_stock_sku_id ON stock (sku_id);
ANALYZE review;
ANALYZE stock;
注意用 CONCURRENTLY,线上建索引不锁表。
第三步:加缓存
商品详情这种读多写少的数据,天然适合缓存。策略:
- 商品基础信息+SKU+促销:缓存 5 分钟(
product:detail:{pid}) - 库存:单独缓存 10 秒(库存变化频繁,缓存太久会超卖感知延迟)
- 评价:缓存 60 秒
缓存代码:
import json
import redis
from functools import wraps
rds = redis.Redis(host="127.0.0.1", port=6379, db=0,
decode_responses=True,
socket_timeout=1,
socket_connect_timeout=1,
max_connections=50)
def cache(key_tpl: str, ttl: int):
def deco(func):
@wraps(func)
def wrapper(pid: int, *args, **kwargs):
key = key_tpl.format(pid=pid)
cached = rds.get(key)
if cached:
return json.loads(cached)
result = func(pid, *args, **kwargs)
rds.setex(key, ttl, json.dumps(result, ensure_ascii=False))
return result
return wrapper
return deco
# 静态部分缓存5分钟
@cache("product:base:{pid}", ttl=300)
def get_base_info(pid: int, db: Session):
...
# 库存单独10秒
@cache("product:stock:{pid}", ttl=10)
def get_stock_info(pid: int, db: Session):
...
踩坑:一开始我把整个响应体缓存了,包括库存,TTL设了300秒。结果运营改了库存,用户5分钟内看到的还是旧库存,被投诉了。后来拆成"静态部分长缓存 + 库存短缓存"才解决。
还有个坑是缓存击穿:热门商品缓存过期瞬间,大量请求同时打到DB。加了个简单的互斥锁:
def get_with_lock(key: str, ttl: int, loader):
val = rds.get(key)
if val:
return json.loads(val)
# setnx做轻量锁,避免击穿
lock_key = f"lock:{key}"
if rds.set(lock_key, "1", nx=True, ex=3):
try:
data = loader()
rds.setex(key, ttl, json.dumps(data, ensure_ascii=False))
return data
finally:
rds.delete(lock_key)
else:
# 没抢到锁,短暂等待后重试一次
time.sleep(0.05)
val = rds.get(key)
return json.loads(val) if val else loader()
五、压测与效果数据
用 locust 压测,同样的场景:100并发用户,持续3分钟,请求同一个热门商品ID。
压测脚本:
from locust import HttpUser, task, between
class ProductUser(HttpUser):
wait_time = between(0.1, 0.3)
@task
def detail(self):
self.client.get("/api/v1/products/10086/detail")
对比数据:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| SQL条数/请求 | 47 | 4(命中缓存时0) | -91% |
| P50 | 480ms | 32ms | 15x |
| P99 | 1200ms | 85ms | 14x |
| QPS | 120 | 1420 | 11.8x |
| CPU单核 | 100% | 35% | - |
| DB连接池 | 常打满 | 峰值8/20 | - |
分阶段看的话:
- 只做批量查询(47→4条SQL):P99 1200ms → 420ms
- 加索引:P99 420ms → 260ms
- 加缓存:P99 260ms → 85ms
可以看到,批量查询贡献最大,缓存是锦上添花但边际收益也很明显。索引那步如果一开始SQL就写对了,其实可以省掉。
六、总结与几个提醒
- 别急着加缓存。缓存会掩盖问题,也会引入一致性问题。先把SQL写对,再加缓存。
- py-spy 是神器。不改代码、低开销,生产环境直接采样,火焰图一看就知道热点在哪。
- N+1 是性能杀手。SQLAlchemy 的
selectinload/joinedload要熟练,实在不行就手写join。 - 索引要配合查询。
ORDER BY + LIMIT场景,联合索引的字段顺序很关键,(product_id, created_at DESC)和(created_at, product_id)效果天差地别。 - 缓存粒度要拆。静态数据长TTL,动态数据短TTL,别一刀切。库存这种强一致的,宁可短TTL也别缓存太久。
- 压测要用真实数据分布。我一开始用随机商品ID压测,缓存命中率虚高;换成热门商品ID(真实场景就是头部集中)才反映真实情况。
最后,如果你也在用Flask,思路完全一样:flask-profiler 或 py-spy 采样,SQLAlchemy的N+1问题、索引、缓存三件套,代码层面把 selectinload 换成 joinedload 或手写查询即可,压测数字会给你答案。
优化不是玄学,是定位→验证→迭代的工程活。希望这篇记录能帮你少走点弯路。