一、问题背景:一个“正常”接口的突然变慢

上周三下午,运维老张甩给我一张监控截图:订单列表接口 /api/orders?user_id=1024 在晚高峰时段P95延迟从600ms飙升至2.1s,QPS从380掉到220。我看了一眼代码——FastAPI + SQLAlchemy 2.0 + PostgreSQL 14,逻辑很简单:查用户订单,关联商品信息,再查一次卖家店铺名。直觉告诉我,问题出在ORM的懒加载上。

但作为老开发,我坚持“先测量再优化”。于是搬出三件套:cProfile(函数级耗时)、py-spy(线上进程无侵入采样)、pg_stat_statements(数据库层慢查询)。如果你还在用 time.time() 手动打点,建议直接跳到第三节。

二、环境与版本:统一压测基线

为了公平对比,我同时跑了Flask(2.3.2)和FastAPI(0.95.1)两个服务,共用同一套ORM模型和数据库。

# 压测环境
Python: 3.11.4
FastAPI: 0.95.1 + uvicorn 0.23.2
Flask: 2.3.2 + gunicorn 21.2.0 (gevent worker)
SQLAlchemy: 2.0.19
PostgreSQL: 14.8 (shared_buffers=4GB, effective_cache_size=12GB)
Redis: 7.0.12 (maxmemory 2GB, allkeys-lru)
压测工具: wrk 4.2.0 (线程8, 连接400, 时长60s)

启动命令注意:FastAPI用 uvicorn main:app --workers 4 --loop uvloop,Flask用 gunicorn -w 4 -k gevent --worker-connections 1000 wsgi:app。别用Flask默认的同步worker,会死得很惨。

三、Profiling第一步:找到罪魁祸首

先用py-spy对运行中的FastAPI进程采样10秒:

py-spy dump --pid 31245 --duration 10 > fastapi_profile.txt

输出片段揭示了真相:

Thread 0x7f8a1c (idle):
    File "sqlalchemy/orm/loading.py:112" in _instance_processor
    File "sqlalchemy/orm/query.py:1280" in _iter_expr
    File "app/order_service.py:45" in get_orders
        - 87.2% of total time

87%的时间在ORM加载实例!再看 pg_stat_statements 输出,发现一个奇怪现象:同样的SQL SELECT * FROM orders WHERE user_id=$1 LIMIT 20 执行了23次,但只有前两次是真正的数据库查询,其余是Python层的懒加载触发。

结论: 典型的N+1查询。SQLAlchemy 2.0默认是懒加载,我在查询订单后访问 order.itemsorder.seller.name,每次访问都触发一次新的SQL。20条订单 = 1次主查询 + 20次商品查询 + 20次店铺查询 = 41次往返。

四、方案设计:从三个层面干掉瓶颈

第一刀:ORM查询优化(消除N+1)
joinedload()selectinload() 显式预加载。这里有个坑:joinedload 对集合类型(list)会生成笛卡尔积,如果订单有大量商品项,结果集膨胀10倍。所以我选 selectinload,它生成第二条IN查询,避免笛卡尔积。

第二刀:数据库索引与查询重写
检查表结构,发现 orders.user_id 有索引,但 orders.status 没有。优化后的查询加了 status='paid' 过滤,必须加复合索引。另外,order_items 表外键 order_id 缺索引,导致商品关联查询走Seq Scan。

第三刀:Redis缓存(读多写少场景)
订单列表属于读多写少(读写比约8:2),且用户不会立即感知数据变化。引入Redis缓存,TTL设90秒,用 user_id:page 作为key。特别注意:缓存击穿(突然过期)用互斥锁解决,缓存雪崩加随机过期时间。

五、核心实现:代码与踩坑实录

1. 修复N+1查询(FastAPI + SQLAlchemy)

# app/order_service.py 优化后
from sqlalchemy.orm import selectinload, Session
from app.models import Order, OrderItem, Seller

def get_orders(db: Session, user_id: int, page: int = 1):
    query = (
        db.query(Order)
        .filter(Order.user_id == user_id, Order.status == 'paid')
        .options(
            selectinload(Order.items).joinedload(OrderItem.product),
            selectinload(Order.seller).joinedload(Seller.shop_name)  # 注意嵌套
        )
        .order_by(Order.created_at.desc())
        .offset((page - 1) * 20)
        .limit(20)
    )
    return query.all()

踩坑1: 我用 joinedload(Order.items).joinedload(OrderItem.product) 时,SQLAlchemy 2.0直接报错 AttributeError: 'Load' object has no attribute 'items'。原因是 items 是集合,必须用 selectinload(Order.items).joinedload(OrderItem.product) 这种链式组合。集合用selectinload,标量用joinedload。

踩坑2: 加了 status='paid' 过滤后,原来跑得飞快的查询突然变慢。检查执行计划发现PostgreSQL没用上 user_id 索引,因为复合条件导致选择性降低。解决方案:创建 (user_id, status, created_at) 复合索引,并ANALYZE更新统计信息。

CREATE INDEX idx_orders_user_status_created ON orders (user_id, status, created_at DESC);
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
ANALYZE orders;

2. Redis缓存装饰器(通用方案)

# app/cache.py
import json
import hashlib
import redis
from functools import wraps
from fastapi import HTTPException

r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def cache_response(ttl: int = 90, prefix: str = "api"):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            # 将参数序列化为key的一部分
            key_data = json.dumps({
                "args": args[1:],  # 跳过self
                "kwargs": kwargs,
            }, sort_keys=True)
            cache_key = f"{prefix}:{func.__name__}:{hashlib.md5(key_data.encode()).hexdigest()}"

            # 尝试读缓存
            cached = r.get(cache_key)
            if cached is not None:
                return json.loads(cached)

            # 互斥锁防止缓存击穿
            lock_key = f"lock:{cache_key}"
            if r.set(lock_key, "1", nx=True, ex=5):
                try:
                    result = await func(*args, **kwargs)
                    # 随机ttl防雪崩
                    actual_ttl = ttl + random.randint(0, 30)
                    r.setex(cache_key, actual_ttl, json.dumps(result, default=str))
                    return result
                finally:
                    r.delete(lock_key)
            else:
                # 获取到锁的线程正在写缓存,等待重试(此处简化处理)
                raise HTTPException(status_code=503, detail="Try again later")
        return wrapper
    return decorator

# 使用示例
@cache_response(ttl=90, prefix="order")
async def get_order_list(user_id: int, page: int = 1):
    # 原本的数据库查询逻辑
    pass

踩坑3: 异步函数里不能直接用 time.sleep 做重试,必须 await asyncio.sleep。且FastAPI的依赖注入中 Request 对象不能序列化,所以key生成时过滤掉了非基础类型参数。

3. Flask侧同样的优化(附对比)

Flask代码大同小异,但要注意:Flask默认同步视图,如果用了 selectinload,线程会阻塞在IO上。建议用 gevent worker,并在 __init__.py 里打补丁:

from gevent import monkey
monkey.patch_all()

否则你会看到SQLAlchemy连接池被占满,报 QueuePool limit of size 5 overflow 10 reached

六、压测数据:优化前后对比

我用wrk跑三轮,取中位数。测试命令:

wrk -t8 -c400 -d60s --latency http://localhost:8000/api/orders?user_id=1024

优化前(FastAPI + 懒加载):

指标 数值
QPS 220 req/s
P50 380ms
P95 2.1s
P99 4.8s
错误率 0.5%(超时)
数据库查询次数/请求 41

优化后(FastAPI + selectinload + Redis缓存):

指标 数值
QPS 1450 req/s
P50 45ms
P95 180ms
P99 250ms
错误率 0%
数据库查询次数/请求 2(缓存命中时0)

Flask优化后:

指标 数值
QPS 980 req/s
P50 72ms
P95 300ms

Flask比FastAPI慢约35%,差距主要在异步框架的协程调度上。但如果只做同步IO操作,gevent worker能把差距缩小到20%以内。结论:如果你的代码全是最优的,FastAPI的异步优势在纯数据库操作下不明显;但一旦有多路IO(如调外部API+读DB),差距会拉大到3倍以上。

七、总结与反思

  1. 不要相信直觉:我最初怀疑是JSON序列化慢,结果profiling显示是ORM。cProfile和py-spy能救你于水火。
  2. 数据库是第一战场:N+1查询是万恶之源,写ORM前先想清楚关联深度。用 echo=Trueslowquery_log 把SQL打印出来。
  3. 缓存是放大器:加了Redis后QPS提升6倍,但要注意缓存一致性。我的场景允许90秒延迟同步,如果你做支付系统,别这么干。
  4. 框架选型:FastAPI的异步是锦上添花,不是雪中送炭。但它的自动OpenAPI文档和类型提示,开发效率确实比Flask高。

最后留个问题:如果订单量过亿,分页到第1000页时,OFFSET 20000 会拖慢查询。你会怎么优化?欢迎评论区讨论。

(附:完整的压测脚本和profiling工具链配置已上传至GitHub,链接见评论区。)