一、问题背景:慢接口引发的“雪崩”预警
这套库存服务原本部署在Flask+Gunicorn上,最近为了异步性能迁移到FastAPI+Uvicorn,但核心查询接口GET /inventory/batch的响应时间不降反升。监控面板上,P99从迁移前的620ms恶化到900ms,而MySQL的CPU使用率已飙至85%。最要命的是,上游订单服务对该接口设置了800ms超时,导致大量请求被强制熔断,形成连锁故障。
这个接口的逻辑并不复杂:接收一批商品SKU列表(平均50个),批量查询库存表,再回源商品表获取名称与类目信息。单看SQL似乎没有明显问题,但实际压测时发现,当并发数超过200,响应时间呈指数级上升。
二、环境与版本:确认基线配置
- 应用框架:FastAPI 0.104.1(生产) / Flask 2.3.3(旧版对比)
- ASGI服务器:Uvicorn 0.24.0,workers=4,loop=auto
- 数据库:MySQL 8.0.33,连接池使用SQLAlchemy 2.0.21(pool_size=10, max_overflow=20)
- 缓存:Redis 7.0.12,Python客户端使用redis-py 5.0.1
- 压测工具:Apache Bench(ab)与wrk 4.2.0
- 机器配置:4C8G云主机(生产),本机开发环境为Apple M1 Pro
压测初始基线(wrk -t8 -c200 -d30s):
- FastAPI版本:平均延迟420ms,P99 900ms,QPS 380
- Flask版本:平均延迟380ms,P99 620ms,QPS 510
(Flask反而更快?这就是问题所在——FastAPI的异步并没有被正确利用,因为整个链路是同步阻塞的。)
三、Profiling定位:先别瞎猜,用数据说话
3.1 用cProfile抓取CPU热点
针对FastAPI版本,我写了一个独立的Profiling脚本,避免污染生产代码:
# profiling/prof_inventory.py
import cProfile
import pstats
import io
import asyncio
from fastapi.testclient import TestClient
from main import app # 假设你的FastAPI应用
def run_profile():
client = TestClient(app)
payload = {
"skus": [f"SKU{i:05d}" for i in range(1, 51)] # 模拟50个SKU
}
# 先预热一次
client.post("/inventory/batch", json=payload)
# 正式Profile
profiler = cProfile.Profile()
profiler.enable()
for _ in range(100): # 循环100次以获取稳定数据
client.post("/inventory/batch", json=payload)
profiler.disable()
result = io.StringIO()
stats = pstats.Stats(profiler, stream=result)
stats.sort_stats("cumulative", "tottime")
stats.print_stats(20)
print(result.getvalue())
if __name__ == "__main__":
run_profile()
运行结果中,排名前5的耗时代码如下:
ncalls tottime cumtime filename:lineno
100 1.283 1.283 {built-in method builtins.sum} ← 意外
100 0.952 4.821 /sqlalchemy/orm/loading.py:198 in load_objects
100 0.741 3.845 /sqlalchemy/orm/strategies.py:100 in _emit_lazyload
100 0.602 2.910 /app/services/inventory.py:38 in fetch_inventory
关键发现:sqlalchemy/orm/strategies.py 中的Lazyload占了累计3.8秒——这就是典型的N+1查询。在FastAPI异步环境中,同步的SQLAlchemy会话阻塞了事件循环,导致并发性能雪崩。
3.2 用py-spy验证生产环境
cProfile只能测单线程,无法看到生产环境的真实阻塞。我用py-spy抓取生产进程的栈信息:
# 安装与抓取
pip install py-spy
py-spy dump --pid --duration 10 > stack_dump.txt
栈信息显示,所有worker都阻塞在MySQLdb.query调用上,且没有任何异步切换。确认了问题本质:同步DB调用 + ORM懒加载 = 灾难。
四、数据库查询优化:从N+1到批量索引
4.1 原罪代码(Flask版遗留):
# services/inventory.py (优化前)
def fetch_inventory(sku_list):
result = {}
for sku in sku_list: # 循环50次
inv = db.session.query(InventoryStock).filter_by(sku=sku).first()
if inv:
product = db.session.query(Product).filter_by(id=inv.product_id).first() # 额外查询
result[sku] = {...}
return result
这就是两个典型的N+1问题:每个SKU单独查询库存表,每个库存记录再查询商品表。50个SKU = 100次DB往返。
4.2 重构为批量查询 + JOIN
# services/inventory.py (优化后 - FastAPI版)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, join
async def fetch_inventory_batch(sku_list: list[str], db: AsyncSession):
# 单次IN查询 + JOIN商品表,消除N+1
stmt = (
select(
InventoryStock.sku,
InventoryStock.quantity,
Product.name,
Product.category
)
.join(Product, Product.id == InventoryStock.product_id)
.where(InventoryStock.sku.in_(sku_list))
)
result = await db.execute(stmt)
rows = result.fetchall()
return [dict(row._mapping) for row in rows]
配合SQLAlchemy 2.0的in_查询,一次IO完成。但这里有个坑:MySQL 8.0对IN列表超过1000项时会拒绝执行,而且如果sku_list有重复值,结果集会多行。所以我在Service层做了去重和分批:
# 分批查询,每批500个SKU
BATCH_SIZE = 500
async def fetch_inventory_batch_safe(sku_list, db):
sku_list = list(set(sku_list)) # 去重
all_rows = []
for i in range(0, len(sku_list), BATCH_SIZE):
chunk = sku_list[i:i+BATCH_SIZE]
all_rows.extend(await fetch_inventory_batch(chunk, db))
return all_rows
4.3 索引优化:EXPLAIN教你做人
用EXPLAIN ANALYZE查看执行计划,发现inventory_stock表上虽然有idx_sku索引,但查询时MySQL选择了全表扫描——因为sku列是VARCHAR(40)且使用utf8mb4字符集,而传入参数是latin1编码,导致类型转换无法走索引。
-- 优化前执行计划关键行
EXPLAIN ANALYZE SELECT * FROM inventory_stock WHERE sku IN ('SKU00001',...);
-- -> Filter: (inventory_stock.sku IN (...)) (cost=12345 rows=50000)
-- -> Table scan on inventory_stock (cost=12345 rows=500000) ← 全表扫
-- 优化:强制字符集一致
ALTER TABLE inventory_stock MODIFY sku VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
CREATE INDEX idx_inv_sku ON inventory_stock(sku(32)); -- 前缀索引,避免长度超限
改完索引后,EXPLAIN显示Index lookup on idx_inv_sku,扫描行数从50万降到50行。DB CPU从85%降至21%。
五、缓存策略:三级缓存架构
即使DB查询优化到单次20ms,高峰期每秒上千次同样查询仍是浪费。我设计了三级缓存:
- 本地缓存(进程内):使用
functools.lru_cache,TTL=1秒,用于防击穿 - Redis缓存:TTL=60秒,键设计为
inv:{md5(sku_list_sorted)},缓存整个批次结果 - 数据库兜底:缓存未命中才查库
装饰器实现(FastAPI异步兼容):
# core/cache.py
from functools import lru_cache
import hashlib
import json
import redis.asyncio as redis
redis_client = redis.from_url("redis://localhost:6379/1", encoding="utf-8")
def async_redis_cache(ttl: int = 60):
def decorator(func):
@lru_cache(maxsize=128) # 秒级本地缓存
async def wrapper(*args, **kwargs):
# 构造缓存key:忽略db等不可哈希参数
sku_list = kwargs.get("sku_list") or args[0]
sku_key = hashlib.md5(json.dumps(sku_list, sort_keys=True).encode()).hexdigest()
cache_key = f"inv:{sku_key}"
# 先查Redis
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)
# 查DB
result = await func(*args, **kwargs)
# 写Redis,注意设置随机过期防止雪崩
import random
actual_ttl = ttl + random.randint(0, 10)
await redis_client.set(cache_key, json.dumps(result), ex=actual_ttl)
return result
# 清除本地缓存的钩子(供测试/更新时调用)
wrapper.cache_clear = wrapper.__wrapped__.cache_clear
return wrapper
return decorator
# 使用示例
@async_redis_cache(ttl=60)
async def fetch_inventory_with_cache(sku_list, db):
return await fetch_inventory_batch_safe(sku_list, db)
踩坑记录:
- Redis序列化使用json.dumps时,如果结果包含Decimal或datetime会失败,我加了自定义default=str参数
- 本地lru_cache不能缓存db对象,所以装饰器内把db排除在key之外(通过args[0]取sku_list)
- 随机过期时间+1~10秒,实测防雪崩效果显著,Redis的keyspace命中率从82%提升至97%
六、效果数据与总结
6.1 压测对比(wrk -t8 -c200 -d30s,同一台4C8G机器)
| 阶段 | 平均延迟 | P99延迟 | QPS | MySQL CPU |
|---|---|---|---|---|
| Flask原始版 | 380ms | 620ms | 510 | 85% |
| FastAPI原始版 | 420ms | 900ms | 380 | 88% |
| FastAPI+DB优化 | 95ms | 180ms | 2100 | 34% |
| FastAPI+DB+缓存 | 38ms | 40ms | 5200 | 8% |
注意一个有趣现象:FastAPI初始版比Flask慢。原因是FastAPI的异步事件循环被同步DB调用阻塞,每个请求占用线程,而Flask的多线程模型反而利用了多核CPU。当我把SQLAlchemy切换为AsyncSession并配合await后,FastAPI才真正释放异步性能。
6.2 生产环境验证
上线后观察一周,P99稳定在40ms左右,上游订单服务的超时告警清零。数据库连接池从高峰期的30个连接降到5个左右,RDS实例规格可以降配节省成本。
6.3 总结与教训
- 先Profiling再优化:不要凭直觉改代码,
cProfile和py-spy是必备工具 - ORM要小心:SQLAlchemy的懒加载(Lazyload)是性能杀手,永远使用
selectinload或join显式加载 - 索引要验证:字符集不一致会让索引失效,
EXPLAIN ANALYZE比猜有用一万倍 - 缓存是止痛药不是解药:本案例中DB优化贡献了60%的改善,缓存锦上添花
- 异步框架要彻底:用了FastAPI就把数据库访问全部改成异步,否则不如继续用Flask
最后,这套调优方案已经沉淀为团队的性能优化Checklist,后续新接口开发都按这个流程走。有任何问题欢迎评论区交流,特别是关于async_redis_cache的边界case——比如缓存穿透的布隆过滤器方案,下次再写一篇单独聊聊。