一、问题背景
事情是这样的,我们有个商品详情接口 /api/v1/products/{id},上线初期数据量小,响应一直稳定在100ms出头。后来商品表涨到80万行,关联的SKU、规格、评价表也水涨船高,接口开始不对劲了。
监控上看到的现象:
- P50 从 90ms 涨到 320ms
- P99 直接干到 1200ms
- QPS 超过 200 之后,uvicorn 的 worker 开始排队,502 频发
最离谱的是,这个接口逻辑看起来"很简单":查商品、查SKU、查规格、查评价数。代码是我半年前写的,当时觉得没问题,现在回头看全是坑。
技术栈先交代清楚:
Python 3.11.6
FastAPI 0.109.0
uvicorn 0.27.0 (4 workers, gunicorn 管理)
SQLAlchemy 2.0.25 (同步ORM)
PostgreSQL 14.10
Redis 7.2.3
注意,我们用的是同步 SQLAlchemy,不是 async。这点后面会聊到,先按下不表。
二、定位:别猜,用 py-spy 看火焰图
遇到性能问题,最忌讳的就是"我觉得是XX慢"。我一开始也怀疑是Redis或者网络,结果打脸。
先用最土但最有效的办法,在接口里埋点计时:
import time
from fastapi import APIRouter
router = APIRouter()
@router.get("/products/{product_id}")
def get_product(product_id: int):
t0 = time.perf_counter()
product = fetch_product(product_id)
t1 = time.perf_counter()
skus = fetch_skus(product_id)
t2 = time.perf_counter()
reviews = fetch_review_count(product_id)
t3 = time.perf_counter()
print(f"product={t1-t0:.1f}ms skus={t2-t1:.1f}ms reviews={t3-t2:.1f}ms")
...
跑了几次,日志大概是这个鬼样子:
product=12.3ms skus=680.5ms reviews=410.2ms
SKU 和评价查询是大头。但具体是哪条SQL?得请出 py-spy。
pip install py-spy==0.3.14
# 找到 uvicorn worker 的 pid
ps aux | grep uvicorn
# 采样 30 秒,生成火焰图
py-spy record -o profile.svg --pid 12345 --duration 30 --rate 200
打开 profile.svg,一眼就看到了:
get_product (87.2%)
└── fetch_skus (54.1%)
└── SQLAlchemy Session.execute (52.8%)
└── psycopg2 cursor.execute (51.9%)
└── fetch_review_count (30.6%)
└── SQLAlchemy ...
87% 的时间在数据库,而且 fetch_skus 里不止一条SQL。翻了代码才发现,我写了个经典的N+1:
def fetch_skus(product_id):
skus = db.query(SKU).filter(SKU.product_id == product_id).all()
result = []
for sku in skus: # 每个SKU都查一次规格
specs = db.query(Spec).filter(Spec.sku_id == sku.id).all()
result.append({"sku": sku, "specs": specs})
return result
一个商品平均 12 个SKU,加上其他查询,单个请求打了 43 条 SQL。这就是 P99 1200ms 的元凶。
三、方案设计
定位清楚后,优化思路分三层:
- SQL层:干掉 N+1,用
selectinload预加载,减少往返 - 查询层:加合适的索引,避免全表扫描
- 缓存层:热点商品详情进 Redis,设合理 TTL 和空值缓存
原则:先优化SQL,再上缓存。因为缓存只能掩盖问题,SQL不优化,缓存击穿时数据库直接躺平。
四、核心实现
4.1 SQLAlchemy 预加载,消灭 N+1
SQLAlchemy 2.0 的 selectinload 会用一条 IN 查询把关联数据一次性捞回来,比 joinedload 更适合一对多(避免笛卡尔积膨胀)。
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func
def fetch_product_detail(db, product_id: int):
stmt = (
select(Product)
.options(selectinload(Product.skus).selectinload(SKU.specs))
.where(Product.id == product_id)
)
product = db.execute(stmt).scalar_one_or_none()
if product is None:
return None
# 评价数单独聚合,避免把全部评价拉回来
review_count = db.execute(
select(func.count(Review.id)).where(Review.product_id == product_id)
).scalar_one()
return build_response(product, review_count)
改造后,SQL 数量从 43 条降到 4 条:1条商品、1条SKU、1条规格、1条评价数。
4.2 索引补齐
EXPLAIN 一看,specs.sku_id 和 reviews.product_id 都没有索引,走的 Seq Scan。补上:
CREATE INDEX CONCURRENTLY idx_skus_product_id ON skus(product_id);
CREATE INDEX CONCURRENTLY idx_specs_sku_id ON specs(sku_id);
CREATE INDEX CONCURRENTLY idx_reviews_product_id ON reviews(product_id);
用 CONCURRENTLY 是因为线上表大,普通 CREATE INDEX 会锁表,业务得停。
4.3 Redis 缓存
热点商品占比很高(约 20% 的商品贡献 80% 的访问),所以缓存收益明显。
import json
import redis
from functools import wraps
rds = redis.Redis(
host="redis.internal",
port=6379,
db=0,
socket_timeout=0.05, # 50ms 超时,快速失败
socket_connect_timeout=0.05,
max_connections=50,
)
CACHE_TTL = 300 # 正常缓存 5 分钟
NULL_TTL = 60 # 空值缓存 1 分钟,防穿透
def cache_product(func):
@wraps(func)
def wrapper(product_id: int):
key = f"product:detail:{product_id}"
try:
cached = rds.get(key)
if cached is not None:
if cached == b"__NULL__":
return None
return json.loads(cached)
except redis.RedisError:
pass # Redis挂了就走DB,别让它拖垮接口
data = func(product_id)
try:
if data is None:
rds.setex(key, NULL_TTL, "__NULL__")
else:
rds.setex(key, CACHE_TTL, json.dumps(data, ensure_ascii=False))
except redis.RedisError:
pass
return data
return wrapper
关键点:Redis 超时设 50ms,且所有异常都吞掉。缓存是用来加速的,不能反过来成为故障源。
4.4 缓存击穿防护
热点key过期瞬间,大量请求会同时打到DB。加个简单的互斥锁:
import time
def get_with_lock(product_id: int):
key = f"product:detail:{product_id}"
lock_key = f"lock:product:{product_id}"
cached = rds.get(key)
if cached is not None:
return json.loads(cached) if cached != b"__NULL__" else None
# 抢锁,最多等 100ms
acquired = rds.set(lock_key, "1", nx=True, ex=3)
if not acquired:
time.sleep(0.05)
cached = rds.get(key)
if cached is not None:
return json.loads(cached) if cached != b"__NULL__" else None
# 兜底:拿不到缓存直接查库
try:
data = fetch_product_detail(db, product_id)
rds.setex(key, CACHE_TTL if data else NULL_TTL,
json.dumps(data) if data else "__NULL__")
return data
finally:
if acquired:
rds.delete(lock_key)
五、踩坑与优化
坑1:selectinload 在数据量大时 IN 列表过长。一个商品SKU不多,但如果反过来查"某分类下所有商品及其SKU",IN 列表可能上千。这种情况要用 selectinload 配合分批,或者 joinedload。我们的场景SKU数少,没这问题。
坑2:Redis 序列化用 json.dumps 会丢精度。商品价格是 Decimal,json.dumps 默认报错。得加 default=str,或者用 orjson。我换了 orjson,序列化速度还快了一倍:
import orjson
rds.setex(key, CACHE_TTL, orjson.dumps(data))
# 读取时
return orjson.loads(cached)
坑3:uvicorn worker 数不是越多越好。我们4核机器,一开始配了8个worker,结果上下文切换太频繁。改成 workers = 2 * cores + 1 = 9?不对,那是CPU密集型的公式。FastAPI 是IO密集型,实际压测下来 4 workers 最稳,8个反而因为GIL和内存竞争变慢。
坑4:同步SQLAlchemy阻塞事件循环。这是我们没动的地方——因为用的是同步ORM,FastAPI 会自动把 def 接口丢到线程池执行,所以没阻塞 loop。但如果接口写成 async def 又调用同步DB,那就会卡死整个事件循环。要么全同步用 def,要么全异步用 async def + asyncpg,别混着来。
六、压测数据
用 locust 压测,机器配置 4C8G,PostgreSQL 单独一台 8C16G。
压测脚本:
from locust import HttpUser, task, between
import random
class ProductUser(HttpUser):
wait_time = between(0.01, 0.05)
@task
def get_product(self):
pid = random.randint(1, 800000)
self.client.get(f"/api/v1/products/{pid}", name="/products/{id}")
优化前后对比(持续压 5 分钟):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P50 | 320ms | 42ms | 7.6x |
| P95 | 890ms | 71ms | 12.5x |
| P99 | 1200ms | 86ms | 14x |
| QPS (稳定) | 180 | 1450 | 8x |
| 平均SQL数/请求 | 43 | 4(命中缓存时0) | - |
| 缓存命中率 | - | 82.3% | - |
| 错误率 | 4.2% | 0% | - |
CPU 使用率从压测时的 95% 降到 38%,PostgreSQL 的活跃连接数从 180 降到 25。
关键收益其实不是缓存,是 N+1 的修复。只做SQL优化(不加缓存)时,P99 就已经降到 210ms 了,缓存是把最后这段又砍了一刀。
七、总结
几点经验,给遇到类似问题的同学:
- 先 profiling 再动手。py-spy 五分钟能省你五小时瞎猜。
- N+1 是API性能的头号杀手。ORM 用着爽,但一定要 review 有没有循环里查库。
- 索引要跟着查询走,别凭感觉加。EXPLAIN 是你的朋友。
- 缓存是加速器不是救火队。SQL 不优化就上缓存,缓存一挂直接雪崩。
- Redis 客户端超时要设短,异常要吞掉,别让缓存故障拖垮主流程。
- 别盲目加 worker。IO密集型应用,worker 数超过核数太多反而更慢,实测为准。
这次优化后,接口稳定跑了两个月,P99 一直在 100ms 以内。下次准备把同步 SQLAlchemy 换成 asyncpg + SQLAlchemy 2.0 async,预计还能再压 20% 左右。有踩过 async 坑的朋友欢迎评论区交流。