一、问题背景:慢接口引发的“雪崩”预警

这套库存服务原本部署在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,高峰期每秒上千次同样查询仍是浪费。我设计了三级缓存:

  1. 本地缓存(进程内):使用functools.lru_cache,TTL=1秒,用于防击穿
  2. Redis缓存:TTL=60秒,键设计为inv:{md5(sku_list_sorted)},缓存整个批次结果
  3. 数据库兜底:缓存未命中才查库

装饰器实现(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时,如果结果包含Decimaldatetime会失败,我加了自定义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 总结与教训

  1. 先Profiling再优化:不要凭直觉改代码,cProfilepy-spy是必备工具
  2. ORM要小心:SQLAlchemy的懒加载(Lazyload)是性能杀手,永远使用selectinloadjoin显式加载
  3. 索引要验证:字符集不一致会让索引失效,EXPLAIN ANALYZE比猜有用一万倍
  4. 缓存是止痛药不是解药:本案例中DB优化贡献了60%的改善,缓存锦上添花
  5. 异步框架要彻底:用了FastAPI就把数据库访问全部改成异步,否则不如继续用Flask

最后,这套调优方案已经沉淀为团队的性能优化Checklist,后续新接口开发都按这个流程走。有任何问题欢迎评论区交流,特别是关于async_redis_cache的边界case——比如缓存穿透的布隆过滤器方案,下次再写一篇单独聊聊。