一、问题背景:一个慢接口引发的血案

先交代下业务场景:一个商品列表API,需要返回商品基本信息、库存、最近3条评价。上线后用户反馈“划不动”,看了下监控,该接口P99延迟在800ms左右,数据库连接池经常被打满。我们当时用的是Flask 2.3 + SQLAlchemy 1.4 + MySQL 8.0,部署在4核8G的云服务器上。

这并不是一个复杂的接口,为什么这么慢?直觉告诉我,肯定不是业务逻辑复杂,而是代码“写得比较随意”。于是我开始用Profiling工具排查。

二、环境与版本

Python 3.10.12
Flask 2.3.3 → FastAPI 0.104.1
SQLAlchemy 1.4.50 → 2.0.23
PyMySQL 1.1.0
Redis 7.0.12 (单实例)
wrk 4.2.0 (压测工具)
MySQL 8.0.33 (buffer_pool_size=2G, innodb_io_capacity=2000)

压测命令统一使用:

wrk -t4 -c32 -d30s --latency http://localhost:5000/products

三、第一步:Profiling定位瓶颈

我先在Flask应用中插入了一个简单的profiling中间件,用cProfile采样,输出到KCacheGrind查看:

# profiling_middleware.py
import cProfile, pstats, io

def profile_request(app):
    @app.before_request
    def start_profile():
        g.profiler = cProfile.Profile()
        g.profiler.enable()

    @app.after_request
    def end_profile(response):
        g.profiler.disable()
        s = io.StringIO()
        ps = pstats.Stats(g.profiler, stream=s).sort_stats('cumtime')
        ps.print_stats(20)
        app.logger.debug("Profile:\n%s", s.getvalue())
        return response

结果是触目惊心的:
- sqlalchemy.orm.query.Query.all() 占了总耗时的63%
- json.dumps() 占12%
- copy.deepcopy() 占8%(这是历史遗留代码)

具体来说,商品列表返回了100条商品,每个商品又去数据库查询库存和评价,典型的N+1问题。而且评价查询用了order_by(create_time.desc()).limit(3),但没加索引,导致文件排序。

四、数据库查询优化:从N+1到一次性加载+索引

方案设计:使用SQLAlchemy的selectinload一次性加载关联对象,同时给评价表的product_idcreate_time加复合索引。

# 优化前:N+1查询
products = Product.query.filter(Product.status==1).all()
for p in products:
    p.stock  # 触发库存查询
    p.reviews[:3]  # 触发评价查询

# 优化后:一次性加载
from sqlalchemy.orm import selectinload

products = db.session.execute(
    select(Product)
    .options(selectinload(Product.stock))
    .options(selectinload(Product.reviews))
    .where(Product.status == 1)
    .order_by(Product.create_time.desc())
).scalars().all()

同时给评价表加索引:

ALTER TABLE reviews ADD INDEX idx_product_time (product_id, create_time DESC);

效果数据:单次请求数据库查询耗时从520ms降到90ms,接口总耗时降到280ms。

踩坑selectinload默认是lazy='select',必须显式指定selectinload,否则SQLAlchemy 1.4会退化成N+1。另外注意limit子查询的写法——如果你想要每个商品只取3条评价,需要用lateral join或者应用层分组,我这里是直接用selectinload加载所有评价后取前3,商品数量不多(100以内)的情况下没问题。

五、迁移到FastAPI + 异步ORM

Flask的同步模型在IO密集型场景下性能一般。我决定迁移到FastAPI,利用asyncioasync SQLAlchemy来提升并发能力。

方案设计:用FastAPI替换Flask路由,SQLAlchemy 2.0的异步模式,配合async def处理请求。

# main_fastapi.py
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy import select
from sqlalchemy.orm import selectinload
import aioredis

DATABASE_URL = "mysql+aiomysql://user:pass@localhost/db?charset=utf8mb4"
engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10, echo=False)
async_session = async_sessionmaker(engine, expire_on_commit=False)

class ProductService:
    @staticmethod
    async def get_product_list(db: AsyncSession):
        stmt = (
            select(Product)
            .options(selectinload(Product.stock))
            .options(selectinload(Product.reviews))
            .where(Product.status == 1)
            .order_by(Product.create_time.desc())
            .limit(100)
        )
        result = await db.execute(stmt)
        products = result.scalars().all()

        # 手动序列化以避免SQLAlchemy对象延迟加载
        data = []
        for p in products:
            reviews = sorted(p.reviews, key=lambda r: r.create_time, reverse=True)[:3]
            data.append({
                "id": p.id,
                "name": p.name,
                "stock": p.stock.quantity if p.stock else 0,
                "reviews": [
                    {"content": r.content, "create_time": r.create_time.isoformat()}
                    for r in reviews
                ]
            })
        return data

app = FastAPI()

@app.get("/products")
async def get_products(db: AsyncSession = Depends(get_db)):
    data = await ProductService.get_product_list(db)
    return {"code": 0, "data": data}

核心优化点
1. 异步数据库查询:await db.execute()非阻塞
2. 手动序列化:避免FastAPI自动序列化时触发ORM延迟加载
3. 连接池配置:pool_size=20, max_overflow=10匹配MySQL最大连接数

踩坑:SQLAlchemy 2.0异步模式下,scalars().all()会返回AsyncResult,需要await。另外,expire_on_commit=False必须加,否则事务结束后对象会过期,在序列化时再次查询数据库。

六、缓存策略:Redis缓存热点数据

数据库查询优化后,接口耗时降到120ms,但还没达到目标。考虑加入Redis缓存,缓存商品列表和评价数据,设置合理的过期时间。

方案设计:缓存key设计为products:list:page:1,TTL 60秒。使用Redis的String类型存储JSON序列化后的数据,配合aioredis异步客户端。

# cache_layer.py
import json
from aioredis import Redis
from typing import Optional

CACHE_TTL = 60  # 秒
CACHE_PREFIX = "products:"

class ProductCache:
    @staticmethod
    def build_cache_key(page: int = 1) -> str:
        return f"{CACHE_PREFIX}list:page:{page}"

    @staticmethod
    async def get_from_cache(redis: Redis, key: str) -> Optional[dict]:
        data = await redis.get(key)
        return json.loads(data) if data else None

    @staticmethod
    async def set_to_cache(redis: Redis, key: str, data: dict, ttl: int = CACHE_TTL):
        await redis.setex(key, ttl, json.dumps(data, default=str))

# 在接口中使用
@app.get("/products")
async def get_products(
    redis: Redis = Depends(get_redis),
    db: AsyncSession = Depends(get_db)
):
    cache_key = ProductCache.build_cache_key()
    cached = await ProductCache.get_from_cache(redis, cache_key)
    if cached:
        return cached

    data = await ProductService.get_product_list(db)
    await ProductCache.set_to_cache(redis, cache_key, data)
    return {"code": 0, "data": data}

优化细节
- 缓存过期时间设为60秒,业务允许最多1分钟的数据延迟
- 用json.dumps(data, default=str)处理datetime序列化
- 防止缓存雪崩:在批量更新商品时,主动删除缓存key,而不是等待过期

效果数据:加了缓存后,缓存命中时接口耗时45ms,QPS从120飙升到2100。

七、压测数据对比:最终效果

使用wrk对三个版本进行压测,结果如下:

版本 P50延迟 P99延迟 QPS 错误率
Flask原始版 520ms 800ms 120 0.5%
优化数据库查询后 180ms 280ms 450 0.0%
FastAPI异步版 80ms 120ms 1100 0.0%
FastAPI+Redis缓存 25ms 45ms 2100 0.0%

完整压测输出示例(FastAPI+Redis):

Running 30s test @ http://localhost:8000/products
  4 threads and 32 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    23.45ms   12.34ms 180.56ms   81.23%
    Req/Sec   542.12     45.23   650.00     72.50%
  Latency Distribution
     50%   21.00ms
     75%   28.00ms
     90%   36.00ms
     99%   45.00ms
  16234 requests in 30.00s, 3.12GB read
Requests/sec:   2165.14

总结:API性能调优的三个核心步骤:
1. Profiling先行:不要凭感觉优化,用数据说话。cProfile或py-spy都可以快速定位热点。
2. 数据库是最大瓶颈:N+1查询、缺少索引、重复序列化是常见问题。ORM加载策略要仔细配置。
3. 异步+缓存是放大器:FastAPI的异步模型能提升并发处理能力,Redis缓存则直接消除数据库压力。

最后给个建议:调优前先设一个明确的目标(比如P99<50ms),然后按步骤执行,每一步都压测量化,不要跳步。如果数据没达到预期,说明还有没发现的瓶颈,继续profiling。