一、问题背景
事情是这样的。我们有个商品详情接口 GET /api/v1/products/{id},跑在 FastAPI 上,2023 年下半年业务量涨起来之后,监控开始不对劲:
- P99 延迟从 200ms 一路涨到 1.2s
- 高峰期 CPU 使用率稳定在 92% 以上
- PostgreSQL 连接池
pool_size=20, max_overflow=10经常耗尽,日志里全是TimeoutError: QueuePool limit of size 20 overflow 10 reached - 运维同事每天在群里 @ 我
接口逻辑本身不复杂:查商品主表、查 SKU 列表、查商品图片、查类目信息、查促销标签,五张表拼一个响应体。代码是典型的 ORM 写法,一个 db.query(Product).get(id) 之后跟着一堆懒加载。上线初期数据量小,没人管;数据量上来之后,问题就藏不住了。
这篇文章记录我从定位到优化的完整过程,包括中间踩的坑。不是教科书,是流水账,但数据都是真的。
二、环境与版本
先把环境交代清楚,不然复现不了:
| 组件 | 版本 / 配置 |
|---|---|
| Python | 3.11.6 |
| FastAPI | 0.109.0 |
| Uvicorn | 0.25.0,workers=4,loop="uvloop" |
| SQLAlchemy | 2.0.25(同步 Session,没用 async) |
| PostgreSQL | 15.4,8C16G,shared_buffers=4GB |
| Redis | 7.2.3,单节点 |
| 压测工具 | wrk 4.2.0 |
| 部署 | 4C8G 容器 × 2 副本,K8s |
注意一个关键点:我们用的是同步 SQLAlchemy Session,跑在 FastAPI 的 def 路由里(FastAPI 会把同步路由丢到线程池执行)。这个选择在后期成了性能天花板之一,后面会讲。
三、方案设计:先定位,再动手
我给自己定的原则是:没有 profiling 数据之前,一行代码都不改。经验告诉我们,拍脑袋优化的结果通常是优化了不痛的地方。
整体分三步:
- 定位:cProfile 找函数级热点,py-spy 看线上真实调用栈(不重启进程)
- 分类:把耗时拆成「数据库查询」「序列化」「业务逻辑」「外部调用」四块
- 优化:按 ROI 排序,先干掉 N+1 和缺索引,再上缓存
3.1 用 cProfile 做函数级剖析
写一个脚本,直接调用接口对应的 service 函数,跑 100 次取统计:
# profile_api.py
import cProfile
import pstats
import io
from app.services.product_service import get_product_detail
from app.db.session import SessionLocal
def run():
db = SessionLocal()
try:
for pid in range(1, 101):
get_product_detail(db, product_id=pid)
finally:
db.close()
if __name__ == "__main__":
pr = cProfile.Profile()
pr.enable()
run()
pr.disable()
s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
ps.print_stats(30)
print(s.getvalue())
输出里有几个刺眼的数字(优化前,单次调用平均):
ncalls tottime cumtime function
501 0.012 0.980 sqlalchemy.orm.query.Query.all
... 0.310 0.310 psycopg2._psycopg.connection.commit ← 每次请求一次 commit
... 0.240 0.240 app.serializers.product.to_dict ← 序列化居然这么贵
501 次 Query.all——100 次请求调了 501 次查询,平均每个请求 5 次,但这是因为循环里还有 SKU 的逐条加载。实际单请求的 SQL 数量我用 echo=True 数过:1 次商品 + 1 次 SKU 列表 + N 次 SKU 图片 + 1 次类目 + 1 次标签,N 平均 8,总共约 11 条 SQL。典型的 N+1。
3.2 用 py-spy 看线上
cProfile 有 overhead,线上不敢开。py-spy 是采样型 profiler,attach 到进程就行:
# 找到 worker 进程
kubectl exec -it product-api-xxx -- py-spy top --pid 1
# 或者生成火焰图
py-spy record -o profile.svg --pid 1 --duration 30
火焰图上最宽的块是 psycopg2 的 execute 和 wait,占 68%,序列化占 19%,剩下是 FastAPI 框架开销。结论很清晰:瓶颈在数据库,不在框架。
四、核心实现
4.1 干掉 N+1:selectinload 预加载
原代码(简化):
# 优化前
product = db.query(Product).filter(Product.id == pid).first()
skus = db.query(Sku).filter(Sku.product_id == pid).all()
for sku in skus:
sku.images # 懒加载,每条 SKU 触发一次 SQL
sku.promotions # 又一次
改成 SQLAlchemy 2.0 的 selectinload,一条主查询 + 三条 IN 查询搞定:
# 优化后
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Product)
.options(
selectinload(Product.skus).selectinload(Sku.images),
selectinload(Product.skus).selectinload(Sku.promotions),
selectinload(Product.category),
)
.where(Product.id == pid)
)
product = db.execute(stmt).scalar_one_or_none()
SQL 数量从 11 条降到 4 条。这里有个细节:selectinload 会发 WHERE sku_id IN (...),SKU 数量超过 500 时建议换 joinedload 或者分批,我们单品 SKU 一般不超过 50,够用。
4.2 复合索引
EXPLAIN ANALYZE 看执行计划,skus 表 1200 万行,product_id 上有单列索引,但查询带了 status = 1 AND deleted_at IS NULL,回表过滤比例高:
Seq Scan on skus (cost=0.00..245123.00 rows=1 width=...) ← 优化前
建复合索引:
CREATE INDEX CONCURRENTLY idx_skus_product_status
ON skus (product_id, status)
WHERE deleted_at IS NULL;
注意用 CONCURRENTLY 避免锁表,部分索引 WHERE deleted_at IS NULL 把索引体积压了约 30%。建完之后 Index Scan,单条查询从 45ms 降到 2.1ms。
4.3 两级缓存
数据库优化完,单请求 DB 耗时从 320ms 降到 28ms,但 QPS 上来后 DB 还是压力大。上缓存:
- L1:进程内
cachetools.TTLCache,容量 10000,TTL 30s,抗热点 key - L2:Redis,TTL 300s + 随机抖动 ±30s 防雪崩
- 失效:商品更新走 MQ 消息,消费者删 Redis key,L1 靠短 TTL 自然过期
import json
import random
from cachetools import TTLCache
import redis
l1 = TTLCache(maxsize=10000, ttl=30)
r = redis.Redis(host="redis", port=6379, decode_responses=True)
def get_product_cached(db, pid: int) -> dict:
if pid in l1:
return l1[pid]
key = f"product:v2:{pid}"
raw = r.get(key)
if raw:
data = json.loads(raw)
l1[pid] = data
return data
data = load_from_db(db, pid) # 4.1 的查询
if data is None:
return None
ttl = 300 + random.randint(-30, 30) # 防雪崩
r.setex(key, ttl, json.dumps(data))
l1[pid] = data
return data
缓存命中率上线一周后稳定在 L1 62% / L2 31% / 回源 7%,DB QPS 从峰值 1.8 万降到 1.2 千。
4.4 序列化优化
火焰图上序列化占 19%,我们原来用 Pydantic v1 的 from_orm 逐字段转换,商品详情嵌套三层。升级到 Pydantic v2(model_validate + from_attributes=True)后,这块耗时降了约 55%。这个改动成本极低,顺手做了。
五、踩坑与优化
坑 1:缓存 key 没带版本,字段新增后旧 JSON 反序列化炸了。 后来 key 统一加 v2 前缀,schema 变更就升版本,老 key 自然过期。
坑 2:selectinload 嵌套太深,SQL 反而变多。 一开始对 Product.skus.images.promotions 三层全预加载,结果一条请求发了 7 条 SQL。后来只预加载两层,第三层按需合并到业务查询里。
坑 3:同步 Session 在线程池里排队。 workers=4、线程池默认 40,DB 连接池只有 30,高并发下线程全在等连接。把 pool_size 调到 50、max_overflow=20,并给 pool_timeout=5 加超时保护,避免无限等待。根治方案是迁 asyncpg + async Session,但改动面大,排到下个迭代了。
坑 4:压测参数不对导致数据失真。 一开始 wrk 只开 2 个连接,测出来 QPS 虚高。后来固定 -c 200 -t 4 -d 60s,并且压测机和被测服务分开机器,才拿到可信数据。
六、效果数据
wrk 压测,wrk -t4 -c200 -d60s http://api/api/v1/products/12345,两副本:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| QPS | 420 | 3100 | +638% |
| P50 | 380ms | 21ms | -94% |
| P99 | 1.21s | 38ms | -97% |
| 单请求 SQL 数 | 11 | 4(缓存命中时 0) | -64% |
| DB CPU | 88% | 23% | — |
| 错误率(超时) | 2.3% | 0% | — |
线上灰度一周,P99 稳定在 40ms 上下,连接池告警清零。
七、总结
回头看,这次优化没什么黑科技,就是老老实实走了「profiling → 定位 → 分类 → 按 ROI 排序」的流程。几个真实的体会:
- 先测量再动手。cProfile 和 py-spy 花了我半天,但省掉了至少一周的瞎改。
- N+1 是 ORM 项目的头号杀手,
selectinload是最低成本的解法,但要控制嵌套深度。 - 索引要看执行计划建,复合索引 + 部分索引的组合效果远好于堆单列索引。
- 缓存要设计失效和防雪崩,TTL 抖动和版本化 key 是必须的,不是可选项。
- 同步 ORM + 线程池有天花板,QPS 上 3000 之后连接池就是下一个瓶颈,async 化要提前规划。
如果你们接口也在几百毫秒徘徊,建议先跑一遍 cProfile,大概率能揪出几个 N+1。代码都在上面,拿去改改就能用。