一、问题背景:一个“看起来没问题”的接口

上个月接了个活,给一个电商后台的订单列表接口做性能优化。这个接口逻辑很简单:根据用户ID分页查订单,返回订单及商品快照。上线前压测发现P95延迟高达1.2秒,QPS只有85,数据库CPU直接飙到80%。

第一反应是“查一下慢SQL”。结果EXPLAIN一看,三个表(orders、order_items、products)都是全表扫描,单次查询耗时380ms。但更让人意外的是,真正的问题不在SQL本身,而在应用层的N+1查询——每查一页20条订单,就要额外执行20次商品快照查询。

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

调优前先固定环境,避免变量干扰:

  • Python 3.10.12(GIL的影响后面会提到)
  • FastAPI 0.104.0 + Uvicorn 0.24.0(worker=4, loop=uvloop)
  • Flask 2.3.3 + Gunicorn 21.2.4(worker=4, 同步worker + gevent)
  • SQLAlchemy 2.0.21(async for FastAPI,sync for Flask)
  • PostgreSQL 15.2(shared_buffers=1GB, work_mem=32MB)
  • Redis 7.0(maxmemory-policy=allkeys-lru)
  • 压测工具:wrk 2.0(12线程,100连接,持续60秒)

压测脚本统一用:

wrk -t12 -c100 -d60s --latency http://localhost:8000/api/orders?user_id=123&page=1&size=20

三、第一步:Profiling定位瓶颈——cProfile + py-spy

别猜,直接测。我先用cProfile跑了一次单请求:

python -m cProfile -s cumulative my_api.py

结果前几行输出:

ncalls  tottime  percall  cumtime  percall  filename:lineno(function)
1       0.002    0.002    0.583    0.583  /app/api/orders.py:22(get_orders)
20      0.001    0.000    0.412    0.020  /app/models.py:145(get_product_snapshot)
20      0.003    0.000    0.310    0.015  /app/serializer.py:88(order_item_to_dict)

一眼看出两个大坑:

  1. 20次get_product_snapshot累计0.412秒——典型的N+1查询。
  2. 序列化order_item_to_dict累计0.310秒——每调一次就做一次Decimal到float转换、datetime格式化,20个订单项*5个商品快照=100次序列化调用。

这时候用py-spy抓一下线上运行的火焰图(因为cProfile在异步环境下不准):

py-spy record -o flame.svg --pid  --duration 30

火焰图确认了:SQLAlchemy session.get()占据了58%的CPU时间,jsonable_encoder占22%。

结论:先别碰缓存,把查询和序列化优化了再说。

四、第二步:数据库优化——Eager Loading + 复合索引

4.1 修复N+1查询

原代码(伪代码,但和真实逻辑一致):

# 优化前:每页20条订单,每条订单查一次商品快照
async def get_orders(user_id, page, size):
    orders = await db.execute(
        select(Order).where(Order.user_id == user_id)
        .offset(page * size).limit(size)
    )
    result = []
    for order in orders.scalars().all():
        items = await db.execute(
            select(OrderItem).where(OrderItem.order_id == order.id)
        )
        for item in items.scalars().all():
            product = await db.get(Product, item.product_id)
            result.append(serialize(order, item, product))
    return result

优化后:用SQLAlchemy 2.0的selectinload一次性加载关联对象。

# 优化后:一次join拉取所有关联数据
async def get_orders(db: AsyncSession, user_id: int, page: int, size: int):
    stmt = (
        select(Order)
        .options(
            selectinload(Order.items).selectinload(OrderItem.product)  # 关键:级联预加载
        )
        .where(Order.user_id == user_id)
        .order_by(Order.created_at.desc())
        .offset(page * size)
        .limit(size)
    )
    result = await db.execute(stmt)
    orders = result.scalars().unique().all()  # 注意:must call unique() for selectinload
    return [serialize_order(o) for o in orders]

4.2 加复合索引

原表索引只有主键。通过pg_stat_statements找到高频查询,添加:

CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC) INCLUDE (status, total_amount);
CREATE INDEX idx_order_items_order_id ON order_items (order_id) INCLUDE (product_id, quantity, price);
CREATE INDEX idx_products_id ON products (id) INCLUDE (name, image_url, sku);

覆盖索引让查询只需要走索引扫描,不需要回表。

优化后单次查询耗时从380ms降到41ms,N+1彻底消失。

五、第三步:序列化优化——手写Pydantic validator

FastAPI默认用jsonable_encoder,慢且耗CPU。我的做法:给Pydantic模型加@field_serializer,让Decimal和datetime在模型层直接转好,避免二次处理。

from pydantic import BaseModel, field_serializer
from datetime import datetime
from decimal import Decimal

class OrderItemOut(BaseModel):
    product_name: str
    quantity: int
    unit_price: Decimal
    total_price: Decimal
    created_at: datetime

    @field_serializer('unit_price', 'total_price')
    def serialize_decimal(self, value: Decimal) -> float:
        return float(value)  # 保留两位小数,避免精度丢失

    @field_serializer('created_at')
    def serialize_dt(self, value: datetime) -> str:
        return value.strftime('%Y-%m-%d %H:%M:%S')

同时把response_model=OrderOut显式声明,FastAPI就不会走jsonable_encoder,而是直接用Pydantic的model_dump_json——速度提升约70%。

序列化这一步,从310ms降到75ms。

六、第四步:缓存策略——Redis缓存热点用户

数据库优化后,单次查询约120ms。但压测发现QPS到不了300——因为PostgreSQL的CPU还是高(约40%)。这时候上缓存。

缓存策略:只缓存活跃用户的前3页,避免冷数据污染。TTL设5分钟,因为订单状态变更不太频繁。

# 缓存装饰器实现(FastAPI版本)
from redis.asyncio import Redis
from fastapi import Depends
import json

redis_client = Redis(host='localhost', port=6379, decode_responses=True)

async def cached_orders(user_id: int, page: int, size: int):
    cache_key = f"orders:{user_id}:{page}:{size}"
    cached = await redis_client.get(cache_key)
    if cached:
        return json.loads(cached)
    # 未命中,查询数据库并写入缓存
    orders = await fetch_orders_from_db(user_id, page, size)
    await redis_client.setex(cache_key, 300, json.dumps(orders))
    return orders

缓存命中率在压测场景下约78%(因为wrk的user_id从1到1000均匀分布,前200个用户占了80%的请求)。命中时响应时间6ms,未命中时120ms,整体平均降到38ms。

七、踩坑与优化:三个隐藏的坑

  1. SQLAlchemy 2.0的unique()陷阱:用了selectinload后,如果结果集有多对多关系,必须调用.unique(),否则返回重复对象。少写这个,压测时数据全是错的,白调了两天。
  2. Redis连接池:FastAPI的异步Redis默认连接池上限50,压测时并发100,直接爆连接错误。要手动设置max_connections=200,否则缓存反而成了瓶颈。
  3. GIL对Flask的影响:Flask用同步worker时,GIL导致CPU密集的序列化操作无法并行。换成gevent后好了点,但整体还是比FastAPI差一截——FastAPI的异步IO + uvicorn的uvloop在处理IO密集场景下优势明显

八、压测数据:FastAPI vs Flask

统一优化后(Eager Loading + 索引 + Redis),wrk压测结果对比:

指标 FastAPI (uvicorn 4w) Flask (gunicorn+gevent 4w)
平均延迟 87ms 134ms
P95延迟 152ms 245ms
QPS 420 285
数据库CPU 25% 38%
Redis命中率 78% 78%

调优前FastAPI是1200ms/85QPS,调优后87ms/420QPS,整体提升约5倍。

Flask的瓶颈在GIL——即使加了gevent,序列化还是串行的。如果生产环境必须用Flask,建议把序列化逻辑改成orjson或者用multiprocessing做CPU隔离。

九、总结与建议

调优的优先级是:先profiling,再查SQL,最后上缓存。别一上来就套Redis,很可能你的数据库查询还有10倍优化空间。

核心收获:
- cProfile/py-spy定位N+1查询和序列化热点
- SQLAlchemy selectinload + 覆盖索引,查询从380ms降到41ms
- Pydantic @field_serializer 替代 jsonable_encoder,序列化从310ms降到75ms
- Redis缓存热点用户前3页,TTL 300s,命中率78%

如果你也在用FastAPI或Flask,建议先跑一下py-spy dump --pid看看你的CPU到底花在哪了——很可能不是数据库,而是你的代码自己。

最后说一句:没有银弹,只有一步步压出来的数据。