一、问题背景
事情是这样的。我们有个商品详情接口 GET /api/v1/products/{id},返回商品基础信息、SKU 列表、库存、促销标签。业务上这个接口被 App 首页、详情页、购物车三处调用,日均请求 300 万左右,高峰期 QPS 大概 400。
上线两周后监控告警开始响:P99 延迟 1.8s,P95 也有 900ms,数据库实例 CPU 常年 70%~85%,慢查询日志里同一个 SQL 一天刷几万条。更离谱的是,这个接口逻辑看起来很简单——查一次商品、查一次 SKU、查一次库存,按理说不该这么慢。
我先做了个粗筛:用 curl 单请求打了几次,冷启动后单次耗时 40ms 左右,看起来还行。但只要并发上来就崩。这说明问题大概率不在单次逻辑,而在并发下的资源竞争或查询放大。于是进入正式的性能定位流程。
二、环境与版本
先把环境交代清楚,不然数据没法复现:
- Python 3.11.6
- FastAPI 0.109.2 + Uvicorn 0.27.1(
workers=4,--loop uvloop) - SQLAlchemy 2.0.25(同步 Session,配合
run_in_threadpool) - PostgreSQL 15.4,连接池
pool_size=10, max_overflow=20 - Redis 7.2.4,
redis-py 5.0.1 - 压测机:Locust 2.20.1,4C8G,与被测服务同内网
- 服务机:8C16G,容器部署,限 4 核
三、方案设计:先定位,再动手
我的原则是没有 profiling 数据就不改代码,否则很容易优化了个寂寞。整体分三步:
- 火焰图定位:用
py-spy对运行中的进程采样,看 CPU 时间花在哪。 - SQL 审计:打开 SQLAlchemy 的 echo,配合
pg_stat_statements看查询次数和耗时分布。 - 压测复现:用 Locust 在固定并发下复现,作为优化前后的基线。
py-spy 的使用很直接,不用改代码、不用重启:
# 找到 uvicorn worker 的 pid
ps -ef | grep uvicorn
# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200
火焰图一出来问题就很明显了:sqlalchemy/orm/loading.py 和 psycopg2 的调用栈占了 60% 以上的采样,serialization 只占不到 8%。也就是说,时间几乎全耗在数据库 IO 上,而不是 Python 逻辑或 JSON 序列化。
再看 SQL 日志,真相大白:一个请求打了 1 + 1 + N 次查询。商品查 1 次,SKU 查 1 次,然后每个 SKU 单独查一次库存——一个商品平均 12 个 SKU,就是 14 次查询。这就是典型的 N+1 问题。并发 400 时,数据库每秒要扛 5600 条查询,连接池直接打满,请求排队等连接,P99 自然飙到秒级。
四、核心实现
4.1 第一步:消灭 N+1,合并为一次 JOIN
原来的代码大概长这样(简化版):
# 优化前:N+1 查询
@app.get("/api/v1/products/{product_id}")
def get_product(product_id: int, db: Session = Depends(get_db)):
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(404, "product not found")
skus = db.query(SKU).filter(SKU.product_id == product_id).all()
result = []
for sku in skus:
# 每个 SKU 单独查库存,N+1 的根源
stock = db.query(Stock).filter(Stock.sku_id == sku.id).first()
result.append({
"sku_id": sku.id,
"price": str(sku.price),
"stock": stock.available if stock else 0,
})
return {"id": product.id, "name": product.name, "skus": result}
改成一次 JOIN 拿到全部数据,用 selectinload 或显式 join 都行。我这里用显式 join,因为要对库存做聚合,ORM 的关系加载反而不方便:
# 优化后:一次 JOIN 查询
from sqlalchemy import select, join
from sqlalchemy.orm import Session
@app.get("/api/v1/products/{product_id}")
def get_product(product_id: int, db: Session = Depends(get_db)):
stmt = (
select(
Product.id, Product.name,
SKU.id.label("sku_id"), SKU.price,
Stock.available,
)
.join(SKU, SKU.product_id == Product.id)
.outerjoin(Stock, Stock.sku_id == SKU.id)
.where(Product.id == product_id)
)
rows = db.execute(stmt).all()
if not rows:
raise HTTPException(404, "product not found")
return {
"id": rows[0].id,
"name": rows[0].name,
"skus": [
{"sku_id": r.sku_id, "price": str(r.price), "stock": r.available or 0}
for r in rows
],
}
查询次数从 14 次降到 1 次。压测 QPS 从 420 涨到 1100,P99 从 1800ms 降到 520ms。有效,但还不够——数据库 CPU 仍在 60% 左右,因为热门商品被反复查询,完全没必要每次都打 DB。
4.2 第二步:Redis 缓存 + 防击穿
商品详情是典型的读多写少,非常适合缓存。但直接 get 不到就查库、查完 set,在热点 key 过期瞬间会有大量请求同时穿透到 DB(缓存击穿)。我的方案是:
- key 设计:
product:detail:{id},TTL 300s 加 ±30s 随机抖动,避免同一批 key 同时过期。 - 防击穿:用 Redis
SET NX做分布式锁,只放一个请求去回源,其余请求短暂等待后重试读缓存。 - 空值缓存:查不到的商品缓存一个短 TTL 的空标记,防穿透。
import json
import random
import time
import redis
from fastapi import HTTPException
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
CACHE_TTL = 300
LOCK_TTL = 5
def get_product_cached(product_id: int, db) -> dict:
key = f"product:detail:{product_id}"
cached = r.get(key)
if cached is not None:
data = json.loads(cached)
if data == "__NULL__":
raise HTTPException(404, "product not found")
return data
lock_key = f"lock:{key}"
# SET NX 抢锁,只有抢到的请求回源
if r.set(lock_key, "1", nx=True, ex=LOCK_TTL):
try:
# double check,防止抢锁期间别人已经写好了缓存
cached = r.get(key)
if cached is not None:
return json.loads(cached)
data = query_product_from_db(product_id, db) # 上面 4.1 的查询逻辑
ttl = CACHE_TTL + random.randint(-30, 30)
if data is None:
r.set(key, json.dumps("__NULL__"), ex=60)
raise HTTPException(404, "product not found")
r.set(key, json.dumps(data), ex=ttl)
return data
finally:
r.delete(lock_key)
else:
# 没抢到锁,短暂自旋等待缓存写入
for _ in range(20):
time.sleep(0.05)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# 兜底:等不到就直接查库,避免无限等待
return query_product_from_db(product_id, db)
这里有个细节值得说:finally 里删锁是必要的,但如果回源过程中进程崩了,锁会靠 LOCK_TTL=5s 自动释放,不会死锁。另外自旋等待最多 1 秒(20 × 50ms),超时兜底直接查库,保证可用性优先于一致性。
4.3 压测脚本
用 Locust 固定 400 并发复现,脚本很简单:
# locustfile.py
import random
from locust import HttpUser, task, between
class ProductUser(HttpUser):
wait_time = between(0.01, 0.05)
@task
def detail(self):
pid = random.randint(1, 5000) # 模拟热点分散
self.client.get(f"/api/v1/products/{pid}", name="/products/{id}")
跑法:locust -f locustfile.py --headless -u 400 -r 50 -t 5m --host http://127.0.0.1:8000。
五、踩坑与优化
坑 1:缓存序列化开销被低估。 一开始我用 json.dumps 存整个字典,压测发现 Redis 往返只要 1ms,但 json.loads 在大对象上花了 3~4ms。后来把返回体精简(去掉冗余字段)、并对价格等字段提前转成字符串,序列化时间降到 1ms 以内。如果对象更大,可以考虑 orjson,实测比标准库快 3 倍左右。
坑 2:连接池参数和 worker 数不匹配。 我们 4 个 worker,每个 pool_size=10, max_overflow=20,理论上最多 120 个连接,但 PG 的 max_connections 只给了 100。高并发下出现 too many clients。调成 pool_size=8, max_overflow=12(每 worker 最多 20,总共 80)后稳定。这个参数一定要按 workers × (pool_size + max_overflow) < max_connections 来算。
坑 3:同步 ORM 阻塞事件循环。 FastAPI 里如果路由是 async def 却用了同步 SQLAlchemy,会阻塞事件循环。我的路由都是 def(同步),FastAPI 会自动丢到线程池,这个没问题;但如果哪天改成 async def,记得用 run_in_threadpool 包一层,或者干脆换 asyncpg + SQLAlchemy 2.0 的 async API。
坑 4:缓存一致性。 商品改价后缓存还是旧值。我们用了「更新时删缓存 + 延迟双删」的简单策略:写库后删一次,500ms 后再删一次,覆盖读请求回填脏数据的窗口。对一致性要求更高的场景,可以上 binlog 订阅(Canal/Debezium)。
六、效果数据
同一台机器、同一份 Locust 脚本、400 并发、持续 5 分钟,三轮取中位数:
| 指标 | 优化前 | JOIN 后 | + Redis 缓存 |
|---|---|---|---|
| QPS | 420 | 1100 | 3100 |
| P50 | 380ms | 90ms | 28ms |
| P95 | 900ms | 310ms | 85ms |
| P99 | 1800ms | 520ms | 120ms |
| DB QPS | 5600 | 1100 | 210 |
| DB CPU | 78% | 61% | 18% |
| 平均查询次数/请求 | 14 | 1 | 0.07(命中率 93%) |
缓存命中率稳定在 93% 左右,剩下 7% 是冷 key 和随机抖动导致的过期回源。DB QPS 从 5600 降到 210,降了两个数量级,这是 P99 改善的最大贡献项。
七、总结
回头看,这次优化没有用什么高深技术,核心就三件事:用 py-spy 确认瓶颈在 DB 而不是 Python、把 N+1 合并成一次 JOIN、给热点读加带防击穿的缓存。但每一步都有细节:
- profiling 一定要在并发压测下做,单请求测不出 N+1 的放大效应;
- 连接池参数要和 worker 数、DB 上限一起算,别拍脑袋;
- 缓存不是
get/set就完事,击穿、穿透、一致性三个问题至少要想清楚两个; - 每次改动都跑一遍压测拿数据,别凭感觉说"应该快了"。
如果你的接口也有类似症状——单请求快、并发就崩、DB CPU 高——建议先别急着加机器,花半小时画个火焰图,答案大概率就在里面。
最后留个开放问题:我们目前用「删缓存 + 延迟双删」保证一致性,跨服务场景下其实不够优雅。有同学用 Debezium 订阅 binlog 做缓存失效的吗?欢迎评论区聊聊你们的方案。