一、问题背景
事情是这样的。我们有个商品详情聚合接口 GET /api/v1/products/{id},返回商品基础信息、SKU列表、库存状态和店铺信息。上线初期数据量小,没人关注性能。直到运营做了一次大促预热,监控告警开始疯狂响:P99 延迟 1.2s,5xx 错误率飙到 8%,网关层大量 504。
我接手时的第一反应是"先别猜,测了再说"。很多同学优化性能喜欢凭直觉改代码,比如上来就加缓存、加连接池,结果瓶颈根本不在那儿,白忙活。所以第一步永远是 profiling。
这个接口的技术栈:
- Python 3.11.6
- FastAPI 0.109.2 + Uvicorn 0.27.1(4 workers)
- SQLAlchemy 2.0.25(async + asyncpg 0.29.0)
- PostgreSQL 15.4
- Redis 7.2.4
- 压测工具:wrk 4.2.0 + locust 2.20.1
机器配置:8C16G,接口部署在 K8s 上,单 Pod 4 workers。
二、定位瓶颈:py-spy + cProfile 双管齐下
线上环境不能随便加 cProfile,开销太大。我的做法是:线上用 py-spy 采样,本地用 cProfile 精确定位。
2.1 py-spy 线上采样
py-spy 的好处是 attach 到运行中的进程,几乎零开销:
# 找到 worker 进程
kubectl exec -it product-api-xxx -- ps aux | grep uvicorn
# 采样 30 秒,生成火焰图
py-spy record -o /tmp/profile.svg --pid 12345 --duration 30 --rate 200
火焰图一看就明白了:60% 以上的时间卡在 asyncpg 的 fetch 上,而且调用栈里出现了大量重复的 SELECT ... FROM sku WHERE product_id = $1。这是典型的 N+1 查询——先查商品,再循环查每个 SKU 的库存,每个 SKU 又单独查一次店铺。
2.2 cProfile 本地复现
本地灌了 50 万商品、平均每个商品 8 个 SKU 的数据,用 cProfile 跑单请求:
import cProfile
import pstats
from app.main import app
from fastapi.testclient import TestClient
client = TestClient(app)
def run():
for _ in range(20):
client.get("/api/v1/products/12345")
profiler = cProfile.Profile()
profiler.enable()
run()
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(30)
输出里排名靠前的:
ncalls tottime cumtime function
180 0.002 1.842 asyncpg/connection.py: fetch
20 0.001 1.201 sqlalchemy/orm/loading.py: load_on_pk_identity
160 0.008 0.923 app/services/inventory.py: get_stock
单请求 20 次循环,平均每次 92ms,其中数据库 IO 占了 85ms 以上。结论很明确:瓶颈在数据库访问模式,不在 Python 代码本身。
顺带发现一个小问题:Pydantic 序列化也占了约 30ms,因为 SKU 列表里每个对象都做了重复的 model_dump(),这个后面一起优化。
三、方案设计
定位清楚后,方案就顺理成章了,分三层:
- 查询层:消灭 N+1,把循环查询改成一次
selectinload预加载 + 一条聚合 SQL 拿库存;给高频查询字段建复合索引。 - 缓存层:商品详情读多写少,加 Redis 缓存,采用 Cache-Aside 模式,TTL 5 分钟 + 主动失效。热点商品加本地 LRU 做二级缓存,扛住瞬时流量。
- 序列化层:Pydantic v2 的
model_validate复用已加载对象,避免重复 dump。
缓存这里有个关键决策:要不要缓存库存? 库存变化频繁,缓存 5 分钟可能导致超卖展示。我的处理是:商品基础信息+SKU 列表缓存 300s,库存单独用一个 10s 短 TTL 的 key,且库存查询走单独的轻量 SQL。这样既保住了性能,又把不一致窗口控制到可接受范围。
四、核心实现
4.1 消灭 N+1:selectinload + 聚合查询
优化前的代码(反面教材):
# ❌ 优化前:典型 N+1
async def get_product_detail(session: AsyncSession, product_id: int):
product = await session.get(Product, product_id)
skus = await session.execute(
select(SKU).where(SKU.product_id == product_id)
)
sku_list = skus.scalars().all()
result = []
for sku in sku_list: # 循环里再查库存
stock = await session.execute(
select(Stock).where(Stock.sku_id == sku.id)
)
result.append({"sku": sku, "stock": stock.scalar_one_or_none()})
return product, result
优化后:
# ✅ 优化后:一次预加载 + 一次聚合
from sqlalchemy.orm import selectinload
from sqlalchemy import select, func
async def get_product_detail(session: AsyncSession, product_id: int):
stmt = (
select(Product)
.options(selectinload(Product.skus).selectinload(SKU.shop))
.where(Product.id == product_id)
)
product = (await session.execute(stmt)).scalar_one_or_none()
if product is None:
return None
sku_ids = [sku.id for sku in product.skus]
# 一条 SQL 拿全部库存,避免循环
stock_rows = await session.execute(
select(Stock.sku_id, Stock.quantity, Stock.updated_at)
.where(Stock.sku_id.in_(sku_ids))
)
stock_map = {row.sku_id: row for row in stock_rows}
return product, stock_map
SQL 从 1 + 1 + N 条(平均 10 条)变成固定 3 条,这是延迟下降的最大贡献项。
4.2 索引优化
用 EXPLAIN ANALYZE 看执行计划,发现 sku.product_id 只有单列索引,但查询实际是 WHERE product_id = ? ORDER BY sort_order,导致额外排序。改成复合索引:
-- 优化前:单列索引,Extra: Using filesort
CREATE INDEX idx_sku_product ON sku(product_id);
-- 优化后:复合索引,覆盖排序
CREATE INDEX CONCURRENTLY idx_sku_product_sort
ON sku(product_id, sort_order);
-- 库存表按 sku_id 查询,加覆盖索引避免回表
CREATE INDEX CONCURRENTLY idx_stock_sku_cover
ON stock(sku_id) INCLUDE (quantity, updated_at);
注意线上建索引一定用 CONCURRENTLY,否则会锁表。这个坑我踩过,别问。
4.3 Redis 缓存 + 本地 LRU 二级缓存
import json
import redis.asyncio as aioredis
from cachetools import TTLCache
redis = aioredis.from_url("redis://:pwd@redis:6379/0", decode_responses=True)
local_cache = TTLCache(maxsize=2000, ttl=30) # 本地二级缓存,30s
async def get_product_cached(session, product_id: int):
cache_key = f"product:detail:{product_id}"
# L1 本地缓存
if (hit := local_cache.get(cache_key)) is not None:
return hit
# L2 Redis
if (raw := await redis.get(cache_key)) is not None:
data = json.loads(raw)
local_cache[cache_key] = data
return data
# 回源
product, stock_map = await get_product_detail(session, product_id)
if product is None:
return None
data = serialize_product(product, stock_map)
await redis.setex(cache_key, 300, json.dumps(data, ensure_ascii=False))
local_cache[cache_key] = data
return data
更新时主动删缓存,删完再写库(先删后写,降低脏读窗口):
async def update_product(session, product_id: int, payload: dict):
await session.execute(update(Product).where(Product.id == product_id).values(**payload))
await session.commit()
await redis.delete(f"product:detail:{product_id}")
local_cache.pop(f"product:detail:{product_id}", None)
4.4 压测脚本
用 wrk 快速压,配合 Lua 脚本随机 product_id,模拟真实分布:
-- bench.lua
math.randomseed(os.time())
request = function()
local id = math.random(1, 500000)
return wrk.format("GET", "/api/v1/products/" .. id)
end
wrk -t8 -c400 -d60s --latency -s bench.lua http://api.example.com
压测前先灌数据,用 pgbench 或直接 INSERT ... SELECT generate_series,保证 50 万商品、8 SKU 分布,否则压出来的数字没有参考价值。
五、踩坑与优化细节
坑 1:缓存雪崩。 第一版所有 key 统一 300s TTL,压测时同时过期,数据库瞬间被打满。改成 TTL = 300 + random.randint(0, 60),打散过期时间。
坑 2:大 key 序列化开销。 商品详情 JSON 平均 8KB,json.dumps 在 1100 QPS 下 CPU 占用明显。换 orjson 后序列化耗时从 0.4ms 降到 0.08ms。
坑 3:连接池不够。 Uvicorn 4 workers,每个 worker 默认 SQLAlchemy 连接池 5+10,压到 400 并发时出现 TimeoutError: connection pool exhausted。调整 pool_size=10, max_overflow=20, pool_timeout=5,同时 PostgreSQL max_connections 从 100 调到 300。
坑 4:Pydantic 重复序列化。 原来 ORM 对象先 model_dump() 再 jsonable_encoder(),做了两遍。改成 ProductResponse.model_validate(product, from_attributes=True) 一次搞定,省了约 25ms。
坑 5:本地缓存一致性问题。 多 Pod 部署时,本地 LRU 有 30s 延迟。对库存这种强一致字段不缓存,只缓存商品静态信息,业务上可接受。
六、效果数据
压测环境:单 Pod 4 workers,400 并发,60s,数据量 50 万商品。
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P50 延迟 | 420ms | 28ms | 15x |
| P99 延迟 | 1210ms | 80ms | 15x |
| 单机 QPS | 180 | 1100 | 6.1x |
| 5xx 错误率 | 8.2% | 0.01% | - |
| 数据库 QPS | 4200 | 380 | -91% |
| CPU 使用率 | 92% | 41% | - |
缓存命中率稳定在 96.3%,数据库压力下降 91%,这也解释了为什么 QPS 能翻 6 倍——瓶颈从 DB 转移到了网络和序列化层。
要注意的是,1100 QPS 是缓存全热的情况。冷启动或缓存击穿时,回源路径靠索引优化也能撑住约 350 QPS,比优化前的 180 好不少。
七、总结
这次调优最大的体会是:别猜瓶颈,先 profiling。我一开始也怀疑是 FastAPI 框架慢,结果火焰图直接打脸——框架开销不到 5%,全是 N+1 查询。
三条可复用的经验:
- N+1 是 API 性能的头号杀手,SQLAlchemy 里优先用
selectinload,循环里出现await session.execute就要警惕。 - 索引要看执行计划,
EXPLAIN ANALYZE比拍脑袋建索引靠谱,复合索引的顺序和覆盖字段很关键。 - 缓存要有层次和降级,L1 本地 + L2 Redis,TTL 打散,热点字段和强一致字段分开处理。
如果你们接口也有类似问题,建议按"采样定位 → 查询优化 → 缓存兜底 → 压测验证"的顺序来,别一上来就堆机器。机器能解决的问题,代码往往也能解决,而且便宜得多。